mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-11-11 08:37:35 +00:00
perf(scope): reduce post-resolution memory overhead (#3766)
* perf(scope): trim post-resolution memory Skip signatures and solver work without indirect consumers. Reuse emit skip sets and add per-pass heap probes. Refs #3695 * ci(scope): gate post-resolution memory probes Refs #3695. * test(scope): make dense Java calls type-correct Refs #3695. --------- Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
This commit is contained in:
parent
136003377a
commit
0c7e775aad
7 changed files with 391 additions and 19 deletions
16
.github/workflows/ci-tests.yml
vendored
16
.github/workflows/ci-tests.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
59
gitnexus/bench/scope-resolution/call-signature-memory.mts
Normal file
59
gitnexus/bench/scope-resolution/call-signature-memory.mts
Normal file
|
|
@ -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');
|
||||
}
|
||||
94
gitnexus/bench/scope-resolution/dense-java.mts
Normal file
94
gitnexus/bench/scope-resolution/dense-java.mts
Normal file
|
|
@ -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<string, number> = {};
|
||||
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',
|
||||
);
|
||||
}
|
||||
|
|
@ -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<string>();
|
||||
const callSignaturesBySite = new Map<string, CallableFlowExpectedSignature>();
|
||||
// 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<string>();
|
||||
if (scopes !== undefined) {
|
||||
for (const parsed of parsedFiles) {
|
||||
|
|
@ -144,11 +152,21 @@ export function collectDeferredIndirectCollection(
|
|||
}
|
||||
}
|
||||
for (const parsed of parsedFiles) {
|
||||
const invokeKeys = new Set<string>();
|
||||
const argumentKeys = new Set<string>();
|
||||
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<string>();
|
||||
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 };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string>(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<string>();
|
||||
const callResultAssignmentSites = new Set<string>();
|
||||
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<string>();
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue