mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
* 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:
parent
e26002c37a
commit
5bf8a17cd5
44 changed files with 3852 additions and 55 deletions
10
.github/workflows/ci-tests.yml
vendored
10
.github/workflows/ci-tests.yml
vendored
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
23
gitnexus/bench/cfg/baselines.json
Normal file
23
gitnexus/bench/cfg/baselines.json
Normal 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."
|
||||
}
|
||||
}
|
||||
288
gitnexus/bench/cfg/measure.mjs
Normal file
288
gitnexus/bench/cfg/measure.mjs
Normal 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 small→large
|
||||
* (`(metric_large/metric_small)/(N_large/N_small)`: ~1.0 is linear, ~4.0 is the
|
||||
* O(n²) 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`);
|
||||
}
|
||||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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). ' +
|
||||
|
|
|
|||
132
gitnexus/src/core/ingestion/cfg/cfg-builder.ts
Normal file
132
gitnexus/src/core/ingestion/cfg/cfg-builder.ts
Normal 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;
|
||||
};
|
||||
63
gitnexus/src/core/ingestion/cfg/collect.ts
Normal file
63
gitnexus/src/core/ingestion/cfg/collect.ts
Normal 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 worker→main 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 };
|
||||
}
|
||||
70
gitnexus/src/core/ingestion/cfg/control-flow-context.ts
Normal file
70
gitnexus/src/core/ingestion/cfg/control-flow-context.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
147
gitnexus/src/core/ingestion/cfg/emit.ts
Normal file
147
gitnexus/src/core/ingestion/cfg/emit.ts
Normal 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;
|
||||
}
|
||||
21
gitnexus/src/core/ingestion/cfg/traversal-result.ts
Normal file
21
gitnexus/src/core/ingestion/cfg/traversal-result.ts
Normal 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] });
|
||||
82
gitnexus/src/core/ingestion/cfg/types.ts
Normal file
82
gitnexus/src/core/ingestion/cfg/types.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
/**
|
||||
* CFG data model — plain, JSON-serializable types (issue #2081, M1).
|
||||
*
|
||||
* These cross the worker→main 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;
|
||||
}
|
||||
581
gitnexus/src/core/ingestion/cfg/visitors/typescript.ts
Normal file
581
gitnexus/src/core/ingestion/cfg/visitors/typescript.ts
Normal 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 };
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 ??
|
||||
|
|
|
|||
|
|
@ -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: off→on would silently drop the
|
||||
* freshly built CFG layer, on→off 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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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 on→off 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;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
53
gitnexus/test/helpers/mini-repo.ts
Normal file
53
gitnexus/test/helpers/mini-repo.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
]
|
||||
`;
|
||||
181
gitnexus/test/integration/cfg/cfg-emit.test.ts
Normal file
181
gitnexus/test/integration/cfg/cfg-emit.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
156
gitnexus/test/integration/cfg/cfg-snapshot.test.ts
Normal file
156
gitnexus/test/integration/cfg/cfg-snapshot.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
52
gitnexus/test/integration/cfg/fixtures/hazards.ts
Normal file
52
gitnexus/test/integration/cfg/fixtures/hazards.ts
Normal 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;
|
||||
22
gitnexus/test/integration/cfg/fixtures/pdg-repo/sample.ts
Normal file
22
gitnexus/test/integration/cfg/fixtures/pdg-repo/sample.ts
Normal 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;
|
||||
}
|
||||
115
gitnexus/test/integration/cfg/fixtures/ten-functions.ts
Normal file
115
gitnexus/test/integration/cfg/fixtures/ten-functions.ts
Normal 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;
|
||||
64
gitnexus/test/integration/cfg/pipeline-pdg.test.ts
Normal file
64
gitnexus/test/integration/cfg/pipeline-pdg.test.ts
Normal 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);
|
||||
});
|
||||
152
gitnexus/test/integration/cfg/worker-roundtrip.test.ts
Normal file
152
gitnexus/test/integration/cfg/worker-roundtrip.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 });
|
||||
|
|
|
|||
120
gitnexus/test/unit/cfg/cfg-builder.test.ts
Normal file
120
gitnexus/test/unit/cfg/cfg-builder.test.ts
Normal 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)
|
||||
});
|
||||
});
|
||||
193
gitnexus/test/unit/cfg/emit-guard.test.ts
Normal file
193
gitnexus/test/unit/cfg/emit-guard.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
39
gitnexus/test/unit/cfg/opt-in.test.ts
Normal file
39
gitnexus/test/unit/cfg/opt-in.test.ts
Normal 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));
|
||||
});
|
||||
});
|
||||
495
gitnexus/test/unit/cfg/typescript-visitor.test.ts
Normal file
495
gitnexus/test/unit/cfg/typescript-visitor.test.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
150
gitnexus/test/unit/pdg-mode-flip.test.ts
Normal file
150
gitnexus/test/unit/pdg-mode-flip.test.ts
Normal 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);
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue