fix(scope-resolution): mark construction-site CALLS edges in reason (opt-in), enable for Zig

PR #1432 human review, item 2: a Zig struct literal `T{ .f = x }` (no
parens) is modelled as a CALLS edge to the type — the Rust `T { .. }` /
Go `T{}` shape — and nothing on the edge told it apart from an invocation
(`get_next_spawn → SpawnRequest` from seven `return SpawnRequest{ … }`).

`ScopeResolver.markConstructionSites` (default off): when set, the edge
emitted for a `callForm === 'constructor'` site gets ` (constructor)`
appended to its reason, in both emit paths — `local-call (constructor)` /
`import-resolved (constructor)` in the free-call fallback and
`scope-resolution: call (constructor)` in the reference bridge. The Zig
resolver opts in. `Reference` gains an optional `callForm`, copied from
the site by `buildReference`, so the bridge can see the form.

Why `reason` and not a property or edge type: relationships carry no
arbitrary properties, a new column changes the relation DDL and moves
SCHEMA_FINGERPRINT, and `reason` is the channel the IMPLEMENTS `-pointer`
receiver form already uses. Why opt-in: the unsuffixed strings are a
pinned contract asserted verbatim by the other language suites
(php/cpp constructor calls expect exactly `import-resolved`); every
non-Zig edge stays byte-identical.

Tests: `references-to-edges-call-form.test.ts` pins both vocabularies
and the default-off behaviour; `zig.test.ts` asserts
`Reflect.string → Accessor` / `Reflect.url → Accessor` carry
`local-call (constructor)` next to a plain invocation, and that every
marked edge targets a Struct.
This commit is contained in:
Navid EMAD 2026-09-02 19:38:26 +02:00
parent c740d716be
commit 97571d23f5
No known key found for this signature in database
9 changed files with 368 additions and 4 deletions

View file

@ -24,6 +24,9 @@
import type { NodeLabel } from '../graph/types.js'; import type { NodeLabel } from '../graph/types.js';
import type { SymbolDefinition } from './symbol-definition.js'; import type { SymbolDefinition } from './symbol-definition.js';
// Type-only, so the `reference-site.ts` → `types.ts` import cycle is erased
// at compile time.
import type { CallForm } from './reference-site.js';
// ─── §2.1 Type aliases ────────────────────────────────────────────────────── // ─── §2.1 Type aliases ──────────────────────────────────────────────────────
@ -752,6 +755,15 @@ export interface Reference {
| 'import-use' | 'import-use'
| 'value-ref' | 'value-ref'
| 'macro'; | 'macro';
/**
* Call form of the site this reference was resolved from, copied verbatim
* from `ReferenceSite.callForm`; set only when `kind === 'call'`. The
* emit phase reads it to tell a construction site (`T{…}`, `new T()`,
* `T { .. }` form `'constructor'`) apart from an invocation, which in the
* graph are both `CALLS` edges. Optional and additive: a `Reference` built
* without it is emitted exactly as before.
*/
readonly callForm?: CallForm;
readonly confidence: number; readonly confidence: number;
readonly evidence: readonly ResolutionEvidence[]; readonly evidence: readonly ResolutionEvidence[];
} }

View file

@ -24,6 +24,12 @@ export const zigScopeResolver: ScopeResolver = {
language: SupportedLanguages.Zig, language: SupportedLanguages.Zig,
languageProvider: zigProvider, languageProvider: zigProvider,
importEdgeReason: 'zig-scope: import', importEdgeReason: 'zig-scope: import',
// A struct literal `T{ .f = x }` is a CALLS edge to the Struct node (the
// Rust `T { .. }` / Go `T{}` shape). Zig has no Constructor nodes, so
// nothing but this marker tells that edge from an invocation on the edge
// itself — `main → SpawnRequest` looked like a call to a function
// (PR #1432 review). Emits `local-call (constructor)` and friends.
markConstructionSites: true,
loadResolutionConfig: (repoPath: string) => loadZigBuildConfig(repoPath), loadResolutionConfig: (repoPath: string) => loadZigBuildConfig(repoPath),

View file

@ -322,6 +322,10 @@ function buildReference(site: ReferenceSite, top: Resolution): Reference {
toDef: top.def.nodeId, toDef: top.def.nodeId,
atRange: site.atRange, atRange: site.atRange,
kind: site.kind, kind: site.kind,
// The call form survives resolution so the graph bridge can mark
// construction sites (`callForm: 'constructor'`) on the CALLS edge it
// emits — a `Reference` otherwise keeps only the resolved def.
...(site.kind === 'call' && site.callForm !== undefined ? { callForm: site.callForm } : {}),
confidence: top.confidence, confidence: top.confidence,
evidence: top.evidence, evidence: top.evidence,
}; };

