feat(ingestion): add control-flow-graph layer for TS/JS (#2081) (#2099)

* feat(cfg): language-agnostic CFG construction core (#2081)

U1 of M1 (CFG layer). Plain JSON-serializable CFG data model (BasicBlockData/
CfgEdgeData/FunctionCfg — must survive the worker→main boundary + ParsedFile
store), a CfgBuilder accumulator (leaders→blocks→edges, synthetic ENTRY/EXIT,
idempotent edges), a ControlFlowContext (break/continue/switch + labeled-jump
target stacks), and a TraversalResult ({entry, dangling exits}). AST-agnostic
and unit-tested on the classic control-flow topologies (if/else, while back-edge,
mid-block return, labeled break/continue) the S2 spike validated; reachability
helper backs the R9 property test.

* feat(ingestion): U2 — TS/JS CFG visitor over tree-sitter AST (#2081)

Add the TS/JS CfgVisitor that walks a function's tree-sitter AST and drives
the U1 CfgBuilder to produce a serializable FunctionCfg. One visitor covers
both languages (shared grammar family).

Handles the classic CFG hazards explicitly (R2, R10):
- loops allocate a dedicated loop-exit block so `break` has a concrete target
  before the loop's successor is known; `continue`/back-edge close the loop
  (while, do-while, C-for with init-once + increment-as-continue-target,
  for-in, for-of)
- switch fallthrough falls out naturally: a non-breaking case yields exits we
  wire to the next case as `fallthrough`; a breaking case wires to the switch
  exit via ControlFlowContext
- try/catch/finally: normal completion AND exceptional flow both route through
  finally (post-domination); a conservative exceptional edge models that the
  protected region may raise to its handler (not just explicit `throw`)
- labeled break/continue resolve against the labeled loop's frame
- early return/throw wire to EXIT/handler and terminate their block

19 hazard tests (one per construct) + AC1 10-function fixture; all green.
No change to the committed U1 core or ControlFlowContext.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(ingestion): U3 — worker CFG build + cfgSideChannel + cache coherence (#2081)

Run the CFG visitor in the parse worker (where the AST lives), serialize the
per-function CFG onto a new ParsedFile.cfgSideChannel, and keep it coherent
across the disk-backed store and the warm/durable parse cache (R3, R4).

- gitnexus-shared parsed-file.ts: add `cfgSideChannel?: unknown` as a DISTINCT
  field from captureSideChannel (different producer/consumer/lifecycle; plain
  JSON data — blocks/edges deliberately lack the `nodeId` the store's interning
  reviver keys on, so no mis-interning).
- cfg/types.ts + visitors/typescript.ts: add CfgVisitor.isFunction so the worker
  enumerates functions (and applies the line budget) by a cheap node-type test.
- cfg/collect.ts (new): collectFunctionCfgs walks the tree, builds one CFG per
  function (nested included), applies maxFunctionLines (over-cap = skipped).
- language-provider.ts: add `cfgVisitor?: CfgVisitor<SyntaxNode>` hook;
  typescript.ts attaches it to both the TS and JS providers (shared grammar).
- parse-worker.ts: read pdg + pdgMaxFunctionLines from workerData (read once at
  init — the worker never sees PipelineOptions), gate the build, attach
  cfgSideChannel alongside captureSideChannel.
- parse-cache.ts: bump SCHEMA_BUMP 4→5 (ParsedFile shape changed) and fold the
  pdg flag into computeChunkHash so a pdg-off cached chunk is NOT reused on a
  --pdg run (the #2038-class warm-cache trap). Default path keeps its keys.
- worker-pool.ts + parse-impl.ts + pipeline.ts: thread pdg/pdgMaxFunctionLines
  PipelineOptions → WorkerPoolOptions → workerData, and into the chunk-hash key.

9 boundary tests: collect contract, JSON round-trip identity (no AST leakage),
the pdg cache-key guard, the line-cap skip, and the no-visitor gate. Full CFG
suite (U1+U2+U3) green; build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(ingestion): U4 — emit BasicBlock + CFG within scope-resolution (#2081)

Emit persisted BasicBlock nodes + CFG edges from each ParsedFile's worker-built
cfgSideChannel, INSIDE scope-resolution's Phase-4 graph emission — the last
point where the worker-built CFGs are loaded (emitParsedFiles carries the
channel; the disk store is cleared right after the orchestrator returns). This
is the architecture the doc-review corrected to: a standalone post-`mro` phase
(the issue's literal subtask) provably reads empty data (KTD1).

- cfg/emit.ts (new): pure emitFileCfgs(graph, cfgs, maxEdgesPerFunction, onWarn).
  BasicBlock id = `BasicBlock:<filePath>:<functionStartLine>:<blockIndex>`
  (KTD3 — funcStart disambiguates blocks across functions in one file; no
  `name` column). CFG edge = CodeRelation type 'CFG' with the edge KIND
  (seq/cond-true/…) in `reason` (kinds can't be their own edge type). Per-
  function edge cap stops at the cap and warns with the dropped count — no
  silent truncation (R6/KTD6).
- run.ts: pdg-gated emit pass over emitParsedFiles after emitPostResolutionEdges
  (store still live); RunScopeResolutionInput gains pdg + pdgMaxEdgesPerFunction.
- phase.ts: thread ctx.options.pdg / pdgMaxEdgesPerFunction into the call.
- pipeline.ts: PipelineOptions.pdgMaxEdgesPerFunction.

6 tests: node/edge shape (KTD3 id, no name, type='CFG', kind in reason),
cross-function id uniqueness, AC2 reachability-from-ENTRY property, the edge
cap's no-silent-truncation contract, and empty-input no-op. Flag-off
byte-identity + full runPipelineFromRepo round-trip land in U7. Build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(cli): U5 — `--pdg` opt-in plumbing (CLI + .gitnexusrc → both sinks) (#2081)

Expose the CFG/PDG substrate as an opt-in and thread it from CLI/.gitnexusrc to
the single source of truth (PipelineOptions.pdg), which fans out to BOTH sinks
already wired in U3/U4: the worker build gate (workerData.pdg) and the
scope-resolution emit gate. Off by default (R7).

- cli/index.ts: `--pdg` commander flag.
- cli/analyze.ts: AnalyzeOptions.pdg + pass `pdg` into runFullAnalysis options.
- cli/analyze-config.ts: KEY_SPECS `pdg` (boolean) so `.gitnexusrc { "pdg": true }`
  normalizes and a non-boolean value fails closed with GitNexusRcError.
- core/run-analyze.ts: AnalyzeOptions.pdg → runPipelineFromRepo({ pdg }).

(The internal PipelineOptions/WorkerPoolOptions/workerData fields + the
parse-cache key fold landed in U3/U4; this unit adds the user-facing surface.
The budget knobs stay at internal defaults for M1.)

Tests: analyze-config pdg normalization + non-boolean rejection; opt-in.test.ts
covers the CLI/file merge precedence and that pdg perturbs the chunk-dispatch
key. The full worker-build + main-emit round-trip is the U7 integration test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(ingestion): U7 — CFG acceptance fixtures, parity, end-to-end + docs (#2081)

Acceptance criteria for the M1 CFG layer:
- AC1: a 10-function TS fixture's CFG node/edge set matches a committed snapshot
  (cfg-snapshot.test.ts).
- AC2: every BasicBlock is reachable from its function ENTRY (property test over
  the emitted graph; the fixture has no dead code).
- AC3: hazard fixtures lock the classic-bug coverage — try/throw/finally
  post-domination + labeled break/continue resolution.
- AC4: the existing pipeline-graph-golden test stays byte-identical with --pdg
  off (verified; no UPDATE_GOLDEN), proving the opt-in adds zero default-run
  drift.
- End-to-end (pipeline-pdg.test.ts): runPipelineFromRepo({ pdg: true }) on a
  tiny repo emits BasicBlock nodes + CFG edges with both endpoints present —
  the true both-sinks proof (worker builds → store → scope-resolution emits);
  the default run emits zero.

Docs: CHANGELOG M1 entry, ARCHITECTURE "Optional CFG/PDG emission" subsection
(why emit is in-phase, not post-mro), README CFG language-support note.

Full CFG suite (U1–U7): 56 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(ingestion): drop unused helper in cfg-snapshot test (#2081)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(review): apply ce-code-review autofix feedback (#2081)

Review (10 reviewers) confirmed OFF-path byte-identity (adversarial + golden)
and found defects all within the --pdg path. Fixes:

- P1 same-line BasicBlock id collision: add a start-column disambiguator to
  FunctionCfg + the id (`BasicBlock:<file>:<line>:<col>:<idx>`) so two functions
  sharing a start line no longer collide under first-writer-wins addNode.
- P1 worker crash-cascade: per-file try/catch around collectFunctionCfgs so a
  CFG-build throw cannot escape to the language-group catch and silently drop
  every remaining file in the group.
- P2 edge-cap drop now logs unconditionally (input.onWarn is validator-gated/
  silent in prod) — upholds the no-silent-truncation guarantee.
- P2 Array.isArray guard before the cfgSideChannel cast in run.ts.
- P2 maxFunctionLines default: worker applies DEFAULT_PDG_MAX_FUNCTION_LINES=2000
  when unset; caps forwarded through run-analyze AnalyzeOptions (closes the
  server-path drop).
- P3 README duplicate paragraph removed; `0`-vs-default docstrings corrected;
  CLI --pdg flag made language-neutral; reachableBlocks JSDoc corrected.
- Documented the break-through-finally + stacked-label CFG limitations.
- Tests: same-line id-collision regression, standalone throw→EXIT, dead-code-
  after-return, async/generator/method coverage, strengthened labeled-continue.

Refuted: the HTTP-500 getNodeQuery finding — M0 already shipped the BasicBlock
branch + name-floor (R12/web-safety handled).

CFG + analyze-config suites: 95 tests green; golden parity (AC4) byte-identical.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf(ingestion): benchmark CFG construction + O(n) block-text accumulation (#2081)

Closes the M1 review's requires_verification perf gap ("no benchmark for
collectFunctionCfgs; a wall-time + cfgSideChannel byte-size regression gate
would catch the extendBlock concatenation before kernel scale").

- bench/cfg/measure.mjs (new): build-free tsx harness timing collectFunctionCfgs
  (parse once, reuse the tree) across three scaling scenarios — straight-line
  (extendBlock path), many-functions (collect walk), branchy (block/edge growth)
  — at 500→2000. Reports a wall-time scaling ratio AND a cfgSideChannel
  byte-size ratio, plus an order-independent sha256 over the emitted blocks/edges
  as the behavior gate. `--check` compares both ratios + the fingerprint against
  bench/cfg/baselines.json; mirrors the scope-capture / python-scope harnesses.
- .github/workflows/ci-tests.yml: run the gate on every test job (build-free,
  alongside the existing scope-capture guards) so an O(n^2) re-regression fails CI.
- cfg-builder.ts: structural fix for the one real hotspot the bench surfaced —
  accumulate basic-block text as fragments joined once in finish(), instead of
  concatenating onto a growing string per coalesced statement (O(n^2) → O(n)).
  Behavior-identical (the CFG fingerprint + the AC1 snapshot are unchanged).

Measured (post-fix): time ratios straight-line ~1.3, many-functions ~1.0,
branchy ~1.1 (all sub-quadratic; a true O(n^2) would be ~4.0). cfgSideChannel
bytes scale linearly (~1.0-1.04). 60 CFG tests green; build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf(ingestion): add memory + disk growth gates to the CFG benchmark (#2081)

Extend bench/cfg/measure.mjs beyond wall-time to the two other scalability
dimensions that matter at kernel scale:

- DISK growth: utf8 byte size of the serialized cfgSideChannel — exactly what a
  --pdg run writes onto every ParsedFile shard (durable store + parse cache).
- MEMORY growth: retained JS heap of the cfgSideChannel payload, measured by the
  release-delta method (heap held minus heap after dropping it) — robust to
  pre-existing garbage and dead-stable run-to-run. Needs `node --expose-gc`;
  without it the heap metric is null and its gate is skipped (local runs still
  work). ci-tests.yml now passes --expose-gc so the heap gate runs in CI.

Both gated on linear scaling in baselines.json (disk_bytes_budget / heap_budget
1.2-1.3). Measured: disk ~1.0-1.04, retained heap ~0.87-1.0 — both linear
(~1KB/function each; ~2MB heap / 1.6MB disk at 2000 functions, --pdg only).
Bumped REPS 7->15 to stabilize the noisier time signal and widened the coarse
time tripwire budgets (the disk/heap gates carry the tight regression detection).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(ingestion): address tri-review + CFG-expert findings (#2081)

Corroborated findings from the tri-review (Codex + CE personas + GitNexus swarm
+ a CFG/program-analysis domain-expert lane). The OFF-path stays byte-identical;
all fixes are within the --pdg path or the benchmark.

- [Codex+CFG-expert] Exceptional `throw` edges now wire EVERY block in a try's
  protected region to the handler, not just the body ENTRY. A branched try body
  (`try { if (x) { use(t); } } catch`) previously left interior blocks with no
  path to `catch` — a taint false-negative into the handler for the M2 PDG pass.
- [Codex+CFG-expert] An unresolved labeled jump (a stacked outer label or a
  labeled non-loop block) now routes to the function EXIT instead of leaving a
  dangling sink — restores the single-exit invariant post-dominator/PDG
  computation needs.
- [Codex] computeChunkHash now folds pdgMaxFunctionLines/pdgMaxEdgesPerFunction
  into the chunk key (not just the pdg boolean), so a warm cache built under one
  cap is never served to a run with a different cap (#2038 class, extended to
  the budgets). Adds PdgCacheKey; boolean form kept for back-compat.
- [perf] visitTry resolves catch/finally in a single namedChild pass (the double
  `namedChildren.find` allocated two throwaway arrays).
- [adversarial] The bench `straight-line` scenario now runs at 2000->8000:
  output is a constant 4 blocks so disk/heap can't see the concat path, and at
  the old N a genuine O(n²) was masked by V8 cons-strings. Verified at the new N:
  the array-join impl ~1.0, a rope-optimized `+=` ~1.0 (correctly not flagged),
  a real O(n²) (re-join-every-append) ~3.8 — budget tightened 2.0->1.5.
- [adversarial+Codex] The bench `--check` now FAILS LOUDLY when run without
  `--expose-gc` instead of silently skipping the retained-heap gate.
- Doc: re-labeled the finally-bypass as a SOUNDNESS (false-negative) limitation
  tracked for M2, not mere "precision."

3 new regression tests (branched-try interior→handler, stacked-label→EXIT,
cap-fold key). 99 CFG tests pass; build clean; bench gate green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(parse-cache): clarify that SCHEMA_BUMP still invalidates caches once (#2099 F6)

The computeChunkHash comment claimed pdg-off warm caches "survive this
change untouched" — true for the key FORMAT, but misleading as an
upgrade-behavior promise: SCHEMA_BUMP 4→5 changes PARSE_CACHE_VERSION
and both stores hard-invalidate on it. Separate the two facts so the
next cache change isn't reasoned about from a false premise.

Review finding F6 (P3) of PR #2099 tri-review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cfg): correct for-loop back-edge kinds when no increment clause (#2099 F5)

A for with a body but no increment emitted an unconditional
header→header 'loop-back' self-edge (a path that never executes the
body) while the real back-edge body→header was labeled 'seq'. Any
consumer identifying loops via reason='loop-back' picked the phantom
edge and excluded the body from the natural loop.

Gate the self-edge on the body being absent (the one case where the
header genuinely re-tests itself) and carry 'loop-back' on the body's
exits when they ARE the back-edge, matching visitWhile/visitForIn.

Review finding F5 (P3) of PR #2099 tri-review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cfg): treat an empty catch clause as a real handler (#2099 F2)

visitTry keyed handler semantics off the traversal result — null for an
empty body, since visitSeq([]) returns null — instead of the syntactic
clause. An empty `catch {}` was therefore treated as NO catch: the
swallowed exception escaped to the outer handler/EXIT, the no-catch
re-propagation misfired past finally, and code after a try whose body
always throws became unreachable from ENTRY — a hard false-negative
source for the M2 taint pass, on an extremely common pattern.

Synthesize one empty block spanning the clause (entry == sole exit)
when the catch body traverses to null, before the protected region is
walked. Exception flow lands in it and rejoins the normal continuation;
all downstream wiring (handler selection, finally routing, the !catchRes
re-propagation gate) operates on the syntactically-correct shape.

Review finding F2 (P2, reproduced) of PR #2099 tri-review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cfg): guard CFG emission per element, not just per outer array (#2099 F4)

The cfgSideChannel guard checked only Array.isArray before casting to
FunctionCfg[] — its own comment promised a wrong-shape value would
'skip emission, not throw a TypeError mid-graph-build', but a malformed
ELEMENT sailed through. Worse, the obvious-looking failure shape never
throws at all: emitFileCfgs string-templates any edge endpoint into the
BasicBlock id and graph inserts are no-throw, so a non-integer endpoint
silently became a dangling 'BasicBlock:…:undefined' edge that degrades
the DB rel-pair COPY to row-by-row fallback inserts much later.

Layered fix matching house precedents (parsedfile-store reviver,
worker-side per-file catch): a per-element shape+content predicate
(arrays + integer edge endpoints) that warns and skips malformed
elements while valid siblings still emit, plus a per-file try/catch
backstop for shapes that genuinely throw (e.g. a null inside blocks).

Review finding F4 (P3) of PR #2099 tri-review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(parse-cache): drop emit-time edge cap from the pdg chunk key (#2099 F3)

pdgMaxEdgesPerFunction is applied exclusively in emitFileCfgs during
scope-resolution on the main thread — the worker never receives it
(workerData carries only pdg + pdgMaxFunctionLines), so the cached
worker output is byte-identical across cap values. Folding it into the
chunk key (added by a prior review round) only converted a free knob
into a repo-sized cost: every cap change forced a full re-parse and a
durable-store rewrite of unchanged data.

Keep pdg + maxFunctionLines (genuinely worker-visible, shape the cached
cfgSideChannel) and document the classification test in the PdgCacheKey
doc comment so the next option gets sorted deliberately: worker-shard
inputs go in this key; persisted-graph-only inputs belong in the
RepoMeta pdg stamp (F1). Chunks written under the old ns string miss
once and prune — no migration needed.

Review finding F3 (P2) of PR #2099 tri-review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(analyze): record pdg config in RepoMeta; force full writeback on mode flip (#2099 F1)

Running --pdg against an already-indexed repo silently persisted ~zero
CFG: incremental eligibility had no pdg term, RepoMeta recorded no
mode, and extractChangedSubgraph keeps only changed-file nodes — on a
no-change --pdg re-run every freshly built BasicBlock was dropped from
the written subgraph ('Incremental: changed=0', run succeeds, zero
rows). The converse flip left zombie mixed-coverage blocks only --force
could clean. Worse, a clean-tree flip hit the alreadyUpToDate fast path
and never ran the pipeline at all.

- RepoMeta gains an additive-optional pdg stamp ({maxFunctionLines,
  maxEdgesPerFunction}, resolved values; absent ≡ pdg-off, which covers
  every legacy meta). No INCREMENTAL_SCHEMA_VERSION bump — that would
  force a one-time full rebuild for everyone. The end-of-run meta is a
  fresh literal, so omitting the field on a pdg-off run is what clears
  the stamp after an on→off flip.
- pdgModeMismatch (pure, exported) compares the resolved triple; the
  flip check sits before the fast path and always logs its notice (not
  gated on options.force — --skills implies force with no message of
  its own), naming the .gitnexusrc pdg key that pins the mode.
- The full-rebuild branch now writes the incrementalInProgress dirty
  flag (toWriteCount: 0 sentinel) before the wipe whenever a prior meta
  exists, mirroring the incremental branch. This closes the crash
  window where a rebuild dying between the bulk load and saveMeta left
  meta/DB inconsistent and the fast path certified zombie (or missing)
  CFG rows indefinitely — and incidentally closes the same pre-existing
  hole for user --force runs. Recovery log reworded accordingly.

Tests: pdg-mode-flip.test.ts (real git + LadybugDB; primary assertion
is a direct BasicBlock table count — meta.stats aggregates
nondeterministic Community/Process rows) covering off→on, steady-state
fast path, on→off zombie cleanup, cap-change rebuild, and dirty-flag +
flip composition; pure-helper tests for default resolution and the
0=unlimited carve-out.

Review finding F1 (P1) of PR #2099 tri-review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergő Magyar 2026-06-10 19:26:45 +01:00 committed by GitHub
parent e26002c37a
commit 5bf8a17cd5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
44 changed files with 3852 additions and 55 deletions

View file

@ -266,6 +266,16 @@ jobs:
run: node --import tsx bench/scope-capture/measure.mjs --check
working-directory: gitnexus
- name: CFG construction time / disk / memory guards (#2081 M1)
# Build-free: asserts collectFunctionCfgs output is unchanged
# (fingerprint) and that wall-time, cfgSideChannel disk bytes, AND
# retained heap all stay sub-quadratic for the straight-line /
# many-functions / branchy scenarios. Catches an O(n^2) re-regression in
# the per-function CFG builder (e.g. an extendBlock concat chain) and a
# memory/disk blow-up. --expose-gc enables the retained-heap measurement.
run: node --expose-gc --import tsx bench/cfg/measure.mjs --check
working-directory: gitnexus
- name: Cross-language pipeline benchmarks (GITNEXUS_BENCH, serial)
env:
GITNEXUS_BENCH: '1'

View file

@ -204,6 +204,10 @@ Language-agnostic scope-resolution resolver. This is the resolution path for eve
Orchestrator: `runScopeResolution(input, provider)` in `scope-resolution/pipeline/run.ts`.
Pipeline phase: `scopeResolutionPhase` in `scope-resolution/pipeline/phase.ts` — iterates the registered `SCOPE_RESOLVERS` over the worker-serialized `ParsedFile`s. (Per-language `emitScopeCaptures` hooks may reuse a cached Tree via the orchestrator's `treeCache`, but in worker-pool runs that cache is empty — Trees can't cross MessageChannels — so they consume the pre-extracted `ParsedFile` instead; § Performance notes.)
### Optional CFG/PDG emission (`--pdg`, #2081 M1)
On a `--pdg` run, the parse worker builds a per-function control-flow graph from the tree-sitter AST (`LanguageProvider.cfgVisitor`; TypeScript/JavaScript in M1) and serializes it onto `ParsedFile.cfgSideChannel` as plain data. Scope-resolution then emits `BasicBlock` nodes + `CFG` edges from that side-channel **inside Phase 4 of `runScopeResolution`, while the disk-backed ParsedFile store is still live** — the only window where the worker-built CFGs are loaded (the store is cleared right after the phase returns). A standalone post-`mro` phase would read an empty store, so the CFG emit deliberately lives in-phase, mirroring the `applyCaptureSideChannel` pattern. The opt-in is off by default (graph byte-identical), folded into the parse-cache key (a pdg-off warm cache is never reused on a `--pdg` run), and bounded by a per-function edge cap that logs any dropped edges. Edge *kind* (`seq`/`cond-true`/`loop-back`/…) rides in the `CFG` relationship's `reason` (CFG is a single `CodeRelation` type, not one type per kind). See `core/ingestion/cfg/`.
### `ScopeResolver` contract
Single interface a language implements to plug into the pipeline. Contract fully documented in `scope-resolution/contract/scope-resolver.ts`.

View file

@ -703,6 +703,8 @@ GitNexus builds a complete knowledge graph of your codebase through a multi-phas
**Imports** — cross-file import resolution · **Named Bindings**`import { X as Y }` / re-export tracking · **Exports** — public/exported symbol detection · **Heritage** — class inheritance, interfaces, mixins · **Type Annotations** — explicit type extraction for receiver resolution · **Constructor Inference** — infer receiver type from constructor calls (`self`/`this` resolution included for all languages) · **Config** — language toolchain config parsing (tsconfig, go.mod, etc.) · **Frameworks** — AST-based framework pattern detection · **Entry Points** — entry point scoring heuristics
**Control flow (CFG, opt-in `--pdg`)** — per-function control-flow graphs (`BasicBlock` nodes + `CFG` edges) feeding the PDG/taint substrate, currently **TypeScript & JavaScript** (#2081 M1); other languages planned. Off by default.
---
## Tool Examples

View file

@ -98,4 +98,26 @@ export interface ParsedFile {
* side effects the contract default) leave this undefined.
*/
readonly captureSideChannel?: unknown;
/**
* Per-function control-flow graphs for this file (#2081 M1, PDG/taint
* substrate). A DISTINCT field from {@link captureSideChannel} different
* producer, consumer, and lifecycle: the worker builds it from the
* tree-sitter AST via `LanguageProvider.cfgVisitor` (only on a `--pdg` run),
* and scope-resolution emits BasicBlock nodes + CFG edges from it while the
* disk-backed ParsedFile store is still live (it is NOT a capture-time
* marker the resolver restores into module maps). Kept separate so a future
* change to either channel's shape invalidates independently.
*
* Shared / ingestion code treats this as opaque (`unknown`) per AGENTS.md.
* Concretely it is a `readonly FunctionCfg[]` (see
* `core/ingestion/cfg/types.ts`) plain JSON-serializable data (no AST
* refs, no class instances) so it round-trips through the parse cache and
* the `parsedfile-store` (whose interning reviver keys on `nodeId`, which
* these blocks/edges deliberately lack).
*
* Optional: `undefined` on non-`--pdg` runs and for languages with no
* `cfgVisitor` the default for every run today.
*/
readonly cfgSideChannel?: unknown;
}

View file

@ -0,0 +1,23 @@
{
"straight-line": {
"fingerprint": "f5524690b5b7d484573710938c5e9a28e08ef0882fea95111f01575c71f4a66a",
"scaling_budget": 1.5,
"disk_bytes_budget": 1.2,
"heap_budget": 1.3,
"_note": "#2081 M1: ONE function, N coalescing statements (extendBlock text accumulation). Runs at 2000->8000 (larger than the other scenarios — output is constant 4 blocks, so disk/heap can't see this path; the TIME ratio is the sole guard). Verified at this N: the array-join impl is ~1.0, a V8-rope-optimized `+=` is also ~1.0 (correctly NOT a real regression — ropes keep naive concat linear), but a genuine O(n²) accumulation (e.g. re-join-the-array-every-append) is ~3.8 — so budget 1.5 catches a true superlinear regression while passing linear concat. disk ~1.03, retained heap ~0.98. Re-baseline the fingerprint only on an intentional CFG-shape change."
},
"many-functions": {
"fingerprint": "c167ccd83086254e2b71eca153ca4a833be14b2d2a3827ab76b49f643aad13d5",
"scaling_budget": 1.5,
"disk_bytes_budget": 1.2,
"heap_budget": 1.3,
"_note": "#2081 M1: N small branchy functions (collect walk + per-function build). Time ~1.0, disk ~1.01, retained heap ~1.0 (~1KB/function; ~2MB at 2000 fns)."
},
"branchy": {
"fingerprint": "944ab56ffc70e195f74d8533a8aadf4930d37d13bcfa47cc4feff29e74ddca5c",
"scaling_budget": 1.8,
"disk_bytes_budget": 1.2,
"heap_budget": 1.3,
"_note": "#2081 M1: ONE function, N sequential ifs (block/edge growth in one CFG). Time ~1.1-1.25 (REPS=15 median; noisiest scenario), disk ~1.04, retained heap ~1.0. Time budget 1.8 absorbs noise while catching ~4.0 quadratic."
}
}

View file

@ -0,0 +1,288 @@
/**
* Build-free CFG-construction measurement harness (#2081 M1).
*
* Times `collectFunctionCfgs` (the per-function CFG builder the parse worker
* runs on a `--pdg` run) on synthetic TS sources at two sizes, in three
* scenarios that each stress a distinct cost dimension:
* - `straight-line`: ONE function with N coalescing statements stresses the
* basic-block text accumulation (the `extendBlock` path);
* - `many-functions`: N small branchy functions stresses the collect walk +
* per-function build + the tree-sitter `namedChildren` accesses;
* - `branchy`: ONE function with N sequential `if`s stresses block/edge
* growth within a single CFG.
*
* For each scenario it reports three scaling ratios at smalllarge
* (`(metric_large/metric_small)/(N_large/N_small)`: ~1.0 is linear, ~4.0 is the
* O() shape the M1 perf review flagged for `extendBlock`'s concat chain):
* - TIME wall-clock of `collectFunctionCfgs` (median of reps);
* - DISK utf8 byte size of the serialized `cfgSideChannel` (what a `--pdg`
* run writes onto every ParsedFile shard);
* - MEMORY retained JS heap of the `cfgSideChannel` payload, by the
* release-delta method (heap held minus heap after dropping it). Requires
* `node --expose-gc`; without it the heap metric is null and its gate skips.
* It also computes an order-independent sha256 fingerprint over the emitted
* blocks/edges of a fixed-size source the correctness gate that a structural
* speedup must leave behavior-identical.
*
* Build-free: imports the `.ts` hotpaths through tsx
* (`node --expose-gc --import tsx bench/cfg/measure.mjs`). Parsing happens ONCE
* per size and the tree is reused across reps so the time measurement isolates
* CFG build cost, not tree-sitter parse time. `maxFunctionLines` is 0 (no cap)
* here on purpose the bench measures the algorithm; the production default cap
* is a separate safety net (and would otherwise skip the large straight-line fn).
*
* Without args: prints one JSON object per scenario.
* With `--check`: asserts each scenario's fingerprint == its committed baseline
* (baselines.json) AND each of the time / disk / heap ratios is below its
* recorded budget; exits non-zero on any drift/regression.
*/
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import { fileURLToPath } from 'node:url';
import Parser from 'tree-sitter';
import TypeScript from 'tree-sitter-typescript';
import { collectFunctionCfgs } from '../../src/core/ingestion/cfg/collect.ts';
import { createTypeScriptCfgVisitor } from '../../src/core/ingestion/cfg/visitors/typescript.ts';
import { getTreeSitterBufferSize } from '../../src/core/ingestion/constants.ts';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const BASELINE_PATH = path.resolve(__dirname, 'baselines.json');
const visitor = createTypeScriptCfgVisitor();
const parser = new Parser();
parser.setLanguage(TypeScript.typescript);
// Large synthetic sources exceed tree-sitter's default read buffer; size it
// from the content exactly as the parse worker does (getTreeSitterBufferSize).
const parse = (src) => parser.parse(src, undefined, { bufferSize: getTreeSitterBufferSize(src) });
// ---- synthetic generators (one cost dimension each) ----
const SCENARIOS = [
{
name: 'straight-line',
// One function, N coalescing simple statements → all fold into one basic
// block whose text is accumulated statement-by-statement (extendBlock).
// Uses LARGER sizes than the other scenarios: this scenario's only cost
// dimension is text accumulation (output size is constant — 4 blocks at any
// N — so the disk/heap ratios can't see it), so the TIME ratio is the sole
// guard against an extendBlock O(n²)-concat re-regression. At small N a
// quadratic is masked by V8 cons-strings + the linear tree-walk and slips
// under the budget; these larger sizes make a real quadratic separate
// cleanly (verified: a `+=` regression here exceeds the budget, the
// array-join impl stays ~1).
small: 2000,
large: 8000,
gen: (n) => {
let s = 'function f() {\n';
for (let i = 0; i < n; i++) s += ` let v${i} = ${i} + 1;\n`;
return s + ' return v0;\n}\n';
},
},
{
name: 'many-functions',
// N independent small functions with a branch + return → stresses the
// tree walk in collectFunctionCfgs and the per-function build.
gen: (n) => {
let s = '';
for (let i = 0; i < n; i++) {
s += `function f${i}(x: number) { if (x > ${i}) { a(); } else { b(); } return x + ${i}; }\n`;
}
return s;
},
},
{
name: 'branchy',
// One function, N sequential `if`s → N condition blocks + 2N+ edges in a
// single CFG; stresses block/edge growth and namedChildren on the body.
gen: (n) => {
let s = 'function f(x: number) {\n';
for (let i = 0; i < n; i++) s += ` if (x > ${i}) { s${i}(); }\n`;
return s + '}\n';
},
},
];
const SMALL = 500;
const LARGE = 2000; // 4× — O(n) ⇒ ratio ~1, O(n²) ⇒ ratio ~4
const REPS = 15; // median over more reps → stabler time signal at small absolute ms
const FP_SIZE = 15; // fixed size for the behavior fingerprint
const NO_CAP = 0; // measure the algorithm, not the production safety cap
// ---- timing ----
function median(xs) {
const s = [...xs].sort((a, b) => a - b);
const m = Math.floor(s.length / 2);
return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
}
function measureCollect(src, file, reps) {
const root = parse(src).rootNode; // parse ONCE; reuse across reps
collectFunctionCfgs(root, visitor, `warmup-${file}`, NO_CAP); // warm JIT (uncounted)
const samples = [];
let out;
for (let i = 0; i < reps; i++) {
const start = process.hrtime.bigint();
out = collectFunctionCfgs(root, visitor, file, NO_CAP);
samples.push(Number(process.hrtime.bigint() - start) / 1e6);
}
return {
ms: median(samples),
blockCount: out.cfgs.reduce((a, c) => a + c.blocks.length, 0),
// DISK growth: utf8 byte size of the serialized cfgSideChannel — exactly
// what a --pdg run writes onto every ParsedFile shard in the durable store
// + parse cache (the field is plain JSON, so this is the on-disk delta).
// Should scale linearly with source covered; a super-linear ratio means the
// CFG duplicates text and bloats warm-cache shards at scale.
diskBytes: Buffer.byteLength(JSON.stringify(out.cfgs), 'utf8'),
};
}
// ---- memory growth: retained heap of the cfgSideChannel payload ----
// Needs `node --expose-gc` to force collection for a clean delta; without it the
// heap metric is reported as null and its --check gate is skipped (so a local
// run without the flag still works).
const GC = typeof global.gc === 'function' ? () => (global.gc(), global.gc()) : null;
function retainedHeapBytes(src, file) {
if (!GC) return null;
// Retained-size-by-RELEASE: measure the heap with the CFGs held, drop them,
// GC, measure again. The drop isolates exactly the JS heap the cfgSideChannel
// payload retains (the extra RAM a --pdg run carries per file until the shard
// is flushed) — robust to pre-existing garbage, which is constant across both
// measurements. The parse tree is a temporary (its native memory isn't on the
// JS heap); block text strings are fresh copies, so they count here.
let cfgs = collectFunctionCfgs(parse(src).rootNode, visitor, file, NO_CAP).cfgs;
GC();
const withCfgs = process.memoryUsage().heapUsed;
if (cfgs.length < 0) throw new Error('unreachable'); // keep cfgs live past withCfgs
cfgs = null;
GC();
const withoutCfgs = process.memoryUsage().heapUsed;
return Math.max(0, withCfgs - withoutCfgs);
}
// ---- correctness fingerprint (order-independent over blocks + edges) ----
function canonicalizeCfg(cfg) {
const blocks = cfg.blocks
.map((b) => `B|${b.index}|${b.startLine}-${b.endLine}|${b.kind}|${b.text}`)
.sort();
const edges = cfg.edges.map((e) => `E|${e.from}->${e.to}|${e.kind}`).sort();
return `${cfg.functionStartLine}:${cfg.functionStartColumn}\n${blocks.join('\n')}\n${edges.join('\n')}`;
}
function fingerprint(scenario) {
const out = collectFunctionCfgs(parse(scenario.gen(FP_SIZE)).rootNode, visitor, 'fp.ts', NO_CAP);
const canon = out.cfgs.map(canonicalizeCfg).sort().join('\n====\n');
return {
fingerprint: crypto.createHash('sha256').update(canon).digest('hex'),
fp_cfgs: out.cfgs.length,
fp_blocks: out.cfgs.reduce((a, c) => a + c.blocks.length, 0),
fp_edges: out.cfgs.reduce((a, c) => a + c.edges.length, 0),
};
}
function measureScenario(scenario) {
// Per-scenario sizes (straight-line needs larger N to separate a concat
// quadratic from noise — see its comment); the rest default to the globals.
const nSmall = scenario.small ?? SMALL;
const nLarge = scenario.large ?? LARGE;
const small = measureCollect(scenario.gen(nSmall), `${scenario.name}.ts`, REPS);
const large = measureCollect(scenario.gen(nLarge), `${scenario.name}.ts`, REPS);
const sizeRatio = nLarge / nSmall;
const scalingRatio = small.ms > 0 ? large.ms / small.ms / sizeRatio : 0;
const diskRatio = small.diskBytes > 0 ? large.diskBytes / small.diskBytes / sizeRatio : 0;
// Memory growth (only when --expose-gc gave us a forced GC).
const heapSmall = retainedHeapBytes(scenario.gen(nSmall), `${scenario.name}.ts`);
const heapLarge = retainedHeapBytes(scenario.gen(nLarge), `${scenario.name}.ts`);
const heapRatio =
heapSmall !== null && heapLarge !== null && heapSmall > 0
? heapLarge / heapSmall / sizeRatio
: null;
return {
scenario: scenario.name,
elapsed_ms_small: Number(small.ms.toFixed(3)),
elapsed_ms_large: Number(large.ms.toFixed(3)),
scaling_ratio: Number(scalingRatio.toFixed(3)),
disk_bytes_small: small.diskBytes,
disk_bytes_large: large.diskBytes,
disk_bytes_ratio: Number(diskRatio.toFixed(3)),
heap_bytes_small: heapSmall,
heap_bytes_large: heapLarge,
heap_ratio: heapRatio === null ? null : Number(heapRatio.toFixed(3)),
blocks_small: small.blockCount,
blocks_large: large.blockCount,
...fingerprint(scenario),
};
}
// ---- run ----
const CHECK = process.argv.includes('--check');
// The retained-heap budget is a primary regression detector, but it can only be
// measured with a forced GC. Rather than let `--check` silently PASS with the
// heap gate skipped (a green no-op if someone drops --expose-gc), fail loudly.
if (CHECK && !GC) {
process.stderr.write(
'[cfg --check] FAIL: retained-heap gate requires --expose-gc. ' +
'Run: node --expose-gc --import tsx bench/cfg/measure.mjs --check\n',
);
process.exit(1);
}
const results = SCENARIOS.map(measureScenario);
if (!CHECK) {
for (const r of results) process.stdout.write(JSON.stringify(r) + '\n');
} else {
const baselines = JSON.parse(fs.readFileSync(BASELINE_PATH, 'utf8'));
const failures = [];
for (const r of results) {
const base = baselines[r.scenario];
if (base === undefined) {
failures.push(`${r.scenario}: no baseline recorded`);
continue;
}
if (r.fingerprint !== base.fingerprint) {
failures.push(
`${r.scenario}: CFG fingerprint drift (got ${r.fingerprint}, expected ${base.fingerprint})`,
);
}
if (r.scaling_ratio >= base.scaling_budget) {
failures.push(
`${r.scenario}: scaling ratio ${r.scaling_ratio} >= budget ${base.scaling_budget} ` +
`(${SMALL}->${LARGE} stmts/fns, ms ${r.elapsed_ms_small}->${r.elapsed_ms_large})`,
);
}
if (base.disk_bytes_budget !== undefined && r.disk_bytes_ratio >= base.disk_bytes_budget) {
failures.push(
`${r.scenario}: cfgSideChannel disk-bytes ratio ${r.disk_bytes_ratio} >= budget ` +
`${base.disk_bytes_budget} (bytes ${r.disk_bytes_small}->${r.disk_bytes_large})`,
);
}
// Heap gate only when measured (--expose-gc present) AND a budget exists.
if (
base.heap_budget !== undefined &&
r.heap_ratio !== null &&
r.heap_ratio >= base.heap_budget
) {
failures.push(
`${r.scenario}: retained-heap ratio ${r.heap_ratio} >= budget ${base.heap_budget} ` +
`(heap ${r.heap_bytes_small}->${r.heap_bytes_large})`,
);
}
process.stdout.write(JSON.stringify(r) + '\n');
}
if (failures.length > 0) {
for (const f of failures) process.stderr.write(`[cfg --check] FAIL: ${f}\n`);
process.exit(1);
}
process.stderr.write(`[cfg --check] PASS (${results.length} scenarios)\n`);
}

View file

@ -85,6 +85,7 @@ const KEY_SPECS: Record<string, KeySpec> = {
skipContextFiles: { target: 'skipAgentsMd', kind: 'boolean' },
skipAiContext: { target: 'skipAgentsMd', kind: 'boolean' },
skipSkills: { target: 'skipSkills', kind: 'boolean' },
pdg: { target: 'pdg', kind: 'boolean' },
indexOnly: { target: 'indexOnly', kind: 'boolean' },
stats: { target: 'stats', kind: 'boolean' },
noStats: { target: 'stats', kind: 'boolean-negate' },

View file

@ -599,6 +599,12 @@ export interface AnalyzeOptions {
verbose?: boolean;
/** Skip AGENTS.md and CLAUDE.md gitnexus block updates. */
skipAgentsMd?: boolean;
/**
* Build the control-flow-graph / PDG substrate (#2081 M1). Opt-in; off by
* default. Threaded to both the worker (CFG build) and scope-resolution
* (BasicBlock/CFG emit).
*/
pdg?: boolean;
/**
* Stats inclusion in AGENTS.md and CLAUDE.md.
*
@ -1122,6 +1128,8 @@ const analyzeCommandImpl = async (
skipGit: options.skipGit,
skipAgentsMd,
skipSkills,
// CFG/PDG substrate opt-in (#2081 M1) — threaded to both sinks downstream.
pdg: options.pdg === true,
// Resolved default branch (CLI > .gitnexusrc > auto-detect > "main")
// threaded into the generated regression-compare example (#243).
defaultBranch: resolvedDefaultBranch,

View file

@ -52,6 +52,11 @@ program
'(no-op when --index-only is also set).',
)
.option('--skip-agents-md', 'Skip updating the gitnexus section in AGENTS.md and CLAUDE.md')
.option(
'--pdg',
'Build the control-flow-graph / PDG substrate (BasicBlock nodes + CFG edges) ' +
'for supported languages. Opt-in; off by default. (#2081 M1)',
)
.option(
'--default-branch <branch>',
'Default branch used in the generated regression-compare example (base_ref). ' +

View file

@ -0,0 +1,132 @@
/**
* CfgBuilder (issue #2081, M1) the language-agnostic accumulator.
*
* A per-language `CfgVisitor` drives this: it creates blocks as it walks
* statements, wires edges (including back-edges and break/continue/return/throw
* targets resolved via {@link ControlFlowContext}), and calls {@link finish} to
* produce the serializable {@link FunctionCfg}. The builder owns the synthetic
* ENTRY (index 0) and EXIT blocks and de-duplicates identical edges so repeated
* `connect` calls (common when wiring a set of dangling exits) stay idempotent.
*
* It has no knowledge of any AST it is exercised directly in unit tests with
* hand-built block sequences, which is how the classic CFG hazards are pinned
* before the tree-sitter visitor (U2) drives it.
*/
import type { BasicBlockData, CfgEdgeData, CfgEdgeKind, FunctionCfg } from './types.js';
interface MutableBlock {
startLine: number;
endLine: number;
/**
* Block source accumulated as fragments, joined once in {@link finish}. A
* coalescing straight-line run appends one fragment per statement; storing
* them as an array and joining at the end keeps that O(n) instead of the
* O(n²) of repeatedly concatenating onto a growing string (a long generated
* init function is the worst case see bench/cfg).
*/
textParts: string[];
kind: BasicBlockData['kind'];
}
export class CfgBuilder {
private readonly blocks: MutableBlock[] = [];
private readonly edges: CfgEdgeData[] = [];
private readonly edgeKeys = new Set<string>();
readonly entryIndex: number;
readonly exitIndex: number;
constructor(
private readonly filePath: string,
private readonly functionStartLine: number,
private readonly functionEndLine: number,
/** Start column of the owning function disambiguates same-line functions
* in the BasicBlock ids (see {@link FunctionCfg.functionStartColumn}).
* Defaults to 0 for hand-built test CFGs that don't model columns. */
private readonly functionStartColumn: number = 0,
) {
this.entryIndex = this.newBlock(functionStartLine, functionStartLine, '', 'entry');
this.exitIndex = this.newBlock(functionEndLine, functionEndLine, '', 'exit');
}
/** Create a block and return its index. */
newBlock(
startLine: number,
endLine: number,
text: string,
kind: BasicBlockData['kind'] = 'normal',
): number {
this.blocks.push({ startLine, endLine, textParts: text ? [text] : [], kind });
return this.blocks.length - 1;
}
/** Add a single edge (idempotent on from+to+kind). */
edge(from: number, to: number, kind: CfgEdgeKind): void {
const key = `${from}->${to}:${kind}`;
if (this.edgeKeys.has(key)) return;
this.edgeKeys.add(key);
this.edges.push({ from, to, kind });
}
/** Wire a set of dangling exits to a single target block with one kind. */
connect(exits: readonly number[], to: number, kind: CfgEdgeKind = 'seq'): void {
for (const from of exits) this.edge(from, to, kind);
}
/** Extend a block's end line as more statements accrue to it. */
extendBlock(index: number, endLine: number, appendText?: string): void {
const b = this.blocks[index];
if (!b) return;
if (endLine > b.endLine) b.endLine = endLine;
if (appendText) b.textParts.push(appendText);
}
get blockCount(): number {
return this.blocks.length;
}
/** Produce the serializable CFG. Caller is responsible for having wired the
* function's dangling exits to {@link exitIndex} before calling. */
finish(): FunctionCfg {
return {
filePath: this.filePath,
functionStartLine: this.functionStartLine,
functionEndLine: this.functionEndLine,
functionStartColumn: this.functionStartColumn,
entryIndex: this.entryIndex,
exitIndex: this.exitIndex,
blocks: this.blocks.map((b, index) => ({
index,
startLine: b.startLine,
endLine: b.endLine,
text: b.textParts.join('\n'),
kind: b.kind,
})),
edges: [...this.edges],
};
}
}
/**
* Block indices reachable from `entryIndex` by following edges. Backs the
* reachability property tests (R9) over hand-built and visitor-produced CFGs.
*/
export const reachableBlocks = (cfg: FunctionCfg): Set<number> => {
const adj = new Map<number, number[]>();
for (const e of cfg.edges) {
const list = adj.get(e.from);
if (list) list.push(e.to);
else adj.set(e.from, [e.to]);
}
const seen = new Set<number>([cfg.entryIndex]);
const stack = [cfg.entryIndex];
while (stack.length) {
const n = stack.pop() as number;
for (const next of adj.get(n) ?? []) {
if (!seen.has(next)) {
seen.add(next);
stack.push(next);
}
}
}
return seen;
};

View file

@ -0,0 +1,63 @@
/**
* collectFunctionCfgs (issue #2081, M1).
*
* Walks a parsed file's tree-sitter tree and builds one {@link FunctionCfg} per
* CFG-bearing function via the language's {@link CfgVisitor}. Runs IN THE PARSE
* WORKER (where the AST lives KTD1/KTD7); the result rides on
* `ParsedFile.cfgSideChannel` across the workermain boundary.
*
* Nested functions are enumerated independently each gets its own CFG, and
* appears as an opaque straight-line block in its enclosing function's CFG (the
* visitor does not descend into nested function bodies). `maxFunctionLines`
* bounds per-function cost: a function whose source span exceeds the cap is
* skipped (and counted) rather than walked, so a pathological mega-function
* cannot blow up worker time/memory. A cap of `0` means no limit.
*/
import type { SyntaxNode } from '../utils/ast-helpers.js';
import type { CfgVisitor, FunctionCfg } from './types.js';
/**
* Default per-function source-line cap used by the worker when the `--pdg` run
* does not specify `pdgMaxFunctionLines`. A function longer than this (almost
* always minified/generated code) is skipped rather than walked its CFG is
* both expensive and low-value. Overridable via `PipelineOptions.pdgMaxFunctionLines`.
*/
export const DEFAULT_PDG_MAX_FUNCTION_LINES = 2000;
export interface CollectedCfgs {
readonly cfgs: readonly FunctionCfg[];
/** Functions skipped for exceeding `maxFunctionLines` (0 ⇒ none skipped). */
readonly skipped: number;
}
export function collectFunctionCfgs(
root: SyntaxNode,
visitor: CfgVisitor<SyntaxNode>,
filePath: string,
maxFunctionLines = 0,
): CollectedCfgs {
const cfgs: FunctionCfg[] = [];
let skipped = 0;
const stack: SyntaxNode[] = [root];
while (stack.length) {
const node = stack.pop() as SyntaxNode;
if (visitor.isFunction(node)) {
const lines = node.endPosition.row - node.startPosition.row + 1;
if (maxFunctionLines > 0 && lines > maxFunctionLines) {
skipped++;
} else {
const cfg = visitor.buildFunctionCfg(node, filePath);
if (cfg) cfgs.push(cfg);
}
}
// Descend regardless (a skipped mega-function may still contain small
// nested functions that are worth a CFG of their own).
for (let i = node.namedChildCount - 1; i >= 0; i--) {
const child = node.namedChild(i);
if (child) stack.push(child);
}
}
return { cfgs, skipped };
}

View file

@ -0,0 +1,70 @@
/**
* ControlFlowContext (issue #2081, M1).
*
* Resolves the targets of `break`/`continue` (plain and labeled) as the visitor
* descends through loops and switches. Loops and switches push a target frame
* on entry and pop it on exit; a labeled statement attaches its label to the
* frame of the construct it labels, so `break outer` / `continue outer` resolve
* against the right enclosing loop/switch rather than the nearest one.
*/
interface LoopFrame {
readonly kind: 'loop';
/** Block a `continue` jumps to (the loop header / update). */
readonly continueTo: number;
/** Block a `break` jumps to (the loop exit / join). */
readonly breakTo: number;
readonly label?: string;
}
interface SwitchFrame {
readonly kind: 'switch';
/** Block a `break` jumps to (after the switch). `continue` is invalid here. */
readonly breakTo: number;
readonly label?: string;
}
type Frame = LoopFrame | SwitchFrame;
export class ControlFlowContext {
private readonly stack: Frame[] = [];
pushLoop(continueTo: number, breakTo: number, label?: string): void {
this.stack.push({ kind: 'loop', continueTo, breakTo, label });
}
pushSwitch(breakTo: number, label?: string): void {
this.stack.push({ kind: 'switch', breakTo, label });
}
pop(): void {
this.stack.pop();
}
/**
* Target block for a `break`. With a label, the nearest enclosing frame
* carrying that label (loop or switch); without, the nearest frame of any
* kind. Returns `undefined` if there is no valid target (malformed input).
*/
breakTarget(label?: string): number | undefined {
for (let i = this.stack.length - 1; i >= 0; i--) {
const f = this.stack[i];
if (label === undefined || f.label === label) return f.breakTo;
}
return undefined;
}
/**
* Target block for a `continue`. With a label, the nearest enclosing **loop**
* carrying that label; without, the nearest loop (switches are skipped you
* cannot `continue` a switch). Returns `undefined` if there is no valid loop.
*/
continueTarget(label?: string): number | undefined {
for (let i = this.stack.length - 1; i >= 0; i--) {
const f = this.stack[i];
if (f.kind !== 'loop') continue;
if (label === undefined || f.label === label) return f.continueTo;
}
return undefined;
}
}

View file

@ -0,0 +1,147 @@
/**
* cfg/emit.ts (issue #2081, M1) serialized side-channel graph.
*
* Pure helper: given a file's per-function CFGs (off `ParsedFile.cfgSideChannel`,
* produced by the worker in U3), emit one persisted `BasicBlock` node per block
* and one `CFG` edge per edge into the {@link KnowledgeGraph}. Invoked from
* scope-resolution (run.ts Phase 4) while the disk-backed ParsedFile store is
* still live the only window where the worker-built CFGs are loaded (KTD1/
* KTD5). Default (`--pdg` off) runs never call this, so the emitted graph stays
* byte-identical to a pre-#2081 run.
*
* BasicBlock id: `BasicBlock:<filePath>:<functionStartLine>:<functionStartColumn>:<blockIndex>`
* (KTD3). The function start line+column segments disambiguate blocks across
* multiple functions in one file including same-line functions since each
* function's block indices restart at 0; blocks carry no `name` (the
* BasicBlock table has no such column). The edge KIND
* (`seq`/`cond-true`/) rides in the relationship `reason` CFG edges are
* values of the single `CodeRelation` table's `type` column (`'CFG'`), so the
* kind cannot be its own edge type and is queried via `reason`.
*/
import type { KnowledgeGraph } from '../../graph/types.js';
import { generateId } from '../../../lib/utils.js';
import type { FunctionCfg } from './types.js';
/**
* Default per-function CFG edge cap. A pathological generated function could
* otherwise emit an unbounded edge set; the cap bounds graph growth and is
* overridable via `--pdg` options. `0` (in options) means no cap (unlimited
* see the `cap` mapping in {@link emitFileCfgs}); `undefined` means this
* default.
*/
export const DEFAULT_MAX_CFG_EDGES_PER_FUNCTION = 5000;
export interface CfgEmitResult {
blocks: number;
edges: number;
/** Edges dropped because a function's edge count exceeded the cap. */
droppedEdges: number;
/** Number of functions that hit the cap. */
cappedFunctions: number;
}
const basicBlockId = (
filePath: string,
functionStartLine: number,
functionStartColumn: number,
blockIndex: number,
): string => `BasicBlock:${filePath}:${functionStartLine}:${functionStartColumn}:${blockIndex}`;
/**
* Whether an untrusted `cfgSideChannel` element is safe to feed to
* {@link emitFileCfgs}. Deliberately NOT full FunctionCfg validation it
* checks exactly the fields whose corruption is SILENT given emit's
* mechanics: {@link basicBlockId} string-templates every id-anchor value
* (filePath, function start line/column, block index, edge endpoints) and
* the graph's addNode/addRelationship are no-throw Map inserts. Unchecked,
* a missing anchor field cross-wires same-`undefined`-id blocks across
* functions (addNode is first-writer-wins), and an edge endpoint that
* matches no block index becomes a dangling `BasicBlock:…:<n>` edge that
* detonates much later at DB bulk-load instead of throwing here so
* endpoints are checked for MEMBERSHIP in the block-index set, not just
* integer-ness. Lives in this module so the guard evolves with the id
* templating it defends (#2099 F4; M2 fields that join the id path must
* join this check).
*/
export const isEmitSafeCfg = (cfg: FunctionCfg | undefined | null): cfg is FunctionCfg => {
if (
typeof cfg?.filePath !== 'string' ||
!Number.isInteger(cfg.functionStartLine) ||
!Number.isInteger(cfg.functionStartColumn) ||
!Array.isArray(cfg.blocks) ||
!Array.isArray(cfg.edges)
) {
return false;
}
const blockIndices = new Set<number>();
for (const b of cfg.blocks) {
if (!Number.isInteger(b?.index)) return false;
blockIndices.add(b.index);
}
return cfg.edges.every((e) => blockIndices.has(e?.from) && blockIndices.has(e?.to));
};
/**
* Emit BasicBlock nodes + CFG edges for every function CFG in `cfgs`.
*
* `maxEdgesPerFunction` caps edges per function. On overflow we stop emitting
* that function's remaining edges and call `onWarn` naming the dropped count
* no silent truncation (KTD6/R6). Block nodes are always fully emitted (their
* count is bounded by the function's statement count); only edges are capped.
*/
export function emitFileCfgs(
graph: KnowledgeGraph,
cfgs: readonly FunctionCfg[],
maxEdgesPerFunction: number = DEFAULT_MAX_CFG_EDGES_PER_FUNCTION,
onWarn?: (message: string) => void,
): CfgEmitResult {
const result: CfgEmitResult = { blocks: 0, edges: 0, droppedEdges: 0, cappedFunctions: 0 };
const cap = maxEdgesPerFunction > 0 ? maxEdgesPerFunction : Infinity;
for (const cfg of cfgs) {
const { filePath, functionStartLine, functionStartColumn } = cfg;
for (const b of cfg.blocks) {
graph.addNode({
id: basicBlockId(filePath, functionStartLine, functionStartColumn, b.index),
label: 'BasicBlock',
properties: {
name: '', // BasicBlock has no name column; identified by id + span
filePath,
startLine: b.startLine,
endLine: b.endLine,
text: b.text,
},
});
result.blocks++;
}
let emittedForFn = 0;
for (const e of cfg.edges) {
if (emittedForFn >= cap) {
const dropped = cfg.edges.length - emittedForFn;
result.droppedEdges += dropped;
result.cappedFunctions++;
onWarn?.(
`[cfg] ${filePath}:${functionStartLine}: per-function CFG edge cap ` +
`(${maxEdgesPerFunction}) reached — dropped ${dropped} of ${cfg.edges.length} edges`,
);
break;
}
const sourceId = basicBlockId(filePath, functionStartLine, functionStartColumn, e.from);
const targetId = basicBlockId(filePath, functionStartLine, functionStartColumn, e.to);
graph.addRelationship({
id: generateId('CFG', `${sourceId}->${targetId}:${e.kind}`),
type: 'CFG',
sourceId,
targetId,
confidence: 1.0,
reason: e.kind, // CfgEdgeKind (seq/cond-true/loop-back/…) — queryable
});
result.edges++;
emittedForFn++;
}
}
return result;
}

View file

@ -0,0 +1,21 @@
/**
* TraversalResult (issue #2081, M1).
*
* Visiting a statement (or a statement sequence) returns the block its control
* flow ENTERS through, plus the set of blocks whose **normal** control flows
* out the bottom (the "dangling exits") to be wired to the entry of whatever
* comes next. Abnormal exits (return/break/continue/throw) are wired directly
* to their targets during the walk and are NOT part of `exits`.
*
* A statement that cannot fall through (e.g. ends in `return`/`throw`, or both
* branches of an `if` return) yields an empty `exits` array.
*/
export interface TraversalResult {
/** Block index control enters this statement/sequence through. */
readonly entry: number;
/** Block indices whose normal control falls out the bottom (may be empty). */
readonly exits: readonly number[];
}
/** A sequence of statements that produced no blocks (e.g. an empty body). */
export const emptyTraversal = (entry: number): TraversalResult => ({ entry, exits: [entry] });

View file

@ -0,0 +1,82 @@
/**
* CFG data model plain, JSON-serializable types (issue #2081, M1).
*
* These cross the workermain boundary and the disk-backed/durable ParsedFile
* store, so they must contain NO tree-sitter AST references, class instances,
* or anything that does not survive `JSON.stringify` `JSON.parse`. Block and
* edge endpoints are referenced by integer index within a function's CFG.
*
* The per-language `CfgVisitor` (built in the parse worker, where the AST
* lives see the M1 plan KTD1/KTD7) produces a `FunctionCfg` per function; the
* array of them is what rides on `ParsedFile.cfgSideChannel`.
*/
/** A basic block: a maximal straight-line run of statements between leaders. */
export interface BasicBlockData {
/** Block index within its function. The synthetic ENTRY is always 0. */
readonly index: number;
readonly startLine: number;
readonly endLine: number;
/** Source snippet for the block (empty for synthetic ENTRY/EXIT). */
readonly text: string;
readonly kind: 'entry' | 'exit' | 'normal';
}
/** Why one block flows to another — drives the `reason` on the emitted CFG edge. */
export type CfgEdgeKind =
| 'seq' // straight-line fallthrough
| 'cond-true' // branch taken (if/while/for condition true)
| 'cond-false' // branch not taken / loop exit
| 'loop-back' // back-edge to a loop header
| 'break' // break → loop/switch exit
| 'continue' // continue → loop header
| 'return' // return → function EXIT
| 'throw' // throw → nearest handler / finally / EXIT
| 'switch-case' // dispatch to a case
| 'fallthrough'; // switch case → next case (no break)
export interface CfgEdgeData {
readonly from: number;
readonly to: number;
readonly kind: CfgEdgeKind;
}
/** One function's control-flow graph. `cfgSideChannel` is `readonly FunctionCfg[]`. */
export interface FunctionCfg {
readonly filePath: string;
/** Source span of the owning function — anchors the BasicBlock node ids. */
readonly functionStartLine: number;
readonly functionEndLine: number;
/**
* Start COLUMN of the owning function. Combined with `functionStartLine` it
* disambiguates the BasicBlock node ids when two functions share a start line
* e.g. `{ a: () => x(), b: () => y() }`, where both arrows begin on the same
* line and each restarts its block indices at 0. Without the column the ids
* collide and the graph's first-writer-wins `addNode` silently drops the
* second function's blocks and cross-wires its edges.
*/
readonly functionStartColumn: number;
readonly entryIndex: number;
readonly exitIndex: number;
readonly blocks: readonly BasicBlockData[];
readonly edges: readonly CfgEdgeData[];
}
/**
* Per-language CFG strategy. Invoked **in the parse worker** for each function
* node. `TNode` is the language's AST node type (tree-sitter `SyntaxNode` for
* TS/JS) kept generic so this module stays AST-library-agnostic. Returns
* `undefined` when the node is not a CFG-bearing function (the caller skips it).
*/
export interface CfgVisitor<TNode = unknown> {
buildFunctionCfg(fnNode: TNode, filePath: string): FunctionCfg | undefined;
/**
* Whether `node` is a CFG-bearing function this visitor handles. Lets the
* worker enumerate functions (and apply the per-function line budget) by a
* cheap node-type test, instead of attempting to build a CFG for every AST
* node. `buildFunctionCfg` still re-checks, so this is purely an optimization
* + the seam the line-budget hooks into.
*/
isFunction(node: TNode): boolean;
}

View file

@ -0,0 +1,581 @@
/**
* TS/JS CfgVisitor (issue #2081, M1).
*
* Walks a TypeScript/JavaScript function's tree-sitter AST and drives the
* language-agnostic {@link CfgBuilder} to produce a serializable
* {@link FunctionCfg}. TS and JS share a grammar family (tree-sitter-typescript
* reuses tree-sitter-javascript's statement nodes), so one visitor covers both.
*
* Design a `visit_<node_type>` dispatch over the statement taxonomy. The
* classic CFG hazards (R10) are handled explicitly:
* - loops allocate a dedicated **loop-exit** block so `break` has a concrete
* target before the loop's successor is known; `continue` targets the
* header/increment; the back-edge closes the loop.
* - `switch` cases fall through naturally: a case body that does not `break`
* yields non-empty `exits`, which we wire to the next case as `fallthrough`;
* a case that `break`s wires to the switch exit (via {@link ControlFlowContext})
* and yields no fall-out.
* - `try/catch/finally` routes both normal completion AND a `throw` in the try
* through `finally` (the finally block post-dominates the try/catch); a
* `throw` with no catch propagates through finally to the enclosing handler.
* - labeled `break`/`continue` resolve against the labeled loop's frame.
*
* Known M1 limitations:
* - SOUNDNESS GAP (M2 blocker, not mere precision): a non-local jump
* (`break`/`continue`/`return`) out of a `try` that has a `finally` edges
* directly to its target rather than routing THROUGH the `finally` block
* first. A future taint/PDG pass will therefore MISS flow mediated by a
* `finally` on the early-exit path (e.g. a value the `finally` taints or
* sanitizes before the `return` reaches its target) a false negative. The
* general fix duplicates `finally` per exit path; deferred past M1 and
* tracked for M2. Normal completion and `throw` DO route through `finally`.
* - A `break`/`continue` to a label on a non-loop/non-switch block, and the
* OUTER label of a doubly-labeled construct (`outer: inner: for (...)`), are
* not modeled. The jump is conservatively routed to the function EXIT (a
* sound over-approximation that keeps the graph single-exit see visitBreak)
* rather than left as a dangling sink; only the precise labeled target is
* unmodeled. Single-labeled loops/switches resolve correctly.
*
* Block/edge accounting and reachability are pinned in
* `test/unit/cfg/cfg-builder.test.ts` (core) and
* `test/unit/cfg/typescript-visitor.test.ts` (this visitor, per hazard).
*/
import type { SyntaxNode } from '../../utils/ast-helpers.js';
import { CfgBuilder } from '../cfg-builder.js';
import { ControlFlowContext } from '../control-flow-context.js';
import type { TraversalResult } from '../traversal-result.js';
import type { CfgVisitor, FunctionCfg } from '../types.js';
/** TS/JS node types that own a CFG-bearing function body. */
const TS_FUNCTION_TYPES = new Set([
'function_declaration',
'function_expression',
'arrow_function',
'method_definition',
'generator_function_declaration',
'generator_function',
'async_function_declaration',
'async_arrow_function',
]);
/** Statement node types that break a basic block (everything else coalesces). */
const CONTROL_FLOW_TYPES = new Set([
'if_statement',
'while_statement',
'do_statement',
'for_statement',
'for_in_statement',
'for_of_statement',
'switch_statement',
'try_statement',
'return_statement',
'break_statement',
'continue_statement',
'throw_statement',
'labeled_statement',
'statement_block',
]);
const LOOP_OR_SWITCH_TYPES = new Set([
'while_statement',
'do_statement',
'for_statement',
'for_in_statement',
'for_of_statement',
'switch_statement',
]);
const startLineOf = (n: SyntaxNode): number => n.startPosition.row + 1;
const endLineOf = (n: SyntaxNode): number => n.endPosition.row + 1;
/** A statement sequence that produced no blocks (empty body) is "transparent". */
type SeqResult = TraversalResult | null;
/**
* Per-function walk state. One instance is created per function so the
* {@link ControlFlowContext}, exception-handler stack, and pending label are
* scoped to that function and never leak across functions.
*/
class TsCfgWalk {
private readonly cfc = new ControlFlowContext();
/** Stack of exception-handler entry blocks (catch/finally) a `throw` jumps to. */
private readonly handlers: number[] = [];
/** Label awaiting the loop/switch it immediately precedes (labeled_statement). */
private pendingLabel: string | undefined;
constructor(private readonly builder: CfgBuilder) {}
/** Statements of a block node, ignoring comments. */
private statementsOf(block: SyntaxNode): SyntaxNode[] {
return block.namedChildren.filter((c) => c.type !== 'comment');
}
/** The `body` block of a node (field, or the first statement_block child). */
private bodyBlockOf(node: SyntaxNode): SyntaxNode | undefined {
return (
node.childForFieldName('body') ?? node.namedChildren.find((c) => c.type === 'statement_block')
);
}
/** Visit a body that may be a `statement_block` or a single statement. */
private visitBody(node: SyntaxNode | undefined | null): SeqResult {
if (!node) return null;
if (node.type === 'statement_block') return this.visitSeq(this.statementsOf(node));
return this.visitStmt(node);
}
/** Wire a sequence of statements, coalescing straight-line runs into blocks. */
visitSeq(stmts: SyntaxNode[]): SeqResult {
let entry: number | undefined;
let dangling: number[] = [];
let openSimple: number | undefined;
for (const stmt of stmts) {
if (CONTROL_FLOW_TYPES.has(stmt.type)) {
openSimple = undefined; // close any open straight-line block
const res = this.visitStmt(stmt);
if (res === null) continue; // transparent (empty nested block)
if (entry === undefined) entry = res.entry;
else this.builder.connect(dangling, res.entry, 'seq');
dangling = [...res.exits];
} else {
// Simple statement — coalesce into the current straight-line block.
if (openSimple === undefined) {
const idx = this.builder.newBlock(startLineOf(stmt), endLineOf(stmt), stmt.text);
if (entry === undefined) entry = idx;
else this.builder.connect(dangling, idx, 'seq');
openSimple = idx;
dangling = [idx];
} else {
this.builder.extendBlock(openSimple, endLineOf(stmt), stmt.text);
}
}
}
if (entry === undefined) return null;
return { entry, exits: dangling };
}
/** Dispatch one statement to its handler. Non-null except for empty blocks. */
visitStmt(stmt: SyntaxNode): SeqResult {
switch (stmt.type) {
case 'if_statement':
return this.visitIf(stmt);
case 'while_statement':
return this.visitWhile(stmt);
case 'do_statement':
return this.visitDoWhile(stmt);
case 'for_statement':
return this.visitFor(stmt);
case 'for_in_statement':
case 'for_of_statement':
return this.visitForIn(stmt);
case 'switch_statement':
return this.visitSwitch(stmt);
case 'try_statement':
return this.visitTry(stmt);
case 'return_statement':
return this.visitReturn(stmt);
case 'throw_statement':
return this.visitThrow(stmt);
case 'break_statement':
return this.visitBreak(stmt);
case 'continue_statement':
return this.visitContinue(stmt);
case 'labeled_statement':
return this.visitLabeled(stmt);
case 'statement_block':
return this.visitSeq(this.statementsOf(stmt));
default:
return this.visitSimple(stmt);
}
}
private visitSimple(stmt: SyntaxNode): TraversalResult {
const idx = this.builder.newBlock(startLineOf(stmt), endLineOf(stmt), stmt.text);
return { entry: idx, exits: [idx] };
}
private visitReturn(stmt: SyntaxNode): TraversalResult {
const idx = this.builder.newBlock(startLineOf(stmt), endLineOf(stmt), stmt.text);
this.builder.edge(idx, this.builder.exitIndex, 'return');
return { entry: idx, exits: [] };
}
private visitThrow(stmt: SyntaxNode): TraversalResult {
const idx = this.builder.newBlock(startLineOf(stmt), endLineOf(stmt), stmt.text);
this.builder.edge(idx, this.currentHandler(), 'throw');
return { entry: idx, exits: [] };
}
private visitBreak(stmt: SyntaxNode): TraversalResult {
const idx = this.builder.newBlock(startLineOf(stmt), endLineOf(stmt), stmt.text);
const target = this.cfc.breakTarget(this.labelOf(stmt));
// An unresolved target — a label this M1 visitor doesn't model (a stacked
// outer label like `outer: inner: for`, or a labeled non-loop block) —
// would otherwise leave this block with NO out-edge, stranding it and
// breaking the single-exit invariant a downstream post-dominator / PDG pass
// relies on. Conservatively route an unresolved jump to the function EXIT
// ("escapes the function"): sound over-approximation, keeps single-exit.
this.builder.edge(idx, target ?? this.builder.exitIndex, 'break');
return { entry: idx, exits: [] };
}
private visitContinue(stmt: SyntaxNode): TraversalResult {
const idx = this.builder.newBlock(startLineOf(stmt), endLineOf(stmt), stmt.text);
const target = this.cfc.continueTarget(this.labelOf(stmt));
// See visitBreak: an unresolved label routes to EXIT to preserve single-exit.
this.builder.edge(idx, target ?? this.builder.exitIndex, 'continue');
return { entry: idx, exits: [] };
}
private visitLabeled(stmt: SyntaxNode): SeqResult {
const body =
stmt.childForFieldName('body') ?? stmt.namedChildren[stmt.namedChildren.length - 1];
if (body && LOOP_OR_SWITCH_TYPES.has(body.type)) {
this.pendingLabel = this.labelOf(stmt);
const res = this.visitStmt(body);
this.pendingLabel = undefined; // clear even if the construct didn't consume it
return res;
}
// Labeled non-loop blocks (break-to-block-label) are not modeled in M1.
return this.visitBody(body);
}
private visitIf(stmt: SyntaxNode): TraversalResult {
const cond = stmt.childForFieldName('condition') ?? stmt;
const condBlock = this.builder.newBlock(startLineOf(stmt), endLineOf(cond), cond.text);
const exits: number[] = [];
const thenRes = this.visitBody(stmt.childForFieldName('consequence'));
if (thenRes) {
this.builder.edge(condBlock, thenRes.entry, 'cond-true');
exits.push(...thenRes.exits);
} else {
exits.push(condBlock); // empty then — true path falls through
}
const elseNode = this.elseBodyOf(stmt);
if (elseNode) {
const elseRes = this.visitBody(elseNode);
if (elseRes) {
this.builder.edge(condBlock, elseRes.entry, 'cond-false');
exits.push(...elseRes.exits);
} else {
exits.push(condBlock); // empty else block
}
} else {
exits.push(condBlock); // no else — false path falls through to the join
}
return { entry: condBlock, exits: [...new Set(exits)] };
}
/** The else body node (unwraps an `else_clause` wrapper if present). */
private elseBodyOf(ifStmt: SyntaxNode): SyntaxNode | undefined {
const alt = ifStmt.childForFieldName('alternative');
if (!alt) return undefined;
if (alt.type === 'else_clause') {
return alt.childForFieldName('body') ?? alt.namedChildren[0];
}
return alt;
}
private visitWhile(stmt: SyntaxNode): TraversalResult {
const label = this.takeLabel();
const cond = stmt.childForFieldName('condition') ?? stmt;
const header = this.builder.newBlock(startLineOf(stmt), endLineOf(cond), cond.text);
const loopExit = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), '');
this.cfc.pushLoop(header, loopExit, label);
const body = this.visitBody(this.bodyBlockOf(stmt));
this.cfc.pop();
if (body) {
this.builder.edge(header, body.entry, 'cond-true');
this.builder.connect(body.exits, header, 'loop-back');
} else {
this.builder.edge(header, header, 'loop-back'); // empty body re-tests
}
this.builder.edge(header, loopExit, 'cond-false');
return { entry: header, exits: [loopExit] };
}
private visitDoWhile(stmt: SyntaxNode): TraversalResult {
const label = this.takeLabel();
const cond = stmt.childForFieldName('condition') ?? stmt;
const condBlock = this.builder.newBlock(startLineOf(cond), endLineOf(cond), cond.text);
const loopExit = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), '');
this.cfc.pushLoop(condBlock, loopExit, label);
const body = this.visitBody(this.bodyBlockOf(stmt));
this.cfc.pop();
const backTarget = body ? body.entry : condBlock;
if (body) this.builder.connect(body.exits, condBlock, 'seq');
this.builder.edge(condBlock, backTarget, 'loop-back'); // cond true → run body again
this.builder.edge(condBlock, loopExit, 'cond-false');
return { entry: backTarget, exits: [loopExit] };
}
private visitFor(stmt: SyntaxNode): TraversalResult {
const label = this.takeLabel();
const init = stmt.childForFieldName('initializer');
const cond = stmt.childForFieldName('condition');
const incr = stmt.childForFieldName('increment');
const header = this.builder.newBlock(
startLineOf(stmt),
cond ? endLineOf(cond) : startLineOf(stmt),
cond ? cond.text : 'for(;;)',
);
const loopExit = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), '');
let incrBlock = header;
if (incr) {
incrBlock = this.builder.newBlock(startLineOf(incr), endLineOf(incr), incr.text);
this.builder.edge(incrBlock, header, 'loop-back');
}
this.cfc.pushLoop(incrBlock, loopExit, label);
const body = this.visitBody(this.bodyBlockOf(stmt));
this.cfc.pop();
if (body) {
this.builder.edge(header, body.entry, 'cond-true');
// With no increment clause the body's exits ARE the back-edge — carry
// the loop-back kind on them (mirroring visitWhile/visitForIn) instead
// of a phantom header→header self-loop that models a path which never
// executes the body. With an increment, the body falls through to the
// increment (`seq`) and the increment carries the loop-back (:338).
this.builder.connect(body.exits, incrBlock, incr ? 'seq' : 'loop-back');
} else {
this.builder.edge(header, incrBlock, 'cond-true');
// Empty body with no increment: the header genuinely re-tests itself.
if (!incr) this.builder.edge(header, header, 'loop-back');
}
this.builder.edge(header, loopExit, 'cond-false');
let entry = header;
if (init) {
const initBlock = this.builder.newBlock(startLineOf(init), endLineOf(init), init.text);
this.builder.edge(initBlock, header, 'seq');
entry = initBlock;
}
return { entry, exits: [loopExit] };
}
private visitForIn(stmt: SyntaxNode): TraversalResult {
const label = this.takeLabel();
const header = this.builder.newBlock(
startLineOf(stmt),
startLineOf(stmt),
this.forInHeaderText(stmt),
);
const loopExit = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), '');
this.cfc.pushLoop(header, loopExit, label);
const body = this.visitBody(this.bodyBlockOf(stmt));
this.cfc.pop();
if (body) {
this.builder.edge(header, body.entry, 'cond-true');
this.builder.connect(body.exits, header, 'loop-back');
} else {
this.builder.edge(header, header, 'loop-back');
}
this.builder.edge(header, loopExit, 'cond-false');
return { entry: header, exits: [loopExit] };
}
private forInHeaderText(stmt: SyntaxNode): string {
const left = stmt.childForFieldName('left')?.text ?? '';
const right = stmt.childForFieldName('right')?.text ?? '';
return left || right ? `for(${left}${right})` : 'for(… in/of …)';
}
private visitSwitch(stmt: SyntaxNode): TraversalResult {
const label = this.takeLabel();
const value = stmt.childForFieldName('value') ?? stmt;
const dispatch = this.builder.newBlock(startLineOf(stmt), endLineOf(value), value.text);
const switchExit = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), '');
this.cfc.pushSwitch(switchExit, label);
const body = stmt.childForFieldName('body');
const cases = body
? body.namedChildren.filter((c) => c.type === 'switch_case' || c.type === 'switch_default')
: [];
const caseResults = cases.map((c) => this.visitSeq(this.caseStatements(c)));
const hasDefault = cases.some((c) => c.type === 'switch_default');
// entryOf[i] = block a dispatch/fallthrough INTO case i lands on (empty
// cases are transparent — they resolve to the next case, or the exit).
const entryOf: number[] = new Array(cases.length);
let after = switchExit;
for (let i = cases.length - 1; i >= 0; i--) {
entryOf[i] = caseResults[i]?.entry ?? after;
after = entryOf[i];
}
for (let i = 0; i < cases.length; i++) {
this.builder.edge(dispatch, entryOf[i], 'switch-case');
}
if (!hasDefault) this.builder.edge(dispatch, switchExit, 'switch-case'); // no-match path
for (let i = 0; i < cases.length; i++) {
const res = caseResults[i];
if (!res) continue;
const fallTarget = i + 1 < cases.length ? entryOf[i + 1] : switchExit;
this.builder.connect(res.exits, fallTarget, 'fallthrough');
}
this.cfc.pop();
return { entry: dispatch, exits: [switchExit] };
}
private caseStatements(caseNode: SyntaxNode): SyntaxNode[] {
const value = caseNode.childForFieldName('value');
return caseNode.namedChildren.filter((c) => c.id !== value?.id && c.type !== 'comment');
}
private visitTry(stmt: SyntaxNode): SeqResult {
const bodyNode = stmt.childForFieldName('body');
// Single pass over named children — tree-sitter's `namedChildren` getter
// allocates a fresh array on every access, so avoid the double `.find`.
let catchClause: SyntaxNode | undefined;
let finallyClause: SyntaxNode | undefined;
for (let i = 0; i < stmt.namedChildCount; i++) {
const c = stmt.namedChild(i);
if (c?.type === 'catch_clause') catchClause = c;
else if (c?.type === 'finally_clause') finallyClause = c;
}
// Build finally first so its entry is known as both a normal join and a
// handler target. The finally body runs in the OUTER handler context.
const finallyRes = finallyClause
? this.visitSeq(this.statementsOf(this.bodyBlockOf(finallyClause) as SyntaxNode))
: null;
// A throw inside catch propagates to finally (if any), else the outer handler.
let catchRes: SeqResult = null;
if (catchClause) {
if (finallyRes) this.handlers.push(finallyRes.entry);
catchRes = this.visitSeq(this.statementsOf(this.bodyBlockOf(catchClause) as SyntaxNode));
if (finallyRes) this.handlers.pop();
if (catchRes === null) {
// Empty (or comment-only) catch body — `catch {}`. The clause still
// CATCHES: handler semantics key off the syntactic clause, not the
// traversal result. Treating it as "no catch" sent the swallowed
// exception to the outer handler/EXIT and left post-try code
// unreachable when the body always throws — a hard false-negative
// for downstream taint. Synthesize one empty block spanning the
// clause (entry == sole exit) so exception flow lands in it and
// rejoins the normal continuation. Created BEFORE the protected
// region is walked, so it never receives a spurious throw edge.
const idx = this.builder.newBlock(startLineOf(catchClause), endLineOf(catchClause), '');
catchRes = { entry: idx, exits: [idx] };
}
}
// Handler for the try body: catch if present, else finally, else outer.
const tryHandler = catchRes?.entry ?? finallyRes?.entry ?? this.currentHandler();
const protectedStart = this.builder.blockCount;
this.handlers.push(tryHandler);
const bodyRes = bodyNode ? this.visitSeq(this.statementsOf(bodyNode)) : null;
this.handlers.pop();
// Conservative exceptional edges: ANY block in the protected region may raise
// to the handler — not just an explicit `throw`, and not just the body ENTRY.
// Edging every block created during the try-body walk keeps exception flow
// sound when the body BRANCHES: an `if` / nested-try / post-branch block whose
// interior blocks would otherwise have no path to the handler — i.e. a taint
// false-negative into `catch` for the downstream PDG analysis. The
// per-function edge cap bounds the count; explicit `throw`s add their own
// (idempotent) edge to the same handler.
if (catchClause || finallyClause) {
for (let b = protectedStart; b < this.builder.blockCount; b++) {
this.builder.edge(b, tryHandler, 'throw');
}
}
const exits: number[] = [];
if (finallyRes) {
// Normal completion of try AND catch both flow through finally.
if (bodyRes) this.builder.connect(bodyRes.exits, finallyRes.entry, 'seq');
if (catchRes) this.builder.connect(catchRes.exits, finallyRes.entry, 'seq');
exits.push(...finallyRes.exits);
// No catch → an exception re-propagates out after finally runs.
if (!catchRes) this.builder.connect(finallyRes.exits, this.currentHandler(), 'throw');
} else {
if (bodyRes) exits.push(...bodyRes.exits);
if (catchRes) exits.push(...catchRes.exits);
}
const entry = bodyRes?.entry ?? finallyRes?.entry ?? catchRes?.entry;
if (entry === undefined) return null;
return { entry, exits: [...new Set(exits)] };
}
/** Nearest enclosing exception handler, or the function EXIT. */
private currentHandler(): number {
return this.handlers.length ? this.handlers[this.handlers.length - 1] : this.builder.exitIndex;
}
/** Consume the label awaiting the loop/switch this call is building. */
private takeLabel(): string | undefined {
const label = this.pendingLabel;
this.pendingLabel = undefined;
return label;
}
private labelOf(stmt: SyntaxNode): string | undefined {
const id =
stmt.childForFieldName('label') ??
stmt.namedChildren.find((c) => c.type === 'statement_identifier');
return id?.text;
}
}
/** Build the CFG for one TS/JS function node (or `undefined` if not a function). */
function buildFunctionCfg(fnNode: SyntaxNode, filePath: string): FunctionCfg | undefined {
if (!TS_FUNCTION_TYPES.has(fnNode.type)) return undefined;
const startLine = startLineOf(fnNode);
const endLine = endLineOf(fnNode);
const startColumn = fnNode.startPosition.column;
const builder = new CfgBuilder(filePath, startLine, endLine, startColumn);
const body = fnNode.childForFieldName('body');
if (!body) return undefined; // overload signature / abstract method — no body
if (body.type !== 'statement_block') {
// Expression-bodied arrow: `() => expr` — one block whose value is returned.
const blk = builder.newBlock(startLineOf(body), endLineOf(body), body.text);
builder.edge(builder.entryIndex, blk, 'seq');
builder.edge(blk, builder.exitIndex, 'return');
return builder.finish();
}
const walk = new TsCfgWalk(builder);
const res = walk.visitSeq(body.namedChildren.filter((c) => c.type !== 'comment'));
if (!res) {
builder.edge(builder.entryIndex, builder.exitIndex, 'seq'); // empty body
return builder.finish();
}
builder.edge(builder.entryIndex, res.entry, 'seq');
builder.connect(res.exits, builder.exitIndex, 'seq'); // normal fall-off → EXIT
return builder.finish();
}
/** Whether a node is a TS/JS function this visitor builds a CFG for. */
function isFunction(node: SyntaxNode): boolean {
return TS_FUNCTION_TYPES.has(node.type);
}
/** The TS/JS CFG visitor (shared by TypeScript and JavaScript). */
export function createTypeScriptCfgVisitor(): CfgVisitor<SyntaxNode> {
return { buildFunctionCfg, isFunction };
}
export { TS_FUNCTION_TYPES };

View file

@ -35,6 +35,7 @@ import type { MethodExtractor } from './method-types.js';
import type { VariableExtractor } from './variable-types.js';
import type { ImportResolverFn } from './import-resolvers/types.js';
import type { SyntaxNode } from './utils/ast-helpers.js';
import type { CfgVisitor } from './cfg/types.js';
import type { NodeLabel } from 'gitnexus-shared';
import type Parser from 'tree-sitter';
import type { ExtractedDecoratorRoute } from './workers/parse-worker.js';
@ -360,6 +361,17 @@ interface LanguageProviderConfig {
*/
readonly collectCaptureSideChannel?: (filePath: string) => unknown;
/**
* Per-language control-flow-graph builder (#2081 M1, PDG/taint substrate).
* Invoked IN THE PARSE WORKER (where the AST lives) for each function node,
* gated on the `--pdg` opt-in; the resulting per-function CFGs are serialized
* onto `ParsedFile.cfgSideChannel` and emitted as BasicBlock nodes + CFG
* edges during scope-resolution. `TNode` is `SyntaxNode` for the tree-sitter
* languages. Default: undefined (language has no CFG support yet TS/JS are
* the M1 set).
*/
readonly cfgVisitor?: CfgVisitor<SyntaxNode>;
/**
* Interpret a raw `@import.statement` capture group into a `ParsedImport`.
* The central finalize algorithm resolves `ParsedImport.targetRaw` to a

View file

@ -16,6 +16,7 @@ import {
javascriptClassConfig,
} from '../class-extractors/configs/typescript-javascript.js';
import type { SyntaxNode } from '../utils/ast-helpers.js';
import { createTypeScriptCfgVisitor } from '../cfg/visitors/typescript.js';
import { typeConfig as typescriptConfig } from '../type-extractors/typescript.js';
import { tsExportChecker } from '../export-detection.js';
import { createImportResolver } from '../import-resolvers/resolver-factory.js';
@ -351,6 +352,8 @@ export const typescriptProvider = defineLanguage({
// canonical capture vocabulary in ./typescript/query.ts
// (TYPESCRIPT_SCOPE_QUERY constant).
emitScopeCaptures: emitTsScopeCaptures,
// CFG/PDG substrate (#2081 M1) — runs in the worker on a --pdg run.
cfgVisitor: createTypeScriptCfgVisitor(),
interpretImport: interpretTsImport,
interpretTypeBinding: interpretTsTypeBinding,
bindingScopeFor: tsBindingScopeFor,
@ -412,6 +415,8 @@ export const javascriptProvider = defineLanguage({
// JSDoc type bindings) live in ./javascript/captures.ts.
// See ./javascript/index.ts for the full per-module rationale.
emitScopeCaptures: emitJsScopeCaptures,
// CFG/PDG substrate (#2081 M1) — TS and JS share the same grammar family.
cfgVisitor: createTypeScriptCfgVisitor(),
interpretImport: interpretJsImport,
interpretTypeBinding: interpretJsTypeBinding,
bindingScopeFor: jsBindingScopeFor,

View file

@ -36,6 +36,7 @@ import {
restoreDurableParsedFileShard,
} from '../../../storage/parsedfile-store.js';
import type { ParseWorkerResult } from '../workers/parse-worker.js';
import { DEFAULT_PDG_MAX_FUNCTION_LINES } from '../cfg/collect.js';
import type { WorkerExtractedData } from '../parsing-processor.js';
import {
processRoutesFromExtracted,
@ -461,6 +462,10 @@ export async function runChunkedParseAndResolve(
// Initialized below before the chunk loop (same deferred-init pattern
// as `parsedFileStorePath`); this closure only runs from the loop.
durableParsedFileStoragePath: durableParsedFileDir,
// CFG/PDG opt-in (#2081 M1) — baked into each worker's workerData so the
// worker builds + attaches cfgSideChannel. Off by default.
pdg: options?.pdg === true,
pdgMaxFunctionLines: options?.pdgMaxFunctionLines,
// Fan each chunk across the whole pool (#worker-idle): without this a
// chunk smaller than the 8 MB sub-batch cap became a single job on a
// single worker. Honors an explicit `subBatchMaxBytes` / env override.
@ -737,7 +742,21 @@ export async function runChunkedParseAndResolve(
filePath: f.path,
contentHash: fileContentHash(f.content),
}));
chunkHash = computeChunkHash(entries);
chunkHash = computeChunkHash(
entries,
// Only worker-visible pdg config participates in the key —
// pdgMaxEdgesPerFunction is emit-time-only and deliberately
// excluded (see PdgCacheKey in parse-cache.ts; #2099 F3). The line
// cap is RESOLVED to the worker's default before folding so an
// explicit-default run shares the default run's keys (the worker
// output is byte-identical either way).
options?.pdg === true
? {
pdg: true,
maxFunctionLines: options?.pdgMaxFunctionLines ?? DEFAULT_PDG_MAX_FUNCTION_LINES,
}
: false,
);
}
const cachedRaw =

View file

@ -50,6 +50,29 @@ export interface PipelineOptions {
* to retain those nodes under `skipGraphPhases`.
*/
skipGraphPhases?: boolean;
/**
* Build the control-flow-graph / PDG substrate (#2081 M1, opt-in via `--pdg`).
* Off by default: workers skip all CFG work and emit no `cfgSideChannel`, and
* scope-resolution emits no BasicBlock nodes or CFG edges so the default
* graph is byte-identical to a pre-#2081 run. Folded into the parse-cache key
* so a pdg-off warm cache is not reused on a `--pdg` run.
*/
pdg?: boolean;
/**
* Per-function source-line cap for worker-side CFG construction.
* `undefined` the worker applies `DEFAULT_PDG_MAX_FUNCTION_LINES`; `0` no
* cap (unlimited). Bounds the cost of a pathological mega-function; over-cap
* functions are skipped (no CFG emitted for them). No CLI flag in M1
* programmatic / server analyze-worker path only.
*/
pdgMaxFunctionLines?: number;
/**
* Per-function CFG edge cap for the scope-resolution emit step.
* `undefined` `DEFAULT_MAX_CFG_EDGES_PER_FUNCTION`; `0` no cap (unlimited).
* Over-cap functions stop at the cap and log a structured drop warning (no
* silent truncation). No CLI flag in M1 programmatic / server path only.
*/
pdgMaxEdgesPerFunction?: number;
/**
* Request parsing with the worker pool disabled. The sequential parser was
* removed the worker pool is the sole parse path so setting this now

View file

@ -350,6 +350,9 @@ export const scopeResolutionPhase: PipelinePhase<ScopeResolutionOutput> = {
prebuiltNodeLookup: sharedNodeLookup,
preExtractedParsedFiles: preExtractedByPath,
scopeIndexStorePath: parsedFileStorePath,
// CFG/PDG emission (#2081 M1) — opt-in; off ⇒ byte-identical graph.
pdg: ctx.options?.pdg === true,
pdgMaxEdgesPerFunction: ctx.options?.pdgMaxEdgesPerFunction,
recordResolutionOutcome: (outcome) => {
resolutionOutcomes.push(outcome);
},

View file

@ -34,6 +34,8 @@ import { extractParsedFile } from '../../scope-extractor-bridge.js';
import { finalizeScopeModel } from '../../finalize-orchestrator.js';
import { resolveReferenceSites, type ResolveStats } from '../../resolve-references.js';
import { buildGraphNodeLookup } from '../graph-bridge/node-lookup.js';
import { emitFileCfgs, isEmitSafeCfg, DEFAULT_MAX_CFG_EDGES_PER_FUNCTION } from '../../cfg/emit.js';
import type { FunctionCfg } from '../../cfg/types.js';
import { resolveDefGraphId } from '../graph-bridge/ids.js';
import { buildPopulatedMethodDispatch } from '../graph-bridge/method-dispatch.js';
import { propagateImportedReturnTypes } from '../passes/imported-return-types.js';
@ -252,6 +254,16 @@ interface RunScopeResolutionInput {
* cache miss is safe (the provider re-parses).
*/
readonly treeCache?: { get(filePath: string): unknown };
/**
* CFG/PDG opt-in (#2081 M1). When true, emit BasicBlock nodes + CFG edges
* from each ParsedFile's worker-built `cfgSideChannel` during Phase-4 graph
* emission (while the disk store is still live). Default/false no CFG
* nodes or edges and a byte-identical graph.
*/
readonly pdg?: boolean;
/** Per-function CFG edge cap. `undefined` {@link DEFAULT_MAX_CFG_EDGES_PER_FUNCTION};
* `0` no cap (unlimited). */
readonly pdgMaxEdgesPerFunction?: number;
/**
* Optional graph-node lookup built ONCE by the caller and shared across
* every language pass. `buildGraphNodeLookup` scans the whole graph and is
@ -679,6 +691,77 @@ export function runScopeResolution(
});
}
// ── CFG/PDG emission (#2081 M1, opt-in via `--pdg`) ──────────────────────
// Emit BasicBlock nodes + CFG edges from each ParsedFile's worker-built
// `cfgSideChannel`, HERE — the last point inside scope-resolution where the
// ParsedFiles are still loaded (`emitParsedFiles` carries the channel; the
// disk store is cleared right after this orchestrator returns, see phase.ts).
// A post-`mro` phase would read empty data (KTD1). Off by default ⇒ zero
// BasicBlock/CFG nodes/edges and a byte-identical graph.
if (input.pdg === true) {
let cfgBlocks = 0;
let cfgEdges = 0;
let cfgDroppedEdges = 0;
for (const pf of emitParsedFiles) {
const cfgs = pf.cfgSideChannel;
// Defensive: cfgSideChannel is opaque (`unknown`) and crosses the cache /
// durable store. A stale or wrong-shape value (e.g. a pre-SCHEMA_BUMP
// shard that slipped the version gate) must skip emission, not throw a
// TypeError mid-graph-build and abort scope-resolution for the language.
if (!Array.isArray(cfgs) || cfgs.length === 0) continue;
try {
// Per-element emit-safety filter (mirrors the parsedfile-store
// reviver's POLICY: valid elements in a mixed array still emit; junk
// is warned and skipped). isEmitSafeCfg lives in cfg/emit.ts next to
// the id templating it defends — see its doc for why anchor-field and
// endpoint-membership checks are load-bearing. Runs INSIDE the try so
// even a predicate-time throw (e.g. a hostile getter) is isolated.
const wellFormed = (cfgs as readonly (FunctionCfg | undefined | null)[]).filter(
isEmitSafeCfg,
);
if (wellFormed.length < cfgs.length) {
logger.warn(
`[cfg] ${pf.filePath}: skipped ${cfgs.length - wellFormed.length} malformed ` +
`cfgSideChannel element(s) (bad shape, missing id-anchor fields, or edge ` +
`endpoints matching no block) — CFG for those functions omitted`,
);
}
if (wellFormed.length === 0) continue;
const emitted = emitFileCfgs(
graph,
wellFormed,
input.pdgMaxEdgesPerFunction ?? DEFAULT_MAX_CFG_EDGES_PER_FUNCTION,
// Log cap-overflow drops UNCONDITIONALLY (not via input.onWarn, which is
// gated behind the semantic-model validator and silent in production) so
// the per-function edge cap never truncates the CFG silently (R6/KTD6).
(message) => logger.warn(message),
);
cfgBlocks += emitted.blocks;
cfgEdges += emitted.edges;
cfgDroppedEdges += emitted.droppedEdges;
} catch (err) {
// Last-resort isolation, mirroring the worker-side per-file try/catch:
// a shape the predicate misses must cost this one file's CFG, not
// abort the language's whole scope-resolution pass mid-graph-build.
// NOTE a mid-emit throw can leave this file's already-inserted
// BasicBlock nodes in the graph (addNode is not transactional) —
// orphaned but inert; the predicate keeps every JSON-representable
// bad shape from reaching this path at all.
logger.warn(
`[cfg] ${pf.filePath}: CFG emission failed (${err instanceof Error ? err.message : String(err)}) — ` +
`this file's CFG is partial or absent`,
);
}
}
if (cfgBlocks > 0) {
logger.debug(
`[scope-resolution] CFG emit (lang=${provider.language}): ` +
`${cfgBlocks} BasicBlock nodes, ${cfgEdges} CFG edges` +
(cfgDroppedEdges > 0 ? `, ${cfgDroppedEdges} edges dropped (per-function cap)` : ''),
);
}
}
if (PROF) {
const tEnd = process.hrtime.bigint();
const ns = (a: bigint, b: bigint): number => Number(b - a) / 1_000_000;

View file

@ -130,6 +130,7 @@ import {
persistDurableParsedFileShardSync,
} from '../../../storage/parsedfile-store.js';
import { extractLaravelRoutes, type ExtractedRoute } from '../route-extractors/laravel.js';
import { collectFunctionCfgs, DEFAULT_PDG_MAX_FUNCTION_LINES } from '../cfg/collect.js';
import { logger } from '../../logger.js';
export type { ExtractedRoute } from '../route-extractors/laravel.js';
@ -155,6 +156,19 @@ const DURABLE_PARSED_FILE_STORAGE_PATH: string | undefined = (
)?.durableParsedFileStoragePath;
let shardSeq = 0;
// ── PDG/CFG opt-in (#2081 M1) ───────────────────────────────────────────────
// Read ONCE at worker init from `workerData` (the worker never sees
// PipelineOptions — config arrives via the pool factory's `workerData`, see
// KTD7 / U5). When `pdg` is set, the worker builds a per-function control-flow
// graph from the tree-sitter AST (where it lives) and serializes it onto
// `ParsedFile.cfgSideChannel`. Off ⇒ no CFG work and no field — the default for
// every run today. `pdgMaxFunctionLines` bounds per-function CFG cost
// (0/undefined ⇒ no cap; see collectFunctionCfgs).
const PDG_ENABLED: boolean = (workerData as { pdg?: boolean } | undefined)?.pdg === true;
const PDG_MAX_FUNCTION_LINES: number =
(workerData as { pdgMaxFunctionLines?: number } | undefined)?.pdgMaxFunctionLines ??
DEFAULT_PDG_MAX_FUNCTION_LINES;
// ── Bootstrap-stage diagnostics (#1741) ────────────────────────────────────
// When GITNEXUS_WORKER_BOOTSTRAP=1 (or --verbose sets GITNEXUS_VERBOSE), each
// worker reports its startup stage timings to stderr — which the pool tees
@ -1233,9 +1247,38 @@ const processFileGroup = (
// copy — scopes/defs are carried by reference) to attach the field rather
// than mutate the frozen object.
const sideChannel = provider.collectCaptureSideChannel?.(file.path);
result.parsedFiles.push(
sideChannel !== undefined ? { ...parsedFile, captureSideChannel: sideChannel } : parsedFile,
);
let withChannels =
sideChannel !== undefined ? { ...parsedFile, captureSideChannel: sideChannel } : parsedFile;
// CFG side-channel (#2081 M1): build the per-function control-flow graph
// here, where the tree-sitter AST is still in hand, and attach it as plain
// serializable data. Only on a --pdg run and only for languages with a
// cfgVisitor (TS/JS in M1). The same disk-store/warm-cache machinery that
// carries captureSideChannel carries this — its coherence rests on the
// SCHEMA_BUMP + the pdg-folded chunk-hash key (see parse-cache.ts).
if (PDG_ENABLED && provider.cfgVisitor) {
// Isolate the CFG build per file: a throw here (an unexpected tree-sitter
// node shape, a deep-nesting stack overflow) must NOT propagate — it
// would escape processFileGroup to the language-group catch, which treats
// any throw as "parser unavailable" and silently drops EVERY remaining
// file in the group. Skip CFG for this one file; parsing + scope
// resolution proceed unaffected (CFG is a strictly-additive opt-in).
try {
const { cfgs } = collectFunctionCfgs(
tree.rootNode,
provider.cfgVisitor,
file.path,
PDG_MAX_FUNCTION_LINES,
);
if (cfgs.length) withChannels = { ...withChannels, cfgSideChannel: cfgs };
} catch (err) {
const message = `CFG build failed for ${file.path}: ${err instanceof Error ? err.message : String(err)}`;
if (parentPort) parentPort.postMessage({ type: 'warning', message });
else logger.warn(message);
}
}
result.parsedFiles.push(withChannels);
}
// Build per-file type environment + constructor bindings in a single AST walk.

View file

@ -232,6 +232,15 @@ export interface WorkerPoolOptions {
* `undefined` no durable write.
*/
durableParsedFileStoragePath?: string;
/**
* CFG/PDG opt-in (#2081 M1). Baked into every spawned worker's `workerData`
* (like the store paths above); when `true`, workers build a per-function
* control-flow graph from the tree-sitter AST and attach it to
* `ParsedFile.cfgSideChannel`. `undefined`/`false` no CFG work.
*/
pdg?: boolean;
/** Per-function source-line cap for worker-side CFG construction (0 ⇒ no cap). */
pdgMaxFunctionLines?: number;
}
export class WorkerPoolDispatchError extends Error {
@ -890,9 +899,12 @@ export const createWorkerPool = (
// signature is unchanged so the zero-arg test factories keep working.
const parsedFileStoreStoragePath = options?.parsedFileStoreStoragePath;
const durableParsedFileStoragePath = options?.durableParsedFileStoragePath;
// CFG/PDG opt-in (#2081 M1) — carried in workerData alongside the store paths.
const pdg = options?.pdg === true;
const pdgMaxFunctionLines = options?.pdgMaxFunctionLines;
const workerStoreData =
parsedFileStoreStoragePath || durableParsedFileStoragePath
? { parsedFileStoreStoragePath, durableParsedFileStoragePath }
parsedFileStoreStoragePath || durableParsedFileStoragePath || pdg
? { parsedFileStoreStoragePath, durableParsedFileStoragePath, pdg, pdgMaxFunctionLines }
: undefined;
const spawnWorker =
options?.workerFactory ??

View file

@ -42,7 +42,10 @@ import {
registerRepo,
cleanupOldKuzuFiles,
INCREMENTAL_SCHEMA_VERSION,
type RepoMeta,
} from '../storage/repo-manager.js';
import { DEFAULT_PDG_MAX_FUNCTION_LINES } from './ingestion/cfg/collect.js';
import { DEFAULT_MAX_CFG_EDGES_PER_FUNCTION } from './ingestion/cfg/emit.js';
import { computeFileHashes, diffFileHashes } from '../storage/file-hash.js';
import {
extractChangedSubgraph,
@ -119,6 +122,19 @@ export interface AnalyzeOptions {
noStats?: boolean;
/** Skip installing standard GitNexus skill files to .claude/skills/gitnexus/. */
skipSkills?: boolean;
/**
* Build the CFG/PDG substrate (#2081 M1). Forwarded to `PipelineOptions.pdg`,
* which threads to BOTH the worker (CFG build, via workerData) AND
* scope-resolution (BasicBlock/CFG emit gate). Off by default.
*/
pdg?: boolean;
/** Per-function source-line cap for worker-side CFG construction (#2081 M1).
* Forwarded to `PipelineOptions.pdgMaxFunctionLines`. No CLI flag in M1
* programmatic / server analyze-worker path only; the worker applies
* `DEFAULT_PDG_MAX_FUNCTION_LINES` when unset. */
pdgMaxFunctionLines?: number;
/** Per-function CFG edge cap. Forwarded to `PipelineOptions.pdgMaxEdgesPerFunction`. */
pdgMaxEdgesPerFunction?: number;
/**
* Default branch threaded into generated AGENTS.md / CLAUDE.md so the
* regression-compare example uses the configured branch instead of a
@ -313,6 +329,41 @@ export const collectBranchCacheKeys = async (
return { keys, complete };
};
/**
* Resolve the requested `--pdg` configuration to the shape recorded in
* `RepoMeta.pdg`, or `undefined` for a pdg-off run. Caps resolve to their
* defaults so an explicit-default run compares equal to a default run
* (`0` = unlimited is preserved as `0`). Pure + exported for testing.
*/
type PdgOptions = Pick<AnalyzeOptions, 'pdg' | 'pdgMaxFunctionLines' | 'pdgMaxEdgesPerFunction'>;
export const resolvePdgConfig = (options: PdgOptions): RepoMeta['pdg'] =>
options.pdg === true
? {
maxFunctionLines: options.pdgMaxFunctionLines ?? DEFAULT_PDG_MAX_FUNCTION_LINES,
maxEdgesPerFunction: options.pdgMaxEdgesPerFunction ?? DEFAULT_MAX_CFG_EDGES_PER_FUNCTION,
}
: undefined;
/**
* Whether the requested `--pdg` configuration differs from the one the
* existing index's DB rows were built under (#2099 F1). An absent recorded
* stamp means pdg-off (every legacy meta `--pdg` shipped opt-in). Any
* mismatch means the incremental writeback (which only persists changed-file
* nodes) cannot produce a coherent index: offon would silently drop the
* freshly built CFG layer, onoff would strand zombie BasicBlocks so the
* caller forces a full writeback. Pure + exported for testing.
*/
export const pdgModeMismatch = (recorded: RepoMeta['pdg'], options: PdgOptions): boolean => {
const requested = resolvePdgConfig(options);
if (!requested && !recorded) return false;
if (!requested || !recorded) return true;
return (
requested.maxFunctionLines !== recorded.maxFunctionLines ||
requested.maxEdgesPerFunction !== recorded.maxEdgesPerFunction
);
};
export async function runFullAnalysis(
repoPath: string,
options: AnalyzeOptions,
@ -473,7 +524,9 @@ export async function runFullAnalysis(
// back to a known-good index is to wipe + rebuild from scratch.
if (existingMeta?.incrementalInProgress) {
log(
'Previous incremental run did not complete cleanly (incrementalInProgress flag set); ' +
// "analyze run", not "incremental run" — since #2099 F1 the flag is a
// generic dirty marker written by BOTH writeback branches.
'Previous analyze run did not complete cleanly (incrementalInProgress flag set); ' +
'forcing full rebuild to restore a known-good index.',
);
options = { ...options, force: true };
@ -482,6 +535,30 @@ export async function runFullAnalysis(
// rebuild path executes.
}
// ── pdg-mode flip forces full writeback (#2099 F1) ─────────────────
// The incremental writeback persists only changed-file nodes, so a pdg
// config differing from the one the DB rows were built under cannot be
// reconciled incrementally: off→on silently drops the freshly built CFG
// layer ("Incremental: changed=0", zero BasicBlock rows), on→off strands
// zombie blocks for unchanged files. MUST sit before the alreadyUpToDate
// fast path below — a clean-tree flip would otherwise early-return without
// running the pipeline at all. The notice is deliberately NOT gated on
// options.force: --skills implies force with no message of its own, and a
// mode change deserves a diagnostic regardless of why a rebuild happens.
if (existingMeta && pdgModeMismatch(existingMeta.pdg, options)) {
const pdgOn = options.pdg === true;
const capsOnly = !!existingMeta.pdg && pdgOn; // both-on can only mismatch via caps
const was = existingMeta.pdg ? 'with --pdg' : 'without --pdg';
const now = pdgOn ? 'with --pdg' : 'without --pdg';
log(
`pdg mode changed (index built ${was}, this run is ${now}` +
`${capsOnly ? ', but with different caps' : ''}); forcing a full ` +
`rebuild so the CFG layer is ${pdgOn ? 'fully persisted' : 'fully removed'}. ` +
`Tip: set \`pdg: ${pdgOn}\` in .gitnexusrc to pin the mode across runs.`,
);
options = { ...options, force: true };
}
// ── Early-return: already up to date ──────────────────────────────
if (existingMeta && !options.force && existingMeta.lastCommit === currentCommit) {
// Non-git folders have currentCommit = '' — always rebuild since we can't detect changes
@ -648,6 +725,11 @@ export async function runFullAnalysis(
{
parseCache,
workerPoolSize: options.workerPoolSize,
// CFG/PDG opt-in (#2081 M1). PipelineOptions.pdg fans out to the worker
// build gate (workerData.pdg) and the scope-resolution emit gate.
pdg: options.pdg === true,
pdgMaxFunctionLines: options.pdgMaxFunctionLines,
pdgMaxEdgesPerFunction: options.pdgMaxEdgesPerFunction,
fetchWrappers: options.fetchWrappers,
},
);
@ -706,6 +788,19 @@ export async function runFullAnalysis(
});
} else {
// Full rebuild path: wipe DB files first.
// Set the dirty flag BEFORE the wipe whenever a prior meta exists,
// mirroring the incremental branch above (#2099 F1, KTD2b). Without it a
// full rebuild crashing between the wipe and the end-of-run saveMeta
// leaves a meta that vouches for a DB it no longer matches — the next
// clean-tree run's fast path would certify a destroyed DB (or, after a
// pdg flip, certify zombie/missing BasicBlock rows indefinitely).
// toWriteCount: 0 is the full-path sentinel (no incremental write set).
if (existingMeta) {
await saveMeta(metaDir, {
...existingMeta,
incrementalInProgress: { startedAt: Date.now(), toWriteCount: 0 },
});
}
await closeLbug();
const lbugFiles = [lbugPath, `${lbugPath}.wal`, `${lbugPath}.lock`];
for (const f of lbugFiles) {
@ -1133,6 +1228,12 @@ export async function runFullAnalysis(
// so a sibling branch's prune can union it and not evict our shards.
cacheKeys: [...parseCache.usedKeys],
incrementalInProgress: undefined as { startedAt: number; toWriteCount: number } | undefined,
// The effective pdg config this run's DB rows were built under
// (#2099 F1). `undefined` on pdg-off runs — this meta is a fresh
// literal (no spread of existingMeta), so omission is what CLEARS the
// stamp after an on→off flip; the next pdgModeMismatch then compares
// off==off and incremental eligibility is restored.
pdg: resolvePdgConfig(options),
};
await saveMeta(metaDir, meta);

View file

@ -55,7 +55,7 @@ import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.j
// the main thread (the #1983 OOM). Because the two stores share this version,
// any future change to the `ParsedFile` serialization shape MUST bump
// SCHEMA_BUMP so both invalidate in lockstep.
const SCHEMA_BUMP = 4;
const SCHEMA_BUMP = 5; // #2081 M1: ParsedFile gained `cfgSideChannel`
const GITNEXUS_PKG_VERSION = (() => {
try {
// package.json sits at gitnexus/package.json — two levels up from
@ -141,12 +141,51 @@ export const fileContentHash = (content: Buffer | string): string => sha256Hex(c
* in the chunk. We sort by filePath before hashing so chunks composed of
* the same files in different order produce the same key.
*/
/** PDG/CFG cache namespace (#2081 M1) every input that changes the
* WORKER-EMITTED `cfgSideChannel` must be folded into the chunk key, and
* ONLY those. The classification test for a future option: does the worker
* see it (workerData) and does it change the bytes the worker writes to the
* shard? `pdgMaxEdgesPerFunction` famously fails that test it is applied
* at EMIT time on the main thread (scope-resolution run.ts), the worker
* never receives it, and the cached output is byte-identical across cap
* values; folding it in (as a prior review round did) only forced a
* spurious full re-parse on every cap change (#2099 F3). Options that
* change the PERSISTED GRAPH but not the shard belong in the RepoMeta pdg
* stamp (incremental-eligibility), not here. */
export interface PdgCacheKey {
readonly pdg?: boolean;
/** Per-function source-line cap (changes WHICH functions get a CFG
* applied in the worker, so it shapes the cached shard). Callers must
* pass the RESOLVED value (the production call site in parse-impl.ts
* applies the worker's default before folding) so an explicit-default
* run shares the default run's keys this function folds whatever it
* is given verbatim. */
readonly maxFunctionLines?: number;
}
export const computeChunkHash = (
entries: Array<{ filePath: string; contentHash: string }>,
pdg: boolean | PdgCacheKey = false,
): string => {
const sorted = [...entries].sort((a, b) => (a.filePath < b.filePath ? -1 : 1));
const joined = sorted.map((e) => `${e.filePath}:${e.contentHash}`).join('\n');
return sha256Hex(joined);
const opts: PdgCacheKey = typeof pdg === 'boolean' ? { pdg } : pdg;
// pdg-off path keeps its pre-#2081 chunk-KEY format verbatim. Note this does
// NOT mean caches survive the M1 upgrade: SCHEMA_BUMP 4→5 changed
// PARSE_CACHE_VERSION, and both loadParseCache (below) and the durable
// parsedfile-store index hard-invalidate on it — every user pays one full
// cold re-parse on upgrade regardless of --pdg. Keeping the key format
// stable only means no SECOND invalidation class is introduced here.
if (!opts.pdg) return sha256Hex(joined);
// Fold the worker-visible --pdg configuration into the key: the boolean
// plus `maxFunctionLines` (decides which functions get a CFG at all, in the
// worker). Without it a warm chunk built under one cap is served to a run
// with a different cap → a stale/under-built CFG: the #2038-class
// option-blind-key trap. `def` marks an unset (default) value so two
// default-cap runs share a key. The emit-time edge cap is deliberately
// absent — see the PdgCacheKey doc comment.
const ns = `pdg:1;maxFn=${opts.maxFunctionLines ?? 'def'}`;
return sha256Hex(`${ns}\n${joined}`);
};
/**

View file

@ -98,16 +98,18 @@ export interface RepoMeta {
*/
fileHashes?: Record<string, string>;
/**
* Crash-recovery dirty flag. Written to meta.json BEFORE any
* destructive DB mutation in an incremental run; cleared on success
* by overwriting meta.json. If a run crashes between, the next run
* sees the flag and forces a full rebuild the cheapest path back
* to a known-good index.
* Crash-recovery dirty flag a generic marker written to meta.json
* BEFORE any destructive DB mutation by BOTH writeback branches
* (incremental since its introduction; full rebuilds over an existing
* meta since #2099 F1); cleared on success by overwriting meta.json.
* If a run crashes between, the next run sees the flag and forces a
* full rebuild the cheapest path back to a known-good index.
*/
incrementalInProgress?: {
/** When the incremental run started (epoch ms). */
/** When the run started (epoch ms). */
startedAt: number;
/** Number of files in the writable set, for diagnostic logs. */
/** Number of files in the writable set, for diagnostic logs.
* `0` on the full-rebuild path (no incremental write set exists). */
toWriteCount: number;
};
/**
@ -127,6 +129,28 @@ export interface RepoMeta {
* branch's still-live shards. Additive/optional; absent in legacy metas.
*/
cacheKeys?: string[];
/**
* The effective `--pdg` configuration this index's DB rows were built
* under (#2099 F1). Presence the BasicBlock/CFG layer exists in the DB;
* ABSENT pdg-off which covers every legacy meta, since `--pdg`
* shipped opt-in. Caps are recorded RESOLVED (defaults applied) so an
* explicit-default run compares equal to a default run. run-analyze
* compares this against the requested options and forces a full
* writeback on any mismatch the incremental path only persists
* changed-file nodes and would otherwise silently drop (or strand) the
* CFG layer on a mode flip. Additive/optional, no
* INCREMENTAL_SCHEMA_VERSION bump (a bump would force a one-time full
* rebuild for every user). NOTE the removal mechanism is load-bearing:
* the end-of-run meta is a fresh object literal, NOT a spread of the
* prior meta, so omitting this field on a pdg-off run is what clears
* the stamp after an onoff flip.
*/
pdg?: {
/** Worker-side per-function source-line cap, resolved (0 = unlimited). */
maxFunctionLines: number;
/** Emit-side per-function CFG edge cap, resolved (0 = unlimited). */
maxEdgesPerFunction: number;
};
}
/**

View file

@ -0,0 +1,53 @@
/**
* Shared mini-repo fixture setup for `runFullAnalysis`-level integration
* tests (incremental-orchestration, pdg-mode-flip). Copies the
* `test/fixtures/mini-repo/src` files into a fresh git-initialized temp
* directory so each test owns a real repo with a real history. Extracted
* from incremental-orchestration.test.ts when pdg-mode-flip.test.ts became
* its second verbatim consumer (#2099) the fixture file list must live in
* exactly one place.
*/
import { execSync } from 'child_process';
import { copyFile, mkdir } from 'fs/promises';
import path from 'path';
import { fileURLToPath } from 'url';
import { createTempDir } from './test-db.js';
const HERE = path.dirname(fileURLToPath(import.meta.url));
const FIXTURE_SRC = path.resolve(HERE, '..', 'fixtures', 'mini-repo', 'src');
const MINI_REPO_FILES = [
'index.ts',
'handler.ts',
'validator.ts',
'formatter.ts',
'middleware.ts',
'logger.ts',
'db.ts',
];
/**
* Copy the mini-repo fixture into a fresh git-initialized temp directory.
* Returns the temp handle so the caller owns cleanup.
*/
export async function setupMiniRepo(
prefix = 'gitnexus-mini-repo-',
): Promise<{ dbPath: string; cleanup: () => Promise<void> }> {
const tmp = await createTempDir(prefix);
const dest = path.join(tmp.dbPath, 'src');
await mkdir(dest, { recursive: true });
for (const n of MINI_REPO_FILES) {
await copyFile(path.join(FIXTURE_SRC, n), path.join(dest, n));
}
execSync('git init', { cwd: tmp.dbPath, stdio: 'pipe' });
execSync('git -c user.name=test -c user.email=t@t -c commit.gpgsign=false add -A', {
cwd: tmp.dbPath,
stdio: 'pipe',
});
execSync('git -c user.name=test -c user.email=t@t -c commit.gpgsign=false commit -q -m initial', {
cwd: tmp.dbPath,
stdio: 'pipe',
});
return tmp;
}

View file

@ -0,0 +1,154 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`U7 — AC1: 10-function fixture CFG snapshot > matches the committed CFG node/edge set 1`] = `
[
{
"blocks": 3,
"edges": [
"0->2:seq",
"2->1:seq",
],
"entry": 0,
"exit": 1,
"startLine": 9,
},
{
"blocks": 6,
"edges": [
"0->2:seq",
"2->3:cond-true",
"2->4:cond-false",
"3->5:seq",
"4->5:seq",
"5->1:seq",
],
"entry": 0,
"exit": 1,
"startLine": 14,
},
{
"blocks": 8,
"edges": [
"0->2:seq",
"2->3:cond-true",
"2->4:cond-false",
"3->7:seq",
"4->5:cond-true",
"4->6:cond-false",
"5->7:seq",
"6->7:seq",
"7->1:seq",
],
"entry": 0,
"exit": 1,
"startLine": 23,
},
{
"blocks": 6,
"edges": [
"0->2:seq",
"2->3:cond-false",
"2->4:cond-true",
"3->5:seq",
"4->2:loop-back",
"5->1:seq",
],
"entry": 0,
"exit": 1,
"startLine": 34,
},
{
"blocks": 8,
"edges": [
"0->6:seq",
"2->3:cond-false",
"2->5:cond-true",
"3->7:seq",
"4->2:loop-back",
"5->4:seq",
"6->2:seq",
"7->1:seq",
],
"entry": 0,
"exit": 1,
"startLine": 41,
},
{
"blocks": 6,
"edges": [
"0->2:seq",
"2->3:cond-false",
"2->4:cond-true",
"3->5:seq",
"4->2:loop-back",
"5->1:seq",
],
"entry": 0,
"exit": 1,
"startLine": 48,
},
{
"blocks": 10,
"edges": [
"0->2:seq",
"2->4:switch-case",
"2->6:switch-case",
"2->8:switch-case",
"3->9:seq",
"4->5:seq",
"5->3:break",
"6->7:seq",
"7->3:break",
"8->3:fallthrough",
"9->1:seq",
],
"entry": 0,
"exit": 1,
"startLine": 55,
},
{
"blocks": 6,
"edges": [
"0->4:seq",
"2->5:seq",
"3->2:seq",
"4->2:seq",
"4->3:throw",
"5->1:seq",
],
"entry": 0,
"exit": 1,
"startLine": 69,
},
{
"blocks": 5,
"edges": [
"0->2:seq",
"2->3:cond-true",
"2->4:seq",
"3->1:return",
"4->1:return",
],
"entry": 0,
"exit": 1,
"startLine": 80,
},
{
"blocks": 8,
"edges": [
"0->2:seq",
"2->3:cond-false",
"2->4:cond-true",
"3->7:seq",
"4->5:cond-true",
"4->6:cond-false",
"5->2:loop-back",
"6->2:loop-back",
"7->1:seq",
],
"entry": 0,
"exit": 1,
"startLine": 87,
},
]
`;

View file

@ -0,0 +1,181 @@
import { describe, it, expect, vi } from 'vitest';
import Parser from 'tree-sitter';
import TypeScript from 'tree-sitter-typescript';
import { collectFunctionCfgs } from '../../../src/core/ingestion/cfg/collect.js';
import { emitFileCfgs } from '../../../src/core/ingestion/cfg/emit.js';
import { getProvider } from '../../../src/core/ingestion/languages/index.js';
import { SupportedLanguages } from '../../../src/config/supported-languages.js';
import type { CfgVisitor, FunctionCfg } from '../../../src/core/ingestion/cfg/types.js';
import type { SyntaxNode } from '../../../src/core/ingestion/utils/ast-helpers.js';
import type { KnowledgeGraph } from '../../../src/core/graph/types.js';
// U4 — emit BasicBlock nodes + CFG edges from the worker-built side-channel
// (R5, R6). Tests the pure emit helper against a recording graph: id shape
// (KTD3), edge `type`/`reason`, the AC2 reachability property, and the
// per-function edge cap's no-silent-truncation contract. The flag-gated
// run.ts wiring + full runPipelineFromRepo round-trip are covered in U7.
interface RecordedNode {
id: string;
label: string;
properties: Record<string, unknown>;
}
interface RecordedRel {
id: string;
type: string;
sourceId: string;
targetId: string;
reason: string;
}
function recordingGraph(): { graph: KnowledgeGraph; nodes: RecordedNode[]; rels: RecordedRel[] } {
const nodes: RecordedNode[] = [];
const rels: RecordedRel[] = [];
const graph = {
addNode: (n: RecordedNode) => nodes.push(n),
addRelationship: (r: RecordedRel) => rels.push(r),
} as unknown as KnowledgeGraph;
return { graph, nodes, rels };
}
function tsRoot(code: string): SyntaxNode {
const parser = new Parser();
parser.setLanguage(TypeScript.typescript);
return parser.parse(code).rootNode;
}
const visitor = (): CfgVisitor<SyntaxNode> => {
const v = getProvider(SupportedLanguages.TypeScript).cfgVisitor;
if (!v) throw new Error('no cfgVisitor');
return v;
};
const cfgsOf = (code: string, filePath = 'f.ts'): readonly FunctionCfg[] =>
collectFunctionCfgs(tsRoot(code), visitor(), filePath).cfgs;
describe('U4 — emitFileCfgs node/edge shape', () => {
it('emits BasicBlock nodes (KTD3 id, no name) + CFG edges carrying the kind in reason', () => {
const cfgs = cfgsOf(`function f(x: number) { if (x) { a(); } else { b(); } }`, 'src/f.ts');
const { graph, nodes, rels } = recordingGraph();
const r = emitFileCfgs(graph, cfgs);
expect(r.blocks).toBe(nodes.length);
expect(r.edges).toBe(rels.length);
expect(nodes.length).toBeGreaterThan(0);
// every node is a BasicBlock with the KTD3 id
// `BasicBlock:<file>:<funcStartLine>:<funcStartCol>:<idx>`
for (const n of nodes) {
expect(n.label).toBe('BasicBlock');
expect(n.id).toMatch(/^BasicBlock:src\/f\.ts:\d+:\d+:\d+$/);
expect(n.properties.filePath).toBe('src/f.ts');
expect(n.properties.name).toBe(''); // no name column
}
// every edge is type 'CFG' and its reason is a CfgEdgeKind
const kinds = new Set(rels.map((e) => e.reason));
expect(rels.every((e) => e.type === 'CFG')).toBe(true);
expect(kinds.has('cond-true')).toBe(true);
expect(kinds.has('cond-false')).toBe(true);
});
it('block ids are unique across two functions in the same file (funcStart disambiguates)', () => {
const cfgs = cfgsOf(`function a() { x(); }\nfunction b() { y(); }`, 'm.ts');
const { graph, nodes } = recordingGraph();
emitFileCfgs(graph, cfgs);
const ids = nodes.map((n) => n.id);
expect(new Set(ids).size).toBe(ids.length); // no collisions
});
it('two functions sharing a start LINE get distinct ids (start-column disambiguates)', () => {
// Both arrows begin on line 1; without the start-column segment in the id
// their block indices (each restarting at 0) collide and graph.addNode's
// first-writer-wins silently drops the second function's blocks.
const cfgs = cfgsOf(`const h = { a: () => foo(), b: () => bar() };`, 'one-line.ts');
expect(cfgs.length).toBe(2);
expect(cfgs[0].functionStartLine).toBe(cfgs[1].functionStartLine); // same line
expect(cfgs[0].functionStartColumn).not.toBe(cfgs[1].functionStartColumn); // diff column
const { graph, nodes } = recordingGraph();
emitFileCfgs(graph, cfgs);
const ids = nodes.map((n) => n.id);
expect(new Set(ids).size).toBe(ids.length); // no collision despite shared line
expect(nodes.length).toBe(cfgs[0].blocks.length + cfgs[1].blocks.length); // all blocks survive
});
});
describe('U4 — AC2: every BasicBlock is reachable from its function ENTRY', () => {
// Fixtures deliberately contain no dead code, so the reachability closure
// from each function's ENTRY (block index 0) must cover all of its blocks.
const FIXTURE = `
function branch(x: number) { if (x) { a(); } else { b(); } c(); }
function loop(xs: number[]) { for (const y of xs) { use(y); } done(); }
function multi(x: number) {
switch (x) { case 1: one(); break; default: other(); }
tail();
}
`;
it('reachability closure from ENTRY covers every emitted block per function', () => {
const cfgs = cfgsOf(FIXTURE, 'r.ts');
const { graph, nodes, rels } = recordingGraph();
emitFileCfgs(graph, cfgs);
const adj = new Map<string, string[]>();
for (const e of rels)
(adj.get(e.sourceId) ?? adj.set(e.sourceId, []).get(e.sourceId)!).push(e.targetId);
for (const cfg of cfgs) {
const prefix = `BasicBlock:r.ts:${cfg.functionStartLine}:${cfg.functionStartColumn}:`;
const entryId = `${prefix}${cfg.entryIndex}`;
const fnNodeIds = nodes.map((n) => n.id).filter((id) => id.startsWith(prefix));
// BFS from ENTRY
const seen = new Set([entryId]);
const stack = [entryId];
while (stack.length) {
const n = stack.pop() as string;
for (const nx of adj.get(n) ?? []) if (!seen.has(nx)) (seen.add(nx), stack.push(nx));
}
for (const id of fnNodeIds) {
expect(seen.has(id), `${id} unreachable from ENTRY`).toBe(true);
}
}
});
});
describe('U4 — per-function edge cap (R6, no silent truncation)', () => {
it('stops at the cap, records the dropped count, and warns', () => {
const cfgs = cfgsOf(`function f(x: number) { if (x) { a(); } else { b(); } c(); }`);
const total = cfgs[0].edges.length;
expect(total).toBeGreaterThan(2);
const { graph, rels } = recordingGraph();
const onWarn = vi.fn();
const r = emitFileCfgs(graph, cfgs, 2, onWarn);
expect(rels.length).toBe(2); // emitted exactly the cap
expect(r.droppedEdges).toBe(total - 2);
expect(r.cappedFunctions).toBe(1);
expect(onWarn).toHaveBeenCalledTimes(1);
expect(onWarn.mock.calls[0][0]).toContain(`dropped ${total - 2} of ${total}`);
});
it('cap of 0 means unlimited (emits every edge, no warning)', () => {
const cfgs = cfgsOf(`function f(x: number) { if (x) { a(); } else { b(); } }`);
const { graph, rels } = recordingGraph();
const onWarn = vi.fn();
const r = emitFileCfgs(graph, cfgs, 0, onWarn);
expect(rels.length).toBe(cfgs[0].edges.length);
expect(r.droppedEdges).toBe(0);
expect(onWarn).not.toHaveBeenCalled();
});
});
describe('U4 — flag-off / empty input emits nothing', () => {
it('no functions ⇒ zero nodes and edges', () => {
const { graph, nodes, rels } = recordingGraph();
const r = emitFileCfgs(graph, []);
expect(nodes).toHaveLength(0);
expect(rels).toHaveLength(0);
expect(r.blocks).toBe(0);
expect(r.edges).toBe(0);
});
});

View file

@ -0,0 +1,156 @@
import { describe, it, expect } from 'vitest';
import fs from 'fs';
import path from 'path';
import Parser from 'tree-sitter';
import TypeScript from 'tree-sitter-typescript';
import { collectFunctionCfgs } from '../../../src/core/ingestion/cfg/collect.js';
import { emitFileCfgs } from '../../../src/core/ingestion/cfg/emit.js';
import { getProvider } from '../../../src/core/ingestion/languages/index.js';
import { SupportedLanguages } from '../../../src/config/supported-languages.js';
import type { CfgVisitor, FunctionCfg } from '../../../src/core/ingestion/cfg/types.js';
import type { SyntaxNode } from '../../../src/core/ingestion/utils/ast-helpers.js';
import type { KnowledgeGraph } from '../../../src/core/graph/types.js';
// U7 — acceptance fixtures for the CFG layer (#2081 M1).
// AC1: the 10-function fixture's CFG node/edge set matches a committed snapshot.
// AC2: every BasicBlock is reachable from its function ENTRY (no dead code).
// AC3: try/throw/finally + labeled break/continue topologies are correct.
// (AC4 — flag-off byte-identical graph — is the existing
// pipeline-graph-golden.test.ts, run with --pdg off.)
const FIXTURES = path.join(__dirname, 'fixtures');
const visitor = ((): CfgVisitor<SyntaxNode> => {
const v = getProvider(SupportedLanguages.TypeScript).cfgVisitor;
if (!v) throw new Error('no cfgVisitor');
return v;
})();
function cfgsOfFile(file: string): readonly FunctionCfg[] {
const source = fs.readFileSync(path.join(FIXTURES, file), 'utf8');
const parser = new Parser();
parser.setLanguage(TypeScript.typescript);
return collectFunctionCfgs(parser.parse(source).rootNode, visitor, file).cfgs;
}
/** Deterministic, line-anchored serialization of a function's CFG. */
function serialize(cfg: FunctionCfg): Record<string, unknown> {
return {
startLine: cfg.functionStartLine,
blocks: cfg.blocks.length,
entry: cfg.entryIndex,
exit: cfg.exitIndex,
edges: cfg.edges.map((e) => `${e.from}->${e.to}:${e.kind}`).sort((x, y) => (x < y ? -1 : 1)),
};
}
interface Rel {
sourceId: string;
targetId: string;
}
function recordingGraph(): { graph: KnowledgeGraph; nodeIds: string[]; rels: Rel[] } {
const nodeIds: string[] = [];
const rels: Rel[] = [];
const graph = {
addNode: (n: { id: string }) => nodeIds.push(n.id),
addRelationship: (r: Rel) => rels.push(r),
} as unknown as KnowledgeGraph;
return { graph, nodeIds, rels };
}
function reaches(adj: Map<string, string[]>, from: string, to: string): boolean {
const seen = new Set([from]);
const stack = [from];
while (stack.length) {
const n = stack.pop() as string;
if (n === to) return true;
for (const nx of adj.get(n) ?? []) if (!seen.has(nx)) (seen.add(nx), stack.push(nx));
}
return seen.has(to);
}
describe('U7 — AC1: 10-function fixture CFG snapshot', () => {
it('matches the committed CFG node/edge set', () => {
const cfgs = cfgsOfFile('ten-functions.ts');
expect(cfgs).toHaveLength(10);
expect(cfgs.map(serialize)).toMatchSnapshot();
});
});
describe('U7 — AC2: every BasicBlock reachable from its function ENTRY', () => {
it('holds for all ten functions (no dead code in the fixture)', () => {
const cfgs = cfgsOfFile('ten-functions.ts');
const { graph, nodeIds, rels } = recordingGraph();
emitFileCfgs(graph, cfgs);
const adj = new Map<string, string[]>();
for (const e of rels)
(adj.get(e.sourceId) ?? adj.set(e.sourceId, []).get(e.sourceId)!).push(e.targetId);
for (const cfg of cfgs) {
const prefix = `BasicBlock:ten-functions.ts:${cfg.functionStartLine}:${cfg.functionStartColumn}:`;
const entryId = `${prefix}${cfg.entryIndex}`;
for (const id of nodeIds.filter((i) => i.startsWith(prefix))) {
expect(reaches(adj, entryId, id), `${id} unreachable from ENTRY`).toBe(true);
}
}
});
});
describe('U7 — AC3: hazard topologies', () => {
function blockAdj(cfg: FunctionCfg): { adj: Map<number, number[]> } {
const adj = new Map<number, number[]>();
for (const e of cfg.edges) (adj.get(e.from) ?? adj.set(e.from, []).get(e.from)!).push(e.to);
return { adj };
}
const blockWith = (cfg: FunctionCfg, sub: string): number => {
const b = cfg.blocks.find((bl) => bl.text.includes(sub));
if (!b) throw new Error(`no block with ${sub}`);
return b.index;
};
const reachIdx = (cfg: FunctionCfg, from: number, to: number): boolean => {
const { adj } = blockAdj(cfg);
const seen = new Set([from]);
const st = [from];
while (st.length) {
const n = st.pop() as number;
if (n === to) return true;
for (const nx of adj.get(n) ?? []) if (!seen.has(nx)) (seen.add(nx), st.push(nx));
}
return false;
};
it('try/throw/finally: normal + exceptional both flow through finally to the post-try block', () => {
const cfgs = cfgsOfFile('hazards.ts');
const fn = cfgs.find((c) => c.blocks.some((b) => b.text.includes('cleanup();')))!;
const fin = blockWith(fn, 'cleanup();');
const after = blockWith(fn, 'afterTry();');
const work = blockWith(fn, 'work();');
const handler = blockWith(fn, 'handle();');
expect(fn.edges.some((e) => e.kind === 'throw')).toBe(true);
expect(reachIdx(fn, work, fin)).toBe(true); // normal path → finally
expect(reachIdx(fn, work, handler)).toBe(true); // exceptional → catch
expect(reachIdx(fn, handler, fin)).toBe(true); // catch → finally
expect(reachIdx(fn, fin, after)).toBe(true); // finally → continuation
});
it('labeled break escapes both loops to the post-loop block, not the inner exit', () => {
const cfgs = cfgsOfFile('hazards.ts');
const fn = cfgs.find((c) => c.blocks.some((b) => b.text.includes('break outer;')))!;
const brk = blockWith(fn, 'break outer;');
expect(reachIdx(fn, brk, blockWith(fn, 'done();'))).toBe(true);
expect(reachIdx(fn, brk, blockWith(fn, 'afterInner();'))).toBe(false);
expect(fn.edges.some((e) => e.kind === 'break')).toBe(true);
});
it('labeled continue returns to the OUTER loop header (not the nearest)', () => {
const cfgs = cfgsOfFile('hazards.ts');
const fn = cfgs.find((c) => c.blocks.some((b) => b.text.includes('continue outer;')))!;
const cont = blockWith(fn, 'continue outer;');
// outer loop iterates `xs`; its header is the only block whose text holds "xs"
const outerHeader = blockWith(fn, 'xs');
expect(
fn.edges.some((e) => e.from === cont && e.to === outerHeader && e.kind === 'continue'),
).toBe(true);
});
});

View file

@ -0,0 +1,52 @@
// AC3 fixtures (#2081 M1): the classic CFG hazards — try/throw/finally
// post-domination and labeled break/continue across nested loops. The visitor
// must route both normal completion AND an exception through `finally`, and
// resolve labeled jumps against the labeled (outer) loop, not the nearest one.
export function tryThrowFinally(flag: boolean): void {
try {
work();
if (flag) {
throw new Error('boom');
}
} catch (e) {
handle();
} finally {
cleanup();
}
afterTry();
}
export function labeledBreak(xs: number[], ys: number[]): void {
outer: for (const x of xs) {
for (const y of ys) {
if (x === y) {
break outer;
}
inner();
}
afterInner();
}
done();
}
export function labeledContinue(xs: number[], ys: number[]): void {
outer: for (const x of xs) {
for (const y of ys) {
if (x === y) {
continue outer;
}
body();
}
}
done();
}
declare function work(): void;
declare function handle(): void;
declare function cleanup(): void;
declare function afterTry(): void;
declare function inner(): void;
declare function afterInner(): void;
declare function body(): void;
declare function done(): void;

View file

@ -0,0 +1,22 @@
// Tiny repo fixture for the end-to-end --pdg pipeline test (#2081 M1).
// Two functions with branches/loops so the emitted CFG has multiple
// BasicBlock nodes and several CFG edge kinds.
export function classify(x: number): string {
if (x > 0) {
return 'pos';
} else if (x < 0) {
return 'neg';
}
return 'zero';
}
export function total(xs: number[]): number {
let sum = 0;
for (const x of xs) {
if (x > 0) {
sum += x;
}
}
return sum;
}

View file

@ -0,0 +1,115 @@
// AC1 fixture (#2081 M1): ten TS functions mixing the control-flow constructs
// the CFG visitor handles. Deliberately NO unreachable/dead code (no statement
// after an unconditional return/throw), so the AC2 property — every BasicBlock
// reachable from its function ENTRY — holds for every function here.
//
// Edit with care: the CFG snapshot (cfg-snapshot.test.ts) keys block/edge
// counts off this exact source, and the per-function start lines anchor it.
export function straight(): void {
a();
b();
}
export function withIf(x: boolean): void {
if (x) {
a();
} else {
b();
}
c();
}
export function withElseIf(x: number): void {
if (x === 1) {
a();
} else if (x === 2) {
b();
} else {
c();
}
d();
}
export function withWhile(x: number): void {
while (x > 0) {
step();
}
done();
}
export function withFor(n: number): void {
for (let i = 0; i < n; i++) {
step();
}
done();
}
export function withForOf(xs: number[]): void {
for (const x of xs) {
use(x);
}
done();
}
export function withSwitch(x: number): void {
switch (x) {
case 1:
one();
break;
case 2:
two();
break;
default:
other();
}
tail();
}
export function withTry(): void {
try {
work();
} catch (e) {
oops();
} finally {
fin();
}
after();
}
export function withReturn(x: boolean): number {
if (x) {
return 1;
}
return 2;
}
export function withNested(xs: number[]): void {
for (const x of xs) {
if (x > 0) {
p();
} else {
q();
}
}
end();
}
declare function a(): void;
declare function b(): void;
declare function c(): void;
declare function d(): void;
declare function step(): void;
declare function done(): void;
declare function use(x: number): void;
declare function one(): void;
declare function two(): void;
declare function other(): void;
declare function tail(): void;
declare function work(): void;
declare function oops(): void;
declare function fin(): void;
declare function after(): void;
declare function p(): void;
declare function q(): void;
declare function end(): void;

View file

@ -0,0 +1,64 @@
import { describe, it, expect, afterAll } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { runPipelineFromRepo } from '../../../src/core/ingestion/pipeline.js';
import type { PipelineResult } from '../../../src/types/pipeline.js';
// U7 — end-to-end proof that the `--pdg` opt-in reaches BOTH sinks: the parse
// worker builds a per-function CFG (workerData.pdg) and scope-resolution emits
// BasicBlock nodes + CFG edges from it (the run gate). Runs the real pipeline
// (workers + scope-resolution) on a tiny repo and inspects the in-memory graph.
// The flag-off run proves the gate: zero CFG nodes/edges (cf. AC4 golden).
const FIXTURE = path.join(__dirname, 'fixtures', 'pdg-repo');
function counts(result: PipelineResult): { basicBlocks: number; cfgEdges: number } {
let basicBlocks = 0;
result.graph.forEachNode((n) => {
if (n.label === 'BasicBlock') basicBlocks++;
});
let cfgEdges = 0;
for (const rel of result.graph.iterRelationships()) {
if (rel.type === 'CFG') cfgEdges++;
}
return { basicBlocks, cfgEdges };
}
const tmpDirs: string[] = [];
function freshRepo(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-pdg-'));
fs.cpSync(FIXTURE, dir, { recursive: true });
tmpDirs.push(dir);
return dir;
}
describe('U7 — end-to-end --pdg pipeline', () => {
afterAll(() => {
for (const d of tmpDirs) fs.rmSync(d, { recursive: true, force: true });
});
it('with --pdg on: emits BasicBlock nodes + CFG edges into the graph', async () => {
const result = await runPipelineFromRepo(freshRepo(), () => {}, { pdg: true });
const { basicBlocks, cfgEdges } = counts(result);
expect(basicBlocks).toBeGreaterThan(0);
expect(cfgEdges).toBeGreaterThan(0);
// CFG edges connect BasicBlocks to BasicBlocks — both endpoints exist.
const blockIds = new Set<string>();
result.graph.forEachNode((n) => {
if (n.label === 'BasicBlock') blockIds.add(n.id);
});
for (const rel of result.graph.iterRelationships()) {
if (rel.type !== 'CFG') continue;
expect(blockIds.has(rel.sourceId)).toBe(true);
expect(blockIds.has(rel.targetId)).toBe(true);
}
}, 60000);
it('with --pdg off (default): emits zero BasicBlock nodes and zero CFG edges', async () => {
const result = await runPipelineFromRepo(freshRepo(), () => {});
const { basicBlocks, cfgEdges } = counts(result);
expect(basicBlocks).toBe(0);
expect(cfgEdges).toBe(0);
}, 60000);
});

View file

@ -0,0 +1,152 @@
import { describe, it, expect } from 'vitest';
import Parser from 'tree-sitter';
import TypeScript from 'tree-sitter-typescript';
import { collectFunctionCfgs } from '../../../src/core/ingestion/cfg/collect.js';
import { computeChunkHash, mapReplacer, mapReviver } from '../../../src/storage/parse-cache.js';
import { getProvider } from '../../../src/core/ingestion/languages/index.js';
import { SupportedLanguages } from '../../../src/config/supported-languages.js';
import type { CfgVisitor } from '../../../src/core/ingestion/cfg/types.js';
import type { SyntaxNode } from '../../../src/core/ingestion/utils/ast-helpers.js';
// U3 — the worker→main boundary + cache coherence for the CFG side-channel.
// These pin the contracts that make the disk-store + warm/durable parse cache
// carry the CFG intact across the --pdg flag (R3, R4) WITHOUT spinning a real
// worker pool: the worker simply calls collectFunctionCfgs (tested here) and
// attaches the result as plain data, and the parse-cache key folds the flag.
function tsRoot(code: string): SyntaxNode {
const parser = new Parser();
parser.setLanguage(TypeScript.typescript);
return parser.parse(code).rootNode;
}
const tsVisitor = (): CfgVisitor<SyntaxNode> => {
const v = getProvider(SupportedLanguages.TypeScript).cfgVisitor;
if (!v) throw new Error('typescript provider has no cfgVisitor');
return v;
};
describe('U3 — TS/JS provider exposes a cfgVisitor; others do not (worker gate)', () => {
it('TS and JS providers carry a cfgVisitor', () => {
expect(getProvider(SupportedLanguages.TypeScript).cfgVisitor).toBeDefined();
expect(getProvider(SupportedLanguages.JavaScript).cfgVisitor).toBeDefined();
});
it('a non-CFG language (Python) has no cfgVisitor ⇒ worker emits no cfgSideChannel', () => {
// `provider.cfgVisitor &&` short-circuits in the worker → no CFG, no field.
expect(getProvider(SupportedLanguages.Python).cfgVisitor).toBeUndefined();
});
});
describe('U3 — collectFunctionCfgs', () => {
it('produces one CFG per function with the expected branch edges', () => {
const root = tsRoot(`
function a(x: number) { if (x) { p(); } else { q(); } }
function b() { return 1; }
`);
const { cfgs, skipped } = collectFunctionCfgs(root, tsVisitor(), 'a.ts');
expect(skipped).toBe(0);
expect(cfgs).toHaveLength(2);
const a = cfgs.find((c) => c.blocks.some((bl) => bl.text.includes('p();')));
expect(a).toBeDefined();
const kinds = new Set(a!.edges.map((e) => e.kind));
expect(kinds.has('cond-true')).toBe(true);
expect(kinds.has('cond-false')).toBe(true);
// every block belongs to its declaring file
for (const c of cfgs) expect(c.filePath).toBe('a.ts');
});
it('a file with no functions yields an empty CFG set (no error)', () => {
const { cfgs, skipped } = collectFunctionCfgs(
tsRoot(`const x = 1; export {};`),
tsVisitor(),
'x.ts',
);
expect(cfgs).toHaveLength(0);
expect(skipped).toBe(0);
});
it('maxFunctionLines skips an over-cap function and counts the skip', () => {
const big = `function big() {\n${' step();\n'.repeat(20)}}`;
const root = tsRoot(`${big}\nfunction small() { ok(); }`);
const { cfgs, skipped } = collectFunctionCfgs(root, tsVisitor(), 'f.ts', 5);
expect(skipped).toBe(1); // big() exceeds the 5-line cap
// small() is still built
expect(cfgs.some((c) => c.blocks.some((bl) => bl.text.includes('ok();')))).toBe(true);
expect(cfgs.some((c) => c.blocks.some((bl) => bl.text.includes('step();')))).toBe(false);
});
});
describe('U3 — CFG side-channel JSON round-trip (no AST leakage, no field loss)', () => {
it('serialize → JSON → deserialize yields an identical CFG', () => {
const root = tsRoot(`function f(xs: number[]) {
for (const x of xs) { if (x > 0) { use(x); } else { break; } }
done();
}`);
const { cfgs } = collectFunctionCfgs(root, tsVisitor(), 'rt.ts');
expect(cfgs.length).toBeGreaterThan(0);
// The worker serializes ParsedFile via mapReplacer; the store revives via
// mapReviver. The CFG is plain data, so it must survive byte-for-byte.
const round = JSON.parse(JSON.stringify(cfgs, mapReplacer), mapReviver);
expect(round).toEqual(cfgs);
// No tree-sitter nodes leaked: every value is a primitive/array/plain object.
for (const c of round) {
for (const b of c.blocks) expect(typeof b.text).toBe('string');
for (const e of c.edges) expect(typeof e.from).toBe('number');
}
});
});
describe('U3 — parse-cache key folds the --pdg flag (R4, #2038-class guard)', () => {
const entries = [
{ filePath: 'b.ts', contentHash: 'h2' },
{ filePath: 'a.ts', contentHash: 'h1' },
];
it('pdg-on and pdg-off produce DIFFERENT chunk keys', () => {
expect(computeChunkHash(entries, false)).not.toBe(computeChunkHash(entries, true));
});
it('the same flag value is stable and order-independent', () => {
const reordered = [...entries].reverse();
expect(computeChunkHash(entries, true)).toBe(computeChunkHash(reordered, true));
expect(computeChunkHash(entries, false)).toBe(computeChunkHash(reordered, false));
});
it('default (no flag arg) equals the explicit pdg-off key — warm caches survive the change', () => {
expect(computeChunkHash(entries)).toBe(computeChunkHash(entries, false));
});
it('the boolean form equals the object form with the same flag (back-compat)', () => {
expect(computeChunkHash(entries, true)).toBe(computeChunkHash(entries, { pdg: true }));
expect(computeChunkHash(entries, false)).toBe(computeChunkHash(entries, { pdg: false }));
});
it('the worker-side line cap is folded into the key — a different maxFunctionLines re-dispatches', () => {
// Guards the #2038-class trap for the WORKER-visible cap: a warm chunk
// built under one maxFunctionLines must NOT be served to a --pdg run with
// a different cap (the cached cfgSideChannel differs — the worker skips
// different functions). Different cap value ⇒ different key.
const base = computeChunkHash(entries, { pdg: true });
expect(computeChunkHash(entries, { pdg: true, maxFunctionLines: 500 })).not.toBe(base);
// Same cap values ⇒ same key (deterministic, order-independent).
const reordered = [...entries].reverse();
expect(computeChunkHash(entries, { pdg: true, maxFunctionLines: 500 })).toBe(
computeChunkHash(reordered, { pdg: true, maxFunctionLines: 500 }),
);
});
it('the EMIT-time edge cap does NOT perturb the key — cached worker output is identical across it (#2099 F3)', () => {
// pdgMaxEdgesPerFunction is applied in scope-resolution on the main
// thread; the worker never sees it, so the cached shard is byte-identical
// across cap values. Folding it in (a prior review round did) only forced
// a spurious full re-parse + durable-store rewrite on every cap change.
const base = computeChunkHash(entries, { pdg: true });
expect(
computeChunkHash(entries, {
pdg: true,
maxEdgesPerFunction: 100,
} as Parameters<typeof computeChunkHash>[1]),
).toBe(base);
});
});

View file

@ -75,6 +75,17 @@ describe('analyze-config (.gitnexusrc support, #243)', () => {
});
});
it('normalizes the pdg opt-in (#2081) and rejects a non-boolean value', async () => {
await writeRc(JSON.stringify({ pdg: true }));
expect(loadAnalyzeConfig(dir)).toEqual({ pdg: true });
await writeRc(JSON.stringify({ pdg: false }));
expect(loadAnalyzeConfig(dir)).toEqual({ pdg: false });
await writeRc(JSON.stringify({ pdg: 'yes' }));
expect(() => loadAnalyzeConfig(dir)).toThrow(/must be a boolean/);
});
it('parses the nested analyze form', async () => {
await writeRc(JSON.stringify({ analyze: { defaultBranch: 'master', skipSkills: true } }));
expect(loadAnalyzeConfig(dir)).toEqual({ defaultBranch: 'master', skipSkills: true });

View file

@ -0,0 +1,120 @@
import { describe, it, expect } from 'vitest';
import { CfgBuilder, reachableBlocks } from '../../../src/core/ingestion/cfg/cfg-builder.js';
import { ControlFlowContext } from '../../../src/core/ingestion/cfg/control-flow-context.js';
// The CFG core is AST-agnostic — these tests drive the builder + context the
// way the TS/JS visitor (U2) will, on the classic control-flow topologies the
// S2 spike validated. They pin block/edge accounting and reachability (R1, R9)
// before any tree-sitter coupling exists.
describe('CfgBuilder', () => {
it('straight-line body: ENTRY → block → EXIT, all reachable', () => {
const b = new CfgBuilder('f.ts', 1, 3);
const body = b.newBlock(2, 2, 'g();');
b.edge(b.entryIndex, body, 'seq');
b.edge(body, b.exitIndex, 'seq');
const cfg = b.finish();
expect(cfg.blocks).toHaveLength(3); // entry, exit, body
expect(cfg.entryIndex).toBe(0);
expect(reachableBlocks(cfg).size).toBe(3);
});
it('empty function: ENTRY → EXIT only', () => {
const b = new CfgBuilder('f.ts', 1, 1);
b.edge(b.entryIndex, b.exitIndex, 'seq');
const cfg = b.finish();
expect(cfg.blocks).toHaveLength(2);
expect(reachableBlocks(cfg)).toEqual(new Set([b.entryIndex, b.exitIndex]));
});
it('if/else diamond: both branches reach the join', () => {
const b = new CfgBuilder('f.ts', 1, 6);
const thenB = b.newBlock(2, 2, 'a();');
const elseB = b.newBlock(4, 4, 'b();');
const join = b.newBlock(6, 6, 'c();');
b.edge(b.entryIndex, thenB, 'cond-true');
b.edge(b.entryIndex, elseB, 'cond-false');
b.connect([thenB, elseB], join, 'seq');
b.edge(join, b.exitIndex, 'seq');
const cfg = b.finish();
const reach = reachableBlocks(cfg);
expect(reach.has(thenB) && reach.has(elseB) && reach.has(join)).toBe(true);
// join has two predecessors
expect(cfg.edges.filter((e) => e.to === join)).toHaveLength(2);
});
it('while loop: body back-edges to header; header exits the loop', () => {
const b = new CfgBuilder('f.ts', 1, 4);
const header = b.newBlock(1, 1, 'while(x)');
const body = b.newBlock(2, 2, 'x--;');
b.edge(b.entryIndex, header, 'seq');
b.edge(header, body, 'cond-true');
b.edge(body, header, 'loop-back');
b.edge(header, b.exitIndex, 'cond-false');
const cfg = b.finish();
expect(cfg.edges).toContainEqual({ from: body, to: header, kind: 'loop-back' });
expect(reachableBlocks(cfg).size).toBe(4); // all reachable
});
it('mid-block return wires to EXIT; trailing block still emitted but unreachable-by-fallthrough', () => {
const b = new CfgBuilder('f.ts', 1, 4);
const ret = b.newBlock(2, 2, 'return 1;');
const dead = b.newBlock(3, 3, 'g();'); // after return — no fallthrough edge into it
b.edge(b.entryIndex, ret, 'seq');
b.edge(ret, b.exitIndex, 'return');
b.edge(dead, b.exitIndex, 'seq');
const cfg = b.finish();
const reach = reachableBlocks(cfg);
expect(reach.has(ret)).toBe(true);
expect(reach.has(dead)).toBe(false); // emitted, but not reachable from ENTRY
});
it('edge() is idempotent on (from,to,kind)', () => {
const b = new CfgBuilder('f.ts', 1, 2);
const x = b.newBlock(1, 1, 'x');
b.edge(b.entryIndex, x, 'seq');
b.edge(b.entryIndex, x, 'seq'); // duplicate
b.connect([b.entryIndex], x, 'seq'); // duplicate via connect
expect(b.finish().edges.filter((e) => e.from === b.entryIndex && e.to === x)).toHaveLength(1);
});
it('finish() indexes blocks contiguously from 0', () => {
const b = new CfgBuilder('f.ts', 1, 2);
b.newBlock(1, 1, 'a');
b.newBlock(2, 2, 'b');
const cfg = b.finish();
expect(cfg.blocks.map((bl) => bl.index)).toEqual([0, 1, 2, 3]);
expect(cfg.blocks[cfg.entryIndex].kind).toBe('entry');
expect(cfg.blocks[cfg.exitIndex].kind).toBe('exit');
});
});
describe('ControlFlowContext', () => {
it('plain break/continue resolve to the nearest loop', () => {
const ctx = new ControlFlowContext();
ctx.pushLoop(/*continueTo*/ 10, /*breakTo*/ 20);
expect(ctx.continueTarget()).toBe(10);
expect(ctx.breakTarget()).toBe(20);
ctx.pop();
expect(ctx.breakTarget()).toBeUndefined();
});
it('break resolves to the nearest switch; continue skips switches to the loop', () => {
const ctx = new ControlFlowContext();
ctx.pushLoop(100, 200); // outer loop
ctx.pushSwitch(30); // inner switch
expect(ctx.breakTarget()).toBe(30); // break → switch
expect(ctx.continueTarget()).toBe(100); // continue skips switch → loop
ctx.pop();
expect(ctx.breakTarget()).toBe(200);
});
it('labeled break/continue resolve to the labeled loop, not the nearest', () => {
const ctx = new ControlFlowContext();
ctx.pushLoop(/*outer*/ 100, 200, 'outer');
ctx.pushLoop(/*inner*/ 110, 210);
expect(ctx.breakTarget('outer')).toBe(200);
expect(ctx.continueTarget('outer')).toBe(100);
expect(ctx.breakTarget()).toBe(210); // unlabeled → nearest (inner)
});
});

View file

@ -0,0 +1,193 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import type { ParsedFile, ScopeId, Scope } from 'gitnexus-shared';
import { runScopeResolution } from '../../../src/core/ingestion/scope-resolution/pipeline/run.js';
import { createKnowledgeGraph } from '../../../src/core/graph/graph.js';
import { createSemanticModel } from '../../../src/core/ingestion/model/semantic-model.js';
import type { ScopeResolver } from '../../../src/core/ingestion/scope-resolution/contract/scope-resolver.js';
import type { FunctionCfg } from '../../../src/core/ingestion/cfg/types.js';
import type { KnowledgeGraph } from '../../../src/core/graph/types.js';
import { _captureLogger } from '../../../src/core/logger.js';
// #2099 F4 — the per-element cfgSideChannel guard at the scope-resolution emit
// site. A malformed element (wrong shape, non-integer edge endpoints, or a
// shape that throws inside emitFileCfgs) must cost at most that element's /
// that file's CFG — never abort the language's scope-resolution pass, and
// never silently emit a dangling `BasicBlock:…:undefined` edge id. Harness:
// runScopeResolution with a stub provider + preExtractedParsedFiles, the same
// trio as run-progress.test.ts (no cfgVisitor needed — the emit path reads the
// channel directly).
const mkScope = (id: ScopeId, filePath: string): Scope => ({
id,
parent: null,
kind: 'Module',
range: { startLine: 1, startCol: 0, endLine: 10, endCol: 0 },
filePath,
bindings: new Map(),
ownedDefs: [],
imports: [],
typeBindings: new Map(),
});
const mkFile = (filePath: string, cfgSideChannel?: unknown): ParsedFile => ({
filePath,
moduleScope: `scope:${filePath}#module`,
scopes: [mkScope(`scope:${filePath}#module`, filePath)],
parsedImports: [],
localDefs: [],
referenceSites: [],
...(cfgSideChannel !== undefined ? { cfgSideChannel } : {}),
});
const stubProvider = {
language: 'python' as const,
languageProvider: {} as ScopeResolver['languageProvider'],
importEdgeReason: 'test',
populateOwners: () => {},
resolveImportTarget: () => null,
mergeBindings: (existing: unknown) => existing,
buildMro: () => new Map(),
propagatesReturnTypesAcrossImports: false,
} as unknown as ScopeResolver;
const validCfg: FunctionCfg = {
filePath: 'a.py',
functionStartLine: 1,
functionEndLine: 3,
functionStartColumn: 0,
entryIndex: 0,
exitIndex: 1,
blocks: [
{ index: 0, startLine: 1, endLine: 1, text: '', kind: 'entry' },
{ index: 1, startLine: 3, endLine: 3, text: '', kind: 'exit' },
],
edges: [{ from: 0, to: 1, kind: 'seq' }],
};
/** Run scope-resolution with `pdg: true` over one file carrying `channel`. */
function emitWith(channel: unknown): KnowledgeGraph {
const graph = createKnowledgeGraph();
const files = [{ path: 'a.py', content: '' }];
const preExtracted = new Map<string, ParsedFile>([['a.py', mkFile('a.py', channel)]]);
runScopeResolution(
{
graph,
model: createSemanticModel(),
files,
preExtractedParsedFiles: preExtracted,
pdg: true,
},
stubProvider,
);
return graph;
}
const basicBlockCount = (graph: KnowledgeGraph): number => {
let n = 0;
graph.forEachNode((node) => {
if (node.label === 'BasicBlock') n++;
});
return n;
};
const cfgEdges = (graph: KnowledgeGraph): { sourceId: string; targetId: string }[] => {
const out: { sourceId: string; targetId: string }[] = [];
graph.forEachRelationship((r) => {
if (r.type === 'CFG') out.push({ sourceId: r.sourceId, targetId: r.targetId });
});
return out;
};
describe('cfgSideChannel emit guard (#2099 F4)', () => {
let cap: ReturnType<typeof _captureLogger>;
beforeEach(() => {
cap = _captureLogger();
});
afterEach(() => {
cap.restore();
});
const warns = (): string[] =>
cap
.records()
.filter((r) => r.level >= 40) // pino warn = 40
.map((r) => String(r.msg));
it('a wrong-shape element [{}] is skipped with a warning naming the file; no throw', () => {
const graph = emitWith([{}]);
expect(basicBlockCount(graph)).toBe(0);
expect(warns()).toHaveLength(1);
expect(warns()[0]).toContain('a.py');
});
it('a non-array channel is silently skipped by the outer guard', () => {
const graph = emitWith('garbage');
expect(basicBlockCount(graph)).toBe(0);
expect(warns()).toHaveLength(0);
});
it('mixed array: the valid element still emits, the malformed one is skipped (per-element policy)', () => {
const graph = emitWith([validCfg, {}]);
expect(basicBlockCount(graph)).toBe(2);
expect(cfgEdges(graph)).toHaveLength(1);
expect(warns()).toHaveLength(1);
});
it('non-integer edge endpoints are rejected by the PREDICATE — zero dangling edge ids (this shape never throws)', () => {
const poisoned = { ...validCfg, edges: [{ from: 'x', to: 1, kind: 'seq' }] };
const graph = emitWith([poisoned]);
expect(basicBlockCount(graph)).toBe(0);
expect(cfgEdges(graph)).toHaveLength(0);
expect(warns()).toHaveLength(1);
});
it('an INTEGER endpoint matching no block index is rejected too — membership, not just integer-ness', () => {
const poisoned = { ...validCfg, edges: [{ from: 0, to: 7, kind: 'seq' }] };
const graph = emitWith([poisoned]);
expect(basicBlockCount(graph)).toBe(0);
expect(cfgEdges(graph)).toHaveLength(0);
expect(warns()).toHaveLength(1);
});
it('missing id-anchor fields (functionStartColumn) are rejected — prevents first-writer-wins id cross-wiring', () => {
const { functionStartColumn: _drop, ...withoutColumn } = validCfg;
const graph = emitWith([withoutColumn]);
expect(basicBlockCount(graph)).toBe(0);
expect(warns()).toHaveLength(1);
});
it('a null element inside blocks is rejected by the predicate — no partial emit, no orphaned nodes', () => {
const poisoned = { ...validCfg, blocks: [validCfg.blocks[0], null] };
const graph = emitWith([poisoned]);
expect(basicBlockCount(graph)).toBe(0); // nothing emitted — not even the valid leading block
expect(warns()).toHaveLength(1);
});
it('backstop: a shape that throws past the predicate (hostile getter) is caught, warned, pass completes', () => {
const hostile = {
...validCfg,
blocks: [
{
get index(): number {
throw new Error('hostile getter');
},
startLine: 1,
endLine: 1,
text: '',
kind: 'normal' as const,
},
],
};
expect(() => emitWith([hostile])).not.toThrow();
expect(warns()).toHaveLength(1);
expect(warns()[0]).toContain('CFG emission failed');
});
it('a well-formed channel emits blocks + edges identically (regression guard)', () => {
const graph = emitWith([validCfg]);
expect(basicBlockCount(graph)).toBe(2);
expect(cfgEdges(graph)).toHaveLength(1);
expect(warns()).toHaveLength(0);
});
});

View file

@ -0,0 +1,39 @@
import { describe, it, expect } from 'vitest';
import { mergeAnalyzeOptions } from '../../../src/cli/analyze-config.js';
import { computeChunkHash } from '../../../src/storage/parse-cache.js';
// U5 — the `--pdg` opt-in plumbing (R7). The flag has TWO sinks downstream of
// PipelineOptions.pdg: the parse worker (CFG build, gated on workerData.pdg)
// and scope-resolution (BasicBlock/CFG emit, gated on the run input). These
// tests pin the observable plumbing: the CLI/.gitnexusrc merge, and that the
// flag perturbs the parse-cache/worker-dispatch key so a pdg run never reuses
// a pdg-off shard. The full worker-build + main-emit round-trip is exercised
// end-to-end by the U7 runPipelineFromRepo({ pdg: true }) integration test.
describe('U5 — --pdg merges from CLI and .gitnexusrc', () => {
it('a CLI --pdg flag flows into the merged options', () => {
expect(mergeAnalyzeOptions({ pdg: true }, undefined).pdg).toBe(true);
});
it('a .gitnexusrc pdg value flows through when the CLI omits it', () => {
expect(mergeAnalyzeOptions({}, { pdg: true }).pdg).toBe(true);
});
it('the CLI flag wins over the file config', () => {
expect(mergeAnalyzeOptions({ pdg: true }, { pdg: false }).pdg).toBe(true);
});
it('absent everywhere ⇒ pdg is undefined (default off)', () => {
expect(mergeAnalyzeOptions({}, undefined).pdg).toBeUndefined();
});
});
describe('U5 — pdg perturbs the parse-cache / worker-dispatch key', () => {
// The chunk hash is what decides whether a chunk is re-dispatched to the
// workers (and thus whether the worker builds a CFG). Folding pdg in is the
// mechanism that makes the worker sink honor the flag across warm caches.
it('a pdg run computes a different chunk key than a pdg-off run', () => {
const entries = [{ filePath: 'a.ts', contentHash: 'h1' }];
expect(computeChunkHash(entries, true)).not.toBe(computeChunkHash(entries, false));
});
});

View file

@ -0,0 +1,495 @@
import { describe, it, expect } from 'vitest';
import Parser from 'tree-sitter';
import TypeScript from 'tree-sitter-typescript';
import type { SyntaxNode } from '../../../src/core/ingestion/utils/ast-helpers.js';
import {
createTypeScriptCfgVisitor,
TS_FUNCTION_TYPES,
} from '../../../src/core/ingestion/cfg/visitors/typescript.js';
import type { FunctionCfg } from '../../../src/core/ingestion/cfg/types.js';
// U2 — the TS/JS CfgVisitor, one hazard per test. Each fixture's distinctive
// statement text (markerWork(), handleErr(), cleanup(), …) lets us find the
// block for a region by text and assert the control-flow topology around it
// (R2, R10). The classic CFG hazards — loops/back-edges, switch fallthrough,
// try/finally post-domination, labeled jumps — are where builders break.
const visitor = createTypeScriptCfgVisitor();
function parse(code: string): SyntaxNode {
const parser = new Parser();
parser.setLanguage(TypeScript.typescript);
return parser.parse(code).rootNode;
}
function collectFunctions(root: SyntaxNode): SyntaxNode[] {
const out: SyntaxNode[] = [];
const stack = [root];
while (stack.length) {
const n = stack.pop() as SyntaxNode;
if (TS_FUNCTION_TYPES.has(n.type)) out.push(n);
for (let i = n.namedChildCount - 1; i >= 0; i--) {
const c = n.namedChild(i);
if (c) stack.push(c);
}
}
return out;
}
/** Build the CFG for the first (outermost-first by traversal) function in code. */
function cfgOf(code: string, index = 0): FunctionCfg {
const fns = collectFunctions(parse(code));
const fn = fns[index];
if (!fn) throw new Error(`no function at index ${index}`);
const cfg = visitor.buildFunctionCfg(fn, 'fixture.ts');
if (!cfg) throw new Error('buildFunctionCfg returned undefined');
return cfg;
}
const block = (cfg: FunctionCfg, substr: string): number => {
const b = cfg.blocks.find((bl) => bl.text.includes(substr));
if (!b) throw new Error(`no block containing ${JSON.stringify(substr)}`);
return b.index;
};
const edgeKinds = (cfg: FunctionCfg): Set<string> => new Set(cfg.edges.map((e) => e.kind));
/** Does control reach `to` from `from` following edges? */
function reaches(cfg: FunctionCfg, from: number, to: number): boolean {
const adj = new Map<number, number[]>();
for (const e of cfg.edges) (adj.get(e.from) ?? adj.set(e.from, []).get(e.from)!).push(e.to);
const seen = new Set([from]);
const stack = [from];
while (stack.length) {
const n = stack.pop() as number;
if (n === to) return true;
for (const nx of adj.get(n) ?? []) if (!seen.has(nx)) (seen.add(nx), stack.push(nx));
}
return seen.has(to);
}
const reachable = (cfg: FunctionCfg, idx: number): boolean => reaches(cfg, cfg.entryIndex, idx);
describe('TS/JS CfgVisitor — structure', () => {
it('straight-line body: ENTRY → block → EXIT', () => {
const cfg = cfgOf(`function f() { a(); b(); c(); }`);
// a/b/c coalesce into one basic block
expect(cfg.blocks.filter((b) => b.kind === 'normal')).toHaveLength(1);
const body = block(cfg, 'a();');
expect(reaches(cfg, cfg.entryIndex, body)).toBe(true);
expect(reaches(cfg, body, cfg.exitIndex)).toBe(true);
});
it('empty body: ENTRY → EXIT', () => {
const cfg = cfgOf(`function f() {}`);
expect(cfg.blocks).toHaveLength(2);
expect(reaches(cfg, cfg.entryIndex, cfg.exitIndex)).toBe(true);
});
it('expression-bodied arrow returns its expression', () => {
const cfg = cfgOf(`const f = (x: number) => x + 1;`);
const expr = block(cfg, 'x + 1');
expect(cfg.edges).toContainEqual({ from: expr, to: cfg.exitIndex, kind: 'return' });
});
});
describe('TS/JS CfgVisitor — branching', () => {
it('if/else diamond emits cond-true + cond-false, both reach the join', () => {
const cfg = cfgOf(`function f(x) { if (x) { a(); } else { b(); } c(); }`);
const kinds = edgeKinds(cfg);
expect(kinds.has('cond-true')).toBe(true);
expect(kinds.has('cond-false')).toBe(true);
const join = block(cfg, 'c();');
expect(reaches(cfg, block(cfg, 'a();'), join)).toBe(true);
expect(reaches(cfg, block(cfg, 'b();'), join)).toBe(true);
});
it('else-if chain: all three arms reachable and rejoin', () => {
const cfg = cfgOf(`function f(x) {
if (x === 1) { a(); }
else if (x === 2) { b(); }
else { c(); }
d();
}`);
const join = block(cfg, 'd();');
for (const arm of ['a();', 'b();', 'c();']) {
expect(reachable(cfg, block(cfg, arm))).toBe(true);
expect(reaches(cfg, block(cfg, arm), join)).toBe(true);
}
});
it('plain if (no else): condition reaches both the body and the join', () => {
const cfg = cfgOf(`function f(x) { if (x) { a(); } b(); }`);
const cond = block(cfg, 'x'); // condition block
const then = block(cfg, 'a();');
const join = block(cfg, 'b();');
expect(reaches(cfg, cond, then)).toBe(true);
expect(reaches(cfg, cond, join)).toBe(true);
expect(reaches(cfg, then, join)).toBe(true);
});
});
describe('TS/JS CfgVisitor — loops', () => {
it('while loop has a back-edge and an exit', () => {
const cfg = cfgOf(`function f(x) { while (x > 0) { step(); } done(); }`);
expect(edgeKinds(cfg).has('loop-back')).toBe(true);
const body = block(cfg, 'step();');
const header = block(cfg, 'x > 0');
expect(cfg.edges).toContainEqual({ from: body, to: header, kind: 'loop-back' });
expect(reaches(cfg, header, block(cfg, 'done();'))).toBe(true);
});
it('do-while runs the body before testing and loops back', () => {
const cfg = cfgOf(`function f(x) { do { step(); } while (x > 0); done(); }`);
const body = block(cfg, 'step();');
expect(reaches(cfg, cfg.entryIndex, body)).toBe(true); // body runs first
expect(edgeKinds(cfg).has('loop-back')).toBe(true);
expect(reaches(cfg, body, block(cfg, 'done();'))).toBe(true);
});
it('C-style for: init once, condition header, back-edge through increment', () => {
const cfg = cfgOf(`function f() { for (let i = 0; i < n; i++) { step(); } done(); }`);
const init = block(cfg, 'let i = 0');
const incr = block(cfg, 'i++');
const body = block(cfg, 'step();');
expect(cfg.edges).toContainEqual({ from: cfg.entryIndex, to: init, kind: 'seq' });
expect(reaches(cfg, body, incr)).toBe(true); // body → increment
expect(edgeKinds(cfg).has('loop-back')).toBe(true);
expect(reaches(cfg, incr, block(cfg, 'done();'))).toBe(true);
});
it('for-of loop builds a header/back-edge/exit', () => {
const cfg = cfgOf(`function f(xs) { for (const x of xs) { use(x); } done(); }`);
expect(edgeKinds(cfg).has('loop-back')).toBe(true);
expect(reaches(cfg, block(cfg, 'use(x)'), block(cfg, 'done();'))).toBe(true);
});
it('for-in loop builds a header/back-edge/exit', () => {
const cfg = cfgOf(`function f(o) { for (const k in o) { use(k); } done(); }`);
expect(edgeKinds(cfg).has('loop-back')).toBe(true);
expect(reaches(cfg, block(cfg, 'use(k)'), block(cfg, 'done();'))).toBe(true);
});
it('for without increment: body carries the loop-back, no phantom header self-loop (#2099 F5)', () => {
const cfg = cfgOf(`function f() { for (let i = 0; i < 3;) { i += 1; } done(); }`);
const header = block(cfg, 'i < 3');
const body = block(cfg, 'i += 1');
// The ONLY loop-back is the real back-edge body→header; a header→header
// self-loop would model a path that re-tests without running the body.
expect(cfg.edges.filter((e) => e.kind === 'loop-back')).toEqual([
{ from: body, to: header, kind: 'loop-back' },
]);
expect(cfg.edges.some((e) => e.from === header && e.to === header)).toBe(false);
expect(reachable(cfg, block(cfg, 'done();'))).toBe(true);
});
it('for(;;) with conditional break: loop-back on the body, break reaches the join (#2099 F5)', () => {
const cfg = cfgOf(`function f() { for (;;) { if (x) break; work(); } done(); }`);
const work = block(cfg, 'work()');
const loopBacks = cfg.edges.filter((e) => e.kind === 'loop-back');
expect(loopBacks).toEqual([expect.objectContaining({ from: work })]);
const header = loopBacks[0].to;
expect(cfg.edges.some((e) => e.from === header && e.to === header)).toBe(false);
expect(edgeKinds(cfg).has('break')).toBe(true);
expect(reachable(cfg, block(cfg, 'done();'))).toBe(true);
});
it('for with increment keeps seq-to-increment and loop-back on the increment (F5 regression guard)', () => {
const cfg = cfgOf(`function f() { for (let i = 0; i < 3; i++) { work(); } done(); }`);
expect(cfg.edges).toContainEqual({
from: block(cfg, 'work()'),
to: block(cfg, 'i++'),
kind: 'seq',
});
expect(cfg.edges).toContainEqual({
from: block(cfg, 'i++'),
to: block(cfg, 'i < 3'),
kind: 'loop-back',
});
});
it('empty body without increment keeps the genuine header self-loop', () => {
const cfg = cfgOf(`function f() { for (let i = 0; i < 3;) {} done(); }`);
const header = block(cfg, 'i < 3');
expect(cfg.edges).toContainEqual({ from: header, to: header, kind: 'loop-back' });
expect(reachable(cfg, block(cfg, 'done();'))).toBe(true);
});
});
describe('TS/JS CfgVisitor — switch', () => {
it('break-terminated cases dispatch to the exit, no fallthrough', () => {
const cfg = cfgOf(`function f(x) {
switch (x) {
case 1: one(); break;
case 2: two(); break;
default: other();
}
after();
}`);
expect(edgeKinds(cfg).has('switch-case')).toBe(true);
const after = block(cfg, 'after();');
expect(reaches(cfg, block(cfg, 'one();'), after)).toBe(true);
expect(reaches(cfg, block(cfg, 'two();'), after)).toBe(true);
// case 1 does NOT fall into case 2 (break severs it)
expect(reaches(cfg, block(cfg, 'one();'), block(cfg, 'two();'))).toBe(false);
});
it('fallthrough: a case without break flows into the next case', () => {
const cfg = cfgOf(`function f(x) {
switch (x) {
case 1: one();
case 2: two(); break;
}
after();
}`);
expect(edgeKinds(cfg).has('fallthrough')).toBe(true);
expect(reaches(cfg, block(cfg, 'one();'), block(cfg, 'two();'))).toBe(true);
});
});
describe('TS/JS CfgVisitor — try/catch/finally (R10)', () => {
it('normal completion AND a throw both flow through finally; finally reaches the post-try block', () => {
const cfg = cfgOf(`function f() {
try {
work();
risky();
} catch (e) {
handleErr();
} finally {
cleanup();
}
afterTry();
}`);
const fin = block(cfg, 'cleanup();');
const after = block(cfg, 'afterTry();');
const work = block(cfg, 'work();');
const handler = block(cfg, 'handleErr();');
expect(edgeKinds(cfg).has('throw')).toBe(true);
// normal path: try body → finally
expect(reaches(cfg, work, fin)).toBe(true);
// exceptional path: try body → catch → finally
expect(reaches(cfg, work, handler)).toBe(true);
expect(reaches(cfg, handler, fin)).toBe(true);
// finally post-dominates and reaches the continuation
expect(reaches(cfg, fin, after)).toBe(true);
});
it('try/finally with no catch: a throw still flows through finally', () => {
const cfg = cfgOf(`function f() {
try { risky(); } finally { cleanup(); }
afterTry();
}`);
const fin = block(cfg, 'cleanup();');
expect(reaches(cfg, block(cfg, 'risky();'), fin)).toBe(true);
expect(reaches(cfg, fin, block(cfg, 'afterTry();'))).toBe(true);
});
it('an INTERIOR block of a branched try body reaches the handler (not just the body entry)', () => {
// Regression guard: the exceptional edge must cover every protected-region
// block, else a throw from inside a branch is invisible to the catch (a
// taint false-negative into `catch` for the downstream PDG analysis).
const cfg = cfgOf(`function f(x) {
try {
guardEntry();
if (x) { deep(); }
} catch (e) { handler(e); }
}`);
const handler = block(cfg, 'handler(e);');
expect(reaches(cfg, block(cfg, 'deep();'), handler)).toBe(true); // interior → handler
expect(reaches(cfg, block(cfg, 'guardEntry();'), handler)).toBe(true);
});
// #2099 F2 — an empty `catch {}` still CATCHES. The synthesized catch block
// has empty text, so locate it as the target of a throw-kind edge.
const throwTargets = (cfg: FunctionCfg): Set<number> =>
new Set(cfg.edges.filter((e) => e.kind === 'throw').map((e) => e.to));
it('empty catch {} swallows: throw lands in the catch, after-code reachable, no escape to EXIT (#2099 F2)', () => {
const cfg = cfgOf(`function f() { try { throw new Error('x'); } catch {} after(); }`);
const targets = throwTargets(cfg);
expect(targets.has(cfg.exitIndex)).toBe(false); // swallowed — never escapes
expect(targets.size).toBe(1);
const synth = [...targets][0];
expect(cfg.blocks[synth].text).toBe('');
expect(cfg.blocks[synth].kind).toBe('normal');
expect(reaches(cfg, synth, block(cfg, 'after();'))).toBe(true);
expect(reachable(cfg, block(cfg, 'after();'))).toBe(true);
});
it('empty catch (e) {} with a binding behaves the same as catch {}', () => {
const cfg = cfgOf(`function f() { try { throw new Error('x'); } catch (e) {} after(); }`);
expect(throwTargets(cfg).has(cfg.exitIndex)).toBe(false);
expect(reachable(cfg, block(cfg, 'after();'))).toBe(true);
});
it('comment-only catch body counts as empty (comments are filtered)', () => {
const cfg = cfgOf(
`function f() { try { throw new Error('x'); } catch { /* ignore */ } after(); }`,
);
expect(throwTargets(cfg).has(cfg.exitIndex)).toBe(false);
expect(reachable(cfg, block(cfg, 'after();'))).toBe(true);
});
it('empty catch + finally: catch flows into finally, no spurious re-propagation past it', () => {
const cfg = cfgOf(`function f() {
try { throw new Error('x'); } catch {} finally { fin(); }
after();
}`);
const fin = block(cfg, 'fin();');
// The swallowing catch exists, so the no-catch re-propagation gate must
// not fire: finally's exit goes to the continuation, never throw→EXIT.
expect(
cfg.edges.some((e) => e.from === fin && e.to === cfg.exitIndex && e.kind === 'throw'),
).toBe(false);
const synth = [...throwTargets(cfg)].filter((t) => t !== fin);
expect(synth.length).toBeGreaterThan(0);
expect(reaches(cfg, synth[0], fin)).toBe(true);
expect(reachable(cfg, block(cfg, 'after();'))).toBe(true);
});
it('non-empty catch is unchanged by the empty-catch synthesis (F2 regression guard)', () => {
const cfg = cfgOf(`function f() { try { a(); } catch (e) { h(); } after(); }`);
expect(throwTargets(cfg).has(block(cfg, 'h();'))).toBe(true);
expect(reaches(cfg, block(cfg, 'h();'), block(cfg, 'after();'))).toBe(true);
});
it('empty try + empty catch does not crash; after-code reachable from ENTRY', () => {
const cfg = cfgOf(`function f() { try {} catch {} after(); }`);
expect(reachable(cfg, block(cfg, 'after();'))).toBe(true);
});
});
describe('TS/JS CfgVisitor — non-local jumps (R10)', () => {
it('early return wires to EXIT and ends its block', () => {
const cfg = cfgOf(`function f(x) { if (x) { return; } tail(); }`);
const ret = block(cfg, 'return;');
expect(cfg.edges).toContainEqual({ from: ret, to: cfg.exitIndex, kind: 'return' });
});
it('labeled break resolves to the outer loop exit, not the inner loop', () => {
const cfg = cfgOf(`function f(xs, ys) {
outer: for (const x of xs) {
for (const y of ys) {
if (x === y) { break outer; }
inner();
}
afterInner();
}
done();
}`);
expect(edgeKinds(cfg).has('break')).toBe(true);
const brk = block(cfg, 'break outer;');
const done = block(cfg, 'done();');
// break outer escapes BOTH loops → reaches the post-loop block
expect(reaches(cfg, brk, done)).toBe(true);
// and does NOT route back through afterInner() (that's the inner loop's normal exit)
expect(reaches(cfg, brk, block(cfg, 'afterInner();'))).toBe(false);
});
it('labeled continue resolves to the labeled loop header', () => {
const cfg = cfgOf(`function f(xs, ys) {
outer: for (const x of xs) {
for (const y of ys) {
if (x === y) { continue outer; }
inner();
}
}
}`);
expect(edgeKinds(cfg).has('continue')).toBe(true);
const cont = block(cfg, 'continue outer;');
const outerHeader = block(cfg, 'x … xs');
expect(
cfg.edges.some((e) => e.from === cont && e.to === outerHeader && e.kind === 'continue'),
).toBe(true);
});
it('an unresolved labeled jump (stacked outer label) routes to EXIT, not a dangling sink', () => {
// `break outer` can't resolve (the outer label is unmodeled in M1), but the
// block must still reach EXIT so the graph stays single-exit for the
// downstream post-dominator / PDG computation — never a stranded sink.
const cfg = cfgOf(`function f(xs, ys) {
outer: inner: for (const x of xs) {
for (const y of ys) { if (x === y) { break outer; } body(); }
}
}`);
const brk = block(cfg, 'break outer;');
expect(edgeKinds(cfg).has('break')).toBe(true);
expect(reaches(cfg, brk, cfg.exitIndex)).toBe(true); // not stranded
});
it('a standalone throw (no enclosing try) wires to EXIT and ends its block', () => {
const cfg = cfgOf(`function f(x) { if (x) { throw new Error(); } done(); }`);
const thr = block(cfg, 'throw new Error();');
expect(cfg.edges).toContainEqual({ from: thr, to: cfg.exitIndex, kind: 'throw' });
// the throw terminates its block — control does not fall into done()
expect(reaches(cfg, thr, block(cfg, 'done();'))).toBe(false);
// done() is still reachable via the if's false branch
expect(reachable(cfg, block(cfg, 'done();'))).toBe(true);
});
it('code after an unconditional return is emitted but unreachable from ENTRY', () => {
const cfg = cfgOf(`function f() { first(); return 1; dead(); }`);
const dead = block(cfg, 'dead();');
expect(reachable(cfg, dead)).toBe(false); // emitted, but no edge reaches it
expect(reachable(cfg, block(cfg, 'first();'))).toBe(true);
});
});
describe('TS/JS CfgVisitor — function-type coverage', () => {
// TS_FUNCTION_TYPES spans more than function_declaration/arrow. Confirm the
// body-walk produces a well-formed CFG for async / generator / method bodies.
it('builds a CFG for async functions, generators, and class methods', () => {
const code = `
async function af(x) { if (x) { await a(); } done(); }
function* gf(xs) { for (const x of xs) { yield x; } }
class C { m(x) { if (x) { p(); } else { q(); } } async am() { await z(); } }
`;
const fns = collectFunctions(parse(code));
// af, gf, m, am — four CFG-bearing functions
const cfgs = fns.map((fn) => visitor.buildFunctionCfg(fn, 'ft.ts')).filter((c) => c);
expect(cfgs.length).toBe(4);
for (const cfg of cfgs) {
expect(cfg).toBeDefined();
if (!cfg) continue;
expect(cfg.blocks[cfg.entryIndex].kind).toBe('entry');
expect(reaches(cfg, cfg.entryIndex, cfg.exitIndex)).toBe(true);
}
});
});
describe('TS/JS CfgVisitor — AC1: 10-function fixture', () => {
const TEN_FN = `
function straight() { a(); b(); }
function withIf(x) { if (x) { a(); } else { b(); } }
function withElseIf(x) { if (x===1) { a(); } else if (x===2) { b(); } else { c(); } }
function withWhile(x) { while (x) { step(); } }
function withFor() { for (let i=0;i<n;i++) { step(); } }
function withForOf(xs) { for (const x of xs) { use(x); } }
function withSwitch(x) { switch (x) { case 1: one(); break; default: other(); } }
function withTry() { try { work(); } catch (e) { oops(); } finally { fin(); } }
function withReturn(x) { if (x) { return 1; } return 2; }
function withLabeled(xs, ys) { outer: for (const x of xs) { for (const y of ys) { break outer; } } }
`;
it('produces one CFG per function, each with a reachable EXIT and contiguous block indices', () => {
const fns = collectFunctions(parse(TEN_FN)).filter((f) => f.type === 'function_declaration');
expect(fns).toHaveLength(10);
for (const fn of fns) {
const cfg = visitor.buildFunctionCfg(fn, 'fixture.ts');
expect(cfg).toBeDefined();
if (!cfg) continue;
// ENTRY is index 0; indices are contiguous 0..n-1
expect(cfg.blocks.map((b) => b.index)).toEqual(cfg.blocks.map((_, i) => i));
expect(cfg.blocks[cfg.entryIndex].kind).toBe('entry');
expect(cfg.blocks[cfg.exitIndex].kind).toBe('exit');
// EXIT is reachable from ENTRY for every function
expect(reaches(cfg, cfg.entryIndex, cfg.exitIndex)).toBe(true);
// No edge endpoint is out of range
for (const e of cfg.edges) {
expect(e.from).toBeGreaterThanOrEqual(0);
expect(e.to).toBeLessThan(cfg.blocks.length);
}
}
});
});

View file

@ -20,10 +20,8 @@
* (Windows LadybugDB handle release can lag; `cleanupTempDir` retries).
*/
import { execSync } from 'child_process';
import { writeFile, readFile, copyFile, mkdir } from 'fs/promises';
import { writeFile, readFile } from 'fs/promises';
import path from 'path';
import { fileURLToPath } from 'url';
import { describe, it, expect } from 'vitest';
import {
getStoragePaths,
@ -32,43 +30,9 @@ import {
INCREMENTAL_SCHEMA_VERSION,
type RepoMeta,
} from '../../src/storage/repo-manager.js';
import { createTempDir } from '../helpers/test-db.js';
import { setupMiniRepo as setupSharedMiniRepo } from '../helpers/mini-repo.js';
const HERE = path.dirname(fileURLToPath(import.meta.url));
const FIXTURE_SRC = path.resolve(HERE, '..', 'fixtures', 'mini-repo', 'src');
/**
* Copy the mini-repo fixture into a fresh git-initialized temp directory.
* Returns the temp handle so the caller owns cleanup.
*/
async function setupMiniRepo(): Promise<{ dbPath: string; cleanup: () => Promise<void> }> {
const tmp = await createTempDir('gitnexus-incr-orch-');
const dest = path.join(tmp.dbPath, 'src');
await mkdir(dest, { recursive: true });
// Copy mini-repo fixture files
const names = [
'index.ts',
'handler.ts',
'validator.ts',
'formatter.ts',
'middleware.ts',
'logger.ts',
'db.ts',
];
for (const n of names) {
await copyFile(path.join(FIXTURE_SRC, n), path.join(dest, n));
}
execSync('git init', { cwd: tmp.dbPath, stdio: 'pipe' });
execSync('git -c user.name=test -c user.email=t@t -c commit.gpgsign=false add -A', {
cwd: tmp.dbPath,
stdio: 'pipe',
});
execSync('git -c user.name=test -c user.email=t@t -c commit.gpgsign=false commit -q -m initial', {
cwd: tmp.dbPath,
stdio: 'pipe',
});
return tmp;
}
const setupMiniRepo = () => setupSharedMiniRepo('gitnexus-incr-orch-');
describe('runFullAnalysis — incremental orchestration', () => {
it('first run populates fileHashes + schemaVersion and clears incrementalInProgress on success', async () => {

View file

@ -0,0 +1,150 @@
/**
* Integration coverage for the pdg-mode flip forced-full-writeback wiring
* (#2099 F1). Sibling of incremental-orchestration.test.ts: real on-disk git
* repo, real LadybugDB, real `runFullAnalysis`.
*
* The P1 these tests pin: the incremental DB writeback persists only
* changed-file nodes, so before the fix a `--pdg` run against an
* already-indexed repo silently persisted ZERO BasicBlock rows
* (`Incremental: changed=0`), and a plain run after a `--pdg` index left
* zombie blocks. The primary assertion is a direct count over the BasicBlock
* table `meta.stats.nodes` aggregates Community/Process rows that are
* re-derived nondeterministically every run, so it is only used as a
* secondary signal here, never with exact equality.
*/
import { describe, it, expect } from 'vitest';
import { getStoragePaths, loadMeta, saveMeta } from '../../src/storage/repo-manager.js';
import { setupMiniRepo as setupSharedMiniRepo } from '../helpers/mini-repo.js';
const setupMiniRepo = () => setupSharedMiniRepo('gitnexus-pdg-flip-');
/** Direct count over the BasicBlock table — the primary truth signal. */
async function countBasicBlocks(repoPath: string): Promise<number> {
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
const { lbugPath } = getStoragePaths(repoPath);
await adapter.initLbug(lbugPath);
try {
const rows = (await adapter.executeQuery(
'MATCH (n:BasicBlock) RETURN count(n) AS c',
)) as Array<{ c: number | bigint }>;
return Number(rows[0]?.c ?? 0);
} finally {
await adapter.closeLbug();
}
}
describe('runFullAnalysis — pdg-mode flip (#2099 F1)', () => {
it('off→on flip forces a full writeback that persists the CFG layer; on→off removes it', async () => {
const repo = await setupMiniRepo();
try {
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
const { storagePath } = getStoragePaths(repo.dbPath);
const logs: string[] = [];
const cb = { onProgress: () => {}, onLog: (m: string) => logs.push(m) };
// 1. Plain index — no CFG layer, no stamp.
await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, cb);
expect(await countBasicBlocks(repo.dbPath)).toBe(0);
expect((await loadMeta(storagePath))!.pdg).toBeUndefined();
// 2. The P1 trigger: --pdg with NO file changes. Pre-fix this hit the
// alreadyUpToDate fast path (or the incremental path with changed=0)
// and persisted nothing. The flip check must force a full rebuild.
logs.length = 0;
const flipOn = await runFullAnalysis(repo.dbPath, { skipAgentsMd: true, pdg: true }, cb);
expect(flipOn.alreadyUpToDate).toBeUndefined();
expect(logs.some((m) => m.includes('pdg mode changed'))).toBe(true);
expect(await countBasicBlocks(repo.dbPath)).toBeGreaterThan(0);
const stamped = await loadMeta(storagePath);
expect(stamped!.pdg).toEqual({ maxFunctionLines: 2000, maxEdgesPerFunction: 5000 });
expect(stamped!.incrementalInProgress).toBeUndefined(); // cleared on success
// 3. Steady state: a second identical --pdg run takes the fast path —
// the flip check must compare equal (KTD5 default resolution).
logs.length = 0;
const steady = await runFullAnalysis(repo.dbPath, { skipAgentsMd: true, pdg: true }, cb);
expect(steady.alreadyUpToDate).toBe(true);
expect(logs.some((m) => m.includes('pdg mode changed'))).toBe(false);
// 4. Flip back: a plain run must fully remove the CFG layer (no
// zombie BasicBlocks) and clear the stamp.
logs.length = 0;
const flipOff = await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, cb);
expect(flipOff.alreadyUpToDate).toBeUndefined();
expect(logs.some((m) => m.includes('pdg mode changed'))).toBe(true);
expect(await countBasicBlocks(repo.dbPath)).toBe(0);
expect((await loadMeta(storagePath))!.pdg).toBeUndefined();
} finally {
await repo.cleanup();
}
}, 600_000);
it('a cap change while pdg stays on forces a rebuild; matching modes keep incremental eligibility', async () => {
const repo = await setupMiniRepo();
try {
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
const { storagePath } = getStoragePaths(repo.dbPath);
const logs: string[] = [];
const cb = { onProgress: () => {}, onLog: (m: string) => logs.push(m) };
await runFullAnalysis(repo.dbPath, { skipAgentsMd: true, pdg: true }, cb);
const blocks = await countBasicBlocks(repo.dbPath);
expect(blocks).toBeGreaterThan(0);
// Cap change with no file changes → mismatch → full rebuild (the
// emit-time cap shapes the persisted edge set; meta must re-stamp).
logs.length = 0;
const capChange = await runFullAnalysis(
repo.dbPath,
{ skipAgentsMd: true, pdg: true, pdgMaxEdgesPerFunction: 1 },
cb,
);
expect(capChange.alreadyUpToDate).toBeUndefined();
expect(logs.some((m) => m.includes('different caps'))).toBe(true);
expect((await loadMeta(storagePath))!.pdg).toEqual({
maxFunctionLines: 2000,
maxEdgesPerFunction: 1,
});
// The CFG layer survives a rebuild under a tighter edge cap (blocks are
// never capped, only edges).
expect(await countBasicBlocks(repo.dbPath)).toBe(blocks);
} finally {
await repo.cleanup();
}
}, 600_000);
it('a dirty flag from a crashed full rebuild composes with the flip check: one rebuild, flag cleared', async () => {
const repo = await setupMiniRepo();
try {
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
const { storagePath } = getStoragePaths(repo.dbPath);
const logs: string[] = [];
const cb = { onProgress: () => {}, onLog: (m: string) => logs.push(m) };
await runFullAnalysis(repo.dbPath, { skipAgentsMd: true, pdg: true }, cb);
// Simulate a full rebuild that died between the pre-wipe dirty-flag
// write (KTD2b: toWriteCount 0 sentinel) and the end-of-run saveMeta.
const meta = (await loadMeta(storagePath))!;
await saveMeta(storagePath, {
...meta,
incrementalInProgress: { startedAt: Date.now(), toWriteCount: 0 },
});
// Next plain run: crash recovery fires (force), the flip ALSO logs its
// notice (decoupled from the force gate), and exactly one rebuild runs.
logs.length = 0;
const recovered = await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, cb);
expect(recovered.alreadyUpToDate).toBeUndefined();
expect(logs.some((m) => m.includes('did not complete cleanly'))).toBe(true);
expect(logs.some((m) => m.includes('pdg mode changed'))).toBe(true);
const after = await loadMeta(storagePath);
expect(after!.incrementalInProgress).toBeUndefined();
expect(after!.pdg).toBeUndefined();
expect(await countBasicBlocks(repo.dbPath)).toBe(0);
} finally {
await repo.cleanup();
}
}, 600_000);
});

View file

@ -328,3 +328,56 @@ describe('deriveEmbeddingCap', () => {
expect(deriveEmbeddingCap(15_000, 10_000).skipForCap).toBe(true);
});
});
describe('pdgModeMismatch / resolvePdgConfig (#2099 F1)', () => {
const DEFAULTS = { maxFunctionLines: 2000, maxEdgesPerFunction: 5000 };
it('resolvePdgConfig: pdg-off run resolves to undefined (the meta field is omitted)', async () => {
const { resolvePdgConfig } = await import('../../src/core/run-analyze.js');
expect(resolvePdgConfig({})).toBeUndefined();
expect(resolvePdgConfig({ pdg: false })).toBeUndefined();
});
it('resolvePdgConfig: caps resolve to their defaults; 0 = unlimited is preserved', async () => {
const { resolvePdgConfig } = await import('../../src/core/run-analyze.js');
expect(resolvePdgConfig({ pdg: true })).toEqual(DEFAULTS);
expect(
resolvePdgConfig({ pdg: true, pdgMaxFunctionLines: 0, pdgMaxEdgesPerFunction: 0 }),
).toEqual({ maxFunctionLines: 0, maxEdgesPerFunction: 0 });
});
it('legacy meta (no recorded stamp) + plain run → no mismatch', async () => {
const { pdgModeMismatch } = await import('../../src/core/run-analyze.js');
expect(pdgModeMismatch(undefined, {})).toBe(false);
});
it('legacy meta + --pdg run → mismatch (the P1 trigger)', async () => {
const { pdgModeMismatch } = await import('../../src/core/run-analyze.js');
expect(pdgModeMismatch(undefined, { pdg: true })).toBe(true);
});
it('recorded stamp + plain run → mismatch (zombie-cleanup direction)', async () => {
const { pdgModeMismatch } = await import('../../src/core/run-analyze.js');
expect(pdgModeMismatch(DEFAULTS, {})).toBe(true);
});
it('explicit defaults compare equal to absent caps (KTD5 normalization)', async () => {
const { pdgModeMismatch } = await import('../../src/core/run-analyze.js');
expect(pdgModeMismatch(DEFAULTS, { pdg: true })).toBe(false);
expect(
pdgModeMismatch(DEFAULTS, {
pdg: true,
pdgMaxFunctionLines: 2000,
pdgMaxEdgesPerFunction: 5000,
}),
).toBe(false);
});
it('a cap change while pdg stays on → mismatch (persisted edges differ)', async () => {
const { pdgModeMismatch } = await import('../../src/core/run-analyze.js');
expect(pdgModeMismatch(DEFAULTS, { pdg: true, pdgMaxEdgesPerFunction: 1 })).toBe(true);
expect(pdgModeMismatch(DEFAULTS, { pdg: true, pdgMaxFunctionLines: 500 })).toBe(true);
// 0 = unlimited differs from the 2000-line default, too.
expect(pdgModeMismatch(DEFAULTS, { pdg: true, pdgMaxFunctionLines: 0 })).toBe(true);
});
});