diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 3fcd451a3..ec5659ed3 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -1341,12 +1341,26 @@ jobs: - name: Measure all languages and check graph and binding baselines run: node --import tsx bench/scope-resolution/measure.mts --check --output scope-resolution-benchmark.json working-directory: gitnexus + # #3695 probes share this runner and artifact; the language matrix runs once. + - name: Check ordinary-call signature allocation + shell: bash + run: node --expose-gc --import tsx bench/scope-resolution/call-signature-memory.mts 500000 --check | tee scope-resolution-call-signature-memory.json + working-directory: gitnexus + - name: Measure dense Java and check its graph baseline + shell: bash + env: + GITNEXUS_DEBUG_HEAP: '1' + run: node --import tsx bench/scope-resolution/dense-java.mts 300 20 10 --check 2>&1 | tee scope-resolution-dense-java.log + working-directory: gitnexus - name: Upload scope-resolution measurements if: always() uses: ./.github/actions/upload-test-artifact with: name: scope-resolution-benchmark - path: gitnexus/scope-resolution-benchmark.json + path: | + gitnexus/scope-resolution-benchmark.json + gitnexus/scope-resolution-call-signature-memory.json + gitnexus/scope-resolution-dense-java.log retention-days: 5 # Locked eval suite. setup-uv and uv itself are immutable so CI exercises diff --git a/gitnexus/bench/scope-resolution/README.md b/gitnexus/bench/scope-resolution/README.md index 862636b0f..86a1615f9 100644 --- a/gitnexus/bench/scope-resolution/README.md +++ b/gitnexus/bench/scope-resolution/README.md @@ -76,3 +76,45 @@ preparation, import-target resolution, SCCs, ambiguity checks, transitive export (only if requested), linking, wildcard expansion, bindings and workspace indexes. `sr-extract-start` precedes `sr-post-nodeLookup`; a last `sr-post-nodeLookup` marker therefore points into finalization after extraction. + +## Post-resolution memory probes (#3695) + +```sh +node --expose-gc --import tsx bench/scope-resolution/call-signature-memory.mts 500000 +GITNEXUS_DEBUG_HEAP=1 node --import tsx bench/scope-resolution/dense-java.mts 300 20 10 +``` + +CI runs both probes once with `--check` in the existing +`scope-resolution-benchmark` job, alongside its all-language measurements. +The ordinary-call check rejects any retained signatures. The dense Java check +requires the default 300/20/10 workload and its reviewed full graph fingerprint +below. Heap and timing remain measurements, without absolute thresholds. +Both outputs join the existing `scope-resolution-benchmark` artifact: the +collector's JSON and dense Java's log, including `sr-emit-*` heap checkpoints. +Omit `--check` for comparisons against older revisions or custom workloads. + +The first probe isolates ordinary calls with no indirect-call facts. Previously +the deferred-call collector retained a signature object and position key for +every call even though none could be consumed. At 500,000 calls, three fresh +processes measured a median 90.29 MiB retained overhead before the change and +0.08 MiB afterward. With `--max-old-space-size=160`, the old collector exhausted +the heap (exit 134); the updated collector completed. Input records remain live +through both measurements. This measures one allocation source, not total +indexing memory. + +The second probe uses real worker parsing and scope resolution on generated +Java: 300 files, 20 methods per file, 10 receiver calls per method. It reports +per-stage heap samples and a fingerprint of complete nodes and relationships. +Before and after the change it produced 66,300 nodes and 126,600 relationships, +with fingerprint `3209302e91e7023328464540ec7d0ac72ac03bf49fa73303817a56ed3d415ef5`. +It uses the in-memory graph helper, so its RSS is not a measurement of the CLI's +streaming graph sink. Calls pass `null` to match each receiver's parameter type; +the 300-file workload compiles with `javac --release 17`. The probe measures +analysis and does not execute the generated Java. + +`GITNEXUS_DEBUG_HEAP=1` now adds `sr-emit-*` checkpoints after receiver calls, +free calls, replay, references, property passes, callable flow, imports and +provider hooks, including elapsed emit time and graph counts. `sr-post-resolve` +marks the end of the initial registry lookup only; substantial resolution still +follows it. These markers distinguish that work on the next reporter run. +The private repository's complete OOM has not been reproduced by these probes. diff --git a/gitnexus/bench/scope-resolution/call-signature-memory.mts b/gitnexus/bench/scope-resolution/call-signature-memory.mts new file mode 100644 index 000000000..66441e39e --- /dev/null +++ b/gitnexus/bench/scope-resolution/call-signature-memory.mts @@ -0,0 +1,59 @@ +/** #3695: retained overhead for ordinary calls with no indirect invocations. + * Run: node --expose-gc --import tsx bench/scope-resolution/call-signature-memory.mts 500000 + * Compare in fresh processes on each revision; heap figures exclude the input. + */ +import assert from 'node:assert/strict'; +import { parseArgs } from 'node:util'; +import type { ParsedFile, ScopeId } from 'gitnexus-shared'; +import { collectDeferredIndirectCollection } from '../../src/core/ingestion/scope-resolution/passes/callable-value-flow.js'; + +const { values, positionals } = parseArgs({ + options: { check: { type: 'boolean', default: false } }, + allowPositionals: true, +}); +assert.ok(positionals.length <= 1, 'Expected at most one call count'); +const total = Number(positionals[0] ?? 500_000); +assert.ok(Number.isSafeInteger(total) && total > 0); +assert.ok(global.gc, 'Run with --expose-gc to measure retained heap'); +const callsPerFile = 1_000; +const files: ParsedFile[] = Array.from({ length: Math.ceil(total / callsPerFile) }, (_, file) => ({ + filePath: `src/main/java/example/services/Service${file}.java`, + moduleScope: `scope:${file}` as ScopeId, + scopes: [], + localDefs: [], + parsedImports: [], + callableFlowSites: [], + referenceSites: Array.from( + { length: Math.min(callsPerFile, total - file * callsPerFile) }, + (_, line) => ({ + kind: 'call', + name: 'save', + inScope: `file${file}:method${line}` as ScopeId, + atRange: { startLine: line + 1, startCol: 4, endLine: line + 1, endCol: 16 }, + arity: 0, + }), + ), +})); +global.gc(); +const before = process.memoryUsage().heapUsed; +const started = performance.now(); +const result = collectDeferredIndirectCollection(files); +const elapsedMs = performance.now() - started; +const after = process.memoryUsage().heapUsed; +global.gc(); +const retained = process.memoryUsage().heapUsed; +assert.equal(result.sites.size, 0); +console.log( + JSON.stringify({ + totalCallSites: total, + signaturesAllocated: result.callSignaturesBySite.size, + elapsedMs, + endpointHeapGrowthMiB: (after - before) / 2 ** 20, + retainedHeapGrowthMiB: (retained - before) / 2 ** 20, + peakRssMiB: process.resourceUsage().maxRSS / 1024, + inputStillLive: files.reduce((n, file) => n + file.referenceSites.length, 0), + }), +); +if (values.check) { + assert.equal(result.callSignaturesBySite.size, 0, 'Ordinary calls retained unused signatures'); +} diff --git a/gitnexus/bench/scope-resolution/dense-java.mts b/gitnexus/bench/scope-resolution/dense-java.mts new file mode 100644 index 000000000..d33b11c96 --- /dev/null +++ b/gitnexus/bench/scope-resolution/dense-java.mts @@ -0,0 +1,94 @@ +/** Dense Java workload for investigating #3695; run in a fresh process. */ +import { createHash } from 'node:crypto'; +import assert from 'node:assert/strict'; +import { parseArgs } from 'node:util'; +import { SupportedLanguages } from 'gitnexus-shared'; +import { parseFilesWithWorkers } from '../../test/helpers/worker-parse.js'; +import { SCOPE_RESOLVERS } from '../../src/core/ingestion/scope-resolution/pipeline/registry.js'; +import { runScopeResolution } from '../../src/core/ingestion/scope-resolution/pipeline/run.js'; + +const { values, positionals } = parseArgs({ + options: { check: { type: 'boolean', default: false } }, + allowPositionals: true, +}); +assert.ok(positionals.length <= 3, 'Expected file, method and call counts'); +const count = Number(positionals[0] ?? 300); +const methods = Number(positionals[1] ?? 20); +const calls = Number(positionals[2] ?? 10); +assert.ok([count, methods, calls].every((value) => Number.isSafeInteger(value) && value > 0)); +assert.ok(count >= 2, 'Use at least two files for cross-file calls'); +if (values.check) { + assert.deepEqual( + [count, methods, calls], + [300, 20, 10], + '--check requires the baseline workload', + ); +} +// Each receiver takes the next class in the ring; null keeps calls type-correct. +const files = Array.from({ length: count }, (_, i) => ({ + path: `src/p${i}/Service${i}.java`, + content: `package p${i}; +import p${(i + 1) % count}.Service${(i + 1) % count}; +public class Service${i} { +${Array.from( + { length: methods }, + (_, m) => ` + public Service${i} method${m}(Service${(i + 1) % count} other) { +${Array.from({ length: calls }, (_, c) => ` var value${c} = other.method${(m + c) % methods}(null);`).join('\n')} + return this; + }`, +).join('\n')} +}`, +})); +const parsed = await parseFilesWithWorkers(files, { poolSize: 1 }); +const counts: Record = {}; +for (const file of parsed.data.parsedFiles) { + for (const fact of file.callableFlowSites ?? []) counts[fact.kind] = (counts[fact.kind] ?? 0) + 1; + counts.callResults = (counts.callResults ?? 0) + (file.callResultAssignmentSites?.length ?? 0); +} +console.log( + 'DENSE_INPUT', + JSON.stringify({ files: files.length, counts, memory: process.memoryUsage() }), +); +const started = performance.now(); +runScopeResolution( + { + graph: parsed.graph, + model: parsed.model, + files, + preExtractedParsedFiles: new Map(parsed.data.parsedFiles.map((file) => [file.filePath, file])), + }, + SCOPE_RESOLVERS.get(SupportedLanguages.Java)!, +); +const ended = performance.now(); +const memory = process.memoryUsage(); +assert.equal(parsed.data.parsedFiles.length, files.length, 'Scope extraction omitted files'); +assert.ok([...parsed.graph.iterRelationshipsByType('CALLS')].length > 0, 'No calls resolved'); +const nodes = [...parsed.graph.iterNodes()].map((n) => JSON.stringify(n)); +const edges = [...parsed.graph.iterRelationships()].map((e) => JSON.stringify(e)); +const peakRssBytes = process.resourceUsage().maxRSS * 1024; +const fingerprint = createHash('sha256') + .update([...nodes, ...edges].sort().join('\n')) + .digest('hex'); +console.log( + 'DENSE_RESULT', + JSON.stringify({ + count, + methods, + calls, + resolveMs: ended - started, + memory, + peakRssBytes, + nodes: nodes.length, + edges: edges.length, + fingerprint, + }), +); +if (values.check) { + // Reviewed before/after #3695: 66,300 nodes and 126,600 relationships. + assert.equal( + fingerprint, + '3209302e91e7023328464540ec7d0ac72ac03bf49fa73303817a56ed3d415ef5', + 'Dense Java graph differs from the reviewed baseline', + ); +} diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/callable-value-flow.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/callable-value-flow.ts index 8e0f23a31..d6d384b0b 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/callable-value-flow.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/callable-value-flow.ts @@ -123,13 +123,21 @@ export function collectDeferredIndirectSites( return collectDeferredIndirectCollection(parsedFiles, scopes).sites; } -/** One `referenceSites` walk for deferred keys and call-signature evidence. */ +/** Collect signatures only for argument facts consumed by indirect-call flow. */ export function collectDeferredIndirectCollection( parsedFiles: readonly ParsedFile[], scopes?: ScopeResolutionIndexes, ): DeferredIndirectCollection { const out = new Set(); const callSignaturesBySite = new Map(); + // Ordinary methods also produce formal/argument facts. Without an invoke + // there is no indirect edge to resolve; indexing every direct call here kept + // an otherwise unused signature object and position string per call (#3695). + if ( + !parsedFiles.some((parsed) => parsed.callableFlowSites?.some((site) => site.kind === 'invoke')) + ) { + return { sites: out, callSignaturesBySite }; + } const flowCells = new Set(); if (scopes !== undefined) { for (const parsed of parsedFiles) { @@ -144,11 +152,21 @@ export function collectDeferredIndirectCollection( } } for (const parsed of parsedFiles) { + const invokeKeys = new Set(); + const argumentKeys = new Set(); + for (const site of parsed.callableFlowSites ?? []) { + if (site.kind === 'invoke') + invokeKeys.add(callableFlowSiteKey(parsed.filePath, site.callSite)); + if (site.kind === 'argument') + argumentKeys.add(callableFlowSiteKey(parsed.filePath, site.callSite)); + } + if (invokeKeys.size === 0 && argumentKeys.size === 0) continue; const canonical = new Set(); for (const site of parsed.referenceSites) { if (site.kind !== 'call') continue; const key = callableFlowSiteKey(parsed.filePath, site.atRange); - canonical.add(key); + if (invokeKeys.has(key)) canonical.add(key); + if (!argumentKeys.has(key)) continue; const signature: CallableFlowExpectedSignature = { ...(site.arity !== undefined ? { parameterCount: site.arity } : {}), ...(site.argumentTypes !== undefined ? { parameterTypes: site.argumentTypes } : {}), @@ -175,6 +193,7 @@ export function collectDeferredIndirectCollection( } } } + if (out.size === 0) callSignaturesBySite.clear(); return { sites: out, callSignaturesBySite }; } @@ -213,7 +232,9 @@ export function emitCallableValueFlow(input: EmitCallableValueFlowInput): Callab } } } - if (facts.length === 0 && invokes.length === 0) { + // Facts propagate values only to the fixed set of canonical invokes. If it + // is empty, building whole-workspace callable indexes cannot emit an edge. + if (invokes.length === 0) { return { emitted: 0, resolvedInvokes: 0, ambiguousInvokes: 0, unmatchedInvokes, iterations: 0 }; } diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts index f45f84cf7..1ca5d7347 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts @@ -125,7 +125,7 @@ import { buildWorkspaceResolutionIndex } from '../workspace-index.js'; import { narrowOverloadCandidates } from '../passes/overload-narrowing.js'; import type { ResolutionOutcome, ResolutionOutcomeRecorder } from '../resolution-outcome.js'; import { withdrawAnsweredReceiverDrops } from '../resolution-outcome.js'; -import { logHeapProbe } from '../../utils/heap-probe.js'; +import { isDebugHeapEnabled, logHeapProbe } from '../../utils/heap-probe.js'; import { parseTruthyEnv } from '../../utils/env.js'; /** @@ -1229,6 +1229,23 @@ export function runScopeResolution( const tResolve = PROF ? process.hrtime.bigint() : 0n; logHeapProbe('sr-post-resolve', `lang=${provider.language}`); + const emitStarted = Date.now(); + const probeEmit = (stage: string, detail = ''): void => { + if (!isDebugHeapEnabled()) return; + logHeapProbe( + `sr-emit-${stage}`, + `lang=${provider.language} elapsed_ms=${Date.now() - emitStarted}` + + ` nodes=${graph.nodeCount} edges=${graph.relationshipCount} ${detail}`, + ); + }; + if (isDebugHeapEnabled()) { + probeEmit( + 'start', + `references=${indexes.referenceSites.length}` + + ` callableFacts=${parsedFiles.reduce((total, parsed) => total + (parsed.callableFlowSites?.length ?? 0), 0)}`, + ); + } + // Value defs bound at MODULE LEVEL. A read of a block-local `const` must not // mint an edge — that would retain the inert locals `pruneLocalSymbols` drops. // @@ -1299,6 +1316,7 @@ export function runScopeResolution( } } + probeEmit('locals-end', `localValues=${functionLocalValueDefIds.size}`); // ── Out-of-core scope seal boundary ───────────────────────────────────── // Pass-A (finalize + propagate + resolve) is done; all whole-language reads // of `Scope.bindings` are behind us. Emit reaches scopes ONLY via @@ -1330,6 +1348,10 @@ export function runScopeResolution( const handledSites = new Set(preEmittedInheritanceSites); const deferredIndirectCollection = collectDeferredIndirectCollection(emitParsedFiles, indexes); const deferredIndirectSites = deferredIndirectCollection.sites; + probeEmit( + 'indirect-collection-end', + `deferred=${deferredIndirectSites.size} signatures=${deferredIndirectCollection.callSignaturesBySite.size}`, + ); const callableArgumentSites = new Set(); const callResultAssignmentSites = new Set(); for (const parsed of emitParsedFiles) { @@ -1395,6 +1417,7 @@ export function runScopeResolution( unresolvedInheritanceByClass, }, ); + probeEmit('receiver-end', `handled=${handledSites.size} emitted=${receiverBound.emitted}`); let receiverExtras = receiverBound.emitted; if (receiverBound.dispatchFanoutSkipped > 0) { // Never drop dispatch coverage silently (#2829) — same contract as the @@ -1462,6 +1485,7 @@ export function runScopeResolution( skipSites: deferredIndirectSites, }, ); + probeEmit('free-calls-end', `handled=${handledSites.size} emitted=${freeCallExtras}`); const replayedCallResultBindings = callableFlowOnly || calleeIdAccumulator === undefined ? 0 @@ -1526,7 +1550,13 @@ export function runScopeResolution( } } } - const referenceSkipSites = new Set(handledSites); + // Receiver/free-call passes have finished reading this set. Extend it for + // the remaining passes instead of retaining three whole-program copies. + const referenceSkipSites = handledSites; + probeEmit( + 'replay-end', + `handled=${handledSites.size} replayedBindings=${replayedCallResultBindings}`, + ); for (const key of deferredIndirectSites) referenceSkipSites.add(key); const { emitted, skipped } = callableFlowOnly ? { emitted: 0, skipped: 0 } @@ -1551,7 +1581,8 @@ export function runScopeResolution( // first-write-wins per edge id, which stops a DUPLICATE but not a second // edge to a DIFFERENT target, and second-guessing a resolved receiver is // exactly the wrong-edge-in-the-safety-gate case this must not create. - const uniqueNameSkipSites = new Set(referenceSkipSites); + const uniqueNameSkipSites = referenceSkipSites; + probeEmit('references-end', `emitted=${emitted} skipped=${skipped}`); for (const [fromScope, refs] of referenceIndex.bySourceScope) { const fromFilePath = indexes.scopeTree.getScope(fromScope)?.filePath; if (fromFilePath === undefined) continue; @@ -1589,6 +1620,7 @@ export function runScopeResolution( // several functions returning the same field name. Sites it resolves are // added to the skip set, so the fallback below never second-guesses them. const sharedPropertyIndex = input.prebuiltPropertyNameIndex ?? buildPropertyNameIndex(graph); + probeEmit('imported-values-end', `skipSites=${uniqueNameSkipSites.size}`); const answeredAfterReceiverPass = new Set(); const returnShapeMembers = callableFlowOnly ? { emitted: 0, memberNotOnShape: 0 } @@ -1625,6 +1657,7 @@ export function runScopeResolution( answeredAfterReceiverPass, ); withdrawAnsweredReceiverDrops(resolutionOutcomes, answeredAfterReceiverPass); + probeEmit('properties-end', `outcomes=${resolutionOutcomes.length}`); // value-ref registrations (#2437): USES edges at the registration sites // plus field-based dispatch — synthesized CALLS from member-call sites to @@ -1668,6 +1701,7 @@ export function runScopeResolution( 'property-dispatch: keys over the fan-out cap were dropped (no CALLS synthesized for them)', ); } + probeEmit('property-dispatch-end'); const callableValueFlow = calleeIdAccumulator === undefined ? { @@ -1697,9 +1731,11 @@ export function runScopeResolution( 'callable-value-flow: candidate set exceeded the cap; no partial CALLS emitted', ), }); + probeEmit('callable-flow-end', `iterations=${callableValueFlow.iterations}`); const importsEmitted = callableFlowOnly ? 0 : emitImportEdges(graph, indexes.imports, indexes.scopeTree, provider.importEdgeReason); + probeEmit('imports-end', `emitted=${importsEmitted}`); // Language-specific supplementary edges (e.g. Vue template-derived // BINDS_EVENT_HANDLER / EMITS_EVENT / CALLS / ACCESSES edges). @@ -1714,6 +1750,7 @@ export function runScopeResolution( recordSourceBinding, }); } + probeEmit('provider-end'); // ── CFG/PDG emission (#2081 M1, opt-in via `--pdg`) ────────────────────── // Emit BasicBlock nodes + CFG edges from each ParsedFile's worker-built diff --git a/gitnexus/test/unit/scope-resolution/callable-value-flow-worklist.test.ts b/gitnexus/test/unit/scope-resolution/callable-value-flow-worklist.test.ts index 9de8ac5f3..03d72c83c 100644 --- a/gitnexus/test/unit/scope-resolution/callable-value-flow-worklist.test.ts +++ b/gitnexus/test/unit/scope-resolution/callable-value-flow-worklist.test.ts @@ -206,26 +206,131 @@ describe('callable-value-flow dependency worklist', () => { }); describe('collectDeferredIndirectCollection', () => { - it('fills call signatures from the first referenceSites walk', () => { + it('retains no per-call signature index when there are no indirect invocations', () => { const parsed: ParsedFile = { filePath: FILE, moduleScope: MODULE, scopes: [], parsedImports: [], localDefs: [], - referenceSites: [ - { - name: 'target', - kind: 'call', - callForm: 'free', - atRange: range(1), - inScope: MODULE, - arity: 0, - }, - ], + referenceSites: Array.from({ length: 10_000 }, (_, line) => ({ + name: 'target', + kind: 'call' as const, + callForm: 'free' as const, + atRange: range(line + 1), + inScope: MODULE, + arity: 0, + })), callableFlowSites: [], }; const collected = collectDeferredIndirectCollection([parsed]); - expect(collected.callSignaturesBySite.get(`${FILE}:1:0`)).toEqual({ parameterCount: 0 }); + expect(collected.sites.size).toBe(0); + expect(collected.callSignaturesBySite.size).toBe(0); + }); + + it('keeps direct-call argument signatures across files when an indirect invoke needs them', () => { + const direct: ParsedFile = { + filePath: 'caller.ts', + moduleScope: MODULE, + scopes: [], + parsedImports: [], + localDefs: [], + referenceSites: [ + { name: 'register', kind: 'call', atRange: range(1), inScope: MODULE, arity: 1 }, + { name: 'unrelated', kind: 'call', atRange: range(2), inScope: MODULE, arity: 0 }, + // Multiple captures at one call keep the richest overload evidence. + { + name: 'register', + kind: 'call', + atRange: range(1), + inScope: MODULE, + arity: 1, + argumentTypes: ['Handler'], + }, + ], + callableFlowSites: [ + { + kind: 'argument', + callSite: range(1), + parameterIndex: 0, + source: operand('handler', 1), + directCalleeName: 'register', + }, + ], + }; + const indirect: ParsedFile = { + filePath: FILE, + moduleScope: MODULE, + scopes: [], + parsedImports: [], + localDefs: [], + referenceSites: [{ name: 'callback', kind: 'call', atRange: range(3), inScope: ENTRY_SCOPE }], + callableFlowSites: [ + { + kind: 'invoke', + callSite: range(3), + inScope: ENTRY_SCOPE, + callee: operand('callback', 3), + invocationKind: 'indirect', + arity: 0, + }, + ], + }; + const collected = collectDeferredIndirectCollection([direct, indirect]); + expect([...collected.sites]).toEqual([`${FILE}:3:0`]); + expect([...collected.callSignaturesBySite]).toEqual([ + ['caller.ts:1:0', { parameterCount: 1, parameterTypes: ['Handler'] }], + ]); + const unmatched = collectDeferredIndirectCollection([ + direct, + { ...indirect, referenceSites: [] }, + ]); + expect(unmatched.sites.size).toBe(0); + expect(unmatched.callSignaturesBySite.size).toBe(0); + }); + + it('skips the callable solver for ordinary facts and reports unmatched invokes', () => { + const graph = createKnowledgeGraph(); + const result = emitCallableValueFlow({ + graph, + scopes: indexes([]), + nodeLookup: buildGraphNodeLookup(graph), + calleeIds: createCalleeIdAccumulator(), + language: 'typescript', + parsedFiles: [ + { + filePath: FILE, + moduleScope: MODULE, + scopes: [], + parsedImports: [], + localDefs: [], + referenceSites: [], + callableFlowSites: [ + { + kind: 'seed', + destination: operand('unused', 1), + targetName: 'target', + targetRange: range(1), + }, + { + kind: 'invoke', + callSite: range(3), + inScope: ENTRY_SCOPE, + callee: operand('stale', 3), + invocationKind: 'indirect', + arity: 0, + }, + ], + }, + ], + }); + expect(result).toEqual({ + emitted: 0, + resolvedInvokes: 0, + ambiguousInvokes: 0, + unmatchedInvokes: 1, + iterations: 0, + }); + expect(graph.relationshipCount).toBe(0); }); });