View file

@ -878,6 +878,30 @@ export interface ScopeResolver {
*/ */
readonly constructorCallTargetsClass?: boolean; readonly constructorCallTargetsClass?: boolean;
/**
* When true, the CALLS edge emitted for a constructor-form site
* (`callForm === 'constructor'`) carries ` (constructor)` appended to its
* `reason` `local-call (constructor)`, `import-resolved (constructor)`,
* `scope-resolution: call (constructor)` so a consumer can tell
* "constructs an instance of" apart from "invokes" on the edge alone.
*
* Opt-in because the unsuffixed strings are a pinned contract: the legacy
* DAG vocabulary (`'import-resolved' | 'local-call' | …`, see the
* same-graph guarantee above) is asserted verbatim by consumers and by the
* per-language resolver suites, constructor sites included. A language
* that links a construction site to the TYPE node itself a struct
* literal `T{…}` in Zig, where nothing but the marker distinguishes the
* edge from an invocation in the schema (PR #1432 review) opts in; the
* default leaves every existing edge byte-identical.
*
* The marker rides in `reason` because relationships carry no arbitrary
* properties (adding one moves SCHEMA_FINGERPRINT the IMPLEMENTS
* `-pointer` precedent in `pipeline/run.ts`). Applies to the free-call
* fallback and the reference bridge; receiver-qualified construction sites
* are not tagged `constructor` by any provider today.
*/
readonly markConstructionSites?: boolean;
/** /**
* How this language spells a construction expression, so the compound * How this language spells a construction expression, so the compound
* receiver resolver can type an INLINE constructor receiver the * receiver resolver can type an INLINE constructor receiver the

View file

@ -6,7 +6,7 @@
* chain looking for an enclosing Function/Method/Class. * chain looking for an enclosing Function/Method/Class.
* 2. Resolve `toDef` target graph-node id via `nodeLookup`. * 2. Resolve `toDef` target graph-node id via `nodeLookup`.
* 3. Emit the edge (`CALLS` / `READS` / `WRITES` / `EXTENDS` / `USES`) * 3. Emit the edge (`CALLS` / `READS` / `WRITES` / `EXTENDS` / `USES`)
* with the standard reason format. * with the standard reason format (`referenceEdgeReason`).
* *
* Skips (without throwing) when either side fails to map either side * Skips (without throwing) when either side fails to map either side
* may legitimately not exist as a graph node (e.g. a resolved target * may legitimately not exist as a graph node (e.g. a resolved target
@ -36,6 +36,36 @@ import { isValueDefinitionLabel } from '../../utils/ast-helpers.js';
*/ */
type ReferenceSiteSkipSet = ReadonlySet<string>; type ReferenceSiteSkipSet = ReadonlySet<string>;
export interface EmitReferencesOptions {
/** When true, a constructor-form call site's edge gets ` (constructor)`
* appended to its reason. See `ScopeResolver.markConstructionSites`. */
readonly markConstructionSites?: boolean;
}
/**
* `reason` of the edge a resolved reference emits: `scope-resolution: <kind>`,
* plus ` (constructor)` for a construction site when the provider opted in
* a struct literal (`T{…}` in Zig, `T { .. }` in Rust, `T{}` in Go) or a
* `new T()` resolves to the type (or its constructor) as a CALLS edge exactly
* like an invocation does, and nothing else on the edge tells the two apart
* (PR #1432 review).
*
* The marker rides in `reason` because relationships carry no arbitrary
* properties adding one would change the relation DDL and move
* SCHEMA_FINGERPRINT (see the IMPLEMENTS `-pointer` precedent in
* `pipeline/run.ts`). The plain `scope-resolution: call` prefix is kept, so a
* consumer matching the prefix still sees every call; one matching the exact
* string sees invocations only.
*/
export function referenceEdgeReason(
ref: Pick<Reference, 'kind' | 'callForm'>,
markConstructionSites: boolean | undefined,
): string {
return markConstructionSites === true && ref.kind === 'call' && ref.callForm === 'constructor'
? 'scope-resolution: call (constructor)'
: `scope-resolution: ${ref.kind}`;
}
/** /**
* Value labels whose defs MAY be function-local. A reference to one of these is * Value labels whose defs MAY be function-local. A reference to one of these is
* dropped only when the def is positively identified as living inside a function * dropped only when the def is positively identified as living inside a function
@ -76,6 +106,7 @@ export function emitReferencesViaLookup(
* Optional so callers that never capture bare identifiers are unchanged. * Optional so callers that never capture bare identifiers are unchanged.
*/ */
functionLocalValueDefIds?: ReadonlySet<string>, functionLocalValueDefIds?: ReadonlySet<string>,
options?: EmitReferencesOptions,
): { emitted: number; skipped: number } { ): { emitted: number; skipped: number } {
let emitted = 0; let emitted = 0;
let skipped = 0; let skipped = 0;
@ -153,7 +184,7 @@ export function emitReferencesViaLookup(
targetId: targetGraphId, targetId: targetGraphId,
type: edgeType, type: edgeType,
confidence: ref.confidence, confidence: ref.confidence,
reason: `scope-resolution: ${ref.kind}`, reason: referenceEdgeReason(ref, options?.markConstructionSites),
}); });
emitted++; emitted++;
} }

