From 97571d23f5bb9e91998a54db4e1b9330ed14e48f Mon Sep 17 00:00:00 2001 From: Navid EMAD Date: Wed, 2 Sep 2026 19:38:26 +0200 Subject: [PATCH] fix(scope-resolution): mark construction-site CALLS edges in reason (opt-in), enable for Zig MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- gitnexus-shared/src/scope-resolution/types.ts | 12 + .../ingestion/languages/zig/scope-resolver.ts | 6 + .../src/core/ingestion/resolve-references.ts | 4 + .../contract/scope-resolver.ts | 24 ++ .../graph-bridge/references-to-edges.ts | 35 ++- .../passes/free-call-fallback.ts | 26 +- .../scope-resolution/pipeline/run.ts | 2 + .../test/integration/resolvers/zig.test.ts | 26 ++ .../references-to-edges-call-form.test.ts | 237 ++++++++++++++++++ 9 files changed, 368 insertions(+), 4 deletions(-) create mode 100644 gitnexus/test/unit/scope-resolution/references-to-edges-call-form.test.ts diff --git a/gitnexus-shared/src/scope-resolution/types.ts b/gitnexus-shared/src/scope-resolution/types.ts index 80b961bda..da9f277b3 100644 --- a/gitnexus-shared/src/scope-resolution/types.ts +++ b/gitnexus-shared/src/scope-resolution/types.ts @@ -24,6 +24,9 @@ import type { NodeLabel } from '../graph/types.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 ────────────────────────────────────────────────────── @@ -752,6 +755,15 @@ export interface Reference { | 'import-use' | 'value-ref' | '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 evidence: readonly ResolutionEvidence[]; } diff --git a/gitnexus/src/core/ingestion/languages/zig/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/zig/scope-resolver.ts index e7a303fb9..e2a94ed0a 100644 --- a/gitnexus/src/core/ingestion/languages/zig/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/zig/scope-resolver.ts @@ -24,6 +24,12 @@ export const zigScopeResolver: ScopeResolver = { language: SupportedLanguages.Zig, languageProvider: zigProvider, 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), diff --git a/gitnexus/src/core/ingestion/resolve-references.ts b/gitnexus/src/core/ingestion/resolve-references.ts index a7a2cf0a3..1b20389d8 100644 --- a/gitnexus/src/core/ingestion/resolve-references.ts +++ b/gitnexus/src/core/ingestion/resolve-references.ts @@ -322,6 +322,10 @@ function buildReference(site: ReferenceSite, top: Resolution): Reference { toDef: top.def.nodeId, atRange: site.atRange, 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, evidence: top.evidence, }; diff --git a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts index 016aa1a29..1832d4526 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts @@ -878,6 +878,30 @@ export interface ScopeResolver { */ 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 * receiver resolver can type an INLINE constructor receiver — the diff --git a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/references-to-edges.ts b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/references-to-edges.ts index 7b0560b22..bece02c19 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/references-to-edges.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/references-to-edges.ts @@ -6,7 +6,7 @@ * chain looking for an enclosing Function/Method/Class. * 2. Resolve `toDef` → target graph-node id via `nodeLookup`. * 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 * 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; +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: `, + * 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, + 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 * 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. */ functionLocalValueDefIds?: ReadonlySet, + options?: EmitReferencesOptions, ): { emitted: number; skipped: number } { let emitted = 0; let skipped = 0; @@ -153,7 +184,7 @@ export function emitReferencesViaLookup( targetId: targetGraphId, type: edgeType, confidence: ref.confidence, - reason: `scope-resolution: ${ref.kind}`, + reason: referenceEdgeReason(ref, options?.markConstructionSites), }); emitted++; } diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts index efbaba71d..98484c1d7 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts @@ -22,6 +22,7 @@ import type { ParameterTypeClass, ParsedFile, Reference, + ReferenceSite, ScopeId, SymbolDefinition, } from 'gitnexus-shared'; @@ -66,6 +67,9 @@ export function emitFreeCallFallback( /** When true, `Type(...)` constructor calls link to the Class def * itself rather than its explicit Constructor. Swift opts in. */ 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 isCallableVisibleFromCaller?: (ctx: { readonly callerParsed: ParsedFile; @@ -621,8 +625,13 @@ export function emitFreeCallFallback( type: 'CALLS', confidence: 0.85, // Match legacy DAG's reason convention so consumers that - // assert `reason === 'import-resolved'` keep working. - reason: fnDef.filePath !== parsed.filePath ? 'import-resolved' : 'local-call', + // assert `reason === 'import-resolved'` keep working. The + // construction-site marker is opt-in for the same reason. + reason: constructionSiteReason( + fnDef.filePath !== parsed.filePath ? 'import-resolved' : 'local-call', + site, + options.markConstructionSites, + ), }); emitted++; } @@ -630,6 +639,19 @@ export function emitFreeCallFallback( 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, + markConstructionSites: boolean | undefined, +): string { + return markConstructionSites === true && site.callForm === 'constructor' + ? `${base} (constructor)` + : base; +} + function siteKey( filePath: string, site: { readonly atRange: { readonly startLine: number; readonly startCol: number } }, diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts index fcc456d66..b7888cc8e 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts @@ -1070,6 +1070,7 @@ export function runScopeResolution( { allowGlobalFallback: provider.allowGlobalFreeCallFallback === true, constructorCallTargetsClass: provider.constructorCallTargetsClass === true, + markConstructionSites: provider.markConstructionSites === true, isFileLocalDef: provider.isFileLocalDef, isBuiltInName: provider.languageProvider.isBuiltInName, freeCallsRequireInstanceOwnership: provider.freeCallsRequireInstanceOwnership === true, @@ -1100,6 +1101,7 @@ export function runScopeResolution( // both correctly emit. See the build site above for why the earlier // allowlist could not be made safe this way. functionLocalValueDefIds, + { markConstructionSites: provider.markConstructionSites === true }, ); // Last-resort property resolution by workspace-unique name (A1/A5). Runs // after every precise pass and only sees what they left behind, so a diff --git a/gitnexus/test/integration/resolvers/zig.test.ts b/gitnexus/test/integration/resolvers/zig.test.ts index 34a344332..97b0e83be 100644 --- a/gitnexus/test/integration/resolvers/zig.test.ts +++ b/gitnexus/test/integration/resolvers/zig.test.ts @@ -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', () => { expect(idsIn('Struct', 'Sorter.zig')).toEqual([ 'Struct:src/Sorter.zig:Sorter', diff --git a/gitnexus/test/unit/scope-resolution/references-to-edges-call-form.test.ts b/gitnexus/test/unit/scope-resolution/references-to-edges-call-form.test.ts new file mode 100644 index 000000000..99cf0ad91 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/references-to-edges-call-form.test.ts @@ -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([ + ['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)', + }, + ]); + }); +});