GitNexus/gitnexus/test/unit/parsedfile-store.test.ts
Gergő Magyar ed8ab1c246
Some checks are pending
CodeQL / Analyze (python) (push) Waiting to run
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
fix(scope-resolution): resolve callable reference flows (#2437) (#2522)
* docs(plans): add provider-hook value-refs plan (#2437)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(plans): deepen #2437 plan to USES + property-dispatch design

Design revised after prior-art research (Kythe ref vs ref/call, Joern
METHOD_REF, Feldthaus field-based call graphs, CodeQL impliedReceiverStep):
registration sites emit reference-class USES, invocation is recovered by a
field-based property-dispatch pass synthesizing CALLS at member-call sites.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(scope-resolution): model provider-hook value references (#2437)

Functions referenced as object-literal property values (provider hooks like
emitScopeCaptures: emitCppScopeCaptures) previously produced no edge at all,
so impact/context reported a false-safe 0 upstream dependents.

Two coordinated halves, per prior art (Kythe ref vs ref/call, Joern
METHOD_REF, Feldthaus ICSE'13 field-based call graphs, CodeQL
impliedReceiverStep):

- Registration -> USES: new ReferenceKind 'value-ref'; TS/JS queries capture
  pair values and shorthand properties (with @reference.property-key);
  emitted as a reference-class USES edge, reason 'scope-resolution:
  value-ref'. Resolution is callable-gated so plain values emit nothing.
- Dispatch -> CALLS: new shared pass emitPropertyDispatchCalls synthesizes
  CALLS (reason 'property-dispatch', confidence 0.7, per-key fan-out cap 32
  calibrated on this repo's 16-provider hook tables) from member-call sites
  to every function registered under the same property key.

Deviation from plan: the pass owns value-ref resolution entirely via the
post-finalize findCallableBindingInScope walker — the shared registries only
see pre-finalize local bindings, so imported hooks (the c-cpp.ts case) were
unresolvable through lookupForSite; Reference.propertyKey passthrough
dropped as unnecessary.

SCHEMA_BUMP 13 -> 14: ParsedFile gains value-ref sites + propertyKey.

Verified end-to-end: impact(emitCppScopeCaptures, upstream) now reports 8
impacted / HIGH with extractParsedFile (true dispatch caller) at d=1 via
property-dispatch and the c-cpp.ts registration via USES.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(scope-resolution): cover value-ref registration and property dispatch (#2437)

Integration: same-file/cross-file/aliased/shorthand registrations emit USES;
non-callable and destructuring values emit nothing; dispatch sites gain
property-dispatch CALLS (incl. JS twins and per-language partitioning);
fan-out-capped keys are dropped entirely; factory-call values unchanged.
Unit: capture-shape pins for @reference.value-ref + @reference.property-key.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(scope-resolution): surface dropped property-dispatch keys in stats (#2437)

Review finding: skippedKeys was returned but discarded — a hook table
larger than the fan-out cap silently reopened the #2437 gap for those
keys. Log dropped keys and fold value-ref USES + dispatch CALLS into
referenceEdgesEmitted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(plans): add callable reference-flow implementation plan

* fix(scope-resolution): close property-dispatch review gaps

* feat(scope-resolution): add callable flow facts

* feat(scope-resolution): resolve callable value flow

* feat(scope-resolution): resolve callable references across providers

* fix: harden callable reference flow resolution

* fix(scope-resolution): preserve callable binding semantics

* docs(plans): add pr-2522-review-fixes plan

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(storage): bump INCREMENTAL_SCHEMA_VERSION for callable-value-flow edges

Callable-value-flow CALLS/USES edges (#2437) can connect two files whose
content did not change, but the incremental write set only covers changed
files — a top-up against a pre-v7 index would silently omit the new edges
for every unchanged file pair, indefinitely. Force the one-time full
re-analyze (review finding 1, #2522).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(storage): sanitize callable-flow sites per-site at load, log drops

The load-time validator rejected the WHOLE ParsedFile when one site was
malformed or over-bound, with no logging — and C++ legitimately emits
empty-string parameterTypes entries ('' = unknown, the
ReferenceSite.argumentTypes convention) for cv-only/ERROR-recovered types,
so real repos fell into a permanent, silent warm-cache-miss reparse loop
through the #1983-sensitive main-thread path (review finding 7, #2522).

Now: '' entries are valid in type arrays; a malformed/over-bound site drops
only itself (counted, warned once per load); only non-array garbage —
evidence the serialization itself is untrustworthy — rejects the file.
Deviation from plan §6 wording: validator-side tolerance replaces emit-side
clamps — smaller diff, same asymmetry closed at the single chokepoint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(scope-resolution): keep declarations in the union for reassigned callable cells

The binding-lookup suppression for fact-constrained cells was wholesale:
reassigning a declared function through its own name (greet = other;
greet()) deferred the call to the solver, which then refused the lexical
lookup that resolves the declaration — an unresolvable RHS yielded zero
CALLS for a call that resolved pre-flow (review finding 8, #2522).

Suppression now applies only to cells bound by FORMAL facts — its actual
purpose (a parameter whose grammar emits no declaration binding must not
adopt a same-named outer function). Copy/alias/store/load destinations keep
their declaration as an inclusion seed (Andersen-style union).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(scope-resolution): count forfeited deferred sites in the budget-bailout warning

On work-budget exhaustion the deferred invoke sites end the run with zero
CALLS — free-call fallback and reference emission already skipped them —
but the warning said 'ordinary graph emission remains untouched', which is
false for exactly those sites. The warning context now carries the
unresolved deferred-site count and the comment states the real cost
(review finding: budget-bailout honesty, #2522).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(scope-resolution): surface dropped property-dispatch keys in stats and warn payload

The over-cap warning carried only a count; the dropped key NAMES were
discarded and RunScopeResolutionStats had no field, so the PR-body claim
'includes them in resolver statistics' was unimplemented (review finding,
#2522; reviewer ask on the fan-out cap). The warn payload now names up to
20 dropped keys and the stats carry propertyDispatchSkippedKeys.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(scope-resolution): drop producer-less ownerQualifiedName from formal sites

No capture emitter anywhere produces @callable-flow.owner-qualified-name —
the solver branch consuming it was unreachable in production, yet the field
was typed, parsed, validated, and unit-tested with hand-built input (review
finding 16, #2522; YAGNI). Re-add with a real producer if C++ qualified
member declarators ever need it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(scope-resolution): drop dead callable-flow knobs

CallableFlowPassingMode 'callable-object' had no producer and no consumer
distinguishing it, and CallableFlowCaptureOptions.extractCallArguments had
no language providing it (unlike its live sibling extractCallCallee) —
review finding 17, #2522 (YAGNI). The invocation-kind 'callable-object'
is a different, live concept and stays.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ingestion): bind subscripted callable cells to the container, not the index

terminalIdentifier iterates children in reverse, so tbl[i] = handler seeded
the INDEX variable's cell (polluting a same-named formal) and tbl[i](7)
looked up the callee under i in a different scope — no join, no CALLS edge
for the classic function-pointer-array dispatch (review finding 12, #2522).
Subscript nodes now recurse into their container field only, in both
bindingIdentifier and terminalIdentifier, across the fielded grammars
(C/C++/JS/TS/Python/Go/Java).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ingestion): make cross-function file-scope callable bindings resolvable

Two stacked gaps killed the canonical C callback-registration pattern
(fp assigned in init(), called in run()) — the exact #2437 false-safe this
PR exists to fix (review finding H1, #2522):

1. isVisibleValueBinding only consulted assignment regions and formals, so
   a call in a function OTHER than the assigning one emitted no invoke
   fact. A declared callable-typed binding is now a value binding wherever
   its declaration is visible (visibleCallableSignature).
2. The C scope query had no @declaration.variable pattern for function-
   pointer declarators — void (*fp)(int); created no scope-tree binding,
   so the seed (init) and invoke (run) cells canonicalized to different
   keys and never joined. Both bare and initialized forms now bind.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(c): detect variadic parameters via the named variadic_parameter node

tree-sitter-c materializes '...' as a named variadic_parameter node; the
anonymous-token checks never matched, so variadic function-pointer
signatures were emitted with a wrong fixed arity and no '...' sentinel
(review finding, #2522). C++ is unaffected ('...' stays an anonymous token
there); the token checks remain for such grammars.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ingestion): emit invoke facts for field-stored callable member calls

The C ops-vtable pattern (o->run = handler; o->run(1)) captured the store
but never the call — the member path in emitCallFacts bailed for languages
without protocol methods, and the value-binding index recorded the member
store under the OBJECT's name ('o'), not the member's ('run') (review
finding 11/M3, #2522). Member destinations now also record their terminal
member name, and a member call whose name-cell has a visible store emits an
indirect invoke — gated on the store so plain accessor calls (map.get)
stay inert.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(cpp): disambiguate (obj->*ptr)() ERROR recovery by token order

tree-sitter-cpp groups the recovered '->*' two ways depending on
error-recovery cost (identifier lengths): [identifier, ERROR '->*m'] or
[ERROR 'obj->*', identifier]. The recovery assumed the first shape, so the
second silently swapped receiver/member and dropped the call site — the
committed test passed only by name luck (review finding H2, #2522). The
identifier's position relative to '->*' inside the ERROR now decides roles.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(cpp): class members are never file-local in hasFileLocalCallableLinkage

The name-keyed file-local set is populated from every static declaration,
so an in-class 'static void make();' (external linkage — in-class static
means no-instance) and any member sharing a name with a static free
function were over-marked, refusing legitimate cross-file
declaration/definition joins (review finding 13/M2, #2522). Method and
Constructor defs now bypass the name-set, per the hook's own linkage-only
contract.

Deviation from plan step 13: the regression is a unit-level contract pin
rather than an end-to-end join test — C++ merges out-of-line member
definitions onto the member node by qualified identity, so the graph shape
cannot discriminate the join refusal for members.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(cpp): classify parameter passing mode from the declarator chain only

A whole-subtree scan for reference_declarator inverted copy vs alias:
void reg(void (*cb)(int& out)) marked the by-value pointer cb as
'reference' because of the NESTED parameter's int&, making the solver
back-propagate formal targets into every caller's argument cell — alias
semantics for a copy (review finding 14/M5, #2522). The chain walk never
descends into nested parameter lists; a reference anywhere ON the chain
(int& x, void (*&cb)(int)) still aliases.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ruby): bare identifiers are calls, not callable references

Ruby parses a receiver-less zero-arg method call identically to a variable
read, so 'action = process' — which CALLS process and stores its return —
seeded action with the callable and minted a wrong CALLS edge from any
dispatch through it, confirmed end-to-end (review finding 15/HIGH, #2522).
New provider knob bareNamesAreCalls: a bare name that is not a provably
local value binding and not an explicit reference form (method(:x),
lambda/proc) emits no flow fact, on both the assignment and argument paths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(go): pair multi-value := positionally instead of cross-wiring

The shared field fallback took the FIRST LHS identifier and the LAST RHS
identifier of Go's expression_list pair, cross-wiring 'a, b := f, g' and
synthesizing a garbage comma-joined qualified name — the real relationships
were silently dropped (review finding 16, #2522). extractAssignment may now
return multiple pairs; Go pairs list entries positionally and emits nothing
for a length mismatch (multi-return call RHS).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(java): drop get/test from callableProtocolMethods

'get' and 'test' collide with ubiquitous non-functional-interface APIs
(Map/List/Optional/Future.get), so every ordinary container access emitted
a spurious callable-object invoke fact — high-volume misleading graph facts
with a cross-wiring risk on receiver-name reuse (review finding 17, #2522).
Supplier.get/Predicate.test dispatch is deliberately traded away until the
check can gate on the receiver's declared type.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(rust): pin the qualified-name no-degrade guard as a hard invariant

Rust's scoped_identifier callable-reference capture over-includes unit enum
variants and associated constants (Shape::Square seeds as if callable);
they stay edge-free only because resolveSeedCandidates refuses to degrade
an unresolved qualified name to a simple-name lookup (review finding 18,
#2522). Capture-side type filtering would false-negative on tuple-variant
constructors, so the guard IS the contract: documented as a hard invariant
(Go's mis-shaped multi-value forms also rely on it) and pinned end-to-end.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(php): remove nonexistent optional_parameter node type

tree-sitter-php has no 'optional_parameter' — defaults ride on
simple_parameter — so the entry was dead weight the #1920 literal gate
does not cover for capture-option Sets (review finding 19, #2522).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(cobol): detect procedure pointers on fixed-format sources

Two stacked defects made the feature a no-op on classic sequence-numbered
fixed format (review finding 20/H3, #2522):
1. parseDataItemClauses' USAGE alternation knew POINTER but not
   PROCEDURE-POINTER/FUNCTION-POINTER, so the dataItems filter was dead.
2. The raw-line fallback scanned UNCLEANED text, where the sequence number
   satisfied the leading digits and the LEVEL NUMBER got captured as the
   pointer name. It now scans preprocessed lines and requires a letter-
   initial name (COBOL data names must contain a letter).
161 COBOL preprocessor/copy-expander tests stay green; free-format matrix
case unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(cobol): skip comment lines in SET seed/copy scans

A commented-out SET (indicator-column '*'/'/' or free-format '*>')
produced a live seed and a false CALLS edge from dead code (review
finding 21/M1, #2522). The scan now skips indicator-column comment lines
and strips inline '*>' tails before matching.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(architecture): document callable-flow-only mode and skipped-key reporting

The Callable-value flow section omitted scopeResolutionEdgeMode:
'callable-flow-only' — a real emit-pipeline branch that suppresses all
ordinary emission for standalone providers (review finding 22, #2522) —
and predated the skipped-key names/stats surfacing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(scope-resolution): correct value-ref resolution attribution and stale pdg-gating comments

The value-ref contract comment claimed MethodRegistry resolution — the
mechanism is the post-finalize findCallableBindingInScope walker owned by
emitPropertyDispatchCalls (resolveReferenceSites skips these sites). Three
'only under --pdg' calleeIdSink comments were falsified by the #2437 gating
change (callee-id-sink.ts's header was updated; these copies were missed).
Review finding 23, #2522.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(ingestion): direct unit coverage for synthesizeCallableFlowCaptures

The 1,100-line shared synthesizer had no test naming it — only downstream
consumers were covered (review finding 24, #2522). Pins seed/invoke/
formal/argument emission, subscript container binding, store-gated member
invokes, produced-value guards, and the bareNamesAreCalls knob over a
minimal options object so assertions target the synthesizer's own
semantics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(resolvers): deepen shallow-language coverage; fix Kotlin/Swift reassignment gaps it exposed

Adds the COBOL SET x TO y copy-branch scenario and conditional-assignment
scenarios for Kotlin, C#, Swift, and Dart (10 languages previously had one
generic case each — review finding 25, #2522). The new scenarios exposed
two real capture gaps, fixed here:
- tree-sitter-kotlin's 'assignment' node is fieldless, so nested
  reassignments (chosen = ::target inside a block) produced no flow facts;
  Kotlin's extractAssignment now decomposes it positionally.
- tree-sitter-swift fields its assignment as target:/result:, neither in
  the shared fallback's field lists; both added.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(infra): literal-validation gate for callable-capture option Sets

The #1920 gate validates query literals and exported configs but not the
module-private *_CALLABLE_CAPTURE_OPTIONS Sets consumed by the shared
synthesizer — a typo'd node type silently captures nothing (PHP shipped a
dead 'optional_parameter'; review finding 26, #2522). Every <key>NodeTypes
Set literal is now validated against its language's grammar; name-carrying
sets (callableProtocolMethods, memberPointerOperators) are deliberately
outside the contract.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(storage): centralize corrupt-fixture casts into makeStoreEntry

The callable-flow store tests scattered 'as unknown as' double-casts per
fixture (review finding 27, #2522; standing no-as-any rule). One typed
helper now owns the single controlled escape hatch for building malformed
serialization-boundary payloads.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(bench): refresh capture fingerprints after review fixes

python-scope: the committed baseline (8d5c3699) never matched this
branch's code — CI's benchmarks arm was red on the PR head (review
finding 2/HIGH, #2522); regenerated (a99e69ab), scaling 1.04 in budget.
scope-capture: ruby/cpp/swift/java/kotlin drifted from the review-fix
commits (bare-name suppression, passing modes + ->* recovery, assignment
fields, protocol narrowing, positional assignment); all 14 languages
re-verified PASS with ratios <= 1.18 against the 1.5 budget.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(docs): untrack docs/plans working documents

docs/ is gitignored (local working docs); the plan files were force-added
past the ignore. Untracked from the index only — they stay on disk.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(golden): regenerate captures goldens after callable-flow review fixes

The per-language digest guards (csharp/go/php/python/ruby/rust/swift)
locked the pre-fix capture output; the review-fix series intentionally
changed it — store-gated member invokes, subscript container binding,
Ruby bare-name suppression, Swift assignment fields, positional pairing.
Regenerated with UPDATE_GOLDEN=1; clean verification run 59/59; all other
parity/golden guards (pipeline-graph, spring-route, python parity) pass
untouched at 33/33.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ingestion): prototypes are callees, not callable value cells

The cross-function visibility fix indexed EVERY signature-bearing
declaration as a value binding — including plain function/method
prototypes (void f(int);). Every call to a declared function then became
an indirect invoke, and with emitCanonicalInvokeReference (C/C++) minted a
free-call reference that resolved through the registry, bypassing the
precise passes' two-phase/ambiguity/subobject suppression — eight phantom
CALLS edges in the cpp resolver suite on CI.

Only declarations whose binding identifier sits under a pointer/
parenthesized declarator (callable-typed variables like void (*fp)(int);)
create value cells now. cpp resolver suite 331/331; callable-value-flow +
C/C++ suites 181/181 (the cross-function fp regression still passes); cpp
fingerprint rebaselined, both bench gates PASS.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 17:20:02 +01:00

499 lines
18 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, it, expect } from 'vitest';
import { mkdtemp, rm, readdir, readFile } from 'fs/promises';
import { tmpdir } from 'os';
import path from 'path';
import type { ParsedFile } from 'gitnexus-shared';
import {
clearParsedFileStore,
persistParsedFileChunk,
persistParsedFileShardSync,
loadParsedFilesForPaths,
getParsedFileStoreDir,
} from '../../src/storage/parsedfile-store.js';
/**
* Build a minimal ParsedFile whose Scope carries `bindings` / `typeBindings`
* Maps — the round-trip's fidelity hinges on those Maps surviving JSON
* serialization (they would otherwise collapse to `{}`).
*/
const makeParsedFile = (filePath: string): ParsedFile =>
({
filePath,
moduleScope: `${filePath}:module`,
parsedImports: [],
localDefs: [
{ nodeId: `Function:${filePath}:fn`, filePath, type: 'Function', qualifiedName: 'fn' },
],
referenceSites: [],
scopes: [
{
id: `${filePath}:module`,
parent: null,
kind: 'Module',
range: { startLine: 1, startCol: 0, endLine: 9, endCol: 0 },
filePath,
bindings: new Map([['fn', [{ defId: `Function:${filePath}:fn`, origin: 'local' }]]]),
ownedDefs: [],
imports: [],
typeBindings: new Map([['x', { name: 'int' }]]),
},
],
}) as unknown as ParsedFile;
/**
* Store payload with arbitrary (possibly corrupt) field overrides. The one
* controlled escape hatch for building malformed serialization-boundary
* fixtures lives HERE instead of double-casts scattered through the tests
* (#2522 review).
*/
function makeStoreEntry(filePath: string, overrides: Record<string, unknown>): ParsedFile {
return {
...(makeParsedFile(filePath) as unknown as Record<string, unknown>),
...overrides,
} as unknown as ParsedFile;
}
describe('parsedfile-store', () => {
it('round-trips ParsedFiles (incl. Scope Maps) and filters by requested paths', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'pfstore-'));
try {
await persistParsedFileChunk(dir, 'chunk-0', [makeParsedFile('a.c'), makeParsedFile('b.c')]);
await persistParsedFileChunk(dir, 'chunk-1', [makeParsedFile('c.c')]);
// Filtering: only requested paths come back.
const loaded = await loadParsedFilesForPaths(dir, new Set(['a.c', 'c.c']));
expect([...loaded.keys()].sort()).toEqual(['a.c', 'c.c']);
expect(loaded.has('b.c')).toBe(false);
// Map fidelity: bindings / typeBindings survive as real Maps.
const a = loaded.get('a.c')!;
const scope = a.scopes[0];
expect(scope.bindings).toBeInstanceOf(Map);
expect(scope.bindings.get('fn')?.[0]?.defId).toBe('Function:a.c:fn');
expect(scope.typeBindings).toBeInstanceOf(Map);
expect((scope.typeBindings.get('x') as { name: string }).name).toBe('int');
} finally {
await rm(dir, { recursive: true, force: true });
}
});
it('writes no shard for an empty chunk', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'pfstore-'));
try {
await persistParsedFileChunk(dir, 'chunk-empty', []);
let shardCount = 0;
try {
shardCount = (await readdir(getParsedFileStoreDir(dir))).length;
} catch {
shardCount = 0; // dir not created — also fine
}
expect(shardCount).toBe(0);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
it('clearParsedFileStore removes all shards (subsequent load is empty)', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'pfstore-'));
try {
await persistParsedFileChunk(dir, 'chunk-0', [makeParsedFile('a.c')]);
await clearParsedFileStore(dir);
const loaded = await loadParsedFilesForPaths(dir, new Set(['a.c']));
expect(loaded.size).toBe(0);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
it('returns empty map when the store is absent', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'pfstore-'));
try {
const loaded = await loadParsedFilesForPaths(dir, new Set(['a.c']));
expect(loaded.size).toBe(0);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
it('round-trips validated callable-flow operand and signature metadata', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'pfstore-'));
try {
const pf = makeStoreEntry('flow.cpp', {
callableFlowSites: [
{
kind: 'seed',
destination: {
name: 'member',
inScope: 'scope:entry',
atRange: { startLine: 3, startCol: 2, endLine: 3, endCol: 8 },
indirection: 0,
addressOf: false,
expressionKind: 'binding',
},
targetName: 'run',
targetQualifiedName: 'Base.run',
targetRange: { startLine: 3, startCol: 12, endLine: 3, endCol: 21 },
expectedSignature: {
parameterCount: 1,
parameterTypes: ['int'],
isConst: true,
},
},
],
});
await persistParsedFileChunk(dir, 'flow', [pf]);
const loaded = await loadParsedFilesForPaths(dir, new Set(['flow.cpp']));
expect(loaded.get('flow.cpp')?.callableFlowSites).toEqual(pf.callableFlowSites);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
it('drops a malformed callable-flow site but retains the file and its other sites (per-site sanitation, #2522)', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'pfstore-'));
try {
const operand = {
name: 'callback',
inScope: 'scope:entry',
atRange: { startLine: 2, startCol: 2, endLine: 2, endCol: 10 },
indirection: 17,
addressOf: false,
expressionKind: 'binding',
};
const invalid = makeStoreEntry('invalid.c', {
callableFlowSites: [
{
kind: 'invoke',
callSite: { startLine: 2, startCol: 2, endLine: 2, endCol: 12 },
inScope: 'scope:entry',
callee: operand,
invocationKind: 'indirect',
arity: 0,
},
],
});
await persistParsedFileChunk(dir, 'invalid', [invalid, makeParsedFile('valid.c')]);
const loaded = await loadParsedFilesForPaths(dir, new Set(['invalid.c', 'valid.c']));
// The file survives with the offending site dropped — a per-file
// rejection here caused a permanent, silent warm-cache reparse loop.
expect(loaded.get('invalid.c')?.callableFlowSites).toEqual([]);
expect(loaded.has('valid.c')).toBe(true);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
it('accepts empty-string parameterTypes entries ("" = unknown type, real C++ extractor output)', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'pfstore-'));
try {
const pf = makeStoreEntry('cv.cpp', {
callableFlowSites: [
{
kind: 'seed',
destination: {
name: 'fp',
inScope: 'scope:entry',
atRange: { startLine: 1, startCol: 0, endLine: 1, endCol: 8 },
indirection: 0,
addressOf: false,
expressionKind: 'binding',
},
targetName: 'handler',
targetRange: { startLine: 1, startCol: 12, endLine: 1, endCol: 19 },
expectedSignature: { parameterCount: 2, parameterTypes: ['int', ''] },
},
],
});
await persistParsedFileChunk(dir, 'cv', [pf]);
const loaded = await loadParsedFilesForPaths(dir, new Set(['cv.cpp']));
expect(loaded.get('cv.cpp')?.callableFlowSites).toEqual(pf.callableFlowSites);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
it('rejects the whole file only when callableFlowSites is non-array garbage', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'pfstore-'));
try {
const garbage = makeStoreEntry('garbage.c', {
callableFlowSites: 'not-an-array',
});
await persistParsedFileChunk(dir, 'garbage', [garbage, makeParsedFile('ok.c')]);
const loaded = await loadParsedFilesForPaths(dir, new Set(['garbage.c', 'ok.c']));
expect(loaded.has('garbage.c')).toBe(false);
expect(loaded.has('ok.c')).toBe(true);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
// #1983 parallel serialization: the sync worker writer and the async writer
// share one serialization core and MUST produce byte-identical shards (the
// loader's deep-equals masks byte drift, so assert raw bytes).
it('persistParsedFileShardSync writes byte-identical shards to the async writer', async () => {
const asyncDir = await mkdtemp(path.join(tmpdir(), 'pfstore-a-'));
const syncDir = await mkdtemp(path.join(tmpdir(), 'pfstore-s-'));
try {
const files = [makeParsedFile('a.c'), makeParsedFile('b.c')];
await persistParsedFileChunk(asyncDir, 'shard', files);
persistParsedFileShardSync(syncDir, 'shard', files);
const asyncBytes = await readFile(
path.join(getParsedFileStoreDir(asyncDir), 'shard.json'),
'utf-8',
);
const syncBytes = await readFile(
path.join(getParsedFileStoreDir(syncDir), 'shard.json'),
'utf-8',
);
expect(syncBytes).toBe(asyncBytes);
} finally {
await rm(asyncDir, { recursive: true, force: true });
await rm(syncDir, { recursive: true, force: true });
}
});
it('persistParsedFileShardSync round-trips through loadParsedFilesForPaths with Maps intact', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'pfstore-'));
try {
persistParsedFileShardSync(dir, 'w1-0', [makeParsedFile('a.c')]);
const loaded = await loadParsedFilesForPaths(dir, new Set(['a.c']));
const scope = loaded.get('a.c')!.scopes[0];
expect(scope.bindings).toBeInstanceOf(Map);
expect(scope.bindings.get('fn')?.[0]?.defId).toBe('Function:a.c:fn');
expect(scope.typeBindings).toBeInstanceOf(Map);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
// #1983 capture side-channel: a ParsedFile may carry a plain-data
// `captureSideChannel` (e.g. C++ ADL / namespace / two-phase marks the worker
// computed). It MUST survive the JSON store round-trip so the main thread can
// restore those module maps WITHOUT a re-parse. Plain objects/arrays only —
// no Maps/Sets — so the interning reviver passes them through unchanged.
it('round-trips a ParsedFile.captureSideChannel (plain data) through the store', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'pfstore-'));
try {
const sideChannel = {
adl: {
argInfoBySite: [
[
6,
4,
[
{
simpleClassName: 'Event',
templateSimpleClassName: '',
templateNamespace: '',
templateArgClassNames: [],
templateArgNamespaces: [],
},
],
],
],
noAdlSites: [[9, 2]],
},
inlineNamespaceRanges: ['1:0:3:1'],
fileLocal: {
fileLocalNames: ['helper'],
anonymousNamespaceRanges: ['4:0:6:1'],
},
twoPhase: {
dependentBases: [['Derived', [['Base', ['detail']]]]],
dependentPackBaseClasses: ['Mix'],
},
};
const pf = makeStoreEntry('app.cpp', {
captureSideChannel: sideChannel,
});
persistParsedFileShardSync(dir, 'w1-0', [pf]);
const loaded = await loadParsedFilesForPaths(dir, new Set(['app.cpp']));
const got = loaded.get('app.cpp')!;
// Deep-equal: the plain-data snapshot survives byte-for-byte (after JSON).
expect((got as { captureSideChannel?: unknown }).captureSideChannel).toEqual(sideChannel);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
// #1983 (Kotlin): the kotlin provider carries a self-describing companion-
// scope side-channel `{ kind: 'kotlin', companionScopes: ScopeId[] }`. It
// shares the single generic `captureSideChannel` field with C++, so confirm
// the (Set→array) plain-data shape survives the JSON store round-trip too.
it('round-trips a Kotlin ParsedFile.captureSideChannel through the store', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'pfstore-'));
try {
const sideChannel = {
kind: 'kotlin',
companionScopes: ['scope:Logger.companion', 'scope:Animal.companion'],
};
const pf = makeStoreEntry('App.kt', {
captureSideChannel: sideChannel,
});
persistParsedFileShardSync(dir, 'w1-0', [pf]);
const loaded = await loadParsedFilesForPaths(dir, new Set(['App.kt']));
const got = loaded.get('App.kt')!;
expect((got as { captureSideChannel?: unknown }).captureSideChannel).toEqual(sideChannel);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
// #1983 (C): the C provider carries a self-describing static-linkage side-
// channel `{ kind: 'c', staticNames: string[] }` (the file-local `static`
// function names the worker recorded). It shares the single generic
// `captureSideChannel` field with C++/Kotlin, so confirm the plain-data shape
// survives the JSON store round-trip too — without it, `static` functions
// leak into cross-file resolution on the worker-only parse path.
it('round-trips a C ParsedFile.captureSideChannel through the store', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'pfstore-'));
try {
const sideChannel = { kind: 'c', staticNames: ['compute', 'helper'] };
const pf = makeStoreEntry('local.c', {
captureSideChannel: sideChannel,
});
persistParsedFileShardSync(dir, 'w1-0', [pf]);
const loaded = await loadParsedFilesForPaths(dir, new Set(['local.c']));
const got = loaded.get('local.c')!;
expect((got as { captureSideChannel?: unknown }).captureSideChannel).toEqual(sideChannel);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
it('persistParsedFileShardSync writes no shard and no directory for empty input', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'pfstore-'));
try {
persistParsedFileShardSync(dir, 'w1-0', []);
let entries: string[] = [];
try {
entries = await readdir(getParsedFileStoreDir(dir));
} catch {
entries = []; // store dir not created — the expected parity with the async writer
}
expect(entries).toHaveLength(0);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
// Def-object dedup: each SymbolDefinition is serialized THREE times — in
// ParsedFile.localDefs, in the owning scope.ownedDefs, and inside
// scope.bindings[].def (BindingRef) — but is ONE object by reference in the
// live extractor. JSON.parse rebuilds three distinct objects; the load reviver
// must re-share them by nodeId (collapsing ~3× the def-object heap on the
// disk-backed/kernel path). Re-sharing is byte-identical to resolution because
// every consumer reads defs by value (nodeId/type), never by object identity.
it("re-shares a def's three serialized copies into one object on load", async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'pfstore-'));
try {
const def = {
nodeId: 'Function:a.c:fn',
filePath: 'a.c',
type: 'Function',
qualifiedName: 'fn',
};
const pf = {
filePath: 'a.c',
moduleScope: 'a.c:module',
parsedImports: [],
localDefs: [def], // copy 1
referenceSites: [],
scopes: [
{
id: 'a.c:module',
parent: null,
kind: 'Module',
range: { startLine: 1, startCol: 0, endLine: 9, endCol: 0 },
filePath: 'a.c',
bindings: new Map([['fn', [{ def }]]]), // copy 3 (BindingRef.def)
ownedDefs: [def], // copy 2
imports: [],
typeBindings: new Map(),
},
],
} as unknown as ParsedFile;
persistParsedFileShardSync(dir, 'w1-0', [pf]);
const loaded = (await loadParsedFilesForPaths(dir, new Set(['a.c']))).get('a.c')!;
const fromLocal = loaded.localDefs[0];
const scope = loaded.scopes[0];
const fromOwned = scope.ownedDefs[0];
const fromBinding = scope.bindings.get('fn')![0].def;
// All three deserialized copies are re-shared into ONE object.
expect(fromLocal).toBe(fromOwned);
expect(fromLocal).toBe(fromBinding);
// Value-identical to what was written.
expect(fromLocal).toEqual({
nodeId: 'Function:a.c:fn',
filePath: 'a.c',
type: 'Function',
qualifiedName: 'fn',
});
} finally {
await rm(dir, { recursive: true, force: true });
}
});
it('keeps defs with distinct nodeIds as distinct objects (no over-collapsing)', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'pfstore-'));
try {
const def1 = {
nodeId: 'Function:a.c:fn1',
filePath: 'a.c',
type: 'Function',
qualifiedName: 'fn1',
};
const def2 = {
nodeId: 'Function:a.c:fn2',
filePath: 'a.c',
type: 'Function',
qualifiedName: 'fn2',
};
const pf = {
filePath: 'a.c',
moduleScope: 'a.c:module',
parsedImports: [],
localDefs: [def1, def2],
referenceSites: [],
scopes: [
{
id: 'a.c:module',
parent: null,
kind: 'Module',
range: { startLine: 1, startCol: 0, endLine: 9, endCol: 0 },
filePath: 'a.c',
bindings: new Map([
['fn1', [{ def: def1 }]],
['fn2', [{ def: def2 }]],
]),
ownedDefs: [def1, def2],
imports: [],
typeBindings: new Map(),
},
],
} as unknown as ParsedFile;
persistParsedFileShardSync(dir, 'w1-0', [pf]);
const loaded = (await loadParsedFilesForPaths(dir, new Set(['a.c']))).get('a.c')!;
expect(loaded.localDefs[0]).not.toBe(loaded.localDefs[1]);
expect(loaded.localDefs[0].nodeId).toBe('Function:a.c:fn1');
expect(loaded.localDefs[1].nodeId).toBe('Function:a.c:fn2');
// Each still re-shares with its own ownedDefs copy.
expect(loaded.localDefs[0]).toBe(loaded.scopes[0].ownedDefs[0]);
expect(loaded.localDefs[1]).toBe(loaded.scopes[0].ownedDefs[1]);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
});