View file

@ -22,6 +22,7 @@ import type {
ParameterTypeClass, ParameterTypeClass,
ParsedFile, ParsedFile,
Reference, Reference,
ReferenceSite,
ScopeId, ScopeId,
SymbolDefinition, SymbolDefinition,
} from 'gitnexus-shared'; } from 'gitnexus-shared';
@ -66,6 +67,9 @@ export function emitFreeCallFallback(
/** When true, `Type(...)` constructor calls link to the Class def /** When true, `Type(...)` constructor calls link to the Class def
* itself rather than its explicit Constructor. Swift opts in. */ * itself rather than its explicit Constructor. Swift opts in. */
readonly constructorCallTargetsClass?: boolean; readonly constructorCallTargetsClass?: boolean;
/** When true, a constructor-form site's edge gets ` (constructor)`
* appended to its reason. See `ScopeResolver.markConstructionSites`. */
readonly markConstructionSites?: boolean;
readonly isFileLocalDef?: (def: SymbolDefinition) => boolean; readonly isFileLocalDef?: (def: SymbolDefinition) => boolean;
readonly isCallableVisibleFromCaller?: (ctx: { readonly isCallableVisibleFromCaller?: (ctx: {
readonly callerParsed: ParsedFile; readonly callerParsed: ParsedFile;
@ -621,8 +625,13 @@ export function emitFreeCallFallback(
type: 'CALLS', type: 'CALLS',
confidence: 0.85, confidence: 0.85,
// Match legacy DAG's reason convention so consumers that // Match legacy DAG's reason convention so consumers that
// assert `reason === 'import-resolved'` keep working. // assert `reason === 'import-resolved'` keep working. The
reason: fnDef.filePath !== parsed.filePath ? 'import-resolved' : 'local-call', // construction-site marker is opt-in for the same reason.
reason: constructionSiteReason(
fnDef.filePath !== parsed.filePath ? 'import-resolved' : 'local-call',
site,
options.markConstructionSites,
),
}); });
emitted++; emitted++;
} }
@ -630,6 +639,19 @@ export function emitFreeCallFallback(
return emitted; return emitted;
} }
/** `reason` of a free-call edge: the legacy string, plus ` (constructor)` for
* a construction site when the provider opted in
* (`ScopeResolver.markConstructionSites`). */
export function constructionSiteReason(
base: string,
site: Pick<ReferenceSite, 'callForm'>,
markConstructionSites: boolean | undefined,
): string {
return markConstructionSites === true && site.callForm === 'constructor'
? `${base} (constructor)`
: base;
}
function siteKey( function siteKey(
filePath: string, filePath: string,
site: { readonly atRange: { readonly startLine: number; readonly startCol: number } }, site: { readonly atRange: { readonly startLine: number; readonly startCol: number } },

View file

@ -1070,6 +1070,7 @@ export function runScopeResolution(
{ {
allowGlobalFallback: provider.allowGlobalFreeCallFallback === true, allowGlobalFallback: provider.allowGlobalFreeCallFallback === true,
constructorCallTargetsClass: provider.constructorCallTargetsClass === true, constructorCallTargetsClass: provider.constructorCallTargetsClass === true,
markConstructionSites: provider.markConstructionSites === true,
isFileLocalDef: provider.isFileLocalDef, isFileLocalDef: provider.isFileLocalDef,
isBuiltInName: provider.languageProvider.isBuiltInName, isBuiltInName: provider.languageProvider.isBuiltInName,
freeCallsRequireInstanceOwnership: provider.freeCallsRequireInstanceOwnership === true, freeCallsRequireInstanceOwnership: provider.freeCallsRequireInstanceOwnership === true,
@ -1100,6 +1101,7 @@ export function runScopeResolution(
// both correctly emit. See the build site above for why the earlier // both correctly emit. See the build site above for why the earlier
// allowlist could not be made safe this way. // allowlist could not be made safe this way.
functionLocalValueDefIds, functionLocalValueDefIds,
{ markConstructionSites: provider.markConstructionSites === true },
); );
// Last-resort property resolution by workspace-unique name (A1/A5). Runs // Last-resort property resolution by workspace-unique name (A1/A5). Runs
// after every precise pass and only sees what they left behind, so a // after every precise pass and only sees what they left behind, so a

View file

@ -666,6 +666,32 @@ describe.skipIf(!zigAvailable)('Zig function-local and anonymous containers (F8)
); );
}); });
it('marks a struct-literal construction site (`return Accessor{…}`) on its CALLS edge, unlike an invocation', () => {
// A struct literal `T{ .f = x }` (no parens) is deliberately modelled as a
// CALLS edge to the type — the same shape Rust `T { .. }` and Go `T{}`
// produce. The PR #1432 review found it indistinguishable from a real
// call: the marker in `reason` is what lets a consumer tell "constructs an
// instance of" apart from "invokes". `Reflect.string` / `Reflect.url`
// each `return Accessor{ .get = R.get, .set = R.set }` (reflect.zig).
// Same-file free-call fallback vocabulary, suffixed because the Zig
// resolver opts into `markConstructionSites`.
const calls = getRelationships(result, 'CALLS');
const reasonsOf = (source: string, target: string): string[] =>
calls.filter((e) => e.source === source && e.target === target).map((e) => e.rel.reason);
expect(reasonsOf('string', 'Accessor')).toEqual(['local-call (constructor)']);
expect(reasonsOf('url', 'Accessor')).toEqual(['local-call (constructor)']);
// The invocation next door keeps its plain reason: `util.helper()` in
// main.zig is a call, not a construction site.
expect(reasonsOf('main', 'helper')).toHaveLength(1);
expect(reasonsOf('main', 'helper')[0]).not.toContain('(constructor)');
// No construction site is emitted as anything but CALLS, and every other
// CALLS edge is an invocation.
const constructionSites = calls.filter((e) => e.rel.reason.endsWith('(constructor)'));
expect(constructionSites.map((e) => e.targetLabel)).toEqual(
constructionSites.map(() => 'Struct'),
);
});
it('gives anonymous containers a host + ordinal identity, so no Method is ownerless and same-named fns never collide', () => { it('gives anonymous containers a host + ordinal identity, so no Method is ownerless and same-named fns never collide', () => {
expect(idsIn('Struct', 'Sorter.zig')).toEqual([ expect(idsIn('Struct', 'Sorter.zig')).toEqual([
'Struct:src/Sorter.zig:Sorter', 'Struct:src/Sorter.zig:Sorter',

View file

@ -0,0 +1,237 @@
/**
* The CALLS edge a construction site emits must be distinguishable from the
* one an invocation emits (PR #1432 review, item 2).
*
* A struct literal (`SpawnRequest{ .a = x }` in Zig, `Foo { a }` in Rust,
* `Foo{}` in Go) and a `new Foo()` are tagged `@reference.call.constructor`
* by their language queries and resolve to the type (or its constructor) as
* an ordinary `kind: 'call'` reference. Both land in the graph as `CALLS`,
* and relationships carry no free-form properties (adding one moves
* SCHEMA_FINGERPRINT), so the call form rides in `reason` the same channel
* the IMPLEMENTS `-pointer` receiver form uses.
*
* Why the assertions are exact strings: a consumer that filters by the
* exact `scope-resolution: call` is promised invocations only, and one that
* matches the prefix is promised every call. Loosening either side would
* silently break one of the two contracts.
*/
import { describe, it, expect } from 'vitest';
import {
buildDefIndex,
buildMethodDispatchIndex,
buildModuleScopeIndex,
buildQualifiedNameIndex,
buildScopeTree,
type NodeLabel,
type Range,
type Reference,
type Scope,
type ScopeId,
type SymbolDefinition,
} from 'gitnexus-shared';
import { createKnowledgeGraph } from '../../../src/core/graph/graph.js';
import type { ScopeResolutionIndexes } from '../../../src/core/ingestion/model/scope-resolution-indexes.js';
import { buildGraphNodeLookup } from '../../../src/core/ingestion/scope-resolution/graph-bridge/node-lookup.js';
import {
emitReferencesViaLookup,
referenceEdgeReason,
} from '../../../src/core/ingestion/scope-resolution/graph-bridge/references-to-edges.js';
import { constructionSiteReason } from '../../../src/core/ingestion/scope-resolution/passes/free-call-fallback.js';
const FILE = 'x.zig';
const range = (sl: number, sc: number): Range => ({
startLine: sl,
startCol: sc,
endLine: sl,
endCol: sc + 4,
});
const def = (nodeId: string, type: SymbolDefinition['type'], qname: string): SymbolDefinition => ({
nodeId,
filePath: FILE,
type,
qualifiedName: qname,
});
function moduleScope(ownedDefs: readonly SymbolDefinition[]): Scope {
return {
id: 'scope:m',
parent: null,
kind: 'Module',
range: { startLine: 1, startCol: 0, endLine: 100, endCol: 0 },
filePath: FILE,
bindings: new Map(),
ownedDefs,
imports: [],
typeBindings: new Map(),
};
}
function makeIndexes(scope: Scope, allDefs: readonly SymbolDefinition[]): ScopeResolutionIndexes {
return {
scopeTree: buildScopeTree([scope]),
defs: buildDefIndex([...allDefs]),
qualifiedNames: buildQualifiedNameIndex([...allDefs]),
moduleScopes: buildModuleScopeIndex([{ filePath: FILE, moduleScopeId: scope.id }]),
methodDispatch: buildMethodDispatchIndex({
owners: [],
computeMro: () => [],
implementsOf: () => [],
}),
imports: new Map(),
bindings: new Map(),
bindingAugmentations: new Map(),
workspaceFqnBindings: new Map(),
workspaceTypeBindings: new Map(),
namespaceFqnBindings: new Map(),
namespaceTypeBindings: new Map(),
accessibleNamespacesByScope: new Map(),
referenceSites: [],
sccs: [],
stats: {
totalFiles: 0,
totalEdges: 0,
linkedEdges: 0,
unresolvedEdges: 0,
sccCount: 0,
largestSccSize: 0,
},
};
}
describe('referenceEdgeReason', () => {
it('marks only the constructor call form; every other kind keeps the plain reason', () => {
expect(referenceEdgeReason({ kind: 'call', callForm: 'constructor' }, true)).toBe(
'scope-resolution: call (constructor)',
);
expect(referenceEdgeReason({ kind: 'call', callForm: 'free' }, true)).toBe(
'scope-resolution: call',
);
expect(referenceEdgeReason({ kind: 'call', callForm: 'member' }, true)).toBe(
'scope-resolution: call',
);
// A `Reference` minted before the field existed (or by a path that does
// not set it) is emitted exactly as before.
expect(referenceEdgeReason({ kind: 'call' }, true)).toBe('scope-resolution: call');
expect(referenceEdgeReason({ kind: 'read' }, true)).toBe('scope-resolution: read');
expect(referenceEdgeReason({ kind: 'type-reference' }, true)).toBe(
'scope-resolution: type-reference',
);
});
it('is opt-in: a provider that did not set `markConstructionSites` gets the pinned string', () => {
// The unsuffixed reasons are a contract asserted verbatim by the other
// language suites; the marker must not leak into them by default.
expect(referenceEdgeReason({ kind: 'call', callForm: 'constructor' }, undefined)).toBe(
'scope-resolution: call',
);
expect(referenceEdgeReason({ kind: 'call', callForm: 'constructor' }, false)).toBe(
'scope-resolution: call',
);
});
it('keeps the plain `scope-resolution: call` as a prefix of the marked form', () => {
// The compatibility promise: prefix matchers keep seeing construction
// sites as calls.
expect(
referenceEdgeReason({ kind: 'call', callForm: 'constructor' }, true).startsWith(
'scope-resolution: call',
),
).toBe(true);
});
});
describe('constructionSiteReason (free-call fallback vocabulary)', () => {
it('suffixes the legacy `local-call` / `import-resolved` only for an opted-in constructor site', () => {
expect(constructionSiteReason('local-call', { callForm: 'constructor' }, true)).toBe(
'local-call (constructor)',
);
expect(constructionSiteReason('import-resolved', { callForm: 'constructor' }, true)).toBe(
'import-resolved (constructor)',
);
expect(constructionSiteReason('local-call', { callForm: 'free' }, true)).toBe('local-call');
expect(constructionSiteReason('local-call', { callForm: 'constructor' }, undefined)).toBe(
'local-call',
);
});
});
describe('emitReferencesViaLookup — construction site vs invocation', () => {
it('emits both as CALLS, and only the construction site carries the constructor marker', () => {
// `fn get_next_spawn() SpawnRequest { helper(); return SpawnRequest{ .a = 1 }; }`
const caller = def('def:get_next_spawn', 'Function', 'get_next_spawn');
const helper = def('def:helper', 'Function', 'helper');
const spawnRequest = def('def:SpawnRequest', 'Struct', 'SpawnRequest');
const allDefs = [caller, helper, spawnRequest];
const indexes = makeIndexes(moduleScope(allDefs), allDefs);
const graph = createKnowledgeGraph();
graph.addNode({
id: 'fn:get_next_spawn',
label: 'Function' as NodeLabel,
properties: { name: 'get_next_spawn', filePath: FILE, qualifiedName: 'get_next_spawn' },
});
graph.addNode({
id: 'fn:helper',
label: 'Function' as NodeLabel,
properties: { name: 'helper', filePath: FILE, qualifiedName: 'helper' },
});
graph.addNode({
id: 'struct:SpawnRequest',
label: 'Struct' as NodeLabel,
properties: { name: 'SpawnRequest', filePath: FILE, qualifiedName: 'SpawnRequest' },
});
const invocation: Reference = {
fromScope: 'scope:m',
toDef: 'def:helper',
atRange: range(2, 4),
kind: 'call',
callForm: 'free',
confidence: 0.9,
evidence: [],
};
const construction: Reference = {
fromScope: 'scope:m',
toDef: 'def:SpawnRequest',
atRange: range(3, 11),
kind: 'call',
callForm: 'constructor',
confidence: 0.9,
evidence: [],
};
const referenceIndex = {
bySourceScope: new Map<ScopeId, readonly Reference[]>([
['scope:m', [invocation, construction]],
]),
};
const result = emitReferencesViaLookup(
graph,
indexes,
referenceIndex,
buildGraphNodeLookup(graph),
undefined,
undefined,
undefined,
{ markConstructionSites: true },
);
expect(result).toEqual({ emitted: 2, skipped: 0 });
const edges = graph.relationships.map((r) => ({
type: r.type,
targetId: r.targetId,
reason: r.reason,
}));
expect(edges).toEqual([
{ type: 'CALLS', targetId: 'fn:helper', reason: 'scope-resolution: call' },
{
type: 'CALLS',
targetId: 'struct:SpawnRequest',
reason: 'scope-resolution: call (constructor)',
},
]);
});
});