Merge branch 'main' into codex/windows-update-doctor

This commit is contained in:
Gergő Magyar 2026-06-20 19:11:30 +01:00 • committed by GitHub
commit fabca76c32
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
158 changed files with 23347 additions and 288 deletions

View file

@ -0,0 +1,71 @@
name: Impact PDG Mutation Report
# Off-the-fast-path mutation oracle for the PDG-backed `impact` mode.
#
# The `--mutation` oracle (bench/impact-pdg/measure.mjs) is a ~280s dynamic
# value-diff check: it mutates each fixture, re-analyzes with `--pdg`, and scores
# the realized recall of the statement slice against the behavioral diff. It is
# far too slow for the PR critical path, so it runs on a nightly schedule (and on
# demand via workflow_dispatch) and uploads the JSON report as an artifact rather
# than gating merges.
#
# The harness shells out to `gitnexus analyze --pdg`, which spawns workers from
# dist/, so dist must be built first — setup-gitnexus with build: 'true' does
# that (mirrors ci-tests.yml).
on:
schedule:
- cron: '0 3 * * *'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
mutation-report:
name: impact-pdg mutation oracle
runs-on: ubuntu-latest
timeout-minutes: 25
permissions:
contents: read
steps:
# persist-credentials: false — this job runs the bench + uploads an
# artifact and never pushes; the default-persisted token in .git/config
# must not be capturable through that upload (zizmor credential-persistence
# / artipacked audit). Mirrors ci-tests.yml.
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
# setup-gitnexus is the repo's composite install action (Node 22 + npm ci);
# build: 'true' runs `node scripts/build.js` so dist/ exists for the
# analyze workers the mutation harness spawns.
- uses: ./.github/actions/setup-gitnexus
with:
build: 'true'
- name: Run PDG impact mutation oracle (~280s)
run: node --import tsx bench/impact-pdg/measure.mjs --mutation --json > mutation-report.json
working-directory: gitnexus
# Regression gate: write a recall summary to the run AND fail if the
# minimum realized recall drops below the (tunable) floor, so a recall
# regression surfaces instead of sitting unread in the artifact. Runs
# before the (always) upload so the artifact is preserved even on a fail.
- name: Gate on mutation recall regression
run: node bench/impact-pdg/gate-mutation-recall.mjs mutation-report.json
working-directory: gitnexus
env:
MUTATION_RECALL_FLOOR: '0.5'
- name: Upload mutation report
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: impact-pdg-mutation-report
path: gitnexus/mutation-report.json
retention-days: 14

View file

@ -94,6 +94,9 @@ export type NodeProperties = {
middleware?: string[];
// BasicBlock (taint/PDG substrate, issue #2080) — reuses filePath/startLine/endLine.
text?: string;
/** BasicBlock: space-joined leaf callee names invoked in the block — the
* statement-precise inter-procedural reach substrate for impact mode. */
callees?: string;
// Extensible
[key: string]: unknown;
};
@ -171,7 +174,20 @@ export type RelationshipType =
* removing it later is a breaking schema change — and it is deliberately
* excluded from `VALID_RELATION_TYPES` so it never enters impact-style
* symbol-space traversal (same posture as the taint substrate edges). */
| 'POST_DOMINATE';
| 'POST_DOMINATE'
/** Per-callee dependence SUMMARY edge (PDG FU-C): a self-loop on a
* Function/Method/Constructor node carrying that callee's RETURN-VALUE
* ASCENT — which formal-parameter indices flow to the function's return
* value, encoded as a versioned bitset in the relation's existing `reason`
* column (the same single-channel pattern `CFG`/`REACHING_DEF`/`CDG` use,
* since the lone `CodeRelation` table has no dedicated label column). A
* later consumer phase lets an interprocedural slice ascend a callee's
* return effect into the caller continuation. Like the taint substrate
* edges it is an internal PDG-engine edge: deliberately EXCLUDED from
* `VALID_RELATION_TYPES` and the web schema so it never leaks into
* callgraph-style impact/relationship surfaces. Emitted only under `--pdg`;
* a default analyze emits zero. */
| 'CALL_SUMMARY';
export interface GraphNode {
id: string;

View file

@ -9,57 +9,57 @@
"_note": "#2081 M1 / #2082 M2: ONE function, N coalescing statements (extendBlock text accumulation + per-statement fact harvest). Runs at 2000->8000. M2 REWROTE the old 'output is constant 4 blocks' note: statement facts make disk/heap LINEAR in N (a free gate on the harvest payload); TIME still guards the concat path (array-join ~1.0; a genuine O(n^2) re-join accumulation is ~3.8). M2 adds rd_scaling_budget (measured ~0.74) and disk_bytes_large_max -- an ABSOLUTE ceiling ~1.35x the measured indexed-encoding bytes (969,986 at N=8000, ~121 B/stmt); a named-record encoding regression (~4x facts bytes) blows it. Re-baseline the fingerprint only on an intentional CFG/harvest-shape change (the canon now includes statements+bindings)."
},
"many-functions": {
"fingerprint": "d881f60e77f0262bdc1b5c7049aa4acf5071e0eabc536476be293c3a133e626e",
"fingerprint": "3a83212717383c2f5cd3179ed28e28d2387ecc0c5ee17044d0290e07da20b8d7",
"scaling_budget": 1.5,
"disk_bytes_budget": 1.2,
"heap_budget": 1.3,
"rd_scaling_budget": 2.0,
"_note": "#2081 M1 / #2082 M2 / #2083 M3 U1: N small branchy functions (collect walk + per-function build + per-function solve). Time ~1.0, disk ~1.01, heap ~1.0, rd ~0.86 (solver is per-function; N functions scale linearly). M3 U1 re-fingerprinted: taint sites join StatementFacts (a()/b() call sites); disk_large 2565641->2721641 (+6.1% measured site-harvest cost at N=2000)."
"_note": "#2081 M1 / #2082 M2 / #2083 M3 U1: N small branchy functions (collect walk + per-function build + per-function solve). Time ~1.0, disk ~1.01, heap ~1.0, rd ~0.86 (solver is per-function; N functions scale linearly). M3 U1 re-fingerprinted: taint sites join StatementFacts (a()/b() call sites); disk_large 2565641->2721641 (+6.1% measured site-harvest cost at N=2000). #2227 U1 re-fingerprinted: SiteRecord.at call-site anchor [line,col] joins the statement-facts canon (resolved-callee-id stack); CFG construction/topology unchanged, only the additive serialized `at` byte drifts the JSON.stringify canon. FU-C re-fingerprinted: BindingEntry.formalIndex joins the binding facts canon (CALL_SUMMARY formal-position keying); CFG topology unchanged."
},
"branchy": {
"fingerprint": "936765bba5c3f8fc7058737c48351e03e4e1da7fed448467e8fcc8a0fb7786ce",
"fingerprint": "414367e6b351ca9b3cd5df3d7229c607a6c203e600075b9dbaa55f3258e919d6",
"scaling_budget": 1.8,
"disk_bytes_budget": 1.2,
"heap_budget": 1.3,
"rd_scaling_budget": 2.0,
"_note": "#2081 M1 / #2082 M2 / #2083 M3 U1: ONE function, N sequential ifs (block/edge growth in one CFG). Time ~1.1-1.25 (noisiest scenario; budget 1.8 absorbs noise, catches ~4.0 quadratic), disk ~1.03, heap ~1.0, rd ~0.7. M3 U1 re-fingerprinted (s{i}() call sites); disk_large 908964->993854 (+9.3%)."
"_note": "#2081 M1 / #2082 M2 / #2083 M3 U1: ONE function, N sequential ifs (block/edge growth in one CFG). Time ~1.1-1.25 (noisiest scenario; budget 1.8 absorbs noise, catches ~4.0 quadratic), disk ~1.03, heap ~1.0, rd ~0.7. M3 U1 re-fingerprinted (s{i}() call sites); disk_large 908964->993854 (+9.3%). #2227 U1 re-fingerprinted: SiteRecord.at call-site anchor [line,col] joins the statement-facts canon (resolved-callee-id stack); CFG topology unchanged. FU-C re-fingerprinted: BindingEntry.formalIndex joins the binding facts canon (CALL_SUMMARY formal-position keying); CFG topology unchanged."
},
"dense-bindings": {
"fingerprint": "e4d7eb3c7e8b3772423af25cef391e0e6b68067b554819e81b543439a487403f",
"fingerprint": "ddb5a3389fa629707960bc1892322c0c2735bbad627829d0685e2da89aeb35fa",
"scaling_budget": 1.8,
"disk_bytes_budget": 1.2,
"heap_budget": 1.3,
"rd_scaling_budget": 2.0,
"_note": "#2082 M2 / #2201 SSA: N bindings live across ~N blocks in one loop -- bindings x blocks scale JOINTLY (the solver-lattice stressor). The dense GEN/KILL worklist measured rd ~5.2 normalized here (the OUT spine copy is O(V) per block, quadratic when V scales with B). The #2201 SSA-sparse solver answers each use's reaching set from the def-use graph WITHOUT a per-block dense lattice, dropping rd to ~0.86 (linear; measured 5-23x faster absolute). Budget tightened 10->2: still absorbs noise + catches a regression to the per-item-rescan class (a per-use scan over all defs is O(n^3) here, ratio >=16), but now also catches a fall-back to the dense quadratic. Fingerprint unchanged -- CFG construction is untouched."
"_note": "#2082 M2 / #2201 SSA: N bindings live across ~N blocks in one loop -- bindings x blocks scale JOINTLY (the solver-lattice stressor). The dense GEN/KILL worklist measured rd ~5.2 normalized here (the OUT spine copy is O(V) per block, quadratic when V scales with B). The #2201 SSA-sparse solver answers each use's reaching set from the def-use graph WITHOUT a per-block dense lattice, dropping rd to ~0.86 (linear; measured 5-23x faster absolute). Budget tightened 10->2: still absorbs noise + catches a regression to the per-item-rescan class (a per-use scan over all defs is O(n^3) here, ratio >=16), but now also catches a fall-back to the dense quadratic. FU-C re-fingerprinted: BindingEntry.formalIndex joins the binding facts canon (CALL_SUMMARY formal-position keying); CFG topology unchanged."
},
"deep-nest": {
"fingerprint": "c0ca870487abc6ff379304c3162003e9e4f9b44aeb2fc29adfcf8d2179c7613a",
"fingerprint": "f2ffa8305a59122f6b52e643f2272e3be882d35bccf87455520ae7fac0b88de2",
"scaling_budget": 1.8,
"disk_bytes_budget": 1.2,
"rd_scaling_budget": 2.0,
"facts_large_min": 150,
"_note": "#2201: N nested loops carrying ONE variable end-to-end (depth 40->160) -- the pathology the dense worklist is superlinear on and whose block-visit total drives it past the blocks×64 ceiling (it would TRUNCATE to empty). rd is measured under the PRODUCTION blocks×64 budget (rdProductionBudget) to prove the ceiling stops firing: the depth-INDEPENDENT SSA solver (phi-nodes capture loop merges statically; no fixpoint iteration) computes the full facts (measured 164 at large) with rd_scaling ~0.68 (linear in depth; measured ~0.57ms at depth 160). facts_large_min tightened 100->150 (#2201 review R7): a partial-truncation regression that still cleared the old floor of 100 (but lost facts of the measured 164) now fails, with ~9% headroom under 164 for noise; the companion rd_all_computed gate also catches any non-'computed' status. rd_scaling_budget 2.0 catches a regression back to superlinear. No heap_budget -- the deep-nest CFG payload is tiny and the retained-heap delta is GC-noise-dominated. Re-baseline the fingerprint only on an intentional CFG/visitor change."
"_note": "#2201: N nested loops carrying ONE variable end-to-end (depth 40->160) -- the pathology the dense worklist is superlinear on and whose block-visit total drives it past the blocks×64 ceiling (it would TRUNCATE to empty). rd is measured under the PRODUCTION blocks×64 budget (rdProductionBudget) to prove the ceiling stops firing: the depth-INDEPENDENT SSA solver (phi-nodes capture loop merges statically; no fixpoint iteration) computes the full facts (measured 164 at large) with rd_scaling ~0.68 (linear in depth; measured ~0.57ms at depth 160). facts_large_min tightened 100->150 (#2201 review R7): a partial-truncation regression that still cleared the old floor of 100 (but lost facts of the measured 164) now fails, with ~9% headroom under 164 for noise; the companion rd_all_computed gate also catches any non-'computed' status. rd_scaling_budget 2.0 catches a regression back to superlinear. No heap_budget -- the deep-nest CFG payload is tiny and the retained-heap delta is GC-noise-dominated. Re-baseline the fingerprint only on an intentional CFG/visitor change. FU-C re-fingerprinted: BindingEntry.formalIndex joins the binding facts canon (CALL_SUMMARY formal-position keying); CFG topology unchanged."
},
"wide-merge": {
"fingerprint": "7a66a844ee3994bd930c1e34bad3d7b410a762220e927c94fb3787f38d745280",
"fingerprint": "4135a740376068c3f2dedc4751c6588cbe9ae36026ddc9f2ae9112b188980cda",
"scaling_budget": 1.8,
"disk_bytes_budget": 1.2,
"heap_budget": 1.3,
"rd_scaling_budget": 2.0,
"facts_large_min": 24000,
"_note": "#2201 review R7: N bindings, EACH assigned in a 3-way branch (a wide multi-operand phi per binding) inside a loop, then all used after the merge. Distinct from dense-bindings (one CHAINED redef per `if`): every binding fans into its OWN wide phi, so this exercises phi-placement + renaming + the reachByScc condensation across MANY independent wide merges. N bindings x constant arms => O(N) facts (measured 26008 at the large size), so the gate is rd_scaling LINEARITY: measured ~1.07 (time 9.3->39.8ms over the 4x size step); budget 2.0 catches a regression to the per-binding-rescan O(N^2) class -- the recurring solver antipattern the reachByScc alias fast path (review R2) guards against. rd is measured under the PRODUCTION blocks×64 budget (rdProductionBudget): all functions report 'computed' (the SSA path does not truncate here), and facts_large_min 24000 (measured 26008, ~7% headroom) + the rd_all_computed gate assert the wide merges compute fully. fp_blocks 82 / fp_edges 112 at FP_SIZE=15. Re-baseline the fingerprint only on an intentional CFG/harvest-shape change."
"_note": "#2201 review R7: N bindings, EACH assigned in a 3-way branch (a wide multi-operand phi per binding) inside a loop, then all used after the merge. Distinct from dense-bindings (one CHAINED redef per `if`): every binding fans into its OWN wide phi, so this exercises phi-placement + renaming + the reachByScc condensation across MANY independent wide merges. N bindings x constant arms => O(N) facts (measured 26008 at the large size), so the gate is rd_scaling LINEARITY: measured ~1.07 (time 9.3->39.8ms over the 4x size step); budget 2.0 catches a regression to the per-binding-rescan O(N^2) class -- the recurring solver antipattern the reachByScc alias fast path (review R2) guards against. rd is measured under the PRODUCTION blocks×64 budget (rdProductionBudget): all functions report 'computed' (the SSA path does not truncate here), and facts_large_min 24000 (measured 26008, ~7% headroom) + the rd_all_computed gate assert the wide merges compute fully. fp_blocks 82 / fp_edges 112 at FP_SIZE=15. Re-baseline the fingerprint only on an intentional CFG/harvest-shape change. #2227 U1 re-fingerprinted: SiteRecord.at call-site anchor [line,col] joins the statement-facts canon (resolved-callee-id stack); CFG topology unchanged. FU-C re-fingerprinted: BindingEntry.formalIndex joins the binding facts canon (CALL_SUMMARY formal-position keying); CFG topology unchanged."
},
"fact-fanout": {
"fingerprint": "83a8243a8aff117f69aeecb39d02a483e6cca70439d75f63e433f4e4ac85578f",
"fingerprint": "57fa834df795d8dba99947cdfba2bb86b3f9111d8bf86d38b77c155273ae5c93",
"scaling_budget": 1.8,
"disk_bytes_budget": 1.2,
"heap_budget": 1.3,
"rd_scaling_budget": 3.0,
"facts_large_max": 16000,
"_note": "#2082 M2 / #2083 M3 U1: N switch-arm defs of one variable + N later uses -- facts are O(defs x uses) BY SPEC, so the gate is BOUNDEDNESS, not linearity: with the production fact limit engaged (DEFAULT_PDG_MAX_REACHING_DEF_FACTS_PER_FUNCTION=16000) the materialized fact count stays pinned at the limit as N grows (facts_large_max), and rd time stays bounded (measured ~1.4). Losing the maxFacts early-stop shows as facts_large exploding quadratically. M3 U1 re-fingerprinted (u{i}(x) call sites); disk_large 996737->1107627 (+11.1%)."
"_note": "#2082 M2 / #2083 M3 U1: N switch-arm defs of one variable + N later uses -- facts are O(defs x uses) BY SPEC, so the gate is BOUNDEDNESS, not linearity: with the production fact limit engaged (DEFAULT_PDG_MAX_REACHING_DEF_FACTS_PER_FUNCTION=16000) the materialized fact count stays pinned at the limit as N grows (facts_large_max), and rd time stays bounded (measured ~1.4). Losing the maxFacts early-stop shows as facts_large exploding quadratically. M3 U1 re-fingerprinted (u{i}(x) call sites); disk_large 996737->1107627 (+11.1%). #2227 U1 re-fingerprinted: SiteRecord.at call-site anchor [line,col] joins the statement-facts canon (resolved-callee-id stack); CFG topology unchanged. FU-C re-fingerprinted: BindingEntry.formalIndex joins the binding facts canon (CALL_SUMMARY formal-position keying); CFG topology unchanged."
},
"taint-dense": {
"fingerprint": "218a1a0c7e092550c233607c67daa401543a25bf8d3f122899d30cd9c30c3a89",
"fingerprint": "f4570c7ae8fde4b7e63b51b4ea641417f911b4267f1f9f2eafadc520020dbf15",
"scaling_budget": 1.5,
"disk_bytes_budget": 1.2,
"heap_budget": 1.3,
@ -69,14 +69,14 @@
"taint_scaling_budget": 2.0,
"taint_reason_bytes_large_max": 198000,
"taint_zero_match_budget": 0.5,
"_note": "#2083 M3 U7 (R10): N functions, each with 12 req.body sources + a 4-hop chain + 13 eval sinks (13 deduped findings/fn) at 125->500 fns; the zero-match control (inp.payload/evalish) keeps the identical CFG shape with zero model hits. BOUNDEDNESS pin: kept findings/function == 8 (the scenario cap) at BOTH sizes -- above means the cap was lost, below means detection regressed; total findings grow linearly with N by design. disk_bytes_large_max is the LOAD-BEARING site-harvest absolute ceiling (densest sites of the suite; measured 2335772 at N=500, ceiling ~1.35x). taint_reason_bytes_large_max caps the persisted TAINTED reason bytes (measured 146827 = ~37 B/finding, ceiling ~1.35x; blows on hop-encoding bloat or cap loss). taint_zero_match_budget 0.5 vs measured 0.15: the zero-match pass (match gate only, no solver) must stay a small fraction of the match-dense pass. taint scaling measured ~0.93 (per-function work is N-linear); time/disk/heap/rd ratios all ~1.0."
"_note": "#2083 M3 U7 (R10): N functions, each with 12 req.body sources + a 4-hop chain + 13 eval sinks (13 deduped findings/fn) at 125->500 fns; the zero-match control (inp.payload/evalish) keeps the identical CFG shape with zero model hits. BOUNDEDNESS pin: kept findings/function == 8 (the scenario cap) at BOTH sizes -- above means the cap was lost, below means detection regressed; total findings grow linearly with N by design. disk_bytes_large_max is the LOAD-BEARING site-harvest absolute ceiling (densest sites of the suite; measured 2335772 at N=500, ceiling ~1.35x). taint_reason_bytes_large_max caps the persisted TAINTED reason bytes (measured 146827 = ~37 B/finding, ceiling ~1.35x; blows on hop-encoding bloat or cap loss). taint_zero_match_budget 0.5 vs measured 0.15: the zero-match pass (match gate only, no solver) must stay a small fraction of the match-dense pass. taint scaling measured ~0.93 (per-function work is N-linear); time/disk/heap/rd ratios all ~1.0. #2227 U1 re-fingerprinted: SiteRecord.at call-site anchor [line,col] joins the statement-facts canon (resolved-callee-id stack); CFG topology unchanged, disk stays under the 3,150,000 ceiling (measured 2,429,131). FU-C re-fingerprinted: BindingEntry.formalIndex joins the binding facts canon (CALL_SUMMARY formal-position keying); CFG topology unchanged."
},
"go:branchy": {
"fingerprint": "bba6ad5452c64125daa1dec4cf25e5e111692748a4ef30f309c9b0e03b3e5017",
"fingerprint": "9baedb7efb61bf1c9d0d8eb51de653258fff52e991834da98de9fc6057f444df",
"scaling_budget": 1.8,
"disk_bytes_budget": 1.2,
"heap_budget": 1.3,
"rd_scaling_budget": 2.0,
"_note": "#2195 U7: the first NON-TS scaling scenario -- the C-family analogue of `branchy`, driven through the Go grammar + Go CFG visitor (lang:'go'). ONE Go function with N sequential `if`s (block/edge growth in a single CFG). The `go:` key namespace keeps it out of the TS baseline keyspace (no collision/re-baseline of a TS scenario). Measured time ~1.08, disk ~1.03, heap ~1.0, rd ~1.06 (budgets mirror the TS `branchy` scenario: scaling 1.8 absorbs single-CFG noise + catches a ~4.0 quadratic). Cross-check: fp_blocks 32 / fp_edges 46 are IDENTICAL to the TS branchy fingerprint shape -- the Go visitor builds the same per-`if` block/edge topology. CFG-only (Go has no registered taint model), so no taint gates. Re-baseline the fingerprint only on an intentional Go CFG/harvest-shape change."
"_note": "#2195 U7: the first NON-TS scaling scenario -- the C-family analogue of `branchy`, driven through the Go grammar + Go CFG visitor (lang:'go'). ONE Go function with N sequential `if`s (block/edge growth in a single CFG). The `go:` key namespace keeps it out of the TS baseline keyspace (no collision/re-baseline of a TS scenario). Measured time ~1.08, disk ~1.03, heap ~1.0, rd ~1.06 (budgets mirror the TS `branchy` scenario: scaling 1.8 absorbs single-CFG noise + catches a ~4.0 quadratic). Cross-check: fp_blocks 32 / fp_edges 46 are IDENTICAL to the TS branchy fingerprint shape -- the Go visitor builds the same per-`if` block/edge topology. CFG-only (Go has no registered taint model), so no taint gates. Re-baseline the fingerprint only on an intentional Go CFG/harvest-shape change. #2227 U1 re-fingerprinted: SiteRecord.at call-site anchor [line,col] joins the statement-facts canon (resolved-callee-id stack); Go CFG topology unchanged."
}
}

View file

@ -1,4 +1,4 @@
{
"fingerprint": "386f432c74f4992455055d8891dbe6c873ea95afa60ef4be023a21aed7b4bcb1",
"fingerprint": "381de8dede253140953775c290bf9a25f82ebf3cc8ecb5250ae06a63f648b089",
"_note": "Byte-identity + bounded-retention gate for streaming/chunked PDG emit (#2202). fingerprint = sha256 of the sorted, header-stripped BasicBlock + PDG-edge data rows of the canonical synthetic set. --check also asserts the streamed PdgEmitSink output is byte-identical to the whole-graph streamAllCSVsToDisk emit (byte_identical_nodes/edges) and that the in-memory graph retains 0 BasicBlocks (resident_basic_blocks === 0, the O(chunk) RSS bound). Regenerate via `node --import tsx bench/emit-persistence/measure-streaming.mjs`."
}

View file

@ -1,5 +1,5 @@
{
"fingerprint": "1b9dd0b783899b47067c36511d241860f291ac736e57682b0ece14148e3958ff",
"fingerprint": "4cc418ea87b6d20a68b5c1139f35d81820b715c63de0ec812e73e2135f5b00b1",
"scaling_budget": 1.8,
"max_ms_large": 1000,
"_note": "fingerprint = sha256 over per-file digests (filename + sha256(file bytes)), entry list sorted — binds each emitted line to its file so a row routed to the WRONG pair file changes the hash, AND catches within-file row reordering (file bytes hashed as-written). Byte-identity gate for #2203 U2/U3. NOTE: a future change that legitimately reorders emit (without changing the node/edge SET) will trip --check; regenerate then. scaling_budget bounds (t_large/t_small)/(LARGE/SMALL): observed ~0.95-1.05 (linear); 1.8 tolerates disk-I/O timing noise on CI while still catching an O(n^2) re-regression (~4x). max_ms_large=1000ms is a coarse absolute backstop (observed ~200ms) that catches a gross uniform slowdown the ratio gate misses; generous so CI host noise won't flake it. Regenerate via `node --import tsx bench/emit-persistence/measure.mjs`."

View file

@ -0,0 +1,798 @@
# `bench/impact-pdg` — PDG-vs-call-graph impact accuracy harness
> **STATUS: LIVE (U7, statement-anchored rework).** This directory holds the
> curated ground-truth fixture corpus **and** the measurement harness
> (`measure.mjs`, `metrics.mjs`, `baselines.json`). Run it with
> `node --import tsx bench/impact-pdg/measure.mjs` (build `dist/` first — see *How
> to run*). The harness drives both `impact` engines over the fixtures — PDG
> **seeded on the criterion's statement line** so it returns the dependence slice
> — prints a stratified P/R/F1 table + a plain-language decision recommendation,
> and gates regressions with `--check`. It now also prints an additive **unified
> impact axes** table that keeps line-level and symbol-level truth separate while
> comparing `callgraph`, unified `pdg`, and the evaluation-only
> `composed-current` control baseline. The measured native result remains:
> **PDG is precise at intra-procedural statement granularity (exact on the intra
> AND mixed fixtures — F1 = 1.000; FU-B-2 made the slice statement-granular, closing
> the block-coalescing recall caveat, and the U2 value-diff oracle now agrees on the
> intra stratum); call-graph remains the comparator for inter-procedural symbol
> granularity; unified PDG must match that composed baseline before any
> default-switch decision.**
## What this measures
`impact` has two engines that answer **different questions at different
granularities**:
- `mode: 'callgraph'` (the default) — inter-procedural BFS over symbol→symbol
edges. It answers *"what other symbols depend on / are called by this one?"* at
**symbol granularity**, scored against `inter_AIS`.
- `mode: 'pdg'` (opt-in) — the unified PDG-facing result. Its local
statement slice comes from the persisted CDG + REACHING_DEF Program Dependence
Graph. Seeded with `line: N` (`impact({mode:'pdg', line:N})`), it returns
`affectedStatements: {line, filePath, text}[]` — the dependent **statements** of
the changed line N — and also attaches inter-procedural symbol reach in
`interproceduralByDepth`/`byDepth` for the same target. The native PDG row is still scored against
`intra_AIS`; the unified axes score its statement and symbol outputs together.
They measure **different scopes**, so the harness scores each at its native
granularity against its native ground truth and reports both side by side. The
"which is more accurate?" question gets an honest, per-scope answer rather than a
single blended number — and the answer is *they answer different questions;
neither strictly dominates*.
## Unified impact axes
The harness also reports a separate unified comparison that is designed for the
current architecture question: *does unified `mode:'pdg'` match the composition of today's engines?* This report is additive. It does not
replace the native table above, and it does not change `baselines.json` gating.
Unified AIS has two namespaces:
- `statement:<filePath>:<line>` for intra-procedural line truth from `intra_AIS`
- `symbol:<symbol>@<filePath>` for inter-procedural symbol truth from `inter_AIS`
Each engine is adapted onto those axes without lossy projection:
- `callgraph` contributes only the `symbol` axis.
- `pdg` contributes the `statement` axis from `affectedStatements` and the
`symbol` axis from its unified `interproceduralByDepth`/`byDepth` inter-procedural reach.
- `composed-current` remains an evaluation-only control row that unions standalone
callgraph symbols with PDG statements.
The report intentionally has no single blended unified F1. `pdg` is now judged
axis-by-axis against `composed-current` so line precision cannot hide
inter-symbol misses, and symbol recall cannot hide statement-level blindness. The
control row is a recall baseline, not a perfection claim: PDG can still
contribute intra-line noise on pure-inter fixtures, so default-switch decisions
should require matching recall while reducing or bounding FPIS.
> **A note on `line`.** A whole-symbol PDG slice (no `line`) is empty by design:
> intra-procedural dependence stays inside the function, so every reachable block
> is already part of the whole-symbol seed. The useful PDG mode is the
> **statement-anchored** one — seed the criterion's changed statement and read
> the dependent statements back. This is the central change the U7 *rework*
> measures; the earlier "PDG is empty / callgraph wins" verdict was an artifact of
> the whole-symbol seed, now replaced.
## Runtime result contract
`impact({mode:'pdg', line:N})` success results carry a target envelope
(`id`, `name`, `type`, `filePath`), `risk: 'UNKNOWN'`, `affectedStatements`,
`affectedStatementCount`, and callgraph-compatible parity fields (`byDepth`,
`byDepthCounts`, `summary`, `affected_processes`, `affected_modules`).
`affectedStatements` is the statement-level PDG slice; `interproceduralByDepth` is the explicit cross-function reach; `byDepth` remains the
compatibility symbol bucket attached by unified PDG mode.
Degraded PDG results are explicit, not empty successes. `no-layer`,
`sub-layer-missing`, and `unknown` responses keep `mode:'pdg'`, target metadata
when the target resolves, `risk:'UNKNOWN'`, a remediation note, and empty parity
fields. Truncation is also explicit: when both depth and per-step limit bounds
fire, `truncatedByReasons` reports both causes.
Deferred architecture remains out of scope for this harness: explicit
`Function|Method -> BasicBlock` containment (`CONTAINS_BLOCK`), inter-procedural
summary edges / realizable call-return paths, mutation-derived AIS, and a hybrid
callgraph+PDG impact mode are follow-up features, not assumptions of the current
statement-level benchmark.
## The corpus
Each case is a tiny self-contained TypeScript source repo plus a
`ground-truth.json`. TypeScript is used throughout because it has the most
mature CFG/PDG support in this codebase.
`line` is the `criterion.line` — the statement the PDG slice seeds on.
| Case | Locus | line | Shape |
|---|---|---|---|
| `intra-dataflow-accumulator` | intra | 8 | loop-carried accumulator def→use (downstream) |
| `intra-dataflow-chain` | intra | 7 | straight-line def→use chain (downstream) |
| `intra-dataflow-reassign` | intra | 9 | reaching defs of a use (upstream, RD-reverse) |
| `intra-control-guard` | intra | 7 | guard-clause control dependence (downstream, CDG-forward) |
| `intra-control-branch` | intra | 7 | if/else-if/else arm control dependence (downstream) |
| `intra-control-loop` | intra | 11 | nested loop+if controllers of a stmt (upstream, CDG-reverse) |
| `inter-dispatcher-thin` | inter | 23 | branch router → 3 handlers (intra slice = routing returns, empty intra_AIS) |
| `inter-facade-delegate` | inter | 21 | guarded sequential delegation chain (empty intra_AIS) |
| `inter-pipeline-stages` | inter | 20 | straight pipeline driver → 3 stages (empty intra_AIS) |
| `mixed-validate-then-call` | mixed | 13 | guard-dominated intra dependence + 1 callee |
| `mixed-compute-and-emit` | mixed | 12 | data-flow-dominated intra dependence + 1 callee |
| `mixed-guarded-dispatch` | mixed | 15 | control+data intra dependence + 2 callees |
| `nobody-interface-excluded` | n/a | — | no-body symbols (KTD6); **excluded** from PDG scoring |
**Minimum corpus floor (KTD9/F3):** ≥ 3 cases per locus stratum, ≥ 12 total
measurable cases. Current: intra = 7, inter = 3, mixed = 3 → 13 measurable
(+1 excluded no-body case). Below this floor the U7 harness must print
"underpowered — directional only" instead of a verdict.
## Annotation schema (`ground-truth.json`)
| Field | Type | Meaning |
|---|---|---|
| `schemaVersion` | int | schema version (currently `1`) |
| `criterion` | `{ name, filePath, direction, line?, marker?, pdgEdgeKinds? }` | the changed symbol — the seed for "what is affected if I change this". `direction` ∈ `downstream` \| `upstream`. **`line`** is the **1-based source line of the statement being changed** — the seed of the statement-anchored PDG slice (`impact({mode:'pdg', line})`). It is chosen from **source semantics** (the def/criterion whose change propagates to the `intra_AIS` lines), *not* by running the traversal (KTD9 annotation-circularity guard), then reconciled against the live traversal in the harness's Step 0. `marker` is a substring unique to the criterion function's body (appears in one of its `BasicBlock.text` fragments); the smoke test uses it to locate the criterion function's blocks deterministically. `pdgEdgeKinds` lists the PDG edge kinds (`REACHING_DEF` \| `CDG`) the criterion function is expected to produce: a pure straight-line data-flow criterion declares only `REACHING_DEF` (no branches → no control dependence), a branching/guard criterion declares both. The smoke test asserts exactly the declared kinds are non-zero on the criterion (so the pure-dataflow archetype isn't forced to carry an artificial branch) and that the criterion produces ≥ 1 PDG edge overall (catching an accidental no-body/zero-edge criterion). `line`, `marker`, and `pdgEdgeKinds` are required for every measurable case; all three are omitted only on `pdgScoring: "exclude"` no-body cases. |
| `intra_AIS` | `AisEntry[]` | symbols/**lines** truly affected WITHIN the same function (the scope where PDG mode is defined). Annotated at **symbol/line granularity, never block-id** (block ids carry fragile `fnLine:fnCol:idx`). |
| `inter_AIS` | `AisEntry[]` | symbols truly affected ACROSS function boundaries (the scope where call-graph mode is defined and intra-procedural PDG is zero-by-design). |
| `locus` | `'intra' \| 'inter' \| 'mixed' \| 'n/a'` | the dominant impact locus; `n/a` only for excluded no-body cases. |
| `pdgScoring` | `'exclude'` (optional) | present (= `"exclude"`) only on no-body cases U7 must drop from PDG denominators. |
| `provenance` | `'manual' \| 'mutation'` | how the AIS was derived. **v1 is `manual` only** — the mutation track (perturb a statement, diff the changed outcomes) needs a fixture-runner + value-diff harness that does not exist yet, so it is deferred. The field stays for forward-compatibility. |
| `analyzerVersion` | string | pinned analyzer version marker (currently the `package.json` version) so ground truth versions against the analyzer. |
| `rationale` | string | prose — WHY each AIS element is in or out. This is what makes manual annotation defensible (SLICEBENCH generate-then-verify discipline). |
`AisEntry` = `{ symbol, filePath, line?, note? }`. `line` is 1-based and present
for intra entries (which are statement-granular); inter entries name a whole
symbol and omit `line`.
`intra_AIS` and `inter_AIS` are **disjoint** for every case (an intra entry is a
line within the criterion function; an inter entry is a different symbol).
## Validity threats (the two that dominate — KTD9)
1. **Ground-truth incompleteness.** A hand-annotated handful of fixtures yields
*point estimates* over a tiny, self-admittedly incomplete corpus. One
mis-annotation can swing F1 by a large fraction, so U7 reports findings as a
**direction**, not a headline decimal, until the corpus grows / the mutation
track lands.
2. **Annotation circularity.** PDG's `intra_AIS` risks being reconciled against
the PDG traversal's own output. **Mitigation (KTD9 annotation-circularity
guard): these annotations are written from SOURCE SEMANTICS first** — reading
the source and reasoning about def→use / control dependence by hand — and
reconciling against the live traversal is **U7's job (its Step 0), not the
annotation's**. Call-graph gets no such home-field annotation, so the
comparison is not rigged toward PDG.
## Methodology — CIS / AIS, stratified (KTD9, Arnold–Bohner)
For each fixture × mode the harness compares the mode's **CIS** (Computed Impact
Set — what it reports as impacted) against the **AIS** (Actual Impact Set — the
curated ground truth), at the mode's **native granularity**, stratified by impact
locus:
- **precision** = |AIS∩CIS| / |CIS| (over-approximation cost),
- **recall** = |AIS∩CIS| / |AIS| (under-approximation; the *dangerous* miss for
a safety tool),
- **F1** = harmonic mean,
- **FPIS** = CIS − AIS (noise), **FNIS** = AIS − CIS (missed),
- **|CIS|/|AIS|** size ratio.
**Each engine is scored at its own granularity against its own ground truth:**
- **PDG → line granularity vs `intra_AIS`.** CIS_pdg is the set of
`affectedStatements` **line** keys (`<filePath>:<line>`) returned by the
line-seeded slice; AIS is the `intra_AIS` line set. This is the unit at which
PDG is precise — the dependent *statements* of the changed line.
- **Call-graph → symbol granularity vs `inter_AIS`.** CIS is the reported
**symbol** keys (`<symbol>@<filePath>`); AIS is the `inter_AIS` symbol set.
This is the unit at which the cross-function blast radius is meaningful.
**Empty-denominator semantics are explicit, never silently 0/1.** |CIS|=0 ⇒
precision is `n/a` (no predictions); |AIS|=0 ⇒ recall is `n/a` (no truth in that
scope). A scope with an `n/a` metric is **excluded** from that metric's mean,
never folded in as 0 (the apples-to-oranges trap, R1). The pure scorer lives in
`metrics.mjs`; its arithmetic is pinned by the deterministic unit test
`test/unit/impact-pdg-metric-math.test.ts` (synthetic sets only — no analyze, no
DB, so it stays out of the flaky full-pipeline lane).
**Stratification.** Each fixture is scored in its **own** locus stratum
(intra/inter/mixed). Within a stratum, the PDG row is line-vs-`intra_AIS` and the
call-graph row is symbol-vs-`inter_AIS`:
- On an **intra** fixture, `inter_AIS` is empty, so call-graph reports no other
symbol → its row is `n/a` (no cross-function truth). PDG is scored against the
real `intra_AIS`.
- On an **inter** fixture, `intra_AIS` is empty by design, so the PDG line slice
returns only the router's own control-dependent statements — FPIS against the
empty truth (precision 0, recall `n/a`). Call-graph is scored against the real
`inter_AIS`. This is the honest *"PDG is intra-procedural; on a pure-inter
fixture it has no meaningful intra ground truth"* result — **symmetric** to
call-graph's empty intra row.
- On a **mixed** fixture, both rows are real: PDG resolves the intra statement
set, call-graph reaches the callee(s).
**The native rows still measure different units.** The PDG native row scores
statement reach, while the callgraph native row scores symbol reach. The unified
axes table is where `pdg` is judged as the composed result: statement reach in
`affectedStatements`, inter-symbol reach in `byDepth`.
## Substrate (the load-bearing mechanism — R8)
`runPipelineFromRepo` is in-memory and never persists, but `impact` queries a
**persisted** `lbugPath` + a `meta.pdg` stamp; there is no exported `runAnalyze`
(the entrypoint `analyzeCommand` calls `process.exit`, unusable in a loop), and
the test-suite `vi.mock` bridge is vitest-only. So the harness runs **real
analyze via a temp `GITNEXUS_HOME`, mock-free**. Per fixture:
1. Point `process.env.GITNEXUS_HOME` at a per-run temp dir (honored by
`repo-manager.getGlobalDir()` — it roots the registry; the per-repo DB lands
in `<fixtureCopy>/.gitnexus/`, so fixtures are copied to a temp working dir
first, keeping the source tree clean).
2. **Shell out** to the real CLI as a child process — child-process isolation
sidesteps `process.exit`; real `saveMeta` + `registerRepo` land in the temp
home; parse workers spawn from `dist/` (so the harness needs a built `dist/`):
```
node --import tsx src/cli/index.ts analyze <fixtureCopy> --pdg --skip-git --index-only
```
3. `new LocalBackend(); await init()` resolves the fixture via the **real**
registry (the parent process sets `GITNEXUS_HOME` too, so `init()` reads the
temp registry, not `~/.gitnexus`).
4. `callTool('impact', …)` ×2 (the absolute path is a tier-1 path match — no
name collision): once `mode:'callgraph'` (symbol BFS), once `mode:'pdg'` with
`line: criterion.line` so it returns the **statement-anchored slice**
(`affectedStatements`). A whole-symbol PDG slice (no `line`) is empty by
design, so the seed line is load-bearing.
5. Teardown the temp home + copy.
### Step 0 — fixture AIS validation (gated on the live traversal; circularity)
Before scoring, the harness reconciles each fixture against the live analyzer
(`metrics.mjs` is annotation-only; Step 0 is the *traversal* reconciliation):
- the criterion must produce **≥ 1 PDG edge** (an accidental no-body / cap-
truncated criterion has unmeasurable ground truth → excluded, logged);
- the criterion symbol must **not** share `(filePath, startLine)` with another
`Function`/`Method` (one count query) — same-line projection ambiguity (R4)
would reconcile AIS against the wrong symbol's edges → excluded, logged.
Per the **annotation-circularity guard**, this reconciliation runs *second*: the
`criterion.line` and the AIS were written from source semantics *first* (read the
source, find the def/criterion whose change propagates), and Step 0 only confirms
the fixture is measurable substrate — it never *derives* ground truth from the
traversal. Where a source-derived belief disagreed with the live block-granular
traversal, the **annotation** was corrected (documented in each
`ground-truth.json` rationale), not the metric re-fit:
- **Direction.** `inter-pipeline-stages`'s AIS named callees while the criterion
was tagged `upstream`; the annotation was corrected to `downstream`.
- **Block coalescing — RESOLVED (FU-B-2, statement-granular).** The CFG coalesces
consecutive straight-line statements into one `BasicBlock`. Before FU-B-2 the
block-granular slice could not pinpoint a coalesced block's *interior*
statements, so `intra-dataflow-chain` (8,9 → inside the line-7 seed block),
`intra-control-guard` (12 → inside the line-11 body block), and
`intra-dataflow-reassign` (8 → inside the line-7 def block) had their `intra_AIS`
interior lines removed as block-granularity artifacts. **FU-B-2 makes the intra
slice statement-granular**: each persisted `REACHING_DEF` edge now carries its
def/use *source lines* (a compact versioned annotation on `reason`), and the
projection walks the self-edge def→use line chain forward from the criterion
(and through every reached coalesced block) to recover those interior
statements. So the three fixtures were re-reconciled UP — chain {10}→{8,9,10},
guard {9,11,13}→{9,11,12,13}, reassign {6,7}→{6,7,8} — restoring the original
source-derived belief the prior block-granularity reconciliation had
under-counted. The annotation fingerprint moved deliberately; the U2 value-diff
oracle had already proved chain's {8,9} independently, so this is a justified
ground-truth correction, not a metric re-fit.
- **Under-counted dependencies.** The combined CDG+REACHING_DEF slice reaches more
than a control-only or single-step reading: `intra-control-branch` (+line 10,
the nested `else if` predicate, control-dependent on the outer branch),
`intra-control-loop` (+lines 6,7, the param block and `count` init reaching the
increment), and `intra-dataflow-reassign` (+line 6, the param def of `a`) gained
lines the original annotation missed.
After reconciliation, the line-seeded slice reproduces each corrected `intra_AIS`
exactly (FPIS = FNIS = 0) on all 7 intra fixtures AND all 3 mixed fixtures (the
FU-A intra-tag scopes the intra axis to the criterion's own function, so the U1
cross-function callee lines no longer count as intra FPIS). The U2 value-diff
oracle now agrees with the static slice at statement granularity on the intra
stratum (chain's {8,9} are in the slice). Call-graph gets no such home-field
annotation, so the comparison is not rigged toward PDG.
## Measured results (analyzer 1.6.7, 13 measurable + 2 excluded; post-U1 + U2 + FU-B-2)
Each engine scored at its **native granularity** against its **native ground
truth** — PDG at line vs `intra_AIS`, call-graph at symbol vs `inter_AIS`:
| Scope | Mode | Granularity | P | R | F1 | \|CIS\|/\|AIS\| | FPIS | FNIS | n |
|---|---|---|---|---|---|---|---|---|---|
| intra | callgraph | symbol/inter | n/a | n/a | n/a | n/a | 0 | 0 | 7 |
| intra | **pdg** | **line/intra** | **1.000** | **1.000** | **1.000** | 1.000 | 0 | 0 | 7 |
| inter | **callgraph** | **symbol/inter** | **1.000** | **1.000** | **1.000** | 1.000 | 0 | 0 | 3 |
| inter | pdg | line/intra | 0.000 | n/a | n/a | n/a | 10 | 0 | 3 |
| mixed | **callgraph** | **symbol/inter** | **1.000** | **1.000** | **1.000** | 1.000 | 0 | 0 | 3 |
| mixed | **pdg** | **line/intra** | **1.000** | **1.000** | **1.000** | 1.000 | 0 | 0 | 3 |
> **Post-FU-B-2 correction.** FU-B-2 makes the intra slice **statement-granular** —
> the persisted `REACHING_DEF` edge carries its def/use source lines, and the
> projection walks the self-edge def→use chain (forward from the criterion, and
> through every reached coalesced block) to recover interior statements. With the
> three coalesced-block fixtures re-reconciled UP (chain {10}→{8,9,10}, guard
> {9,11,13}→{9,11,12,13}, reassign {6,7}→{6,7,8}), **intra/pdg stays F1 = 1.000
> (FPIS = FNIS = 0)** and the U2 value-diff oracle now AGREES with the slice on the
> intra stratum (the old 0.333 statement-level recall on `intra-dataflow-chain` is
> now 1.000 — the block-coalescing blind spot is closed, not merely matched by a
> blind annotation). **mixed/pdg is now F1 = 1.000** (was 0.468 post-U1): the FU-A
> intra-tag scopes the intra axis to the criterion's own function, so the U1
> cross-function callee statements live on the inter symbol axis, not as intra
> FPIS. The remaining inter/pdg FPIS = 10 are the router's own control-dependent
> returns scored against an empty `intra_AIS` (by design — see the `n/a`/`0`
> explanation below).
Read it honestly:
- **PDG mode is precise at intra-procedural statement granularity — exact on the 7
intra AND the 3 mixed fixtures.** The line-seeded slice returns *exactly* the
reconciled `intra_AIS` (F1 = 1.000, FPIS = FNIS = 0) on both strata. It precisely
identifies the dependent statements of the changed line (def→use chains,
control-dependent arms, reaching defs); the earlier "empty / no signal" result was
the whole-symbol-seed artifact, and the post-U1 mixed precision dip (0.468) was
closed by the FU-A intra-tag (cross-function callee lines score on the inter axis,
not as intra FPIS). **FU-B-2 closed the block-coalescing blind spot:** the intra
slice is now statement-granular (REACHING_DEF edges carry their def/use source
lines; the projection walks the self-edge def→use chain through each coalesced
block), so the U2 value-diff oracle that previously proved a statement-level recall
of 0.333 on `intra-dataflow-chain` (lines `chain.ts:8,9`) now measures **1.000** —
the slice and the dynamic oracle agree at statement granularity on the intra stratum.
- **Call-graph mode is exact on the cross-function questions.** On all 3 inter
fixtures and all 3 mixed fixtures it recovers every callee — F1 = 1.000. It is
the engine for "what else calls/uses this?".
- **The two `n/a` / `0` cells are by design, not defects.** *intra/call-graph*: a
self-contained function calls no other symbol, so call-graph reports nothing and
`inter_AIS` is empty → no cross-function truth to score (`n/a`). *inter/pdg*: a
pure-inter router has an empty `intra_AIS`, and the line-seeded slice returns the
router's *own* control-dependent routing returns — FPIS against the empty truth
(precision 0, recall `n/a`). These are **symmetric**: each engine is blind to
the other's native scope. The per-case lines surface each statement slice (`pdg
line/intra: …`) and each callee set (`cg symbol/inter: …`), while the unified
table verifies whether `pdg` now carries both axes.
## Decision recommendation (the verdict — F2)
> **The two engines answer different questions at different granularities, and
> neither dominates.**
>
> - **`mode:'callgraph'` (the default)** is the correct engine for the
> *inter-procedural* safety question — *"what else depends on / calls this
> symbol?"* It recovers the cross-function callees exactly (inter & mixed F1 =
> 1.0 on this corpus) and carries the cross-function reach the blast radius
> needs. Use it for cross-symbol impact.
> - **`mode:'pdg'` (opt-in, seeded with `line:N`, where `analyze --pdg` persisted
> the layer)** is **precise at intra-procedural *statement* granularity** —
> *"which statements inside this function does changing line N affect?"* On the
> 7 intra fixtures AND the 3 mixed fixtures it reproduces the dependent-statement
> set exactly (intra & mixed PDG F1 = 1.0, FPIS = FNIS = 0): the FU-A intra-tag
> scopes the intra axis to the criterion's own function (cross-function reach goes
> on the inter symbol axis), and FU-B-2 made the slice statement-granular so the U2
> value-diff oracle now agrees on the intra stratum (the old block-coalescing
> recall caveat — 0.333 on one chain fixture — is closed: recall 1.000). This
> is still a question call-graph **cannot answer at all** (it has no notion of a statement).
>
> `mode:'pdg'` now composes those surfaces in one result: `affectedStatements`
> carries statement-level dependence and `interproceduralByDepth`/`byDepth` carries
> inter-procedural symbols. `mode:'callgraph'` remains the option-driven comparator/default. The
> unified axes table keeps `composed-current` as the control baseline that PDG
> must match or beat before any default-switch decision.
> match or exceed while reducing or bounding FPIS. Reach for the line-seeded
> PDG when you need statement-level dependence *inside* a function; reach for
> call-graph when you need
> *cross-function* reach. The earlier verdict
> ("PDG is empty / call-graph wins") was an artifact of the **whole-symbol** seed
> — a whole-symbol slice has nothing to report because intra-procedural dependence
> never leaves the function. Seeding the changed *statement* is what makes PDG's
> precision measurable, and it measures as exact.
## Validity threats (the two that dominate — KTD9)
1. **Ground-truth incompleteness.** A hand-annotated handful of fixtures yields
*point estimates* over a tiny, self-admittedly incomplete corpus. One
mis-annotation can swing F1 by a large fraction, so the harness reports
findings as a **direction**, not a headline decimal, and prints an explicit
"underpowered — directional only" banner when the corpus falls below the
floor.
2. **Annotation circularity.** PDG's `intra_AIS` risks being reconciled against
the PDG traversal's own output. **Mitigation:** these annotations are written
from SOURCE SEMANTICS first (U6) — reading the source and reasoning about
def→use / control dependence by hand — and reconciling against the live
traversal is the harness's **Step 0**, run *second*, only to confirm
measurability. Call-graph gets no such home-field annotation, so the
comparison is not rigged toward PDG.
## Underpowered-corpus rule (F3)
**Minimum corpus floor: ≥ 3 measurable cases per locus stratum, ≥ 12 total.**
Current corpus is above the floor (intra 7, inter 3, mixed 3 = 13
measurable; +1 excluded no-body) — so the harness prints headline decimals. When
the measurable count after exclusions drops below the floor, it instead prints
**"underpowered — directional only"** and reports the DIRECTION ("PDG exact at
intra statement granularity; call-graph exact at inter symbol granularity")
rather than headline decimals — decimal precision (`F1 0.74 vs 0.68`) implies a
confidence a sub-floor corpus cannot support. Even at the floor the F1 = 1.0
results should be read as *"exact on this small, deliberately-simple corpus"*, not
*"exact in general"* — see the validity threats.
## Annotation fingerprint + `--check` (two gates, KTD10)
`--check` runs **two non-byte-identity gates** (an exact-equality gate would go
perpetually red on legitimate accuracy changes):
1. **One-sided F1 regression band** per mode per scope: fail iff `F1 < band − ε`;
improvements pass freely. `ε` and the per-`(scope,mode)` bands are versioned
in `baselines.json`. The four live bands are **intra/pdg = 1.0**, **mixed/pdg =
1.0**, **inter/callgraph = 1.0**, **mixed/callgraph = 1.0**. A `null` band
means F1 is genuinely undefined for that cell on this corpus (intra/callgraph
and inter/pdg — see *Measured results*) — the gate skips it.
2. **Order-independent annotation fingerprint** over the curated ground-truth
set (a SHA-256 over a sorted, line-collapsed canonicalization — mirrors the
`bench/cfg/measure.mjs` *technique*, written here, not a literal import). Any
unreviewed edit to a `ground-truth.json` (criterion **including
`criterion.line`**, AIS membership, locus, direction, edge kinds) trips it; a
pure reordering of AIS entries does not.
**Substrate stability (F5).** Real analyze is the repo's flaky lane, so `--check`
applies **median-of-K** across `GN_IMPACT_PDG_K` runs *before* comparing F1 to
the band, so substrate noise can't trip the metric gate. Default K = 1 (the
fixtures are tiny and deterministic in practice); raise it
(`GN_IMPACT_PDG_K=3`) in a flaky CI lane.
## Runtime budget
Each fixture costs **one full `analyze --pdg` child process** (a fresh tree-sitter
parse + CFG/PDG build + persist) plus two in-process `impact` calls (one
call-graph, one line-seeded PDG). On these tiny fixtures that is ≈
**3–6 s/fixture**, so the full 13-fixture corpus runs in roughly **45–80 s**
wall-clock single-threaded (K = 1). A K-fold `--check` multiplies by K. For a
fast substrate smoke, scope to a subset:
`--only=intra-dataflow-chain,inter-dispatcher-thin,mixed-guarded-dispatch` (or
`GN_IMPACT_PDG_ONLY=…`). Not wired into `npm test` (matches the other benches);
the deterministic metric-math unit test *is* in `npm test`.
## How to run
```sh
cd gitnexus
node scripts/build.js # REQUIRED: workers spawn from dist/
node --import tsx bench/impact-pdg/measure.mjs # print the stratified report + verdict
node --import tsx bench/impact-pdg/measure.mjs --json # machine report (for re-baselining)
node --import tsx bench/impact-pdg/measure.mjs --check # gate against baselines.json (exit non-zero on regression)
node --import tsx bench/impact-pdg/measure.mjs --only=a,b,c # fast subset (substrate smoke)
node --import tsx bench/impact-pdg/real-code.mjs # latency + quality-proxy probe on indexed GitNexus
node --import tsx bench/impact-pdg/real-code.mjs --json --check # machine report + broad real-code gates
node --import tsx bench/impact-pdg/blast-radius.mjs # real-code localization: PDG slice vs whole-function body
node --import tsx bench/impact-pdg/blast-radius.mjs --direction upstream
```
### Real-code performance and quality proxy probe
`real-code.mjs` complements the AIS-backed fixture harness. It runs direct
`LocalBackend.callTool("impact", ...)` calls against an already-indexed real
repository (default `--repo GitNexus`) and measures:
- callgraph vs PDG median/p95 latency over `--repeat` samples;
- whether unified PDG's inter-procedural symbol reach preserves the callgraph
symbol set for the same target/direction;
- degraded, partial, no-block-at-line, and PDG bridge evidence counts.
This is a quality proxy, not an accuracy score: a real repo has no curated AIS,
so the probe cannot prove correctness. Use it to catch performance regressions,
degraded indexes, symbol-reach drift, and excessive `unproven-bridge` evidence on
real code. Use `measure.mjs` for the ground-truth precision/recall/F1 gate.
The default cases are statement-anchored at a CFG **block-start** line. The CFG
coalesces straight-line statements into one `BasicBlock`, so a mid-block anchor
resolves to no block start and degrades to `pdg-no-block-at-line` — honest, but it
then exercises only the symbol axis. The harness still detects and counts that
degradation; the curated anchors avoid it so every case also exercises a real
intra-procedural slice. (This is the same statement-anchoring discipline the
fixture corpus uses, applied to real code.)
A representative run on the indexed GitNexus tree (~17.5k symbols, PDG layer
persisted via `analyze --pdg` with ~171k `BasicBlock`s) — read it *directionally*,
not as a baseline, since wall-clock latency is host- and noise-dependent:
- **Symbol reach is preserved exactly.** Unified `mode:'pdg'` reproduces the
`mode:'callgraph'` inter-procedural symbol set on every case — mean and min
recall = precision = **1.000**. This is the load-bearing check: the PDG-facing
result must not silently drop or invent cross-function reach.
- **Each case carries a real statement slice** (`affectedStatements` non-empty,
2–27 statements here), so the intra axis is genuinely exercised.
- **Latency overhead is modest** — PDG median ≈ **1.2–1.4×** the callgraph median
(callgraph ≈ 90–250 ms/case, PDG ≈ 150–280 ms/case). The first call of a fresh
backend carries a one-time DB-warmup spike the p95 reflects.
- **Bridge evidence is direction-shaped, by design.** Downstream
statement-anchored seeds label most inter-procedural reach `unproven-bridge`
(the symbol's first-hop call site sits in a *different* statement than the
seeded one, so the local slice does not prove the dependence); upstream and
whole-symbol reach is `callgraph-bridge`. So `unprovenBridgeRatio ≈ 0.7` is the
*expected* shape for statement-anchored downstream seeds — a faithful
proven-vs-reachable signal, **not** a regression.
- **No degraded / error / partial / no-block-at-line cases**, and `--check` is
green. Default gates: min symbol recall ≥ 0.95, PDG median ≤ 5000 ms (override
via `GN_REAL_CODE_PDG_MIN_SYMBOL_RECALL` / `GN_REAL_CODE_PDG_MAX_MEDIAN_MS`).
### Is PDG-mode impact actually better than callgraph-only? (four-axis verdict)
"Better" is not one thing, so each candidate claim is tested separately and
reported honestly — including where PDG is *not* better. The evidence combines
the AIS-backed fixture gate (`measure.mjs`, which proves *correctness*) with two
real-code probes on the live GitNexus index (`real-code.mjs` and
`blast-radius.mjs`, which measure *magnitude at scale*: 120 functions per
direction, 240 total, plus the 5-case probe). `blast-radius.mjs` anchors each
function on an early-interior block (`floor(M/3)`), a conservative slice-maximizing
choice, and compares the PDG statement slice to the whole function body (`M`
blocks).
| Claim | Verdict | Evidence |
|---|---|---|
| **Tighter / fewer false alarms** | ✅ confirmed for localization and correctness | *Correctness:* the line-seeded slice equals the curated intra dependence exactly on the 7 **intra** fixtures AND the 3 **mixed** fixtures (F1 = 1.000, FPIS = FNIS = 0): the FU-A intra-tag keeps cross-function reach on the inter axis, and FU-B-2's statement-granular slice closed the block-coalescing blind spot — the U2 value-diff oracle now measures statement-level recall **1.000** on the chain fixture (was 0.333). *Magnitude (RECORDED, not re-run this session):* the slice is a median **0.26** (downstream) / **0.21** (upstream) of the function body; **240/240** functions localized below whole-body — a ~74–79% cut in the intra-procedural inspection set, with no proven dropped dependency. |
| **Catches impact callgraph misses** | ✅ confirmed (new axis) | Callgraph emits *no* statement-level output (unified intra-line CIS = 0, recall 0 on every fixture); PDG recovers every true dependent statement (intra recall = 1.000). PDG answers a def→use / control-dependence question callgraph cannot represent at all. |
| **Finds *more* callers/callees** | ❌ refuted (tie, by design) | Full PDG inter-procedural reach is **identical** to callgraph on 240/240 real functions (0 pdg-only, 0 callgraph-only). PDG bridges inter-procedural reach *through* the call graph, so it never finds reach the call graph misses. |
| **Tighter cross-function reach (statement-precise)** | ✅ confirmed (precision, additive) | `mode:'pdg'` now also exposes `statementPreciseByDepth` — the callees actually invoked from the changed line's dependence slice (`BasicBlock.callees`), dropping symbols only reachable from independent statements. Strictly tighter than callgraph on **52/90** with-slice functions (median proven **1** vs callgraph **2** symbols, median statement-precision **0.67**); the full reach stays available alongside it. `statementPrecision` reports the cut. Upstream seeds have no statement discriminator, so they stay all-proven (callgraph-equal) by design. |
| **Faster / cheaper** | ❌ refuted | PDG carries ~**1.2–1.6×** callgraph latency (the slice query + the slice-callees lookup). It buys precision, not speed. |
**Headline.** PDG makes `impact` *much* better at the localization/precision
question — *"what exactly does changing **this** statement affect?"* It narrows the
intra-procedural blast radius to roughly a quarter-to-a-third of the function body
with ground-truth-proven correctness, adds a statement-level dependence axis
callgraph has no answer for, and — via the persisted `BasicBlock.callees` substrate
— now also reports a **statement-precise** cross-function reach (only the callees
the changed line actually reaches), strictly tighter than callgraph on roughly half
of with-slice functions. It is deliberately **not** a *wider* or *faster*
cross-function reach: the full callgraph reach is preserved alongside the precise
view, and `mode:'callgraph'` remains the comparator for raw blast radius. The
surfaces compose — that is the point of the unified result, not a default switch.
Reproduce the verdict:
```sh
node --import tsx bench/impact-pdg/measure.mjs # correctness (F1 / FPIS / FNIS vs AIS)
node --import tsx bench/impact-pdg/blast-radius.mjs # localization magnitude (downstream)
node --import tsx bench/impact-pdg/blast-radius.mjs --direction upstream
node --import tsx bench/impact-pdg/real-code.mjs # symbol-reach preservation + latency
```
### Resolved-symbol-id soundness (the `calleeIds` upgrade)
The statement-precise cross-function bridge originally matched callgraph-reached
callees to the slice by **leaf name** (`BasicBlock.callees`). That is a heuristic
with two failure modes: same-leaf-name **collision** (two distinct `get`s both
proven — a false positive) and import-alias/rename (call-site leaf ≠ resolved name
— a false negative). The bridge now matches the **resolved callee symbol-id**
(`BasicBlock.calleeIds`, the per-block union of resolved ids joined to each call
site by exact position), which is sound by construction; the leaf-name match
remains the graceful fallback for pre-v3 indexes, blocks with no captured ids, and
truncation-capped blocks. `name-collision.mjs` diffs the two on the same real
slices (`fpEliminated` = collision FPs the id bridge removes; `fnRecovered` =
alias FNs it recovers).
Realized effect on a random single-statement sample (per language, exact
seed∪reachable slice):
| Language | repo | fpEliminated | fnRecovered | name-collision ambiguity |
|---|---|---:|---:|---:|
| Java | commons-lang | 2.1% | 0% | 3.9% |
| PHP | monolog | 2.6% | 1.8% | 12.2% |
| C# | commandline | 0% | 4.8% | 0% |
| TS | ky | 0% | 0% | 0% (no regression) |
Honest reading of these numbers:
- **The aggregate effect on a *median* edit is modest (≈0–3%).** This matches the
pre-build measurement: realized name-collision concentrates in the small tail of
high-fan-out delegating functions, not the typical single-statement slice (the
per-function reach is usually 1–2 callees, where a same-name collision is
impossible). The win is **soundness**, gated cleanly by the
`intra-overloaded-callee` fixture (id proves exactly the one called overload;
name-match over-attributes both — `measure.mjs --check` Gate 3), not a large
aggregate FP cut.
- **It is bidirectional.** The id key also *recovers* alias/rename false negatives
the name match can never prove (C# 4.8%, PHP 1.8%) — callees invoked under a
name that differs from their resolved symbol name.
- **The id bridge is exactly as precise as GitNexus's call resolver — no more, no
less.** Where the resolver emits a *multi-candidate* set for one ambiguous call
(e.g. `printer.getX()` on a typed field resolving to `getX` on **both** the field
type and the enclosing class), the bridge faithfully proves the whole candidate
set (sound — it never drops a real target). The residual "ambiguity" on the
worst-case tail is therefore the **resolver's** receiver-type precision, not a
name-matching artifact; improving it is a resolver-precision follow-up (sibling
to the C++ overload under-resolution follow-up).
**Language scope.** The id bridge (and the name bridge) applies wherever the CFG
harvests call sites. As of the call-site-harvesting extension this is **all 12
supported languages** — the original six (TS/JS, Java, C#, Go, C/C++, PHP) plus
Kotlin, Swift, Dart, Ruby, Rust, and Python, which were migrated from the no-site
def/use accumulator to the shared `CallSiteFactAccumulator` (each verified that its
`SiteRecord.at` anchor matches that language's `@reference.call` resolution anchor
byte-exact, so the resolved-id join lands). Their BasicBlocks now carry `callees`
*and* `calleeIds`. Realized benefit still tracks each language's collision tail and
its call-resolver precision (e.g. Python/Ruby route most calls to stdlib/builtins,
which carry no in-repo id), but the substrate is uniform. The one remaining
language-shaped gap is **C++ overload under-resolution** (a resolver issue, not a
harvesting one — see the C++ caveat above).
Reproduce (needs a `--pdg` index of the target repo built under schema v3):
```sh
node --import tsx bench/impact-pdg/name-collision.mjs --repo commons-lang --src 'src/main/java/'
node --import tsx bench/impact-pdg/name-collision.mjs --repo monolog --src 'src/Monolog/'
```
### Inter-procedural forward slice (U1 — `calleeIds` descent)
The `mode:'pdg'` slice was originally **intra-procedural**: the CDG +
REACHING_DEF traversal stayed inside the seeded function, and cross-function
reach was bolted on only through the call-graph bridge. U1 makes the statement
slice itself cross function boundaries: after the intra slice completes (and
before block→symbol projection), a bounded **DOWNSTREAM-only** descent gathers
the slice blocks' resolved `calleeIds`, batch-resolves them to callee spans
(one `s.id IN $ids` UNION-ALL over Function/Method/Constructor — keyed on the
*resolved* id, so no same-line ambiguity), seeds each callee, and runs the SAME
intra BFS within it, unioning the newly-reachable blocks into the slice. This is
**HRB context-insensitive forward closure** — the approach Joern ships (no full
SDG). Bounds: a default **3 inter-procedural function hops** (`maxDepth` caps the
per-hop intra step budget), a total node cap, and a shared `visited` set that
guarantees termination over recursion/cycles. The cross-function reach **deepens
`affectedStatements`** (the statement-level slice); the owning-symbol `byDepth`
stays a single collapsed bucket (block-hops are not call-hops). A pre-namespace-v4
index (no `calleeIds` column) yields no callee ids, so the descent is a no-op and
the result degrades cleanly to the prior intra-only behavior.
**Soundness caveats** (also stamped verbatim into the result `note` whenever the
slice crosses a hop):
1. **Context-insensitive.** A dependence may be attributed to a callee only
reachable from a *different* call site of the same function (bounded
over-inclusion — the same imprecision the call-graph mode already has).
2. **Return-value ascent IS captured (CALL_SUMMARY); out-param / exception
ascent deferred.** A caller statement that depends on a callee's RETURN value
is now in the slice when the callee carries a persisted `CALL_SUMMARY`
return-flow summary (FU-C): the descent re-seeds the caller's continuation from
the call block, and FU-B-2 surfaces the dependent call/continuation statements
at statement granularity (the self-edge def→use walk). What remains deferred:
out-parameter / mutated-argument ascent, callee-written shared / captured
variables, and exception ascent (a throw the callee raises that the caller
catches) — these need an alias / try-catch model. A pre-FU-C (v3) `--pdg` index
has no `CALL_SUMMARY` edges, so return-value ascent is absent there until a
re-index (the result `note` steers to it).
3. **No cross-boundary alias model.** Aliasing of arguments/heap across the call
boundary is not modeled.
4. **Precision is bounded by the call RESOLVER's precision.** Multi-candidate
dispatch and C++ overload under-resolution flow through faithfully — the
descent is sound (it never drops a real target), but it inherits exactly the
resolver's precision, no more and no less.
### U2 — dynamic mutation oracle (independent ground-truth cross-check)
The PR's **#1 declared validity threat is annotation circularity** (the manual
`intra_AIS` risks being reconciled against the very traversal it scores). U2 adds
an **independent, CI-runnable check**: a real **dynamic forward slice** computed
by **value-diff**, not by reading the static slice. It lives in
`mutation-oracle.mjs` (substrate) + the pure scorers in `metrics.mjs`, gated
behind a new `--mutation` flag. **It is bench-additive: no `src/` change, no
schema change; the default report run + its F1 `--check` gate + fingerprints are
byte-identical without `--mutation`.**
**Why value-diff, not coverage.** Per Voas's PIE model (TSE'92), an observable
fault needs **P**ropagation + **I**nfection + **E**xecution. Coverage is only E;
*dependence* requires I+P = an actual VALUE CHANGE at a downstream point. So the
oracle's `behavioral_AIS` is the set of statements whose **observed value
changed** when the criterion line was mutated — a genuine
[Agrawal-Horgan PLDI'90](https://doi.org/10.1145/93542.93576) **dynamic slice**,
not a coverage trace. The static⊇dynamic soundness relation (Tip'95) then says a
sound static slicer must contain the dynamic slice on the executed paths, so
`B ⊆ slice` is the recall expectation.
**Per fixture, the oracle:**
1. **Mutates the criterion line only** (≤ 4 mutants, line-scoped regex operators:
AOR `+ - * / %`, ROR `> < <= >= === !==`, LCR `|| && !`, CRP numeric-literal →
`k+1`/`0`, UOI negate-RHS when no operator is flippable). EQUIVALENT mutants
(empty `behavioral_AIS`) and syntactically-invalid mutants are discarded.
2. **Derives inputs** with a tiny TYPE-DRIVEN generator from the criterion fn's
params — `number → [5,-3,0]`, `number[] → [[1,2,3],[-1,-2],[]]`, `boolean →
[true,false]`, `string → ['a','b','z']` (multi-input covers both branch arms).
The tuples used are recorded in the sidecar.
3. **Instruments the ORIGINAL TS AST** with Babel (`@babel/parser` + `traverse` +
`generator`, `retainLines:true` so loc lines stay 1-based `filePath:line` — the
SAME space as the slice, no source-map needed): a value-transparent
`__trace(EXPR, line, filePath, occ)` wraps VariableDeclarator init /
AssignmentExpression RHS / ReturnStatement arg / CallExpression and returns
`EXPR` unchanged. Runs original + each mutant via **tsx dynamic-import** on the
SAME inputs from the SAME temp working copy the analyze step used.
4. `behavioral_AIS = { filePath:line where serialize(orig) != serialize(mut) }`
for some input/occurrence (value changed / appeared / disappeared), **EXCLUDING
the criterion line**, unioned over inputs then over non-equivalent mutants. A
deterministic serializer handles `undefined`/`NaN`/`±Infinity`/stable object key
order.
**The metric (`--mutation`, report-only this landing).** Let `slice =
pdgLineCis(results.pdg.affectedStatements)` (the SAME live static slice the F1
metric scores), `B = behavioral_AIS`, `M = intra_AIS`:
- **`mutation_recall = |B ∩ slice| / |B|`** (pure `mutationRecall` in
`metrics.mjs`). `recall < 1.0` ⇒ `B ∖ slice` is a statement the oracle PROVED
depends on the criterion that the static slice MISSED. `B ∖ slice` is printed
explicitly and **every** such line is classified as **(a) known-U1-no-ascent-gap**,
**(b) driver/model artifact** (block-coalescing interior of a coalesced
BasicBlock, or the upstream-fixture oracle-direction mismatch), or **(c) novel
recall hole** — so a reader is never misled (see *Caveats* below).
- **Circularity cross-check: `B ∖ M`** (pure `circularityDiff`). Non-empty on an
**intra** fixture ⇒ the manual annotation missed a real dependence — the headline
independent evidence U2 exists to produce. Reported as a **WARN with the lines**;
it does **not** fail. (On inter/mixed fixtures `intra_AIS` is empty-by-design, so
`B ∖ M` there is expected cross-function reach, labelled as such, not a miss.)
- **Precision is NOT gated.** `slice ∖ B` is expected sound over-approximation
(the static slice legitimately over-includes); `|slice ∖ B|` is reported
informationally only.
**Phasing — report-then-gate.** `measure.mjs --mutation --check` prints a
`Gate 4 (mutation recall, REPORT-ONLY)` line + the numbers but **does NOT
`process.exit(1)`** on `recall < 1.0` this landing. Flipping it to a hard gate is a
one-flag change: `--mutation-strict` already fails the build on **NOVEL** holes
only (block-coalescing artifacts and documented U1 gaps are excluded by the same
classifier the report uses).
**Caveats handled.**
- **R1 — the two `upstream` fixtures** (`reassignSum`, `filterPositive`) are a
forward-oracle mismatch. The oracle runs in its native downstream sense and does
the circularity cross-check, but the recall **gate is NOT applied** to them; this
is **printed** as `oracle-direction-excluded`, never a silent skip.
- **R2 — U1's DOCUMENTED no-ascent gap.** A caller statement depending on a callee
RETURN/out-param/thrown-exception is not in the intra slice without
`CALL_SUMMARY`. A `recall < 1.0` from callee-effect ascent into the caller
continuation is a **KNOWN gap**, not a novel bug — the report classifies it
`known-U1-no-ascent-gap` with the lines. `nobody-interface-excluded` (no body)
stays oracle-excluded; `intra-overloaded-callee` runs as **id-discrimination
corroboration** (mutating the alpha arm changes `Alpha.process`'s output, not
`Beta.process`'s), included as corroboration — **not** an AIS recall case.
**Artifacts.** All instrumented mutants are generated under an `os.tmpdir()` dir
(`gn-impact-pdg-mut-*`), **never inside `fixtures/`**. The only persisted new file
per fixture is `mutation-ground-truth.json` (a regenerated audit cache;
`provenance:'mutation'`; **separate from the manual `ground-truth.json`, never
overwriting it**). It is data, matched by no vitest project (the default glob is
`test/**/*.test.ts`); the integration test carries a **tripwire** asserting no
`*.test.ts` ever appears under `bench/impact-pdg/`.
**Runtime.** Each fixture costs one extra `analyze --pdg` child process (the same
substrate the F1 loop uses) plus a handful of in-process tsx dynamic-imports
(original + ≤ 4 mutants × the input tuples), all on tiny fixtures — roughly
**4–7 s/fixture**, so the full `--mutation` pass adds on the order of a minute over
the base run. The oracle runs **once** (not median-of-K): the value-diff is the
load-bearing signal, deterministic, not substrate-noise-prone like F1.
**Cross-language sweep is a separate task.** The fixtures are TypeScript (the
maturest CFG/PDG support here) and the instrumenter is TS-AST-based. Extending the
oracle to the other languages requires re-indexing per-language fixture corpora
under `--pdg` and a per-language instrumenter — a separate re-indexing task, out of
scope for this landing.
**Reproduce:**
```sh
node --import tsx bench/impact-pdg/measure.mjs --mutation # per-fixture recall + circularity rows
node --import tsx bench/impact-pdg/measure.mjs --mutation --check # + report-only Gate 4
node --import tsx bench/impact-pdg/measure.mjs --mutation --check --mutation-strict # hard-fail on NOVEL holes
```
### Re-baseline (after a reviewed accuracy or ground-truth change)
1. `node --import tsx bench/impact-pdg/measure.mjs --json` and read
`annotationFingerprint` + `strata[scope][mode].f1`.
2. Copy those into `baselines.json` (`annotationFingerprint`, the `f1Bands`
cells), bump `analyzerVersion` if the analyzer moved, adjust `epsilon` only
deliberately.
3. Confirm `--check` is green.
The fixtures are also validated by the integration test
`test/integration/impact-pdg-fixtures.test.ts` (schema well-formedness + a smoke
test that each fixture analyzes under `--pdg` and the criterion function produces
its declared CDG / REACHING_DEF edges — a zero-edge criterion has unmeasurable
ground truth).

View file

@ -0,0 +1,21 @@
{
"_doc": "U7 impact-PDG accuracy baselines. THREE NON-byte-identity gates (KTD10 + U9): (1) annotationFingerprint — an order-independent digest over the curated ground-truth set (INCLUDING criterion.line, the PDG slice seed, AND the U9 `idBridge` block); any unreviewed edit to a ground-truth.json trips it (re-baseline deliberately after review). (2) f1Bands — a ONE-SIDED F1 regression band per mode per scope: a DROP below (band - epsilon) fails; improvements pass freely. A `null` band means F1 is genuinely undefined for that (scope,mode) on this corpus — the gate skips it (nothing to regress against). (3) U9 resolved-id soundness axis — every fixture carrying an `idBridge` ground-truth block must prove EXACTLY its `idBridge.idProven` set statement-precise (via the resolved symbol id) while the leaf-NAME match strictly over-attributes the eliminated collision id (`idBridge.fpEliminated`). The expected sets live in the ground-truth `idBridge` block, covered by Gate 1's fingerprint, so Gate 3 has no separate baseline number. measure.mjs --check applies median-of-K across GN_IMPACT_PDG_K runs before comparing, so substrate flakiness (the real-analyze lane, F5) cannot trip the band. Re-baseline: `node --import tsx bench/impact-pdg/measure.mjs --json` → copy annotationFingerprint and strata[scope][mode].f1 here, bump analyzerVersion if the analyzer moved.",
"analyzerVersion": "1.6.7",
"epsilon": 0.05,
"annotationFingerprint": "f5792b91c0e2da2b80dbac1cbbee0219aa532806234646c35fa3edfc17d4e700",
"f1Bands": {
"intra": {
"callgraph": null,
"pdg": 1.0
},
"inter": {
"callgraph": 1.0,
"pdg": null
},
"mixed": {
"callgraph": 1.0,
"pdg": 1.0
}
},
"_f1BandsNote": "FU-B-2 re-reconciled: the intra slice is now STATEMENT-granular (the persisted REACHING_DEF edge carries the FULL ordered list of def/use source-line pairs for its (block-pair, binding) group, and the projection walks the self-edge def->use chain forward to fixpoint to recover a coalesced block's interior statements — including a SAME-BINDING reassignment chain `acc@24->acc@25->acc@26`, which all share one deduped edge and need the whole pair list, not just the first pair). The annotationFingerprint MOVED deliberately (d5cb0045->f5792b91): three intra fixtures had their intra_AIS re-reconciled UP to the now-measurable statement set — intra-dataflow-chain {10}->{8,9,10}, intra-control-guard {9,11,13}->{9,11,12,13}, intra-dataflow-reassign {6,7}->{6,7,8} — each restoring the original source-derived belief the prior block-granularity reconciliation had under-counted. Per-fixture justification (NOT a metric re-fit): (a) intra-dataflow-chain (downstream) is independently corroborated — the U2 dynamic value-diff oracle had ALREADY proved behavioral_AIS={8,9,10} (and flagged 8/9 as a manual-annotation circularity miss) BEFORE the edit; (b) intra-control-guard (downstream) is justified by source semantics — line 12 `const z = y + 1` is control-dependent on the guard AND data-dependent on `y@11` in the reached post-guard body block — and likewise corroborated by the downstream U2 oracle; (c) intra-dataflow-reassign is UPSTREAM, so U2 is oracle-direction-EXCLUDED and does NOT justify it — line 8 `total = total + b` is kept purely on SOURCE SEMANTICS: it is the immediately-reaching definition of the criterion use `return total`@9 (the last unkilled def of `total` reaching line 9), a genuine upstream reaching-def. After re-reconciliation intra/pdg stays F1=1.0 (P=1.0 R=1.0 FPIS=0 across all 7 intra fixtures) and mixed/pdg is F1=1.0 (the FU-A intra-tag keeps cross-function callee lines on the inter axis; the recovered interior intra statements match exactly). U7-rework numbers (statement-anchored PDG slice). The two engines are scored at DIFFERENT granularities against DIFFERENT ground truths: PDG at intra-procedural LINE granularity vs intra_AIS (seeded on criterion.line), call-graph at inter-procedural SYMBOL granularity vs inter_AIS. NON-NULL bands: intra/pdg=1.0 (the line-seeded slice exactly reproduces intra_AIS across all 6 intra fixtures — FPIS=0, FNIS=0), mixed/pdg=1.0 (see below), inter/callgraph=1.0 (full cross-function callee recall), mixed/callgraph=1.0 (reaches every callee). mixed/pdg is back at 1.0 after FU-A: the U1 inter-procedural slice still UNIONS the cross-function (callee) reach into affectedStatements, but each statement now carries a projection-only scope:'intra'|'inter' tag (intra iff its owning function file + 1-based start line match the criterion's), and the intra-line axis is scored against the intra-tagged statements only (pdgLineCis(...,'intra')). So the callee lines are no longer counted as intra-axis FPIS (precision climbs 0.468->1.0) while recall stays 1.0 (FNIS=0 — every intra_AIS line is still in the slice). The cross-function reach is preserved on the separate inter symbol axis; the separated per-axis view is in the report's 'Unified impact axes' section. The one-sided band still catches any FURTHER drop (a real recall regression, or added intra over-approximation). NULL cells: intra/callgraph (a self-contained function calls no other symbol → CIS and inter_AIS both empty → F1 n/a) and inter/pdg (a pure-inter router has an empty intra_AIS by design; the line-seeded slice returns the router's own control-dependent statements as FPIS, precision 0, recall n/a → F1 n/a). The intra_AIS of 6 fixtures was reconciled against the live traversal during the rework (CFG block-coalescing of straight-line statements + the combined CDG+RD reverse slice picking up data deps); see each ground-truth.json rationale for the per-fixture correction. These four non-null bands are the live regression guard."
}

View file

@ -0,0 +1,361 @@
/**
* Real-code blast-radius / localization probe — the "is PDG-mode impact actually
* better than callgraph-only?" evidence harness.
*
* `real-code.mjs` checks that unified `mode:'pdg'` PRESERVES callgraph symbol
* reach and how much it costs. This script answers the sharper question: when you
* change a single statement inside a real function, how much does PDG NARROW the
* impact set versus the pre-PDG answer ("you changed something in F → inspect all
* of F")? It samples real functions from an already-indexed repo and, per
* function, compares:
*
* - intra axis (the localization win): |PDG statement slice| vs |whole function
* body| (block units). A ratio < 1 means PDG points at a subset of the body
* instead of the whole thing. Correctness of that subset is NOT proven here —
* it is anchored by the AIS-backed `measure.mjs` gate, which shows the
* line-seeded slice is exact (intra/mixed PDG F1 = 1.0, FPIS = FNIS = 0). So
* a smaller slice is a genuine over-approximation cut, not a dropped-truth
* risk.
* - inter axis (the honest non-win): the PDG interprocedural symbol set vs the
* callgraph symbol set for the same target. They are equal by design (PDG
* bridges interprocedural reach through the call graph), so this probe
* surfaces any divergence rather than assuming it.
* - cost: callgraph vs PDG latency.
*
* This is a quality proxy on real code (no curated AIS), exactly like
* `real-code.mjs`. Read magnitudes as directional; the correctness claim lives in
* `measure.mjs`.
*
* Methodology note: the seed anchor is an EARLY-interior block (index
* floor(M/3)). For a downstream/forward slice that is a conservative,
* slice-maximizing choice — it understates rather than inflates the localization
* win — so the measured cut is a lower bound on a typical interior edit.
*/
import path from 'node:path';
import { performance } from 'node:perf_hooks';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(__dirname, '..', '..');
function readOption(argv, name, fallback = undefined) {
const eq = argv.find((arg) => arg.startsWith(`--${name}=`));
if (eq) return eq.slice(name.length + 3);
const idx = argv.indexOf(`--${name}`);
if (idx >= 0 && idx + 1 < argv.length) return argv[idx + 1];
return fallback;
}
function hasFlag(argv, name) {
return argv.includes(`--${name}`);
}
export function median(xs) {
if (xs.length === 0) return null;
const sorted = [...xs].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 === 1 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
}
function round(value, digits = 3) {
if (value === null || value === undefined || Number.isNaN(value)) return null;
const scale = 10 ** digits;
return Math.round(value * scale) / scale;
}
function fmt(value, digits = 2) {
return value === null || value === undefined ? 'n/a' : Number(value).toFixed(digits);
}
/**
* Parse a GitNexus `cypher` markdown table into row objects. Only used on columns
* that cannot contain a `|` (ids, identifiers, integers) so the split is safe.
*/
export function parseMarkdownRows(markdown) {
if (!markdown) return [];
const lines = markdown.split('\n').filter((l) => l.trim().startsWith('|'));
if (lines.length < 2) return [];
const head = lines[0]
.split('|')
.slice(1, -1)
.map((s) => s.trim());
return lines.slice(2).map((l) => {
const cells = l
.split('|')
.slice(1, -1)
.map((s) => s.trim());
const o = {};
head.forEach((h, i) => (o[h] = cells[i]));
return o;
});
}
/** Stable symbol-id set from an impact `byDepth` record (mirrors real-code.mjs). */
export function symbolSetFromByDepth(byDepth) {
const out = new Set();
for (const items of Object.values(byDepth ?? {})) {
for (const item of items ?? []) {
if (!item || typeof item !== 'object') continue;
if (typeof item.id === 'string' && item.id.length > 0) {
out.add(item.id);
continue;
}
const name = typeof item.name === 'string' ? item.name : '(unknown)';
const filePath = typeof item.filePath === 'string' ? item.filePath : '(unknown)';
out.add(`${name}@${filePath}`);
}
}
return out;
}
/**
* Pure aggregation over the per-function measurements. Kept dependency-free so the
* deterministic unit test can assert the arithmetic without analyze/DB.
*/
export function summarizeBlastRadius(cases) {
const ratios = cases.map((c) => c.ratio).filter((v) => v !== null && v !== undefined);
return {
n: cases.length,
localization: {
medianSliceOverBody: round(median(ratios)),
meanSliceOverBody: ratios.length
? round(ratios.reduce((a, b) => a + b, 0) / ratios.length)
: null,
medianBodyBlocks: median(cases.map((c) => c.bodyBlocks)),
medianSliceBlocks: median(cases.map((c) => c.sliceBlocks)),
casesSliceSmallerThanBody: cases.filter((c) => c.sliceBlocks < c.bodyBlocks).length,
},
interSymbol: {
casesPdgFindsMore: cases.filter((c) => c.pdgOnly > 0).length,
casesPdgFindsFewer: cases.filter((c) => c.cgOnly > 0).length,
casesIdentical: cases.filter((c) => c.pdgOnly === 0 && c.cgOnly === 0).length,
totalPdgOnlySymbols: cases.reduce((a, c) => a + c.pdgOnly, 0),
totalCgOnlySymbols: cases.reduce((a, c) => a + c.cgOnly, 0),
},
// Statement-precise inter-procedural reach (the axis-3 precision win): the
// proven subset invoked from the changed line's slice, vs the full callgraph
// reach. Only the with-slice cases can discriminate; empty-slice/upstream
// cases preserve full reach (precision 1) and are reported separately.
statementPrecise: {
casesWithSlice: cases.filter((c) => c.sliceBlocks > 0).length,
casesTighterThanCallgraph: cases.filter((c) => c.statementPreciseSymbols < c.callgraphSymbols)
.length,
medianStatementPrecision: round(
median(cases.map((c) => c.statementPrecision).filter((v) => v !== null && v !== undefined)),
),
medianPreciseSymbols: median(cases.map((c) => c.statementPreciseSymbols)),
medianCallgraphSymbols: median(cases.map((c) => c.callgraphSymbols)),
},
latency: {
medianCallgraphMs: round(median(cases.map((c) => c.callgraphMs))),
medianPdgMs: round(median(cases.map((c) => c.pdgMs))),
medianPdgOverCallgraph: round(
median(
cases
.map((c) => (c.callgraphMs > 0 ? c.pdgMs / c.callgraphMs : null))
.filter((v) => v !== null),
),
),
},
};
}
async function cypherRows(backend, repo, query) {
const res = await backend.callTool('cypher', { repo, query });
return parseMarkdownRows(res?.markdown);
}
async function run() {
const argv = process.argv.slice(2);
const repo = readOption(argv, 'repo', 'GitNexus');
const sample = Math.max(1, Number(readOption(argv, 'sample', '120')));
const minBlocks = Math.max(2, Number(readOption(argv, 'min-blocks', '6')));
const src = readOption(argv, 'src', 'gitnexus/src/');
const direction = readOption(argv, 'direction', 'downstream');
const depth = Math.max(1, Number(readOption(argv, 'depth', '3')));
const limit = Math.max(1, Number(readOption(argv, 'limit', '200')));
const json = hasFlag(argv, 'json');
const { LocalBackend } = await import(
path.join(REPO_ROOT, 'src', 'mcp', 'local', 'local-backend.ts')
);
const backend = new LocalBackend();
const initialized = await backend.init();
if (!initialized)
throw new Error('no indexed repositories found; run gitnexus analyze --pdg first');
try {
// Candidate functions + methods with a body worth localizing.
const candidateQuery = (label) =>
`MATCH (f:${label}) WHERE f.filePath STARTS WITH '${src}' AND f.endLine > f.startLine + 18 ` +
`RETURN f.name AS name, f.filePath AS filePath, f.startLine AS startLine, ` +
`f.endLine AS endLine, '${label}' AS kind`;
let candidates = [
...(await cypherRows(backend, repo, candidateQuery('Function'))),
...(await cypherRows(backend, repo, candidateQuery('Method'))),
].filter((c) => c.name && /^[A-Za-z_$][\w$]*$/.test(c.name));
// Stride-sample for file diversity instead of taking the first N.
const stride = Math.max(1, Math.floor(candidates.length / (sample * 3)));
candidates = candidates.filter((_, i) => i % stride === 0);
const cases = [];
let degraded = 0;
for (const c of candidates) {
if (cases.length >= sample) break;
const lo = Number(c.startLine);
const hi = Number(c.endLine);
if (!Number.isFinite(lo) || !Number.isFinite(hi)) continue;
// The function's OWN blocks (id prefix fnStartLine == lo+1, 1-based) — the
// line range alone would also capture nested closures.
const blockRows = await cypherRows(
backend,
repo,
`MATCH (b:BasicBlock) WHERE b.filePath = '${c.filePath}' AND b.startLine >= ${lo} ` +
`AND b.startLine <= ${hi + 1} RETURN b.id AS id, b.startLine AS startLine ORDER BY b.startLine`,
);
const fnLine1b = String(lo + 1);
const own = blockRows.filter((r) => {
const parts = r.id.split(':');
return parts[parts.length - 3] === fnLine1b;
});
const bodyBlocks = own.length;
if (bodyBlocks < minBlocks) continue;
const startLines = own
.map((r) => Number(r.startLine))
.filter(Number.isFinite)
.sort((a, b) => a - b);
const anchor = startLines[Math.max(1, Math.floor(bodyBlocks / 3))];
if (!Number.isFinite(anchor)) continue;
const base = {
repo,
target: c.name,
file_path: c.filePath,
kind: c.kind,
direction,
maxDepth: depth,
limit,
includeTests: true,
};
let t = performance.now();
const cg = await backend.callTool('impact', { ...base, mode: 'callgraph' });
const callgraphMs = performance.now() - t;
if (cg?.error) continue;
t = performance.now();
const pdg = await backend.callTool('impact', { ...base, mode: 'pdg', line: anchor });
const pdgMs = performance.now() - t;
if (pdg?.error) continue;
if (pdg?.pdgLayer && pdg.pdgLayer !== 'ready') {
degraded++;
continue;
}
if (pdg?.epistemic === 'pdg-no-block-at-line') continue;
const sliceBlocks = pdg?.affectedStatementCount ?? 0;
const cgSyms = symbolSetFromByDepth(cg?.byDepth ?? {});
const pdgSyms = symbolSetFromByDepth(
pdg?.interproceduralByDepth ?? pdg?.pdgInterprocedural?.byDepth ?? {},
);
// Statement-precise (proven) inter-procedural reach: the subset invoked
// from the criterion's dependence slice. Tighter than callgraph when the
// changed line reaches only some of the function's callees.
const preciseSyms = symbolSetFromByDepth(
pdg?.pdgInterprocedural?.statementPreciseByDepth ?? {},
);
const pdgOnly = [...pdgSyms].filter((x) => !cgSyms.has(x)).length;
const cgOnly = [...cgSyms].filter((x) => !pdgSyms.has(x)).length;
cases.push({
name: c.name,
kind: c.kind,
file: c.filePath,
anchor,
bodyBlocks,
sliceBlocks,
ratio: bodyBlocks ? round(sliceBlocks / bodyBlocks) : null,
callgraphSymbols: cgSyms.size,
pdgSymbols: pdgSyms.size,
statementPreciseSymbols: preciseSyms.size,
statementPrecision:
typeof pdg?.pdgInterprocedural?.statementPrecision === 'number'
? round(pdg.pdgInterprocedural.statementPrecision)
: null,
pdgOnly,
cgOnly,
epistemic: pdg?.epistemic ?? null,
callgraphMs: round(callgraphMs, 1),
pdgMs: round(pdgMs, 1),
});
}
const summary = summarizeBlastRadius(cases);
const report = {
repo,
direction,
sample: cases.length,
minBlocks,
degradedSkipped: degraded,
generatedAt: new Date().toISOString(),
note: 'Real-code localization proxy: slice-vs-body magnitude only. Correctness is anchored by measure.mjs (AIS-backed).',
summary,
cases,
};
if (json) {
process.stdout.write(JSON.stringify(report, null, 2) + '\n');
return;
}
const loc = summary.localization;
const inter = summary.interSymbol;
const prec = summary.statementPrecise;
const lat = summary.latency;
const lines = [];
lines.push('=== impact-PDG real-code blast-radius / localization probe ===');
lines.push(
`repo ${repo} | direction ${direction} | functions ${cases.length} | minBlocks ${minBlocks}`,
);
lines.push('');
lines.push(
`Localization (axis: tighter): PDG slice is a median ${fmt(loc.medianSliceOverBody)} of the ` +
`whole function body (mean ${fmt(loc.meanSliceOverBody)}); median body ${loc.medianBodyBlocks} ` +
`blocks -> slice ${loc.medianSliceBlocks}; ${loc.casesSliceSmallerThanBody}/${cases.length} ` +
`functions localized below whole-body.`,
);
lines.push(
`Inter-symbol reach (axis: more callers/callees): full reach identical to callgraph on ` +
`${inter.casesIdentical}/${cases.length} functions ` +
`(pdg-only ${inter.totalPdgOnlySymbols}, callgraph-only ${inter.totalCgOnlySymbols}).`,
);
lines.push(
`Statement-precise reach (axis: tighter cross-function): ${prec.casesTighterThanCallgraph}/` +
`${prec.casesWithSlice} with-slice functions narrow below full callgraph reach; median ` +
`statement-precision ${fmt(prec.medianStatementPrecision)} (proven median ` +
`${prec.medianPreciseSymbols} vs callgraph ${prec.medianCallgraphSymbols} symbols).`,
);
lines.push(
`Latency (axis: faster): callgraph median ${fmt(lat.medianCallgraphMs, 1)}ms, ` +
`pdg median ${fmt(lat.medianPdgMs, 1)}ms, pdg/cg ${fmt(lat.medianPdgOverCallgraph)}x.`,
);
lines.push('');
lines.push(
'Interpretation: PDG narrows the intra-procedural impact set (the slice is a ' +
'fraction of the body); its correctness — that the narrowed set drops no real ' +
'dependency — is the AIS-backed measure.mjs result (intra/mixed PDG F1 = 1.0). PDG ' +
'does NOT widen cross-function reach (equal to callgraph by design) and is NOT faster.',
);
process.stdout.write(lines.join('\n') + '\n');
} finally {
await backend.dispose().catch(() => {});
}
}
if (path.resolve(process.argv[1] ?? '') === fileURLToPath(import.meta.url)) {
run().catch((err) => {
process.stderr.write(`[impact-pdg-blast-radius] ERROR: ${err?.stack || err}\n`);
process.exit(1);
});
}

View file

@ -0,0 +1,33 @@
{
"schemaVersion": 1,
"criterion": {
"name": "dispatch",
"filePath": "src/dispatcher.ts",
"direction": "downstream",
"line": 23,
"marker": "kind === 'b'",
"pdgEdgeKinds": ["REACHING_DEF", "CDG"]
},
"locus": "inter",
"provenance": "manual",
"analyzerVersion": "1.6.7",
"intra_AIS": [],
"inter_AIS": [
{
"symbol": "handleA",
"filePath": "src/dispatcher.ts",
"note": "routed to when kind === 'a'; its behavior is part of dispatch's true blast radius"
},
{
"symbol": "handleB",
"filePath": "src/dispatcher.ts",
"note": "routed to when kind === 'b'"
},
{
"symbol": "handleDefault",
"filePath": "src/dispatcher.ts",
"note": "the fallthrough route"
}
],
"rationale": "criterion.line=23 — the dispatcher's own first body statement `if (kind === 'a')` (source semantics: the router's work begins here). intra_AIS is EMPTY BY DESIGN: `dispatch` is a thin router whose TRUE impact is ENTIRELY cross-function — changing it affects the three handlers it routes to (handleA/handleB/handleDefault), captured in inter_AIS. The only intra statements are the routing branch returns, which carry no work of their own, so there is no meaningful intra-procedural ground truth. Step-0 reconciliation: the statement-anchored PDG slice from line 23 DOES return the routing return statements (the branch predicates control-depend on each other and each return), but those are NOT the meaningful blast radius — PDG is intra-procedural, so on a pure-inter fixture its intra slice is noise relative to the (empty) intra ground truth. This is the symmetric counterpart of call-graph's empty intra slice on the intra fixtures: each engine is blind to the other's locus. The call-graph mode (inter-procedural) finds all three handlers exactly. Anchors the `inter` stratum. NOTE: the smoke test requires `dispatch` to produce SOME CDG/REACHING_DEF edges (its routing branches do) — a zero-edge criterion would be unmeasurable; but those edges are not the AIS."
}

View file

@ -0,0 +1,43 @@
{
"schemaVersion": 1,
"provenance": "mutation",
"criterion": {
"name": "dispatch",
"filePath": "src/dispatcher.ts",
"line": 23
},
"paramTypes": ["string", "number"],
"inputs": [
["a", 5],
["a", -3],
["a", 0],
["b", 5],
["b", -3],
["b", 0]
],
"behavioral_AIS": [
"src/dispatcher.ts:10",
"src/dispatcher.ts:14",
"src/dispatcher.ts:18",
"src/dispatcher.ts:24",
"src/dispatcher.ts:27",
"src/dispatcher.ts:29"
],
"mutants": [
{
"op": "ROR",
"text": " if (kind !== 'a') {",
"equivalent": false,
"diffLines": [
"src/dispatcher.ts:10",
"src/dispatcher.ts:14",
"src/dispatcher.ts:18",
"src/dispatcher.ts:24",
"src/dispatcher.ts:27",
"src/dispatcher.ts:29"
]
}
],
"skipped": null,
"note": "AUTO-GENERATED dynamic forward-slice oracle (U2). VALUE-DIFF behavioral AIS (Infection+Propagation, not coverage). Regenerated by `measure.mjs --mutation`. Independent cross-check of the manual ground-truth.json — NOT a hand annotation."
}

View file

@ -0,0 +1,30 @@
// Thin cross-function dispatcher. `dispatch`'s TRUE impact is entirely
// cross-function: it just routes to `handleA` / `handleB` / `handleDefault`.
// Intra-procedural PDG over `dispatch` returns ~no truly-affected statements
// of interest (the routing branch returns are control-dependent on the
// selector, but the *meaningful* blast radius — the work — lives in the
// callees), so PDG inter-AIS recall here is ~0 BY DESIGN. The call-graph mode
// is the right tool: its inter-procedural reach finds the three handlers.
export function handleA(payload: number): number {
return payload + 1;
}
export function handleB(payload: number): number {
return payload * 2;
}
export function handleDefault(payload: number): number {
return payload;
}
export function dispatch(kind: string, payload: number): number {
// criterion: a thin router. Changing it affects the callees it routes to.
if (kind === 'a') {
return handleA(payload);
}
if (kind === 'b') {
return handleB(payload);
}
return handleDefault(payload);
}

View file

@ -0,0 +1,33 @@
{
"schemaVersion": 1,
"criterion": {
"name": "processOrder",
"filePath": "src/facade.ts",
"direction": "downstream",
"line": 21,
"marker": "enrich(order)",
"pdgEdgeKinds": ["REACHING_DEF", "CDG"]
},
"locus": "inter",
"provenance": "manual",
"analyzerVersion": "1.6.7",
"intra_AIS": [],
"inter_AIS": [
{
"symbol": "validate",
"filePath": "src/facade.ts",
"note": "the guard delegate; processOrder's behavior depends on it"
},
{
"symbol": "enrich",
"filePath": "src/facade.ts",
"note": "transforms the order before persistence"
},
{
"symbol": "persist",
"filePath": "src/facade.ts",
"note": "the terminal delegate in the sequence"
}
],
"rationale": "criterion.line=21 — the facade's first body statement, the guard `if (!validate(order))` (source semantics: the facade's work begins here). intra_AIS is EMPTY BY DESIGN: `processOrder` sequences three delegates (validate, enrich, persist) with one guard; its true blast radius is the delegation chain — inter_AIS — not its own body, which only routes values between calls. The guard return and the local `enriched` binding carry no independent work beyond shuttling delegate results, so there is no meaningful intra ground truth. Step-0 reconciliation: the statement-anchored PDG slice from line 21 returns the guard's control-dependent body statements, but those are noise relative to the (empty) intra ground truth — PDG is intra-procedural and cannot reach the cross-function delegates. The call-graph mode walks validate->enrich->persist exactly. Anchors the `inter` stratum with a sequential-delegation shape (distinct from the dispatcher's branching shape). The single guard gives `processOrder` a CDG edge so the smoke test's edge-presence requirement is met."
}

View file

@ -0,0 +1,34 @@
{
"schemaVersion": 1,
"provenance": "mutation",
"criterion": {
"name": "processOrder",
"filePath": "src/facade.ts",
"line": 21
},
"paramTypes": ["number"],
"inputs": [[5], [-3], [0]],
"behavioral_AIS": [
"src/facade.ts:12",
"src/facade.ts:16",
"src/facade.ts:22",
"src/facade.ts:24",
"src/facade.ts:25"
],
"mutants": [
{
"op": "LCR",
"text": " if (validate(order)) {",
"equivalent": false,
"diffLines": [
"src/facade.ts:12",
"src/facade.ts:16",
"src/facade.ts:22",
"src/facade.ts:24",
"src/facade.ts:25"
]
}
],
"skipped": null,
"note": "AUTO-GENERATED dynamic forward-slice oracle (U2). VALUE-DIFF behavioral AIS (Infection+Propagation, not coverage). Regenerated by `measure.mjs --mutation`. Independent cross-check of the manual ground-truth.json — NOT a hand annotation."
}

View file

@ -0,0 +1,26 @@
// Facade that delegates to a layered set of helpers. `processOrder`'s true
// impact is cross-function: it sequences `validate`, `enrich`, and `persist`.
// Its own body is a thin sequence of calls with a single guard; the real work
// (and the real blast radius) is in the delegates. PDG intra-AIS is ~empty by
// design; the call-graph mode walks the delegation chain.
export function validate(order: number): boolean {
return order > 0;
}
export function enrich(order: number): number {
return order + 100;
}
export function persist(order: number): number {
return order;
}
export function processOrder(order: number): number {
// criterion: a facade. Its impact flows into the three delegates.
if (!validate(order)) {
return -1;
}
const enriched = enrich(order);
return persist(enriched);
}

View file

@ -0,0 +1,33 @@
{
"schemaVersion": 1,
"criterion": {
"name": "runPipeline",
"filePath": "src/pipeline.ts",
"direction": "downstream",
"line": 20,
"marker": "stageTransform(acc)",
"pdgEdgeKinds": ["REACHING_DEF", "CDG"]
},
"locus": "inter",
"provenance": "manual",
"analyzerVersion": "1.6.7",
"intra_AIS": [],
"inter_AIS": [
{
"symbol": "stageParse",
"filePath": "src/pipeline.ts",
"note": "first pipeline stage runPipeline invokes; its output feeds the next"
},
{
"symbol": "stageTransform",
"filePath": "src/pipeline.ts",
"note": "second stage; depends on stageParse's output threaded through `acc`"
},
{
"symbol": "stageEmit",
"filePath": "src/pipeline.ts",
"note": "terminal stage"
}
],
"rationale": "criterion.line=20 — the driver's first body statement `let acc = seed` (source semantics: the pipeline's threading begins here). Direction is DOWNSTREAM (dependencies): changing `runPipeline` affects the callees it invokes. In GitNexus `impact` vocabulary, downstream = dependencies/callees; the stages are what the driver CALLS, so the correct tag is downstream (an earlier draft mislabeled this `upstream`, conflating the English 'upstream sources' with GitNexus's caller-direction — corrected after the harness surfaced a direction-vs-AIS contradiction). intra_AIS is EMPTY BY DESIGN: the driver threads `acc` through three stage calls (stageParse->stageTransform->stageEmit); the dependencies that matter cross function boundaries — the three stage functions — so inter_AIS holds them. The `acc` reassignments only carry delegate results, no independent computation, so there is no meaningful intra ground truth. Step-0 reconciliation: the statement-anchored PDG slice from line 20 returns the intra `acc` def->use / guard statements, but those are noise relative to the (empty) intra ground truth — PDG is intra-procedural and cannot reach the cross-function stages. The call-graph mode (downstream) reaches the three stages exactly. The `enabled` guard (added so the driver carries a CDG edge) keeps the criterion measurable for the smoke test without changing the cross-function locus. Distinct from the dispatcher (branch-routed) and facade (guarded-sequence) shapes — this is a straight pipeline driver."
}

View file

@ -0,0 +1,47 @@
{
"schemaVersion": 1,
"provenance": "mutation",
"criterion": {
"name": "runPipeline",
"filePath": "src/pipeline.ts",
"line": 20
},
"paramTypes": ["number", "boolean"],
"inputs": [
[5, true],
[5, false],
[-3, true],
[-3, false],
[0, true],
[0, false]
],
"behavioral_AIS": [
"src/pipeline.ts:11",
"src/pipeline.ts:15",
"src/pipeline.ts:22",
"src/pipeline.ts:24",
"src/pipeline.ts:25",
"src/pipeline.ts:26",
"src/pipeline.ts:27",
"src/pipeline.ts:7"
],
"mutants": [
{
"op": "UOI",
"text": " let acc = -(seed);",
"equivalent": false,
"diffLines": [
"src/pipeline.ts:11",
"src/pipeline.ts:15",
"src/pipeline.ts:22",
"src/pipeline.ts:24",
"src/pipeline.ts:25",
"src/pipeline.ts:26",
"src/pipeline.ts:27",
"src/pipeline.ts:7"
]
}
],
"skipped": null,
"note": "AUTO-GENERATED dynamic forward-slice oracle (U2). VALUE-DIFF behavioral AIS (Infection+Propagation, not coverage). Regenerated by `measure.mjs --mutation`. Independent cross-check of the manual ground-truth.json — NOT a hand annotation."
}

View file

@ -0,0 +1,28 @@
// A pipeline driver whose impact is cross-function: `runPipeline` calls each
// stage in turn. The stages themselves carry the work; the driver loops over
// them. Annotated UPSTREAM from the driver: "what does runPipeline depend on?"
// -> the stage functions it invokes. Intra-PDG is ~empty by design.
export function stageParse(n: number): number {
return n + 1;
}
export function stageTransform(n: number): number {
return n * 3;
}
export function stageEmit(n: number): number {
return n - 2;
}
export function runPipeline(seed: number, enabled: boolean): number {
// criterion (upstream): a driver. It depends on the three stage functions.
let acc = seed;
if (!enabled) {
return acc; // a guard so the driver itself carries a CDG edge
}
acc = stageParse(acc);
acc = stageTransform(acc);
acc = stageEmit(acc);
return acc;
}

View file

@ -0,0 +1,42 @@
{
"schemaVersion": 1,
"criterion": {
"name": "classify",
"filePath": "src/branch.ts",
"direction": "downstream",
"line": 7,
"marker": "'zero'",
"pdgEdgeKinds": ["REACHING_DEF", "CDG"]
},
"locus": "intra",
"provenance": "manual",
"analyzerVersion": "1.6.7",
"intra_AIS": [
{
"symbol": "classify",
"filePath": "src/branch.ts",
"line": 9,
"note": "`return 'pos'` is control-dependent on the `x > 0` arm"
},
{
"symbol": "classify",
"filePath": "src/branch.ts",
"line": 10,
"note": "the `else if (x < 0)` predicate is itself control-dependent on the outer `x > 0` branch (it runs only on the false arm)"
},
{
"symbol": "classify",
"filePath": "src/branch.ts",
"line": 11,
"note": "`return 'neg'` is control-dependent on the `x < 0` else-if arm"
},
{
"symbol": "classify",
"filePath": "src/branch.ts",
"line": 13,
"note": "`return 'zero'` is control-dependent on the fallthrough arm"
}
],
"inter_AIS": [],
"rationale": "criterion.line=7 — the if predicate `if (x > 0)`, the branch whose change propagates (source semantics: the predicate controls which arm runs). DOWNSTREAM CDG controller->dependent edges reach the arm returns (lines 9, 11, 13) AND the nested `else if (x < 0)` test (line 10). CORRECTION (Step-0 reconciliation): the source-derived belief was intra_AIS={9,11,13} (the three returns only), but the `else if` predicate on line 10 is ITSELF control-dependent on the outer branch — it executes only on the `x > 0` false arm — so line 10 is a genuine CDG dependent the original annotation omitted. The CFG models it as its own predicate BasicBlock (`(x < 0)`), and the slice from line 7 returns {9, 10, 11, 13} exactly. The rationale's own note already flagged line 10 as nested control flow; this corrects it from out-of-set to in-set. All dependents intra-procedural; inter_AIS empty. Exercises CDG-forward over a multi-arm branch."
}

View file

@ -0,0 +1,28 @@
{
"schemaVersion": 1,
"provenance": "mutation",
"criterion": {
"name": "classify",
"filePath": "src/branch.ts",
"line": 7
},
"paramTypes": ["number"],
"inputs": [[5], [-3], [0]],
"behavioral_AIS": ["src/branch.ts:11", "src/branch.ts:13", "src/branch.ts:9"],
"mutants": [
{
"op": "ROR",
"text": " if (x <= 0) {",
"equivalent": false,
"diffLines": ["src/branch.ts:11", "src/branch.ts:13", "src/branch.ts:9"]
},
{
"op": "CRP",
"text": " if (x > 1) {",
"equivalent": true,
"diffLines": []
}
],
"skipped": null,
"note": "AUTO-GENERATED dynamic forward-slice oracle (U2). VALUE-DIFF behavioral AIS (Infection+Propagation, not coverage). Regenerated by `measure.mjs --mutation`. Independent cross-check of the manual ground-truth.json — NOT a hand annotation."
}

View file

@ -0,0 +1,14 @@
// Pure intra-procedural CONTROL-dependence fixture: an if/else-if/else.
// The branch predicate controls which return statement runs. Changing the
// branch (the criterion) control-affects all three arms via CDG, all within
// the same function.
export function classify(x: number): string {
if (x > 0) {
// criterion: the branch predicate controls the arms below
return 'pos'; // control-dependent on the branch (first arm)
} else if (x < 0) {
return 'neg'; // control-dependent on the branch (second arm)
}
return 'zero'; // control-dependent on the branch (fallthrough arm)
}

View file

@ -0,0 +1,30 @@
{
"schemaVersion": 1,
"criterion": {
"name": "gate",
"filePath": "src/gate.ts",
"direction": "downstream",
"line": 10,
"marker": "!ok",
"pdgEdgeKinds": ["CDG"]
},
"locus": "intra",
"provenance": "manual",
"analyzerVersion": "1.6.7",
"intra_AIS": [
{
"symbol": "gate",
"filePath": "src/gate.ts",
"line": 11,
"note": "`return 0` is control-dependent on the guard (true arm)"
},
{
"symbol": "gate",
"filePath": "src/gate.ts",
"line": 13,
"note": "`return 1` is control-dependent on the guard (executes only when the guard is false)"
}
],
"inter_AIS": [],
"rationale": "SOURCE-ONLY fixture (KTD9 annotation-circularity anchor): the intra_AIS below was written PURELY from language semantics and is deliberately NOT reconciled against the live traversal — there is no `CORRECTION (Step-0 reconciliation)` block. criterion.line=10 is the guard predicate `if (!ok)`; downstream CDG controller->dependent edges reach the true-arm `return 0` (line 11) and the fall-through `return 1` (line 13). Each arm is a single statement on its own block, so unlike the coalescing-prone sibling control fixtures there is no consecutive-statement merge to surprise the annotation: the source-derived set {11, 13} is also the measurable set. If the traversal scores this F1=1.0 it is an INDEPENDENT confirmation (the analyzer matched an un-reconciled, semantics-only annotation), not self-consistency — the one corpus data point that breaks the circularity threat the other fixtures document. All dependents are WITHIN `gate`; inter_AIS empty."
}

View file

@ -0,0 +1,22 @@
{
"schemaVersion": 1,
"provenance": "mutation",
"criterion": {
"name": "gate",
"filePath": "src/gate.ts",
"line": 10
},
"paramTypes": ["boolean"],
"inputs": [[true], [false]],
"behavioral_AIS": ["src/gate.ts:11", "src/gate.ts:13"],
"mutants": [
{
"op": "LCR",
"text": " if (ok) {",
"equivalent": false,
"diffLines": ["src/gate.ts:11", "src/gate.ts:13"]
}
],
"skipped": null,
"note": "AUTO-GENERATED dynamic forward-slice oracle (U2). VALUE-DIFF behavioral AIS (Infection+Propagation, not coverage). Regenerated by `measure.mjs --mutation`. Independent cross-check of the manual ground-truth.json — NOT a hand annotation."
}

View file

@ -0,0 +1,14 @@
// Pure intra-procedural CONTROL-dependence fixture — SOURCE-ONLY (KTD9 anchor).
// Unlike the sibling control fixtures, this fixture's intra_AIS is NOT reconciled
// against the live traversal: each guarded arm is a SINGLE statement on its own
// block, so there is no consecutive-statement coalescing to surprise the
// source-derived annotation. It exists to give the corpus one independent data
// point — if the traversal matches an annotation written purely from language
// semantics, F1=1.0 is a genuine confirmation, not self-consistency.
export function gate(ok: boolean): number {
if (!ok) {
return 0;
}
return 1;
}

View file

@ -0,0 +1,42 @@
{
"schemaVersion": 1,
"criterion": {
"name": "guarded",
"filePath": "src/guard.ts",
"direction": "downstream",
"line": 7,
"marker": "y + 1",
"pdgEdgeKinds": ["REACHING_DEF", "CDG"]
},
"locus": "intra",
"provenance": "manual",
"analyzerVersion": "1.6.7",
"intra_AIS": [
{
"symbol": "guarded",
"filePath": "src/guard.ts",
"line": 9,
"note": "`return -1` is control-dependent on the guard's true arm"
},
{
"symbol": "guarded",
"filePath": "src/guard.ts",
"line": 11,
"note": "the post-guard body block `const y = x * 2; const z = y + 1;` (lines 11-12, coalesced) runs only when the guard is false: control-dependent (block-start representative)"
},
{
"symbol": "guarded",
"filePath": "src/guard.ts",
"line": 12,
"note": "`const z = y + 1` is control-dependent on the guard AND data-dependent on `y` (interior of the coalesced 11-12 body block; recovered statement-granular by FU-B-2 walking the `y@11->z@12` self-edge in the reached body block)"
},
{
"symbol": "guarded",
"filePath": "src/guard.ts",
"line": 13,
"note": "`return z` is control-dependent on the guard"
}
],
"inter_AIS": [],
"rationale": "criterion.line=7 — the guard predicate `if (!ok)` (source semantics: the guard controls whether the post-guard body runs). DOWNSTREAM CDG controller->dependent edges reach the true-arm `return -1` (line 9), the post-guard body, and `return z` (line 13). CORRECTION (FU-B-2 re-reconciliation): the CFG COALESCES the two consecutive post-guard defs `const y = x * 2` and `const z = y + 1` into ONE BasicBlock starting at line 11. BEFORE FU-B-2 the block-granular slice could not pinpoint the interior line 12, so the measurable set was {9, 11, 13}. FU-B-2 makes the slice STATEMENT-granular: the reached body block (11-12) is itself walked over its self REACHING_DEF def->use lines (`y@11->z@12`, decoded from the FU-B-2 `reason` annotation), surfacing line 12. So the measurable control/data-dependent set is now {9, 11, 12, 13} — the slice from line 7 returns exactly that, restoring the ORIGINAL source-derived belief (the block-coalescing under-count is repaired, not a metric re-fit). All dependents WITHIN `guarded`; inter_AIS empty. Canonical guard-clause CDG shape (#559); isolates the CDG-forward arm of KTD4 combined with the FU-B-2 intra-block data-dep recovery on a control-reached body block."
}

View file

@ -0,0 +1,29 @@
{
"schemaVersion": 1,
"provenance": "mutation",
"criterion": {
"name": "guarded",
"filePath": "src/guard.ts",
"line": 7
},
"paramTypes": ["boolean", "number"],
"inputs": [
[true, 5],
[true, -3],
[true, 0],
[false, 5],
[false, -3],
[false, 0]
],
"behavioral_AIS": ["src/guard.ts:11", "src/guard.ts:12", "src/guard.ts:13", "src/guard.ts:9"],
"mutants": [
{
"op": "LCR",
"text": " if (ok) {",
"equivalent": false,
"diffLines": ["src/guard.ts:11", "src/guard.ts:12", "src/guard.ts:13", "src/guard.ts:9"]
}
],
"skipped": null,
"note": "AUTO-GENERATED dynamic forward-slice oracle (U2). VALUE-DIFF behavioral AIS (Infection+Propagation, not coverage). Regenerated by `measure.mjs --mutation`. Independent cross-check of the manual ground-truth.json — NOT a hand annotation."
}

View file

@ -0,0 +1,14 @@
// Pure intra-procedural CONTROL-dependence fixture: the guard clause.
// The early-return guard predicate controls whether the post-guard body runs.
// Changing the guard (the criterion) control-affects the body statements via
// CDG controller->dependent edges, all within the same function.
export function guarded(ok: boolean, x: number): number {
if (!ok) {
// criterion: the guard predicate controls the arms below
return -1; // control-dependent on the guard (true arm)
}
const y = x * 2; // control-dependent on the guard (false arm reaches here)
const z = y + 1; // control-dependent on the guard, data-dependent on `y`
return z; // control-dependent on the guard
}

View file

@ -0,0 +1,42 @@
{
"schemaVersion": 1,
"criterion": {
"name": "filterPositive",
"filePath": "src/loop.ts",
"direction": "upstream",
"line": 11,
"marker": "count + 1",
"pdgEdgeKinds": ["REACHING_DEF", "CDG"]
},
"locus": "intra",
"provenance": "manual",
"analyzerVersion": "1.6.7",
"intra_AIS": [
{
"symbol": "filterPositive",
"filePath": "src/loop.ts",
"line": 6,
"note": "the function-signature/parameter block defines `xs`, which the `for` loop iterates — an upstream reaching dependency of the increment"
},
{
"symbol": "filterPositive",
"filePath": "src/loop.ts",
"line": 7,
"note": "`let count = 0` is the initial def of `count` reaching the increment via REACHING_DEF — an upstream data dependency"
},
{
"symbol": "filterPositive",
"filePath": "src/loop.ts",
"line": 8,
"note": "the enclosing `for` loop guard controls whether the if and its body run (transitive CDG controller)"
},
{
"symbol": "filterPositive",
"filePath": "src/loop.ts",
"line": 10,
"note": "the inner `if (x > 0)` predicate directly controls the `count` increment (immediate CDG controller)"
}
],
"inter_AIS": [],
"rationale": "criterion.line=11 — the `count = count + 1` increment (source semantics: UPSTREAM asks which definitions/predicates govern this statement). CORRECTION (Step-0 reconciliation): the source-derived belief was intra_AIS={8,10} (the two CONTROL predicates only). But the statement-anchored PDG slice is a COMBINED CDG+REACHING_DEF reverse slice, so it correctly also reaches the DATA dependencies of the increment: line 7 (`let count = 0`, the initial reaching def of `count`) and line 6 (the parameter block defining `xs`, which feeds the `for` loop). The original annotation under-counted by considering only control dependence. The full upstream slice is {6, 7, 8, 10}; the slice from line 11 returns exactly that. Both controllers (8, 10) and both data deps (6, 7) are intra-procedural; inter_AIS empty. Exercises the CDG+RD-reverse slice over nested control structure."
}

View file

@ -0,0 +1,28 @@
{
"schemaVersion": 1,
"provenance": "mutation",
"criterion": {
"name": "filterPositive",
"filePath": "src/loop.ts",
"line": 11
},
"paramTypes": ["number[]"],
"inputs": [[[1, 2, 3]], [[-1, -2]], [[]]],
"behavioral_AIS": ["src/loop.ts:14"],
"mutants": [
{
"op": "AOR",
"text": " count = count - 1; // criterion (upstream): controlled by the loop AND the if",
"equivalent": false,
"diffLines": ["src/loop.ts:14"]
},
{
"op": "CRP",
"text": " count = count + 2; // criterion (upstream): controlled by the loop AND the if",
"equivalent": false,
"diffLines": ["src/loop.ts:14"]
}
],
"skipped": null,
"note": "AUTO-GENERATED dynamic forward-slice oracle (U2). VALUE-DIFF behavioral AIS (Infection+Propagation, not coverage). Regenerated by `measure.mjs --mutation`. Independent cross-check of the manual ground-truth.json — NOT a hand annotation."
}

View file

@ -0,0 +1,15 @@
// Pure intra-procedural CONTROL-dependence fixture, annotated UPSTREAM.
// The criterion is the loop body; upstream asks "what controls whether this
// runs?" The answer is the enclosing loop guard. Exercises the CDG-reverse arm
// of KTD4 over a loop's control structure, all within one function.
export function filterPositive(xs: number[]): number {
let count = 0;
for (const x of xs) {
// the loop guard controls the body below
if (x > 0) {
count = count + 1; // criterion (upstream): controlled by the loop AND the if
}
}
return count;
}

View file

@ -0,0 +1,30 @@
{
"schemaVersion": 1,
"criterion": {
"name": "total",
"filePath": "src/accumulator.ts",
"direction": "downstream",
"line": 8,
"marker": "sum + x",
"pdgEdgeKinds": ["REACHING_DEF", "CDG"]
},
"locus": "intra",
"provenance": "manual",
"analyzerVersion": "1.6.7",
"intra_AIS": [
{
"symbol": "total",
"filePath": "src/accumulator.ts",
"line": 10,
"note": "for-loop body re-defines/uses `sum`: data-dependent on the initial def"
},
{
"symbol": "total",
"filePath": "src/accumulator.ts",
"line": 12,
"note": "return reads the accumulated `sum`: data-dependent on every prior def"
}
],
"inter_AIS": [],
"rationale": "criterion.line=8 — the `sum` definition `let sum = 0`, the statement whose change propagates (chosen from source semantics: `sum` is the loop-carried accumulator). DOWNSTREAM from line 8, the REACHING_DEF def->use edges reach the in-loop redefinition/use (line 10) and the final return's use (line 12). These two lines are the only truly-affected statements and they are all WITHIN `total` — so intra_AIS is exactly {line 10, line 12}; inter_AIS is empty (the function calls nothing). Step-0 reconciliation: the statement-anchored PDG slice from line 8 returns exactly {10, 12} — an EXACT match to this source-derived annotation (no correction needed). The call-graph mode only knows `total` exists and has no inbound edges, so it reports the empty cross-function set; PDG resolves the precise dependent statements the call-graph cannot express below function granularity."
}

View file

@ -0,0 +1,22 @@
{
"schemaVersion": 1,
"provenance": "mutation",
"criterion": {
"name": "total",
"filePath": "src/accumulator.ts",
"line": 8
},
"paramTypes": ["number[]"],
"inputs": [[[1, 2, 3]], [[-1, -2]], [[]]],
"behavioral_AIS": ["src/accumulator.ts:10", "src/accumulator.ts:12"],
"mutants": [
{
"op": "CRP",
"text": " let sum = 1; // criterion: the def of `sum`",
"equivalent": false,
"diffLines": ["src/accumulator.ts:10", "src/accumulator.ts:12"]
}
],
"skipped": null,
"note": "AUTO-GENERATED dynamic forward-slice oracle (U2). VALUE-DIFF behavioral AIS (Infection+Propagation, not coverage). Regenerated by `measure.mjs --mutation`. Independent cross-check of the manual ground-truth.json — NOT a hand annotation."
}

View file

@ -0,0 +1,13 @@
// Pure intra-procedural data-flow fixture.
// `sum` is a loop-carried accumulator: its definition flows forward to the
// next iteration's use and to the final `return`. Changing the `sum` def
// (the criterion) affects only statements within this same function via
// REACHING_DEF def->use edges. Nothing crosses a function boundary.
export function total(xs: number[]): number {
let sum = 0; // criterion: the def of `sum`
for (const x of xs) {
sum = sum + x; // use of `sum` (and a redefinition) — data-dependent on the def
}
return sum; // use of `sum` — data-dependent
}

View file

@ -0,0 +1,36 @@
{
"schemaVersion": 1,
"criterion": {
"name": "chainCompute",
"filePath": "src/chain.ts",
"direction": "downstream",
"line": 7,
"marker": "b - 3",
"pdgEdgeKinds": ["REACHING_DEF"]
},
"locus": "intra",
"provenance": "manual",
"analyzerVersion": "1.6.7",
"intra_AIS": [
{
"symbol": "chainCompute",
"filePath": "src/chain.ts",
"line": 8,
"note": "`const b = a * 2` is data-dependent on `a` (interior of the coalesced 7-9 block; recovered statement-granular by FU-B-2)"
},
{
"symbol": "chainCompute",
"filePath": "src/chain.ts",
"line": 9,
"note": "`const c = b - 3` is data-dependent on `b` (transitively on `a`; interior of the coalesced 7-9 block; recovered statement-granular by FU-B-2)"
},
{
"symbol": "chainCompute",
"filePath": "src/chain.ts",
"line": 10,
"note": "`return c` is transitively data-dependent on `a` via the chain — a distinct downstream BasicBlock"
}
],
"inter_AIS": [],
"rationale": "criterion.line=7 — the def of `a` (`const a = input + 1`), the changed statement (source semantics: `a` seeds the straight-line def->use chain a->b->c->return). DOWNSTREAM from line 7. CORRECTION (FU-B-2 re-reconciliation): the CFG COALESCES consecutive straight-line statements into ONE BasicBlock: lines 7-9 (`const a`, `const b`, `const c`) share a single block whose start line is 7 — the criterion's own seed block. BEFORE FU-B-2 the block-granular slice could not pinpoint the interior statements 8/9 (they lived inside the seed block, which the traversal excludes), so the measurable intra_AIS was {10} only. FU-B-2 makes the intra slice STATEMENT-granular: the persisted REACHING_DEF edge now carries its def/use SOURCE LINES (codec annotation on `reason`), and the projection walks the self-edge def->use chain (a@7->b@8->c@9) forward from the criterion line to recover the interior dependents. So the measurable intra_AIS is now {8, 9, 10} — the slice returns exactly {8,9,10}, an EXACT match to the corrected annotation. This is an independently-justified ground-truth correction, NOT a metric re-fit: the U2 dynamic value-diff oracle PROVED behavioral_AIS = {8,9,10} (mutation-ground-truth.json) and flagged lines 8/9 as a manual-annotation circularity miss BEFORE this edit — the dynamic oracle and the static slice agree. Isolates the RD-forward arm of the KTD4 truth table; inter_AIS empty (no calls)."
}

View file

@ -0,0 +1,28 @@
{
"schemaVersion": 1,
"provenance": "mutation",
"criterion": {
"name": "chainCompute",
"filePath": "src/chain.ts",
"line": 7
},
"paramTypes": ["number"],
"inputs": [[5], [-3], [0]],
"behavioral_AIS": ["src/chain.ts:10", "src/chain.ts:8", "src/chain.ts:9"],
"mutants": [
{
"op": "AOR",
"text": " const a = input - 1; // criterion: the def of `a`",
"equivalent": false,
"diffLines": ["src/chain.ts:10", "src/chain.ts:8", "src/chain.ts:9"]
},
{
"op": "CRP",
"text": " const a = input + 2; // criterion: the def of `a`",
"equivalent": false,
"diffLines": ["src/chain.ts:10", "src/chain.ts:8", "src/chain.ts:9"]
}
],
"skipped": null,
"note": "AUTO-GENERATED dynamic forward-slice oracle (U2). VALUE-DIFF behavioral AIS (Infection+Propagation, not coverage). Regenerated by `measure.mjs --mutation`. Independent cross-check of the manual ground-truth.json — NOT a hand annotation."
}

View file

@ -0,0 +1,11 @@
// Pure intra-procedural data-flow fixture: a straight-line def->use chain.
// Changing the first definition `a` flows through `b` and `c` to the return,
// all within one function. There are no branches and no calls — the purest
// REACHING_DEF chain.
export function chainCompute(input: number): number {
const a = input + 1; // criterion: the def of `a`
const b = a * 2; // data-dependent on `a`
const c = b - 3; // data-dependent on `b` (transitively on `a`)
return c; // data-dependent on `c`
}

View file

@ -0,0 +1,36 @@
{
"schemaVersion": 1,
"criterion": {
"name": "reassignSum",
"filePath": "src/reassign.ts",
"direction": "upstream",
"line": 9,
"marker": "total + b",
"pdgEdgeKinds": ["REACHING_DEF"]
},
"locus": "intra",
"provenance": "manual",
"analyzerVersion": "1.6.7",
"intra_AIS": [
{
"symbol": "reassignSum",
"filePath": "src/reassign.ts",
"line": 6,
"note": "the function-signature/parameter block defines `a`, whose value flows into `total = a`; it is a genuine reaching def upstream of the criterion use"
},
{
"symbol": "reassignSum",
"filePath": "src/reassign.ts",
"line": 7,
"note": "the coalesced def block `let total = a; total = total + b;` (lines 7-8) — block-start representative; `let total = a` is a reaching def of the criterion use `return total`"
},
{
"symbol": "reassignSum",
"filePath": "src/reassign.ts",
"line": 8,
"note": "`total = total + b` is the immediately-reaching def of the criterion use (interior of the coalesced 7-8 def block; recovered statement-granular by FU-B-2 walking the `total@7->total@8` self-edge in the reached def block)"
}
],
"inter_AIS": [],
"rationale": "criterion.line=9 — the `return total` use (source semantics: UPSTREAM asks which definitions reach this use of `total`). CORRECTION (FU-B-2 re-reconciliation): the CFG COALESCES the two consecutive defs `let total = a` and `total = total + b` into ONE BasicBlock starting at line 7, and REACHING_DEF also reaches the parameter block (line 6, defining `a`). BEFORE FU-B-2 the block-granular slice could not pinpoint the interior line 8, so the measurable set was {6, 7}. FU-B-2 makes the slice STATEMENT-granular: the reached def block (7-8) is walked over its self REACHING_DEF def->use lines (`total@7->total@8`, decoded from the FU-B-2 `reason` annotation), surfacing line 8. So the measurable upstream slice is now {6 (param def of `a`), 7 (coalesced block start), 8 (the interior reassign)} — the slice from line 9 returns exactly that, restoring the ORIGINAL source-derived belief {7,8} plus the param def 6 (the block-coalescing under-count is repaired, not a metric re-fit). The intra-block forward def->use walk is direction-agnostic by design; here line 8 IS upstream-relevant (it is the immediately-reaching def of the criterion use). All intra-procedural; inter_AIS empty. Isolates the RD-reverse arm of the KTD4 truth table over a reassigned variable, with FU-B-2 interior recovery on the reached coalesced def block."
}

View file

@ -0,0 +1,29 @@
{
"schemaVersion": 1,
"provenance": "mutation",
"criterion": {
"name": "reassignSum",
"filePath": "src/reassign.ts",
"line": 9
},
"paramTypes": ["number", "number"],
"inputs": [
[5, 5],
[5, -3],
[5, 0],
[-3, 5],
[-3, -3],
[-3, 0]
],
"behavioral_AIS": [],
"mutants": [
{
"op": "UOI",
"text": " return -(total); // criterion: use of `total`",
"equivalent": true,
"diffLines": []
}
],
"skipped": null,
"note": "AUTO-GENERATED dynamic forward-slice oracle (U2). VALUE-DIFF behavioral AIS (Infection+Propagation, not coverage). Regenerated by `measure.mjs --mutation`. Independent cross-check of the manual ground-truth.json — NOT a hand annotation."
}

View file

@ -0,0 +1,10 @@
// Pure intra-procedural data-flow fixture, annotated UPSTREAM.
// The criterion is the final `return total` (a use of `total`); upstream asks
// "what does this use depend on?" The answer is every reaching definition of
// `total` within the function. Exercises the reverse RD arm of KTD4.
export function reassignSum(a: number, b: number): number {
let total = a; // first def of `total` — reaches the use below
total = total + b; // second def of `total` (uses prior) — reaches the use below
return total; // criterion: use of `total`
}

View file

@ -0,0 +1,24 @@
{
"schemaVersion": 1,
"criterion": {
"name": "route",
"filePath": "src/route.ts",
"direction": "downstream",
"line": 32,
"marker": "alpha.process(x)",
"pdgEdgeKinds": ["REACHING_DEF", "CDG"]
},
"locus": "inter",
"pdgScoring": "exclude",
"provenance": "manual",
"analyzerVersion": "1.6.7",
"intra_AIS": [],
"inter_AIS": [],
"idBridge": {
"seedLine": 32,
"idProven": ["Method:src/route.ts:Alpha.process#1"],
"nameWouldProve": ["Method:src/route.ts:Alpha.process#1", "Method:src/route.ts:Beta.process#1"],
"fpEliminated": ["Method:src/route.ts:Beta.process#1"]
},
"rationale": "RESOLVED-SYMBOL-ID SOUNDNESS gate (plan 2026-06-18-001 U9; Covers R1, R5). Two methods share the LEAF name `process` but resolve to DISTINCT ids — `Method:src/route.ts:Alpha.process#1` and `Method:src/route.ts:Beta.process#1`. `route` invokes BOTH (one in each `if` arm), so the symbol-graph BFS reaches both as depth-1 direct callees. criterion.line=32 is `out = alpha.process(x)` — its block calls ONLY `alpha.process`, so the dependence slice's `BasicBlock.calleeIds` carries `Alpha.process#1` alone (verified: `calleeIds = 'Method:src/route.ts:Alpha.process#1'`). The RESOLVED-ID bridge therefore proves EXACTLY `Alpha.process#1` (`pdgInterprocedural.statementPreciseByDepth[1]` = that single id; `Beta.process#1` is reached but `unproven-bridge`). The leaf-NAME bridge over the SAME reached items + the SAME slice would prove BOTH (the slice `callees` = {process}; both callees share that leaf), over-attributing `Beta.process#1` — the collision false-positive (`fpEliminated`). `idBridge` records the gate: id-proven == the single correct id; name-match proves 2. `pdgScoring:\"exclude\"` keeps this fixture OUT of the intra/inter strata F1 bands (its locus is genuinely cross-function and its intra_AIS/inter_AIS are not the measured quantity — the id-vs-name soundness diff is); the dedicated id-bridge axis in measure.mjs scores it and `--check` gates the statement-precise id set against `idBridge.idProven`. inter_AIS is intentionally empty: this fixture exists to gate the id-vs-name discrimination, not the cross-function recall the sibling inter fixtures already cover. The ids embed the repo-relative `src/route.ts` path (stable: the harness analyzes a temp copy whose repo-relative paths match)."
}

View file

@ -0,0 +1,29 @@
{
"schemaVersion": 1,
"provenance": "mutation",
"criterion": {
"name": "route",
"filePath": "src/route.ts",
"line": 32
},
"paramTypes": ["number", "boolean"],
"inputs": [
[5, true],
[5, false],
[-3, true],
[-3, false],
[0, true],
[0, false]
],
"behavioral_AIS": ["src/route.ts:38"],
"mutants": [
{
"op": "UOI",
"text": " out = -(alpha.process(x));",
"equivalent": false,
"diffLines": ["src/route.ts:38"]
}
],
"skipped": null,
"note": "AUTO-GENERATED dynamic forward-slice oracle (U2). VALUE-DIFF behavioral AIS (Infection+Propagation, not coverage). Regenerated by `measure.mjs --mutation`. Independent cross-check of the manual ground-truth.json — NOT a hand annotation."
}

View file

@ -0,0 +1,39 @@
// Resolved-symbol-id SOUNDNESS fixture (U9 — plan 2026-06-18-001).
//
// Two callees share the LEAF name `process` but resolve to DISTINCT symbol ids
// (`Alpha.process` vs `Beta.process`). `route` invokes BOTH — so the symbol-graph
// BFS reaches both as direct callees — but each call lives in its OWN control
// block (separate `if` arms), so the SEED line's dependence slice contains only
// the block that calls `alpha.process`. The id bridge proves exactly
// `Alpha.process` for that seed line; the leaf-NAME bridge would prove BOTH
// (`process` is in the slice block's `callees`), over-attributing `Beta.process`.
// This fixture gates that the resolved-id bridge eliminates the same-leaf-name
// collision false-positive.
export class Alpha {
process(value: number): number {
return value + 1;
}
}
export class Beta {
process(value: number): number {
return value * 2;
}
}
export function route(x: number, useAlpha: boolean): number {
const alpha = new Alpha();
const beta = new Beta();
let out = 0;
if (useAlpha) {
// SEED line: this block calls ONLY alpha.process — its slice carries the
// resolved id `Alpha.process`, NOT `Beta.process`.
out = alpha.process(x);
} else {
// Independent block: beta.process is reached by the BFS (shared `process`
// leaf name) but is NOT on the alpha-seed line's dependence slice.
out = beta.process(x);
}
return out;
}

View file

@ -0,0 +1,42 @@
{
"schemaVersion": 1,
"criterion": {
"name": "computeAndEmit",
"filePath": "src/mixed.ts",
"direction": "downstream",
"line": 12,
"marker": "score + v",
"pdgEdgeKinds": ["REACHING_DEF", "CDG"]
},
"locus": "mixed",
"provenance": "manual",
"analyzerVersion": "1.6.7",
"intra_AIS": [
{
"symbol": "computeAndEmit",
"filePath": "src/mixed.ts",
"line": 14,
"note": "loop body `score = score + v` re-defines/uses `score`: data-dependent"
},
{
"symbol": "computeAndEmit",
"filePath": "src/mixed.ts",
"line": 16,
"note": "`level = score > 10 ? ...` is data-dependent on the accumulated `score`"
},
{
"symbol": "computeAndEmit",
"filePath": "src/mixed.ts",
"line": 17,
"note": "the `emit(level)` arg is data-dependent on `level`/`score`"
}
],
"inter_AIS": [
{
"symbol": "emit",
"filePath": "src/mixed.ts",
"note": "called with the computed level; cross-function reach"
}
],
"rationale": "criterion.line=12 — the `score` def `let score = 0` (source semantics: `score` seeds the loop-carried data chain). Criterion `computeAndEmit` is mixed-locus. DOWNSTREAM from line 12, the `score` def drives the loop-carried data chain (line 14), feeds the threshold `level` (line 16), which feeds the `emit` call arg (line 17): all intra-procedural data dependence, so intra_AIS holds those three statements. Separately, the call to `emit` is a cross-function reach captured in inter_AIS. The two sets are disjoint (lines within `computeAndEmit` vs the distinct symbol `emit`). Step-0 reconciliation: the statement-anchored PDG slice from line 12 returns {14, 16, 17} — an EXACT match to this source-derived intra_AIS (no correction needed); call-graph reaches {emit} exactly. Distinct from mixed-validate-then-call: this case's intra dependence is data-flow-dominated (accumulator + ternary) rather than guard-dominated, broadening mixed-stratum coverage."
}

View file

@ -0,0 +1,22 @@
{
"schemaVersion": 1,
"provenance": "mutation",
"criterion": {
"name": "computeAndEmit",
"filePath": "src/mixed.ts",
"line": 12
},
"paramTypes": ["number[]"],
"inputs": [[[1, 2, 3]], [[-1, -2]], [[]]],
"behavioral_AIS": ["src/mixed.ts:14"],
"mutants": [
{
"op": "CRP",
"text": " let score = 1; // def; loop-carried accumulation below",
"equivalent": false,
"diffLines": ["src/mixed.ts:14"]
}
],
"skipped": null,
"note": "AUTO-GENERATED dynamic forward-slice oracle (U2). VALUE-DIFF behavioral AIS (Infection+Propagation, not coverage). Regenerated by `measure.mjs --mutation`. Independent cross-check of the manual ground-truth.json — NOT a hand annotation."
}

View file

@ -0,0 +1,18 @@
// Mixed-locus fixture: `computeAndEmit` has a real intra-procedural data-flow
// computation (a running `score` accumulated in a loop, then thresholded) AND a
// cross-function reach (it calls `emit`). The criterion's blast radius spans
// both loci.
export function emit(level: string): string {
return '[' + level + ']';
}
export function computeAndEmit(values: number[]): string {
// criterion: mixed. Intra: score accumulation + threshold; inter: emit().
let score = 0; // def; loop-carried accumulation below
for (const v of values) {
score = score + v; // data-dependent on prior `score`
}
const level = score > 10 ? 'high' : 'low'; // data-dependent on `score`
return emit(level); // cross-function reach; arg data-dependent on `level`
}

View file

@ -0,0 +1,47 @@
{
"schemaVersion": 1,
"criterion": {
"name": "route",
"filePath": "src/mixed.ts",
"direction": "downstream",
"line": 15,
"marker": "key === 0",
"pdgEdgeKinds": ["REACHING_DEF", "CDG"]
},
"locus": "mixed",
"provenance": "manual",
"analyzerVersion": "1.6.7",
"intra_AIS": [
{
"symbol": "route",
"filePath": "src/mixed.ts",
"line": 16,
"note": "the guard `if (urgent || key === 0)` is data-dependent on `key` and controls the arms"
},
{
"symbol": "route",
"filePath": "src/mixed.ts",
"line": 18,
"note": "`return fast(n)` is control-dependent on the guard's true arm"
},
{
"symbol": "route",
"filePath": "src/mixed.ts",
"line": 20,
"note": "`return slow(n)` is control-dependent on the complementary arm"
}
],
"inter_AIS": [
{
"symbol": "fast",
"filePath": "src/mixed.ts",
"note": "callee on the urgent/even route; cross-function reach"
},
{
"symbol": "slow",
"filePath": "src/mixed.ts",
"note": "callee on the fallthrough route; cross-function reach"
}
],
"rationale": "criterion.line=15 — the `key` def `const key = n % 2` (source semantics: `key` feeds the guard predicate below). Criterion `route` is mixed-locus with BOTH a guard whose predicate reads the intra def `key` (line 15 -> line 16) and control-gates both return arms (lines 18, 20), AND two cross-function reaches (fast, slow). DOWNSTREAM from line 15: intra_AIS holds the guard (16) + the two control-dependent return statements (18, 20); inter_AIS holds the two callees. Disjoint by construction (lines within `route` vs distinct symbols). Step-0 reconciliation: the statement-anchored PDG slice from line 15 returns {16, 18, 20} — an EXACT match to this source-derived intra_AIS; call-graph reaches {fast, slow} exactly. This mixed case combines control AND data dependence intra-procedurally with branching cross-function reach — the richest mixed shape, complementing the data-dominated and guard-dominated mixed cases."
}

View file

@ -0,0 +1,35 @@
{
"schemaVersion": 1,
"provenance": "mutation",
"criterion": {
"name": "route",
"filePath": "src/mixed.ts",
"line": 15
},
"paramTypes": ["number", "boolean"],
"inputs": [
[5, true],
[5, false],
[-3, true],
[-3, false],
[0, true],
[0, false]
],
"behavioral_AIS": ["src/mixed.ts:10", "src/mixed.ts:18", "src/mixed.ts:20", "src/mixed.ts:6"],
"mutants": [
{
"op": "AOR",
"text": " const key = n * 2; // def; used in the guard below",
"equivalent": true,
"diffLines": []
},
{
"op": "CRP",
"text": " const key = n % 3; // def; used in the guard below",
"equivalent": false,
"diffLines": ["src/mixed.ts:10", "src/mixed.ts:18", "src/mixed.ts:20", "src/mixed.ts:6"]
}
],
"skipped": null,
"note": "AUTO-GENERATED dynamic forward-slice oracle (U2). VALUE-DIFF behavioral AIS (Infection+Propagation, not coverage). Regenerated by `measure.mjs --mutation`. Independent cross-check of the manual ground-truth.json — NOT a hand annotation."
}

View file

@ -0,0 +1,21 @@
// Mixed-locus fixture: `route` has an intra-procedural guard chain that both
// computes a `key` (data flow) and control-gates which callee runs (control
// flow), AND reaches two callees cross-function. Both loci are non-trivial.
export function fast(n: number): number {
return n;
}
export function slow(n: number): number {
return n + n;
}
export function route(n: number, urgent: boolean): number {
// criterion: mixed. Intra: key + guard; inter: fast()/slow().
const key = n % 2; // def; used in the guard below
if (urgent || key === 0) {
// control + data dependent on `key`
return fast(n); // control-dependent on the guard; cross-function reach
}
return slow(n); // control-dependent on the complementary arm; cross-function reach
}

View file

@ -0,0 +1,48 @@
{
"schemaVersion": 1,
"criterion": {
"name": "handleRequest",
"filePath": "src/mixed.ts",
"direction": "downstream",
"line": 13,
"marker": "normalized + 1",
"pdgEdgeKinds": ["REACHING_DEF", "CDG"]
},
"locus": "mixed",
"provenance": "manual",
"analyzerVersion": "1.6.7",
"intra_AIS": [
{
"symbol": "handleRequest",
"filePath": "src/mixed.ts",
"line": 14,
"note": "the guard `if (normalized < 0)` is data-dependent on `normalized` and controls the arms"
},
{
"symbol": "handleRequest",
"filePath": "src/mixed.ts",
"line": 16,
"note": "`return -1` is control-dependent on the guard"
},
{
"symbol": "handleRequest",
"filePath": "src/mixed.ts",
"line": 18,
"note": "`const adjusted = normalized + 1` is data-dependent on `normalized`"
},
{
"symbol": "handleRequest",
"filePath": "src/mixed.ts",
"line": 19,
"note": "the `persist(adjusted)` arg is data-dependent on `adjusted`/`normalized`; the return is control-dependent on the guard"
}
],
"inter_AIS": [
{
"symbol": "persist",
"filePath": "src/mixed.ts",
"note": "called with the adjusted value; cross-function reach"
}
],
"rationale": "criterion.line=13 — the `normalized` def `const normalized = raw * 2` (source semantics: `normalized` seeds both the data chain and the guard). Criterion `handleRequest` is mixed-locus. DOWNSTREAM from line 13, the `normalized` def drives BOTH a data chain (guard test line 14, `adjusted` line 18, the call arg line 19) and a control structure (the guard controls lines 16 and 19). So intra_AIS holds the four control/data-dependent statements within the function. Separately, `handleRequest` calls `persist` — a genuine cross-function reach — so inter_AIS holds `persist`. Step-0 reconciliation: the statement-anchored PDG slice from line 13 returns {14, 16, 18, 19} — an EXACT match to this source-derived intra_AIS; call-graph reaches {persist} exactly. This is the case where the two modes are complementary: PDG resolves the intra statement set precisely, call-graph reaches the callee. intra_AIS and inter_AIS are disjoint by construction (one is lines within `handleRequest`, the other is a different symbol `persist`)."
}

View file

@ -0,0 +1,28 @@
{
"schemaVersion": 1,
"provenance": "mutation",
"criterion": {
"name": "handleRequest",
"filePath": "src/mixed.ts",
"line": 13
},
"paramTypes": ["number"],
"inputs": [[5], [-3], [0]],
"behavioral_AIS": ["src/mixed.ts:18", "src/mixed.ts:19", "src/mixed.ts:8"],
"mutants": [
{
"op": "AOR",
"text": " const normalized = raw / 2; // def; data-dependent uses below",
"equivalent": false,
"diffLines": ["src/mixed.ts:18", "src/mixed.ts:19", "src/mixed.ts:8"]
},
{
"op": "CRP",
"text": " const normalized = raw * 3; // def; data-dependent uses below",
"equivalent": false,
"diffLines": ["src/mixed.ts:18", "src/mixed.ts:19", "src/mixed.ts:8"]
}
],
"skipped": null,
"note": "AUTO-GENERATED dynamic forward-slice oracle (U2). VALUE-DIFF behavioral AIS (Infection+Propagation, not coverage). Regenerated by `measure.mjs --mutation`. Independent cross-check of the manual ground-truth.json — NOT a hand annotation."
}

View file

@ -0,0 +1,20 @@
// Mixed-locus fixture: `handleRequest` has BOTH genuine intra-procedural
// dependence (a normalized value computed and reused across statements, guarded
// by a validity check) AND a cross-function reach (it calls `persist`).
// Changing it affects intra statements (the normalize->guard->use chain) AND
// the callee.
export function persist(value: number): number {
return value;
}
export function handleRequest(raw: number): number {
// criterion: mixed. Intra: normalized flows through the guard to the call.
const normalized = raw * 2; // def; data-dependent uses below
if (normalized < 0) {
// control-dependent guard
return -1; // control-dependent on the guard
}
const adjusted = normalized + 1; // data-dependent on `normalized`
return persist(adjusted); // cross-function reach; arg data-dependent on `adjusted`
}

View file

@ -0,0 +1,15 @@
{
"schemaVersion": 1,
"criterion": {
"name": "Shape",
"filePath": "src/nobody.ts",
"direction": "downstream"
},
"locus": "n/a",
"pdgScoring": "exclude",
"provenance": "manual",
"analyzerVersion": "1.6.7",
"intra_AIS": [],
"inter_AIS": [],
"rationale": "This is the KTD6 no-body case. `Shape` (an interface), `ShapeName` (a type alias), and `AbstractShape.perimeter` (an abstract method) have no CFG body, so they emit ZERO PDG (CDG/REACHING_DEF) edges. Their `intra_AIS` is genuinely undefined under PDG, not empty-because-safe — a confident `impactedCount:0/LOW` would be the exact false-safe the impact tool exists to prevent (#2129/#1858 lineage). The case is tagged `pdgScoring: \"exclude\"` and `locus: \"n/a\"` so the U7 harness drops it from PDG precision/recall denominators (and logs the exclusion, never scoring it 0/0). It exists to assert the exclusion path is honored — it is the only fixture the smoke test exempts from the 'criterion produces CDG+REACHING_DEF edges' requirement. intra_AIS and inter_AIS are both empty by definition (no body)."
}

View file

@ -0,0 +1,16 @@
// No-body fixture (the KTD6 case). An interface, a type alias, and an abstract
// method have NO CFG body, so they produce ZERO PDG edges. PDG mode cannot
// score these — a bare `impactedCount:0` would read as a confident "safe",
// the exact false-safe impact exists to prevent. The harness EXCLUDES this
// case from PDG scoring (pdgScoring: "exclude"); it exists to assert the
// exclusion path, not to be measured.
export interface Shape {
area(): number; // criterion: an interface method declaration — no body, no CFG
}
export type ShapeName = 'circle' | 'square';
export abstract class AbstractShape {
abstract perimeter(): number; // abstract method — no body
}

View file

@ -0,0 +1,45 @@
// CI gate for the nightly impact-PDG mutation oracle (#2227 tri-review, U11).
//
// The oracle (`measure.mjs --mutation --json`) uploads a machine report as a
// nightly artifact, but nothing read it back — a realized-recall regression
// would silently sit in an artifact nobody opens. This gate:
// 1. always writes a recall summary to the GitHub job summary (visible on the
// run without downloading the artifact), and
// 2. fails the job when the MINIMUM realized recall across scored mutation
// cases drops below MUTATION_RECALL_FLOOR (tunable env, conservative
// default) — so a mutant the slicer stops catching surfaces as a red run.
//
// Usage: node bench/impact-pdg/gate-mutation-recall.mjs [report.json]
import fs from 'node:fs';
const reportPath = process.argv[2] ?? 'mutation-report.json';
const floor = Number(process.env.MUTATION_RECALL_FLOOR ?? '0.5');
const report = JSON.parse(fs.readFileSync(reportPath, 'utf8'));
const checks = Array.isArray(report?.mutation?.checks) ? report.mutation.checks : [];
const scored = checks.filter((c) => typeof c.recall === 'number');
const recalls = scored.map((c) => c.recall);
const min = recalls.length ? Math.min(...recalls) : null;
const mean = recalls.length ? recalls.reduce((a, b) => a + b, 0) / recalls.length : null;
const below = scored.filter((c) => c.recall < floor);
const fmt = (x) => (x === null ? 'n/a' : x.toFixed(3));
const summary = [
'## impact-PDG mutation oracle',
'',
`- scored cases: ${scored.length} of ${checks.length}`,
`- min realized recall: ${fmt(min)} (floor ${floor})`,
`- mean realized recall: ${fmt(mean)}`,
`- cases below floor: ${below.length}${below.length ? ' — ' + below.map((c) => c.name).join(', ') : ''}`,
'',
].join('\n');
if (process.env.GITHUB_STEP_SUMMARY) {
fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, summary + '\n');
}
process.stdout.write(summary + '\n');
if (min !== null && min < floor) {
console.error(`Mutation recall regression: min realized recall ${fmt(min)} < floor ${floor}`);
process.exit(1);
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,498 @@
/**
* Pure scorer + annotation canonicalizer for the impact-PDG accuracy harness
* (U7). NO substrate here — no `runPipelineFromRepo`, no `LocalBackend`, no DB,
* no child-process `analyze`. Everything in this module is a pure function over
* plain symbol-set inputs, so the metric-math unit test
* (`test/unit/impact-pdg-metric-math.test.ts`) can import and assert the
* arithmetic deterministically, staying OUT of the flaky full-pipeline lane
* (Arch-review Issue 5). `measure.mjs` imports these for the live loop.
*
* ── CIS / AIS framing (KTD9 — Arnold–Bohner) ───────────────────────────────
* CIS = Computed Impact Set: what a mode REPORTS as impacted.
* AIS = Actual Impact Set: the curated ground-truth set truly affected.
* precision = |AIS∩CIS| / |CIS| (over-approximation cost; ∅ CIS ⇒ undefined)
* recall = |AIS∩CIS| / |AIS| (under-approximation; ∅ AIS ⇒ undefined)
* F1 = harmonic mean (undefined if either is undefined)
* FPIS = CIS − AIS (false positives — noise)
* FNIS = AIS − CIS (false negatives — the DANGEROUS miss for a safety tool)
*
* ── Granularity: the two engines measure DIFFERENT scopes (U7 rework) ───────
* The two `impact` engines answer different questions at different granularities,
* so they are scored against different ground truths:
*
* - **PDG mode** (`impact({mode:'pdg', line:N})`) is scored at intra-procedural
* STATEMENT granularity. The statement-anchored slice returns
* `affectedStatements: {line,filePath,text}[]` — the dependent statements of
* the criterion line N. CIS_pdg is the set of those LINE keys
* (`<filePath>:<line>`, via `pdgLineCis`); AIS is the `intra_AIS` line set
* (via `intraLineAis`). This is the unit at which PDG is precise.
* - **Call-graph mode** is scored at inter-procedural SYMBOL granularity. CIS is
* the reported symbols (`<symbol>@<filePath>`, via `symbolKey`); AIS is the
* `inter_AIS` symbol set (via `aisByScope`). This is the unit at which the
* call-graph blast radius is meaningful.
*
* Neither native row is a strict refinement of the other: the PDG native
* metric resolves dependent statements WITHIN a function, while call-graph
* resolves cross-function symbol reach. The unified axes report checks whether
* mode:'pdg' carries both outputs without blending their granularities.
*
* `partitionCisByScope`/`aisByScope` (symbol-level) remain for the call-graph
* path; `pdgLineCis`/`intraLineAis` (line-level) drive the PDG path.
*/
/** Order-independent symbol key. Collapses statement lines onto their symbol. */
export function symbolKey(symbol, filePath) {
return `${symbol}@${filePath}`;
}
/**
* Statement-LINE key for the PDG path (U7 rework). PDG mode is now scored at
* intra-procedural STATEMENT granularity: the `impact({mode:'pdg', line})` slice
* returns `affectedStatements: {line, filePath, text}[]`, and the intra ground
* truth is the per-line `intra_AIS`. A line key is `<filePath>:<line>` —
* order-independent and statement-granular (NOT collapsed onto the owning
* symbol the way `symbolKey` is). This is the unit at which PDG is precise.
*/
export function lineKey(filePath, line) {
return `${filePath}:${line}`;
}
/**
* Unified-impact key spaces keep the two granularities explicit. A tagged key
* is never compared across axes: `statement:src/a.ts:10` and
* `symbol:handler@src/a.ts` are different measurement units by design.
*/
export function unifiedLineKey(filePath, line) {
return `statement:${lineKey(filePath, line)}`;
}
export function unifiedSymbolKey(symbol, filePath) {
return `symbol:${symbolKey(symbol, filePath)}`;
}
/**
* CIS_pdg = the set of affected-statement LINE keys from an impact pdg result.
*
* `scope` (FU-A) ∈ `undefined | 'intra' | 'inter'`:
* - `undefined` → the all-union over the full slice (back-compat; the
* metric-math unit test and the U2 mutation-oracle rely on this form).
* - `'intra'` / `'inter'` → keep only statements carrying that `scope` tag, so
* U1's cross-function reach stops landing on the intra-line axis as FPIS.
*/
export function pdgLineCis(affectedStatements, scope) {
const out = new Set();
for (const s of affectedStatements ?? []) {
if (scope !== undefined && s?.scope !== scope) continue;
if (s && typeof s.line === 'number' && typeof s.filePath === 'string') {
out.add(lineKey(s.filePath, s.line));
}
}
return out;
}
/** AIS_intra = the set of `intra_AIS` LINE keys (statement-granular ground truth). */
export function intraLineAis(gt) {
const out = new Set();
for (const e of gt.intra_AIS ?? []) {
if (e && typeof e.line === 'number' && typeof e.filePath === 'string') {
out.add(lineKey(e.filePath, e.line));
}
}
return out;
}
/** Unified AIS = two explicit axes, never one blended line+symbol set. */
export function unifiedAis(gt) {
const intraLine = new Set();
for (const e of gt.intra_AIS ?? []) {
if (e && typeof e.line === 'number' && typeof e.filePath === 'string') {
intraLine.add(unifiedLineKey(e.filePath, e.line));
}
}
const interSymbol = new Set();
for (const e of gt.inter_AIS ?? []) {
if (e && typeof e.symbol === 'string' && typeof e.filePath === 'string') {
interSymbol.add(unifiedSymbolKey(e.symbol, e.filePath));
}
}
return { intraLine, interSymbol };
}
/** Canonicalize an iterable of {symbol,filePath} (or pre-made keys) → a Set. */
export function toKeySet(entries) {
const out = new Set();
for (const e of entries) {
if (typeof e === 'string') out.add(e);
else out.add(symbolKey(e.symbol, e.filePath));
}
return out;
}
function intersectionSize(a, b) {
let n = 0;
const [small, large] = a.size <= b.size ? [a, b] : [b, a];
for (const x of small) if (large.has(x)) n++;
return n;
}
/** a − b as a sorted array of keys. */
export function difference(a, b) {
const out = [];
for (const x of a) if (!b.has(x)) out.push(x);
return out.sort();
}
/**
* Core CIS-vs-AIS scorer. `cis` / `ais` are Sets of canonical symbol keys.
*
* Empty-denominator semantics are EXPLICIT (not silently 0 or 1):
* - |CIS|=0 ⇒ precision = null (no predictions to be right/wrong about).
* - |AIS|=0 ⇒ recall = null (nothing to find — this scope has no truth).
* - F1 = null whenever precision or recall is null OR both are 0.
* A null metric is REPORTED as `n/a`, never averaged in as 0 — collapsing it to
* 0 would punish a mode for a scope that simply has no ground truth (the
* apples-to-oranges trap, R1).
*/
export function score(cis, ais) {
const tp = intersectionSize(cis, ais);
const precision = cis.size === 0 ? null : tp / cis.size;
const recall = ais.size === 0 ? null : tp / ais.size;
let f1 = null;
if (precision !== null && recall !== null && precision + recall > 0) {
f1 = (2 * precision * recall) / (precision + recall);
}
return {
tp,
cisSize: cis.size,
aisSize: ais.size,
precision,
recall,
f1,
fpis: difference(cis, ais), // CIS − AIS (noise / over-approx)
fnis: difference(ais, cis), // AIS − CIS (missed / under-approx)
fpisCount: cis.size - tp,
fnisCount: ais.size - tp,
// |CIS|/|AIS| size ratio (>1 over-approximates, <1 under). null if |AIS|=0.
cisAisRatio: ais.size === 0 ? null : cis.size / ais.size,
};
}
/**
* Cross-mode comparison of two CIS sets against a shared AIS (KTD9 set-diffs).
* Jaccard(callgraph_CIS, pdg_CIS) + directional set-diffs, each split into
* `true` (∩AIS — a real find the other mode missed) vs `noise` (−AIS — a false
* positive the other mode avoided).
*/
export function compareModes(callgraphCis, pdgCis, ais) {
const union = new Set([...callgraphCis, ...pdgCis]);
const inter = intersectionSize(callgraphCis, pdgCis);
const jaccard = union.size === 0 ? null : inter / union.size;
const pdgOnly = difference(pdgCis, callgraphCis);
const callgraphOnly = difference(callgraphCis, pdgCis);
const splitByAis = (keys) => {
const trueFinds = keys.filter((k) => ais.has(k)).sort();
const noise = keys.filter((k) => !ais.has(k)).sort();
return { all: keys, true: trueFinds, noise };
};
return {
jaccard,
intersectionSize: inter,
unionSize: union.size,
pdgOnly: splitByAis(pdgOnly),
callgraphOnly: splitByAis(callgraphOnly),
};
}
/**
* Aggregate per-case scores for ONE (mode, scope) into a corpus row. Averaging
* follows KTD9 "per change, averaged over the corpus": a case with a null metric
* (e.g. |CIS|=0 precision) is EXCLUDED from that metric's mean (counted in
* `nMetric`), never folded in as 0. The macro-average is over the cases that
* actually have the metric defined; `nCases` records the stratum size for the
* underpowered-corpus floor (F3).
*/
export function aggregate(perCaseScores) {
const avg = (sel) => {
const xs = perCaseScores.map(sel).filter((v) => v !== null && v !== undefined);
if (xs.length === 0) return { mean: null, n: 0 };
return { mean: xs.reduce((a, b) => a + b, 0) / xs.length, n: xs.length };
};
const p = avg((s) => s.precision);
const r = avg((s) => s.recall);
const f = avg((s) => s.f1);
const ratio = avg((s) => s.cisAisRatio);
return {
nCases: perCaseScores.length,
precision: p.mean,
nPrecision: p.n,
recall: r.mean,
nRecall: r.n,
f1: f.mean,
nF1: f.n,
cisAisRatio: ratio.mean,
// Summed FPIS/FNIS counts over the stratum (totals, not means) — the
// absolute over/under-approximation volume.
fpis: perCaseScores.reduce((a, s) => a + (s.fpisCount ?? 0), 0),
fnis: perCaseScores.reduce((a, s) => a + (s.fnisCount ?? 0), 0),
};
}
/**
* Partition a mode's reported CIS keys into per-scope sub-CIS, given the
* criterion's own symbol key. INTRA = the criterion symbol itself (the only
* symbol whose blocks/edges are intra-procedural); INTER = every OTHER reported
* symbol (callees / cross-function reach). `unresolved` shadow entries (id null,
* surfaced under a file) are kept in INTER — they are non-criterion reach the
* mode could not attribute to a named symbol, and dropping them would hide a
* recall fact (R9). MIXED scope unions both.
*/
export function partitionCisByScope(cisKeys, criterionKey) {
const intra = new Set();
const inter = new Set();
for (const k of cisKeys) {
if (k === criterionKey) intra.add(k);
else inter.add(k);
}
return { intra, inter, mixed: new Set([...intra, ...inter]) };
}
/**
* Build the scope-appropriate AIS key sets from a ground-truth record.
* - intra: the criterion symbol itself (intra_AIS lines collapse onto it). A
* case with a non-empty intra_AIS contributes {criterion}; an empty intra_AIS
* contributes ∅ (no intra truth → recall n/a, not 0).
* - inter: the distinct callee symbols named in inter_AIS.
* - mixed: the union.
* Keys are `<symbol>@<filePath>` with paths normalised to the criterion's path
* style (the fixture annotations and the analyzer both use repo-relative
* `src/...` paths, so no rewrite is needed — asserted by Step 0).
*/
export function aisByScope(gt) {
const critKey = symbolKey(gt.criterion.name, gt.criterion.filePath);
const intra = new Set();
if (Array.isArray(gt.intra_AIS) && gt.intra_AIS.length > 0) intra.add(critKey);
const inter = toKeySet(
(gt.inter_AIS ?? []).map((e) => ({ symbol: e.symbol, filePath: e.filePath })),
);
return { criterionKey: critKey, intra, inter, mixed: new Set([...intra, ...inter]) };
}
const tagSymbolKeys = (keys) => new Set([...keys].map((k) => `symbol:${k}`));
const tagLineKeys = (keys) => new Set([...keys].map((k) => `statement:${k}`));
/**
* Current callgraph unified CIS: inter-symbol axis only. The seed/criterion
* symbol is filtered because `inter_AIS` is cross-function by construction.
*/
export function callgraphUnifiedCis(gt, symbolCisKeys) {
const { criterionKey } = aisByScope(gt);
const inter = new Set([...symbolCisKeys].filter((k) => k !== criterionKey));
return { intraLine: new Set(), interSymbol: tagSymbolKeys(inter) };
}
/**
* Unified PDG CIS: statement axis from affectedStatements plus, once runtime
* mode:'pdg' composes interprocedural reach, symbol axis from byDepth. The
* criterion symbol is filtered because inter_AIS is cross-function by
* construction. Passing only lineCisKeys preserves the old intra-only shape for
* focused metric tests.
*/
export function pdgUnifiedCis(lineCisKeys, symbolCisKeys = new Set(), gt = null) {
const { criterionKey } = gt ? aisByScope(gt) : { criterionKey: null };
const inter = criterionKey
? new Set([...symbolCisKeys].filter((k) => k !== criterionKey))
: new Set(symbolCisKeys);
return { intraLine: tagLineKeys(lineCisKeys), interSymbol: tagSymbolKeys(inter) };
}
/** Evaluation-only composed baseline: callgraph inter-symbol + PDG intra-line. */
export function composeUnifiedCis(...parts) {
const intraLine = new Set();
const interSymbol = new Set();
for (const part of parts) {
for (const k of part.intraLine ?? []) intraLine.add(k);
for (const k of part.interSymbol ?? []) interSymbol.add(k);
}
return { intraLine, interSymbol };
}
/** Score one engine/candidate against unified AIS without blending axes. */
export function scoreUnifiedAxes(cis, ais) {
return {
intraLine: score(cis.intraLine ?? new Set(), ais.intraLine ?? new Set()),
interSymbol: score(cis.interSymbol ?? new Set(), ais.interSymbol ?? new Set()),
};
}
export function aggregateUnifiedScores(perCaseScores) {
const intraLine = aggregate(perCaseScores.map((s) => s.intraLine));
const interSymbol = aggregate(perCaseScores.map((s) => s.interSymbol));
const definedRecalls = [intraLine.recall, interSymbol.recall].filter(
(v) => v !== null && v !== undefined,
);
return {
intraLine,
interSymbol,
minRecall: definedRecalls.length ? Math.min(...definedRecalls) : null,
fpis: (intraLine.fpis ?? 0) + (interSymbol.fpis ?? 0),
fnis: (intraLine.fnis ?? 0) + (interSymbol.fnis ?? 0),
};
}
/**
* Order-independent annotation-set fingerprint (KTD10). Mirrors the
* bench/cfg/measure.mjs canonicalization TECHNIQUE (sort every collection,
* stringify deterministically, hash) — but is annotation-set-shaped and written
* here, NOT a literal import of `canonicalizeCfg`. Any unreviewed edit to a
* ground-truth.json (criterion, AIS membership, locus, direction, edge kinds)
* changes the digest, tripping a `--check` gate distinct from the F1 band.
*
* `hash` is injected (node:crypto in the harness; a stub in the unit test) so
* this module pulls no node-only deps that would complicate the test import.
*/
export function canonicalizeAnnotationSet(fixtures) {
const canonAis = (entries) =>
(entries ?? [])
.map((e) => `${e.symbol}|${e.filePath}|${e.line ?? '-'}`)
.sort()
.join(';');
// U9 resolved-id soundness block (optional): the expected id-proven set, the
// name-match over-attribution set, and the eliminated collision id(s). It is
// part of the ground truth — an unreviewed edit changes the gate, so it MUST
// trip the fingerprint. Sorted so the digest is order-independent; absent on
// fixtures without an `idBridge` block (canonicalized as `-`).
const canonIdBridge = (b) => {
const sorted = (xs) => [...(xs ?? [])].sort().join(',');
return b
? `${b.seedLine ?? '-'}|${sorted(b.idProven)}|${sorted(b.nameWouldProve)}|${sorted(b.fpEliminated)}`
: '-';
};
const lines = fixtures
.map((fx) => {
const c = fx.gt.criterion;
const kinds = Array.isArray(c.pdgEdgeKinds) ? [...c.pdgEdgeKinds].sort().join(',') : '-';
// `line` is the criterion's 1-based statement anchor (U7 — the seed of the
// statement-anchored PDG slice). It is part of the ground truth: changing
// which statement the slice seeds on changes the measured PDG impact set,
// so an unreviewed `criterion.line` edit MUST trip the fingerprint gate.
return [
`case=${fx.name}`,
`schema=${fx.gt.schemaVersion}`,
`crit=${c.name}|${c.filePath}|${c.direction}|${c.line ?? '-'}|${c.marker ?? '-'}|${kinds}`,
`locus=${fx.gt.locus}`,
`pdgScoring=${fx.gt.pdgScoring ?? '-'}`,
`provenance=${fx.gt.provenance}`,
`intra=${canonAis(fx.gt.intra_AIS)}`,
`inter=${canonAis(fx.gt.inter_AIS)}`,
`idBridge=${canonIdBridge(fx.gt.idBridge)}`,
].join('\n');
})
.sort()
.join('\n====\n');
return lines;
}
/** SHA-256 the canonical string with an injected hashing function. */
export function fingerprintAnnotationSet(fixtures, sha256Hex) {
return sha256Hex(canonicalizeAnnotationSet(fixtures));
}
// ── U2: mutation/dynamic-oracle PURE scorers (dependency-free, unit-testable) ─
//
// `behavioralAis` (B) is the dynamic forward slice the oracle PROVED by value-diff;
// `slice` is the SAME live static PDG slice the F1 metric scores (pdgLineCis of
// `affectedStatements`); `manualAis` (M) is the manual `intra_AIS` line set. All
// three are plain string Sets of `<filePath>:<line>` keys. These functions are
// pure math over those sets, so `test/unit/impact-pdg-metric-math.test.ts` asserts
// them deterministically (no DB / analyze / Babel / random).
/**
* mutation_recall = |B ∩ slice| / |B|. A recall < 1.0 means B ∖ slice is a
* statement the dynamic oracle PROVED depends on the criterion that the static
* slice MISSED — a real recall hole (or a known U1 ascent gap). |B|=0 ⇒ recall
* is `null` (the oracle proved nothing to find — equivalent mutants only, or an
* oracle-excluded fixture), never 0. `missing` = B ∖ slice (the dangerous miss),
* `extra` = slice ∖ B (sound static over-approximation; reported, NOT gated).
*/
export function mutationRecall(behavioralAis, slice) {
const B = behavioralAis instanceof Set ? behavioralAis : new Set(behavioralAis);
const S = slice instanceof Set ? slice : new Set(slice);
const tp = intersectionSize(B, S);
const recall = B.size === 0 ? null : tp / B.size;
return {
recall,
bSize: B.size,
sliceSize: S.size,
intersection: tp,
missing: difference(B, S), // B ∖ slice — recall hole (sorted)
extra: difference(S, B), // slice ∖ B — sound over-approximation (informational)
};
}
/**
* Circularity cross-check: B ∖ M. A NON-EMPTY result means the manual annotation
* MISSED a real dependence the dynamic oracle proved — the headline independent
* evidence U2 exists to produce. Reported as a WARN with the specific lines; it
* is NOT a failure (the corpus documents annotation incompleteness as threat #1).
* `confirmed` = B ∩ M (the manual lines the oracle independently re-derived).
*/
export function circularityDiff(behavioralAis, manualAis) {
const B = behavioralAis instanceof Set ? behavioralAis : new Set(behavioralAis);
const M = manualAis instanceof Set ? manualAis : new Set(manualAis);
return {
beyondManual: difference(B, M), // B ∖ M — manual missed these (WARN)
confirmed: [...B].filter((k) => M.has(k)).sort(), // B ∩ M
manualOnly: difference(M, B), // M ∖ B — manual claimed, oracle did not prove
};
}
/**
* A mutant is EQUIVALENT iff its behavioral AIS is empty (it changed no observed
* value at any non-criterion line on any input). Equivalent mutants are discarded
* from the union (they carry no dependence signal). Accepts the oracle's per-mutant
* `{ diffLines }` record or a bare line array/Set.
*/
export function isEquivalentMutant(behavioralAis) {
const lines = Array.isArray(behavioralAis?.diffLines) ? behavioralAis.diffLines : behavioralAis;
const set = lines instanceof Set ? lines : new Set(lines ?? []);
return set.size === 0;
}
/**
* Order-independent canonical string over the mutation-oracle output set (one
* entry per fixture: criterion key + sorted behavioral AIS + sorted non-equivalent
* mutant ops). Mirrors the annotation-fingerprint TECHNIQUE so a CHANGE in what the
* oracle proves is detectable. `hash` is injected (node:crypto in the harness; a
* stub in the unit test) so this module pulls no node-only deps.
*/
export function canonicalizeMutationSet(perFixture) {
return perFixture
.map((f) => {
const ais = [...(f.behavioralAis ?? [])].sort().join(',');
const ops = [...(f.mutants ?? [])]
.filter((m) => !isEquivalentMutant(m))
.map((m) => m.op)
.sort()
.join(',');
return [`case=${f.name}`, `crit=${f.criterionKey ?? '-'}`, `ais=${ais}`, `ops=${ops}`].join(
'\n',
);
})
.sort()
.join('\n====\n');
}
export function fingerprintMutationSet(perFixture, sha256Hex) {
return sha256Hex(canonicalizeMutationSet(perFixture));
}
/** median of a numeric array (substrate-stability gate, F5). */
export function median(xs) {
if (xs.length === 0) return null;
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;
}

View file

@ -0,0 +1,541 @@
/**
* U2 — SOUND mutation/dynamic ground-truth ORACLE for the impact-PDG forward
* slice. An INDEPENDENT check on the hand-annotated `intra_AIS`: the PR's #1
* declared validity threat is annotation circularity, so this module derives a
* REAL DYNAMIC FORWARD SLICE by VALUE-DIFF (Infection + Propagation — NOT mere
* coverage), and the harness cross-checks it against both the static PDG slice
* (recall) and the manual annotation (circularity).
*
* Research grounding (the design this implements):
* - Agrawal & Horgan, "Dynamic Program Slicing", PLDI'90 — a dynamic slice is
* the set of statements that actually affected the criterion on an execution.
* - Tip, "A Survey of Program Slicing Techniques" (1995) — static ⊇ dynamic for
* a sound static slicer on the executed paths.
* - Voas, "PIE / propagation-infection-execution", TSE'92 — a fault is observed
* only when it is executed (E), infects state (I), and PROPAGATES (P) to an
* observable point. Coverage alone is only E; dependence needs I+P = an actual
* VALUE CHANGE. So `behavioral_AIS` is computed from value diffs, not coverage.
*
* ── What it does, per fixture ───────────────────────────────────────────────
* 1. MUTATE the criterion line ONLY (≤4 mutants, line-scoped regex operators:
* AOR, ROR, LCR, CRP, UOI). Discard EQUIVALENT mutants (empty behavioral_AIS).
* 2. Derive inputs via a tiny TYPE-DRIVEN generator from the criterion fn's
* params (number, number[], boolean, string). Multi-input covers both arms.
* 3. INSTRUMENT the ORIGINAL TS AST with a value-transparent `__trace` wrapper on
* VariableDeclarator.init / AssignmentExpression RHS / ReturnStatement.arg /
* CallExpression — recording `(filePath:line, occ) -> serialized value` and
* returning the expression unchanged. loc lines are 1-based filePath:line in
* the SAME space as the static slice (no source-map needed).
* 4. behavioral_AIS = { filePath:line where serialize(orig) != serialize(mut)
* for some input/occurrence }, EXCLUDING the criterion line, unioned over
* inputs then over non-equivalent mutants.
*
* NO production/src import. Pure ESM + Babel + tsx dynamic-import. All generated/
* instrumented artifacts live under an os.tmpdir() dir (gn-impact-pdg-mut-*),
* NEVER inside fixtures/. The only persisted file is the per-fixture
* `mutation-ground-truth.json` SIDECAR (data, separate from the manual
* `ground-truth.json`, never overwriting it).
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { parse } from '@babel/parser';
import _traverse from '@babel/traverse';
import _generate from '@babel/generator';
import * as t from '@babel/types';
const traverse = _traverse.default ?? _traverse;
const generate = _generate.default ?? _generate;
// ── deterministic value serializer ───────────────────────────────────────────
// undefined -> '#u', NaN/Infinity handled, stable key order for plain objects.
// A value change / appearance / disappearance is what makes a line "behavioral".
export function serializeValue(v) {
if (v === undefined) return '#u';
if (v === null) return '#n';
if (typeof v === 'number') {
if (Number.isNaN(v)) return '#NaN';
if (v === Infinity) return '#+Inf';
if (v === -Infinity) return '#-Inf';
return 'n:' + String(v);
}
if (typeof v === 'boolean') return 'b:' + (v ? '1' : '0');
if (typeof v === 'string') return 's:' + v;
if (typeof v === 'bigint') return 'B:' + v.toString();
if (typeof v === 'function') return 'fn';
if (Array.isArray(v)) return '[' + v.map(serializeValue).join(',') + ']';
if (typeof v === 'object') {
const keys = Object.keys(v).sort();
return '{' + keys.map((k) => k + '=' + serializeValue(v[k])).join(',') + '}';
}
return String(v);
}
// ── type-driven input generator ───────────────────────────────────────────────
// number -> [5, -3, 0]; number[] -> [[1,2,3], [-1,-2], []]; boolean -> [true,false];
// string -> ['a','b','z']. Multi-input is REQUIRED to cover both branch arms.
const TYPE_VALUES = {
number: [5, -3, 0],
'number[]': [[1, 2, 3], [-1, -2], []],
boolean: [true, false],
string: ['a', 'b', 'z'],
unknown: [0],
};
function normalizeTypeAnnotation(node) {
if (!node) return 'unknown';
// node is a TSTypeAnnotation wrapper; unwrap to the inner type.
const ty = node.typeAnnotation ?? node;
if (t.isTSNumberKeyword(ty)) return 'number';
if (t.isTSBooleanKeyword(ty)) return 'boolean';
if (t.isTSStringKeyword(ty)) return 'string';
if (t.isTSArrayType(ty)) {
if (t.isTSNumberKeyword(ty.elementType)) return 'number[]';
return 'unknown';
}
return 'unknown';
}
/**
* Cartesian product of per-parameter candidate value lists, capped so the run
* stays cheap. Returns an array of argument tuples.
*/
function inputTuplesFor(paramTypes, cap = 6) {
let tuples = [[]];
for (const ty of paramTypes) {
const vals = TYPE_VALUES[ty] ?? TYPE_VALUES.unknown;
const next = [];
for (const partial of tuples) {
for (const v of vals) {
next.push([...partial, v]);
if (next.length >= cap * 4) break;
}
}
tuples = next;
}
// Deduplicate by serialized tuple, then cap.
const seen = new Set();
const out = [];
for (const tup of tuples) {
const key = tup.map(serializeValue).join('|');
if (seen.has(key)) continue;
seen.add(key);
out.push(tup);
if (out.length >= cap) break;
}
return out;
}
// ── line-scoped mutation operators (regex on the criterion line text) ─────────
// AOR arithmetic, ROR relational, LCR logical, CRP numeric-literal, UOI negate.
// Each yields ≤1 replacement per applicable token; we cap the total at 4.
function lineMutants(rawLine) {
// Split off a trailing line-comment so operators never mutate inside it (a `-`
// injected into a comment string is harmless but a non-greedy UOI wrap would
// otherwise swallow the comment and produce invalid syntax). The comment is
// re-appended verbatim to every mutant so loc lines are preserved.
const cm = rawLine.match(/^(.*?)(\s*\/\/.*)$/);
const line = cm ? cm[1] : rawLine;
const comment = cm ? cm[2] : '';
const mutants = [];
const push = (text, op) => {
const full = text + comment;
if (full !== rawLine && !mutants.some((m) => m.text === full)) mutants.push({ text: full, op });
};
// AOR: swap the FIRST binary arithmetic operator. Order matters — try + then -
// etc. so a line with `a + 1` mutates `+`.
const aor = [
[/(?<=[\w)\]\s])\+(?=[\s\w(])/, '-'],
[/(?<=[\w)\]\s])-(?=[\s\w(])/, '+'],
[/(?<=[\w)\]\s])\*(?=[\s\w(])/, '/'],
[/(?<=[\w)\]\s])\/(?=[\s\w(])/, '*'],
[/(?<=[\w)\]\s])%(?=[\s\w(])/, '*'],
];
for (const [re, rep] of aor) {
if (re.test(line)) {
push(line.replace(re, rep), 'AOR');
break;
}
}
// ROR: relational/equality. Longer operators first so `<=` is not split.
const ror = [
[/===/, '!=='],
[/!==/, '==='],
[/<=/, '>'],
[/>=/, '<'],
[/(?<![<>=!])<(?![<=])/, '>='],
[/(?<![<>=!])>(?![>=])/, '<='],
];
for (const [re, rep] of ror) {
if (re.test(line)) {
push(line.replace(re, rep), 'ROR');
break;
}
}
// LCR: logical connector / negation. Connectors first; then unary `!` flip on a
// guard predicate (`if (!ok)` ⇒ `if (ok)`, a real control-flow change).
const lcr = [
[/\|\|/, '&&'],
[/&&/, '||'],
];
let lcrApplied = false;
for (const [re, rep] of lcr) {
if (re.test(line)) {
push(line.replace(re, rep), 'LCR');
lcrApplied = true;
break;
}
}
if (!lcrApplied) {
const neg = line.match(/(?<=[(\s])!(?=[\w(])/);
if (neg) push(line.replace(/(?<=[(\s])!(?=[\w(])/, ''), 'LCR');
}
// CRP: first standalone numeric literal -> k+1 (and 0 if not already 0).
const numMatch = line.match(/(?<![\w.])(\d+)(?![\w.])/);
if (numMatch) {
const k = Number(numMatch[1]);
push(line.replace(numMatch[0], String(k + 1)), 'CRP');
}
// UOI: when no operator was flippable on the line, negate the RHS of an
// assignment/declarator/return by wrapping it — but only if the line carries a
// value-bearing `=` or `return`. Keep it value-transparent-but-different.
if (mutants.length === 0) {
const eq = line.match(
/^(\s*(?:const|let|var)\s+\w+\s*=\s*|.*?\breturn\s+|\s*\w+\s*=\s*)(.+?)(;?\s*)$/,
);
if (eq) {
const [, head, rhs, tail] = eq;
push(`${head}-(${rhs})${tail}`, 'UOI');
}
}
return mutants.slice(0, 4);
}
// ── value-transparent instrumentation of the ORIGINAL TS AST ─────────────────
/**
* Wrap value-bearing expressions with __trace(EXPR, line, filePath, occ). occ is
* a per-line occurrence counter so a line evaluated multiple times (a loop body)
* records each occurrence. Returns the instrumented source string (TS preserved;
* tsx strips the types on import).
*/
function instrument(src, filePath) {
const ast = parse(src, { sourceType: 'module', plugins: ['typescript'] });
const occ = new Map();
const wrap = (nodePath) => {
const node = nodePath.node;
if (!node || !node.loc) return;
// never re-wrap our own trace call
if (t.isCallExpression(node) && t.isIdentifier(node.callee, { name: '__trace' })) return;
const line = node.loc.start.line;
const o = (occ.get(line) ?? 0) + 1;
occ.set(line, o);
nodePath.replaceWith(
t.callExpression(t.identifier('__trace'), [
node,
t.numericLiteral(line),
t.stringLiteral(filePath),
t.numericLiteral(o),
]),
);
nodePath.skip();
};
traverse(ast, {
VariableDeclarator(p) {
if (p.node.init) wrap(p.get('init'));
},
AssignmentExpression(p) {
// wrap the RHS; the assignment value itself is observed at its own line via
// the declarator/return sites, so wrapping the RHS captures the new value.
if (p.node.right) wrap(p.get('right'));
},
ReturnStatement(p) {
if (p.node.argument) wrap(p.get('argument'));
},
CallExpression(p) {
// a bare call statement (effectful) — wrap so its return value/occurrence is
// observed. Skips our own __trace / __tick instrumentation calls.
if (
t.isIdentifier(p.node.callee, { name: '__trace' }) ||
t.isIdentifier(p.node.callee, { name: '__tick' })
)
return;
wrap(p);
},
});
// Bound every loop with a back-edge step budget: prepend `__tick()` to each loop
// body so a NON-TERMINATING mutant (e.g. a flipped operator that makes a loop
// never exit) throws `__GN_NONTERM` instead of hanging the in-process run.
// Recursion self-terminates via stack overflow, so only loops need this guard.
traverse(ast, {
'ForStatement|ForInStatement|ForOfStatement|WhileStatement|DoWhileStatement'(p) {
p.ensureBlock();
p.get('body').unshiftContainer(
'body',
t.expressionStatement(t.callExpression(t.identifier('__tick'), [])),
);
},
});
const body = generate(ast, { retainLines: true }).code;
const preamble =
'const __traceLog=[];\n' +
'let __ticks=0;\n' +
'function __tick(){ if(++__ticks>50000){ throw new Error("__GN_NONTERM"); } }\n' +
'function __trace(v,line,file,occ){__traceLog.push({line,file,occ,v});return v;}\n' +
'export {__traceLog as __GN_TRACE_LOG};\n';
return preamble + body;
}
// ── run one instrumented module on a tuple, collect (line:occ -> serialized) ──
async function runTraced(moduleFile, fnName, args) {
// bust the import cache so the original and each mutant are distinct modules.
const url = pathToFileURL(moduleFile).href + `?v=${Math.random().toString(36).slice(2)}`;
const mod = await import(url);
const log = mod.__GN_TRACE_LOG;
log.length = 0;
const fn = mod[fnName];
if (typeof fn !== 'function') {
throw new Error(`instrumented module has no exported function ${fnName}`);
}
let threw = null;
let nonTerminating = false;
try {
fn(...args);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
if (msg.includes('__GN_NONTERM')) nonTerminating = true;
threw = msg;
}
// A non-terminating mutant yields no finite value trace — return empty so the
// diff SKIPS it (logged, excluded from behavioral_AIS) rather than letting its
// partial loop trace fabricate spurious per-iteration diffs.
if (nonTerminating) return { observed: new Map(), threw, nonTerminating: true };
// (filePath:line, occ) -> serialized value. A line may appear multiple times;
// key on occurrence so a per-iteration value change is observed.
const observed = new Map();
for (const rec of log) {
observed.set(`${rec.file}:${rec.line}#${rec.occ}`, serializeValue(rec.v));
}
return { observed, threw, nonTerminating: false };
}
/** parse `function name(params)` signatures to get the criterion's param types. */
function criterionParamTypes(src, fnName) {
const ast = parse(src, { sourceType: 'module', plugins: ['typescript'] });
let types = [];
traverse(ast, {
'FunctionDeclaration|FunctionExpression|ArrowFunctionExpression'(p) {
const id = p.node.id;
const isMatch =
(id && id.name === fnName) ||
(t.isVariableDeclarator(p.parent) &&
t.isIdentifier(p.parent.id) &&
p.parent.id.name === fnName);
if (!isMatch) return;
types = p.node.params.map((param) => {
const ann = t.isIdentifier(param) ? param.typeAnnotation : param.typeAnnotation;
return normalizeTypeAnnotation(ann);
});
p.stop();
},
});
return types;
}
/**
* Derive the behavioral (dynamic) AIS for a fixture.
*
* @param {{name:string, dir:string, gt:object}} fx — fixture record (gt is the
* manual ground-truth.json).
* @param {string} workDir — the SAME temp working copy the analyze step used
* (so `workDir/src/<file>` line numbers align with the static slice keys).
* @returns {Promise<{
* behavioralAis: string[], // sorted `<filePath>:<line>` keys (criterion excluded)
* criterionLine: number,
* criterionKey: string,
* filePath: string,
* inputs: unknown[][], // the tuples used
* paramTypes: string[],
* mutants: {op:string, text:string, equivalent:boolean, diffLines:string[]}[],
* skipped: null|string, // a reason if the oracle could not run
* }>}
*/
export async function deriveBehavioralAis(fx, workDir) {
const filePath = fx.gt.criterion.filePath; // repo-relative `src/...`
const fnName = fx.gt.criterion.name;
const criterionLine = fx.gt.criterion.line;
const criterionKey = `${filePath}:${criterionLine}`;
const absSrc = path.join(workDir, filePath);
const empty = {
behavioralAis: [],
criterionLine: criterionLine ?? null,
criterionKey,
filePath,
inputs: [],
paramTypes: [],
mutants: [],
skipped: null,
};
if (!criterionLine || !fs.existsSync(absSrc)) {
return { ...empty, skipped: `criterion file/line missing (${absSrc}:${criterionLine})` };
}
const src = fs.readFileSync(absSrc, 'utf8');
const srcLines = src.split('\n');
const lineText = srcLines[criterionLine - 1];
if (lineText === undefined) {
return { ...empty, skipped: `criterion line ${criterionLine} out of range` };
}
const paramTypes = criterionParamTypes(src, fnName);
const inputs = inputTuplesFor(paramTypes);
if (inputs.length === 0) {
return { ...empty, paramTypes, skipped: 'no inputs derivable' };
}
const mutantSpecs = lineMutants(lineText);
if (mutantSpecs.length === 0) {
return { ...empty, paramTypes, inputs, skipped: 'no applicable mutation operator on line' };
}
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-impact-pdg-mut-'));
const mutants = [];
const aisLines = new Set();
try {
// Instrument the ORIGINAL once; copy the whole src tree so cross-file imports
// (if any) still resolve, then overwrite the criterion file.
fs.cpSync(path.join(workDir, 'src'), path.join(tmp, 'src'), { recursive: true });
const instrumentedOriginal = instrument(src, filePath);
const origFile = path.join(tmp, filePath);
fs.writeFileSync(origFile, instrumentedOriginal);
// Baseline traces (one per input tuple).
const baseline = [];
for (const args of inputs) baseline.push(await runTraced(origFile, fnName, args));
let mi = 0;
for (const spec of mutantSpecs) {
mi += 1;
const mutLines = srcLines.slice();
mutLines[criterionLine - 1] = spec.text;
const mutSrc = mutLines.join('\n');
// Instrument the MUTANT source (same loc space — the mutated line keeps its
// line number; retainLines preserves all other lines). A regex operator can
// occasionally produce syntactically invalid TS (e.g. a `-` injected where a
// unary context makes it ambiguous); such a mutant is not a valid program, so
// it is SKIPPED (recorded as invalid, never crashes the pass).
let instrumentedMutant;
try {
instrumentedMutant = instrument(mutSrc, filePath);
} catch {
mutants.push({
op: spec.op,
text: spec.text,
equivalent: true,
invalid: true,
diffLines: [],
});
continue;
}
const mutFile = path.join(tmp, `mut${mi}__${path.basename(filePath)}`);
fs.writeFileSync(mutFile, instrumentedMutant);
const diffLines = new Set();
let nonTerminating = false;
for (let i = 0; i < inputs.length; i++) {
const mutRun = await runTraced(mutFile, fnName, inputs[i]);
if (mutRun.nonTerminating) {
nonTerminating = true;
break;
}
const base = baseline[i];
// union of keys observed in either run (a value can appear/disappear).
const keys = new Set([...base.observed.keys(), ...mutRun.observed.keys()]);
for (const k of keys) {
const bv = base.observed.get(k);
const mv = mutRun.observed.get(k);
if (bv !== mv) {
// strip the occurrence suffix back to a `<filePath>:<line>` key.
const lineOnly = k.slice(0, k.lastIndexOf('#'));
diffLines.add(lineOnly);
}
}
// A divergence in throw-behaviour is itself propagation to the return
// point: attribute it to the criterion line's continuation. We DON'T add
// the criterion line (excluded below), but a differing throw with no value
// diff still implies the function-result line changed — captured via the
// return-line value diff already (return not reached => key disappears).
}
if (nonTerminating) {
// The criterion mutation made a downstream loop non-terminating. This proves
// divergence but yields no observable per-statement value trace, so it is
// LOGGED and EXCLUDED from behavioral_AIS — excluding it keeps recall sound
// (behavioral_AIS stays a subset of the true dynamic forward slice).
mutants.push({
op: spec.op,
text: spec.text,
equivalent: false,
nonTerminating: true,
diffLines: [],
});
continue;
}
// EXCLUDE the criterion line itself.
diffLines.delete(criterionKey);
const diffArr = [...diffLines].sort();
const equivalent = diffArr.length === 0;
mutants.push({ op: spec.op, text: spec.text, equivalent, diffLines: diffArr });
// EQUIVALENT mutants (empty behavioral set) are DISCARDED from the union.
if (!equivalent) for (const l of diffArr) aisLines.add(l);
}
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
return {
behavioralAis: [...aisLines].sort(),
criterionLine,
criterionKey,
filePath,
inputs,
paramTypes,
mutants,
skipped: null,
};
}
/**
* Write/refresh the per-fixture audit sidecar (provenance:'mutation'). SEPARATE
* from the manual ground-truth.json — never overwrites it. Returns the path.
*/
export function writeMutationSidecar(fx, derived) {
const out = {
schemaVersion: 1,
provenance: 'mutation',
criterion: {
name: fx.gt.criterion.name,
filePath: derived.filePath,
line: derived.criterionLine,
},
paramTypes: derived.paramTypes,
inputs: derived.inputs,
behavioral_AIS: derived.behavioralAis,
mutants: derived.mutants,
skipped: derived.skipped,
note:
'AUTO-GENERATED dynamic forward-slice oracle (U2). VALUE-DIFF behavioral AIS ' +
'(Infection+Propagation, not coverage). Regenerated by `measure.mjs --mutation`. ' +
'Independent cross-check of the manual ground-truth.json — NOT a hand annotation.',
};
const sidecarPath = path.join(fx.dir, 'mutation-ground-truth.json');
fs.writeFileSync(sidecarPath, JSON.stringify(out, null, 2) + '\n');
return sidecarPath;
}

View file

@ -0,0 +1,599 @@
/**
* Realized name-collision probe for the PDG-impact statement-precise bridge.
*
* The bridge labels a callgraph-reached callee "proven" (callgraph-bridge) iff its
* LEAF NAME appears in the changed line's dependence-slice block callees
* (`pdgBridgeEvidenceForImpact`, pdg-impact.ts). Because the match is by name, two
* distinct reached symbols that share a leaf name (e.g. two `get`s) are BOTH proven
* whenever that name is in the slice — but the slice's call site(s) resolve to a
* specific subset, so the extras are over-attribution (false-proven). This is the
* documented "conservative SUPERSET" caveat (pdg-impact.ts:1352).
*
* This probe QUANTIFIES that over-attribution on real code, to decide whether a
* sound resolved-symbol-id bridge is worth building.
*
* Key property that makes an index-only measurement rigorous: a collision
* false-positive is ALWAYS a name-ambiguous proven label. To be proven a symbol
* must first be reached, so every same-name over-attribution surfaces as >=2
* DISTINCT proven symbol-ids sharing one leaf name. Counting those is therefore
* COMPLETE for collision-FP. It is an UPPER BOUND (a slice could legitimately call
* two same-named callees on different lines, in which case both proven labels are
* correct), so the realized FP is in [0, ambiguous]. A near-zero result is a
* decisive no-go; a material result motivates the exact line-join confirmation
* (which needs a re-run that captures per-call-site resolved ids — the persisted
* CALLS edge has no call-site line, BasicBlock.callees is a deduped leaf-name set).
*
* Focus is DEPTH 1: name-matching only fires at the first hop; deeper proven labels
* are inherited from their depth-1 ancestor (betterBridgeEvidence), a different
* (transitive) imprecision, not name collision.
*
* ── U8: realized id-vs-name diff (the R5 proof, exact-slice) ─────────────────
* After U5/U6 the live bridge matches RESOLVED callee symbol-ids: on an index that
* carries `BasicBlock.calleeIds`, `pdgInterprocedural.statementPreciseByDepth` is
* the SOUND id-proven set. The `ambiguityRate` above then collapses to ~0 (ids
* discriminate same-named callees) — itself proof the collision is gone. To
* MEASURE what the id bridge changed versus the old name bridge on the SAME real
* slices, this probe also recomputes, per function, the NAME-proven set the
* leaf-name predicate WOULD prove on the EXACT seed∪reachable slice (not a
* function-level proxy — the impact result exposes `seedBlocks`/`reachableBlocks`,
* so we query those exact blocks' `callees` and replicate the bridge's
* `sliceCalleeNames.has(name)` fallback), and diffs the two reached-item sets:
* - fpEliminated = name-proven ∖ id-proven (collision FALSE-POSITIVES removed:
* labels the name match would prove that the id match drops)
* - fnRecovered = id-proven ∖ name-proven (import-alias FALSE-NEGATIVES
* recovered: labels the id match proves that the name match would miss)
* Both sets are keyed by reached-item id (resolved symbol-id), falling back to
* `name@filePath` for the rare id-less reached item. The scoring is factored into
* the dependency-free pure `scoreIdVsName`/`summarizeIdVsName` (asserted by
* test/unit/impact-pdg-id-vs-name-metrics.test.ts), exactly like
* `summarizeBlastRadius` in blast-radius.mjs.
*/
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { median, parseMarkdownRows } from './blast-radius.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(__dirname, '..', '..');
// `CALLEES_TRUNCATED_SENTINEL` (cfg/emit.ts) — a slice block that hit the
// per-statement site cap marks its callee set INCOMPLETE; the bridge keeps such
// reach callgraph-equal (callee-unknown) rather than under-proving.
const CALLEES_TRUNCATED_SENTINEL = '*';
function round(value, digits = 3) {
if (value === null || value === undefined || Number.isNaN(value)) return null;
const scale = 10 ** digits;
return Math.round(value * scale) / scale;
}
function readOption(argv, name, fallback = undefined) {
const eq = argv.find((arg) => arg.startsWith(`--${name}=`));
if (eq) return eq.slice(name.length + 3);
const idx = argv.indexOf(`--${name}`);
if (idx >= 0 && idx + 1 < argv.length) return argv[idx + 1];
return fallback;
}
function hasFlag(argv, name) {
return argv.includes(`--${name}`);
}
function fmt(value, digits = 2) {
return value === null || value === undefined ? 'n/a' : Number(value).toFixed(digits);
}
/**
* Proven (callgraph-bridge) items at a given depth from a `statementPreciseByDepth`
* record. Items carry { id, name, ... }; the projection already dropped
* unproven-bridge, so every item here is a proven label.
*/
function provenItemsAtDepth(byDepth, depth) {
const items = byDepth?.[depth] ?? byDepth?.[String(depth)] ?? [];
return Array.isArray(items) ? items : [];
}
/** Full depth-1 inter-procedural reach (proven + unproven) — the direct callees. */
function provGetReachedD1(pdg) {
const byDepth = pdg?.interproceduralByDepth ?? pdg?.pdgInterprocedural?.byDepth ?? {};
return provenItemsAtDepth(byDepth, 1);
}
/**
* Group proven items by leaf name, counting DISTINCT symbol ids per name. A name
* mapping to >=2 distinct ids is an ambiguous (non-discriminating) proven group:
* the name-match proved all of them, but the slice resolves to a subset.
*/
function nameCollisionStats(provenItems) {
const idsByName = new Map();
for (const it of provenItems) {
if (!it || typeof it !== 'object') continue;
const name = typeof it.name === 'string' ? it.name : '';
if (!name) continue;
const id =
typeof it.id === 'string' && it.id
? it.id
: `${name}@${typeof it.filePath === 'string' ? it.filePath : '?'}`;
let set = idsByName.get(name);
if (!set) {
set = new Set();
idsByName.set(name, set);
}
set.add(id);
}
let provenLabels = 0;
let ambiguousLabels = 0; // proven labels whose name is shared by >=2 distinct ids
let excessLabels = 0; // sum(count - 1) over ambiguous names = central FP estimate
const ambiguousNames = [];
for (const [name, ids] of idsByName) {
const c = ids.size;
provenLabels += c;
if (c >= 2) {
ambiguousLabels += c;
excessLabels += c - 1;
ambiguousNames.push({ name, count: c });
}
}
ambiguousNames.sort((a, b) => b.count - a.count);
return { provenLabels, ambiguousLabels, excessLabels, ambiguousNames };
}
export function summarize(cases) {
const withProven = cases.filter((c) => c.provenLabels > 0);
const sum = (sel) => cases.reduce((a, c) => a + sel(c), 0);
const totalProven = sum((c) => c.provenLabels);
const totalAmbiguous = sum((c) => c.ambiguousLabels);
const totalExcess = sum((c) => c.excessLabels);
const totalReachedD1 = sum((c) => c.reachedD1);
const totalDivergent = sum((c) => c.divergentReached);
return {
n: cases.length,
functionsWithProvenLabels: withProven.length,
functionsWithAmbiguity: cases.filter((c) => c.ambiguousLabels > 0).length,
totalProvenLabels: totalProven,
totalAmbiguousLabels: totalAmbiguous,
totalExcessLabels: totalExcess,
// Upper bound on collision-FP as a fraction of all proven labels.
ambiguityRate: totalProven > 0 ? round(totalAmbiguous / totalProven) : null,
// Central FP estimate (assumes ~1 distinct resolved symbol per slice leaf name).
excessRate: totalProven > 0 ? round(totalExcess / totalProven) : null,
medianProvenPerFn: median(withProven.map((c) => c.provenLabels)),
// FN / aliasing axis: depth-1 reached callees whose resolved name is in NO
// block leaf of the owning function (alias/rename/dynamic) — the surface where
// a truly-on-slice callee can never be name-proven.
totalReachedD1,
totalDivergentReached: totalDivergent,
divergenceRate: totalReachedD1 > 0 ? round(totalDivergent / totalReachedD1) : null,
functionsWithDivergence: cases.filter((c) => c.divergentReached > 0).length,
};
}
/**
* PURE replica of the bridge's depth-1 evidence predicate
* (`pdgBridgeEvidenceForImpact`, pdg-impact.ts) over the SAME inputs, returning
* the two counterfactual proven sets for ONE function's slice:
* - `nameProven` = what the LEAF-NAME bridge would prove,
* - `idProven` = what the RESOLVED-ID bridge proves.
*
* The predicate (mirrored exactly so the whole-symbol / sentinel fallbacks cancel
* on both sides and ONLY the discriminating divergence survives the diff):
* 1. sliceCalleeNames empty → prove ALL (whole-symbol fallback)
* 2. sentinel ('*') in sliceCalleeNames → prove ALL (callee-unknown, capped)
* 3a. id path (ids present): prove iff item.id ∈ sliceCalleeIds
* 3b. name path (no ids): prove iff item.name ∈ sliceCalleeNames
* The name-counterfactual ALWAYS uses 3b at step 3 (what name-match would decide);
* the id set uses 3a when ids are present, else 3b (graceful degrade — identical
* to the name set on a pre-v3 index, so the diff is then structurally empty).
*
* `discriminating` flags the only regime where the two can differ (names present,
* no sentinel, ids present); non-discriminating slices fall back identically and
* contribute 0 to fpEliminated/fnRecovered by construction.
*
* Pure: no DB / analyze / Date / random — deterministic over plain inputs.
*/
export function bridgeProvenSets(reachedItems, sliceCalleeNames, sliceCalleeIds) {
const names =
sliceCalleeNames instanceof Set ? sliceCalleeNames : new Set(sliceCalleeNames ?? []);
const ids = sliceCalleeIds instanceof Set ? sliceCalleeIds : new Set(sliceCalleeIds ?? []);
const items = Array.isArray(reachedItems) ? reachedItems : [];
const wholeSymbol = names.size === 0;
const truncated = names.has(CALLEES_TRUNCATED_SENTINEL);
const idsPresent = ids.size > 0;
const discriminating = !wholeSymbol && !truncated && idsPresent;
// Step 1/2: whole-symbol or sentinel ⇒ both bridges prove ALL reached items.
// Otherwise apply the step-3 membership predicate.
const fallbackProvesAll = wholeSymbol || truncated;
const provenBy = (member) => (fallbackProvesAll ? [...items] : items.filter(member));
const nameProven = provenBy((it) => typeof it?.name === 'string' && names.has(it.name));
const idProven = provenBy((it) =>
discriminating
? typeof it?.id === 'string' && ids.has(it.id)
: typeof it?.name === 'string' && names.has(it.name),
);
return { nameProven, idProven, discriminating, wholeSymbol, truncated };
}
/**
* Stable identity key for a reached/proven item. The resolved symbol id is the
* sound key (an alias/collision shares the leaf NAME but NEVER the id); fall back
* to `name@filePath` only for the rare id-less reached item (dynamic/unresolved),
* mirroring `symbolSetFromByDepth` in blast-radius.mjs.
*/
export function reachedItemKey(item) {
if (!item || typeof item !== 'object') return '';
if (typeof item.id === 'string' && item.id.length > 0) return item.id;
const name = typeof item.name === 'string' ? item.name : '(unknown)';
const filePath = typeof item.filePath === 'string' ? item.filePath : '(unknown)';
return `${name}@${filePath}`;
}
/** A de-duplicated key set over reached items (drops empty/unkeyable items). */
function keySetOf(items) {
const out = new Set();
for (const it of Array.isArray(items) ? items : []) {
const key = reachedItemKey(it);
if (key) out.add(key);
}
return out;
}
/**
* PURE scorer (R5): diff the NAME-proven and ID-proven statement-precise sets for
* ONE function's slice. No DB / analyze / Date / random — deterministic over plain
* reached-item arrays so the unit test can assert the arithmetic.
*
* - `fpEliminated` = |name-proven ∖ id-proven| — collision false-positives the
* id bridge removed (the name predicate would prove these; the id predicate
* drops them because their resolved id is not on the exact slice).
* - `fnRecovered` = |id-proven ∖ name-proven| — import-alias false-negatives the
* id bridge recovered (the id predicate proves these via the resolved id even
* though their leaf name is absent from the slice's `callees`).
*
* Identical sets ⇒ both counts 0. Keys are sorted so the output is order-stable
* regardless of input ordering (determinism).
*/
export function scoreIdVsName(nameProvenItems, idProvenItems) {
const nameKeys = keySetOf(nameProvenItems);
const idKeys = keySetOf(idProvenItems);
const fpEliminatedKeys = [...nameKeys].filter((k) => !idKeys.has(k)).sort();
const fnRecoveredKeys = [...idKeys].filter((k) => !nameKeys.has(k)).sort();
return {
nameProven: nameKeys.size,
idProven: idKeys.size,
fpEliminated: fpEliminatedKeys.length,
fnRecovered: fnRecoveredKeys.length,
fpEliminatedKeys,
fnRecoveredKeys,
};
}
/**
* PURE aggregation over per-function `scoreIdVsName` outputs (the U8 headline).
* Dependency-free for the deterministic unit test, mirroring `summarizeBlastRadius`.
*/
export function summarizeIdVsName(cases) {
const sum = (sel) => cases.reduce((a, c) => a + sel(c), 0);
const totalNameProven = sum((c) => c.nameProven ?? 0);
const totalIdProven = sum((c) => c.idProven ?? 0);
const totalFpEliminated = sum((c) => c.fpEliminated ?? 0);
const totalFnRecovered = sum((c) => c.fnRecovered ?? 0);
return {
n: cases.length,
totalNameProven,
totalIdProven,
totalFpEliminated,
totalFnRecovered,
functionsWithFpEliminated: cases.filter((c) => (c.fpEliminated ?? 0) > 0).length,
functionsWithFnRecovered: cases.filter((c) => (c.fnRecovered ?? 0) > 0).length,
functionsWithDiscriminatingSlice: cases.filter((c) => c.discriminatingSlice === true).length,
// Fraction of name-proven labels the id bridge proved were over-attribution.
fpEliminatedRate: totalNameProven > 0 ? round(totalFpEliminated / totalNameProven) : null,
// Fraction of id-proven labels the name bridge would have missed (alias FN).
fnRecoveredRate: totalIdProven > 0 ? round(totalFnRecovered / totalIdProven) : null,
};
}
async function cypherRows(backend, repo, query) {
const res = await backend.callTool('cypher', { repo, query });
return parseMarkdownRows(res?.markdown);
}
/**
* Callee NAME set AND resolved-ID set for the EXACT dependence slice (seed ∪
* reachable blocks the impact result exposes) — exactly the `sliceCalleeNames` /
* `sliceCalleeIds` the bridge unions over the slice (`local-backend.ts`). The
* sentinel `'*'` (a capped block) is PRESERVED in the name set so the replica
* predicate can take the callee-unknown fallback. This is the EXACT slice, NOT a
* function-level proxy. Block ids carry no quotes, so the `IN [...]` literal is
* safe (same convention as the candidate queries above). On a pre-v3 index the
* `calleeIds` column is absent → the query errors → ids degrade to empty (the
* bridge then name-matches, and the id-vs-name diff is structurally 0).
*/
async function sliceCalleeSetsOf(backend, repo, sliceBlockIds) {
const names = new Set();
const ids = new Set();
if (!Array.isArray(sliceBlockIds) || sliceBlockIds.length === 0) return { names, ids };
const idList = sliceBlockIds.map((id) => `'${id}'`).join(', ');
const nameRows = await cypherRows(
backend,
repo,
`MATCH (b:BasicBlock) WHERE b.id IN [${idList}] RETURN b.callees AS callees`,
);
for (const r of nameRows) {
for (const n of String(r.callees ?? '').split(' ')) if (n) names.add(n);
}
try {
const idRows = await cypherRows(
backend,
repo,
`MATCH (b:BasicBlock) WHERE b.id IN [${idList}] RETURN b.calleeIds AS calleeIds`,
);
for (const r of idRows) {
for (const i of String(r.calleeIds ?? '').split(' '))
if (i && i !== CALLEES_TRUNCATED_SENTINEL) ids.add(i);
}
} catch {
// pre-v3 index: no `calleeIds` column — leave ids empty (graceful degrade).
}
return { names, ids };
}
async function run() {
const argv = process.argv.slice(2);
const repo = readOption(argv, 'repo', 'GitNexus');
const sample = Math.max(1, Number(readOption(argv, 'sample', '120')));
const minBlocks = Math.max(2, Number(readOption(argv, 'min-blocks', '6')));
const src = readOption(argv, 'src', 'gitnexus/src/');
const depth = Math.max(1, Number(readOption(argv, 'depth', '3')));
const limit = Math.max(1, Number(readOption(argv, 'limit', '200')));
const json = hasFlag(argv, 'json');
const { LocalBackend } = await import(
path.join(REPO_ROOT, 'src', 'mcp', 'local', 'local-backend.ts')
);
const backend = new LocalBackend();
const initialized = await backend.init();
if (!initialized)
throw new Error('no indexed repositories found; run gitnexus analyze --pdg first');
try {
const candidateQuery = (label) =>
`MATCH (f:${label}) WHERE f.filePath STARTS WITH '${src}' AND f.endLine > f.startLine + 18 ` +
`RETURN f.name AS name, f.filePath AS filePath, f.startLine AS startLine, ` +
`f.endLine AS endLine, '${label}' AS kind`;
let candidates = [
...(await cypherRows(backend, repo, candidateQuery('Function'))),
...(await cypherRows(backend, repo, candidateQuery('Method'))),
].filter((c) => c.name && /^[A-Za-z_$][\w$]*$/.test(c.name));
const stride = Math.max(1, Math.floor(candidates.length / (sample * 3)));
candidates = candidates.filter((_, i) => i % stride === 0);
const cases = [];
let degraded = 0;
for (const c of candidates) {
if (cases.length >= sample) break;
const lo = Number(c.startLine);
const hi = Number(c.endLine);
if (!Number.isFinite(lo) || !Number.isFinite(hi)) continue;
const blockRows = await cypherRows(
backend,
repo,
`MATCH (b:BasicBlock) WHERE b.filePath = '${c.filePath}' AND b.startLine >= ${lo} ` +
`AND b.startLine <= ${hi + 1} RETURN b.id AS id, b.startLine AS startLine, ` +
`b.callees AS callees ORDER BY b.startLine`,
);
const fnLine1b = String(lo + 1);
const own = blockRows.filter((r) => {
const parts = r.id.split(':');
return parts[parts.length - 3] === fnLine1b;
});
// Union of all leaf call names across the function's own blocks — the
// complete set name-matching could ever prove. A reached direct callee whose
// resolved (definition) name is NOT in here can NEVER be name-proven: it is
// called via an alias/rename, dynamically, or as a filtered member-read —
// the false-negative (import-alias) surface.
const blockLeafUnion = new Set();
for (const r of own) {
for (const n of String(r.callees ?? '').split(' '))
if (n && n !== '*') blockLeafUnion.add(n);
}
const bodyBlocks = own.length;
if (bodyBlocks < minBlocks) continue;
const startLines = own
.map((r) => Number(r.startLine))
.filter(Number.isFinite)
.sort((a, b) => a - b);
const anchor = startLines[Math.max(1, Math.floor(bodyBlocks / 3))];
if (!Number.isFinite(anchor)) continue;
const pdg = await backend.callTool('impact', {
repo,
target: c.name,
file_path: c.filePath,
kind: c.kind,
direction: 'downstream',
maxDepth: depth,
limit,
includeTests: true,
mode: 'pdg',
line: anchor,
});
if (pdg?.error) continue;
if (pdg?.pdgLayer && pdg.pdgLayer !== 'ready') {
degraded++;
continue;
}
if (pdg?.epistemic === 'pdg-no-block-at-line') continue;
const spByDepth = pdg?.pdgInterprocedural?.statementPreciseByDepth ?? {};
const d1 = nameCollisionStats(provenItemsAtDepth(spByDepth, 1));
// All-depth (depth-1 firing + inherited deeper) for context only.
const allProven = Object.keys(spByDepth).flatMap((d) =>
provenItemsAtDepth(spByDepth, Number(d)),
);
const all = nameCollisionStats(allProven);
// FN / aliasing axis: depth-1 reached direct callees (proven + unproven)
// whose resolved name is absent from EVERY block leaf of the function — so
// name-matching can never prove them even if they are on the slice.
const reachedD1 = provGetReachedD1(pdg);
let reachedD1Names = 0;
let divergentReached = 0;
const seenReached = new Set();
for (const it of reachedD1) {
const nm = it && typeof it.name === 'string' ? it.name : '';
const id = it && typeof it.id === 'string' ? it.id : `${nm}@?`;
if (!nm || seenReached.has(id)) continue;
seenReached.add(id);
reachedD1Names += 1;
if (!blockLeafUnion.has(nm)) divergentReached += 1;
}
// ── U8 realized id-vs-name diff on the EXACT slice ──────────────────────
// Both proven sets are computed by the SAME bridge predicate replica
// (`bridgeProvenSets`) over the depth-1 reached callees and the EXACT
// seed∪reachable slice's `callees`/`calleeIds` — so the whole-symbol and
// sentinel fallbacks (which prove ALL reached items identically on both
// sides) cancel, and only the discriminating divergence survives:
// fpEliminated = name-proven ∖ id-proven (collision FP removed),
// fnRecovered = id-proven ∖ name-proven (import-alias FN recovered).
// On a pre-v3 index `calleeIds` is absent → the id set == the name set →
// both diffs are structurally 0 (the honest degraded reading).
const exactSlice = [
...(Array.isArray(pdg?.seedBlocks) ? pdg.seedBlocks : []),
...(Array.isArray(pdg?.reachableBlocks) ? pdg.reachableBlocks : []),
];
const { names: sliceCalleeNames, ids: sliceCalleeIds } = await sliceCalleeSetsOf(
backend,
repo,
exactSlice,
);
const proven = bridgeProvenSets(reachedD1, sliceCalleeNames, sliceCalleeIds);
const idVsName = scoreIdVsName(proven.nameProven, proven.idProven);
cases.push({
name: c.name,
kind: c.kind,
file: c.filePath,
anchor,
sliceBlocks: pdg?.affectedStatementCount ?? 0,
statementPrecision:
typeof pdg?.pdgInterprocedural?.statementPrecision === 'number'
? round(pdg.pdgInterprocedural.statementPrecision)
: null,
// headline = depth 1 (where name-matching actually fires)
provenLabels: d1.provenLabels,
ambiguousLabels: d1.ambiguousLabels,
excessLabels: d1.excessLabels,
topAmbiguous: d1.ambiguousNames.slice(0, 4),
allDepthProven: all.provenLabels,
allDepthAmbiguous: all.ambiguousLabels,
reachedD1: reachedD1Names,
divergentReached,
// U8 exact-slice id-vs-name diff
nameProven: idVsName.nameProven,
idProven: idVsName.idProven,
fpEliminated: idVsName.fpEliminated,
fnRecovered: idVsName.fnRecovered,
fpEliminatedKeys: idVsName.fpEliminatedKeys,
fnRecoveredKeys: idVsName.fnRecoveredKeys,
// True only when names present, no sentinel, and ids present — the regime
// where the id and name bridges can diverge (else both whole-symbol/name
// fall back identically). Lets the summary confirm the diff is concentrated
// on discriminating slices, not a fallback artifact.
discriminatingSlice: proven.discriminating,
});
}
const summary = summarize(cases);
const idVsNameSummary = summarizeIdVsName(cases);
const report = {
repo,
direction: 'downstream',
sample: cases.length,
minBlocks,
degradedSkipped: degraded,
generatedAt: new Date().toISOString(),
note:
'Realized name-collision probe (depth 1). ambiguousLabels = proven labels whose ' +
'leaf name is shared by >=2 distinct reached symbol-ids — an UPPER BOUND on ' +
'collision false-positives (complete: every collision-FP is such a label). ' +
'excessLabels = sum(count-1) per ambiguous name = central FP estimate. U8 ' +
'idVsName diffs the EXACT-slice name-proven vs id-proven sets: fpEliminated = ' +
'realized collision FP the id bridge removes; fnRecovered = realized alias FN it ' +
'recovers. On a v3+ (calleeIds) index ambiguityRate should collapse to ~0.',
summary,
idVsName: idVsNameSummary,
cases,
};
if (json) {
process.stdout.write(JSON.stringify(report, null, 2) + '\n');
return;
}
const s = summary;
const lines = [];
lines.push('=== impact-PDG realized name-collision probe (depth 1) ===');
lines.push(`repo ${repo} | downstream | functions ${cases.length} | minBlocks ${minBlocks}`);
lines.push('');
lines.push(
`Proven labels: ${s.totalProvenLabels} across ${s.functionsWithProvenLabels} functions ` +
`(median ${s.medianProvenPerFn}/fn).`,
);
lines.push(
`Name-ambiguous proven labels (UPPER BOUND on collision-FP): ${s.totalAmbiguousLabels} ` +
`(${fmt((s.ambiguityRate ?? 0) * 100, 1)}% of proven), in ${s.functionsWithAmbiguity}/` +
`${cases.length} functions.`,
);
lines.push(
`Excess proven labels (central FP estimate, sum(count-1)): ${s.totalExcessLabels} ` +
`(${fmt((s.excessRate ?? 0) * 100, 1)}% of proven).`,
);
lines.push(
`FN / aliasing surface: ${s.totalDivergentReached}/${s.totalReachedD1} depth-1 reached ` +
`callees (${fmt((s.divergenceRate ?? 0) * 100, 1)}%) have a resolved name absent from ` +
`every block leaf (alias/rename/dynamic), in ${s.functionsWithDivergence}/${cases.length} ` +
`functions — name-matching can never prove these.`,
);
lines.push('');
const v = idVsNameSummary;
lines.push('--- U8 realized id-vs-name diff (exact seed∪reachable slice) ---');
lines.push(
`Name-proven labels: ${v.totalNameProven} | id-proven labels: ${v.totalIdProven} ` +
`(across ${v.n} functions; ${v.functionsWithDiscriminatingSlice} have a discriminating ` +
`slice where the two bridges can diverge).`,
);
lines.push(
`fpEliminated (collision FP the id bridge REMOVES, name∖id): ${v.totalFpEliminated} ` +
`(${fmt((v.fpEliminatedRate ?? 0) * 100, 1)}% of name-proven), in ` +
`${v.functionsWithFpEliminated}/${cases.length} functions.`,
);
lines.push(
`fnRecovered (alias FN the id bridge RECOVERS, id∖name): ${v.totalFnRecovered} ` +
`(${fmt((v.fnRecoveredRate ?? 0) * 100, 1)}% of id-proven), in ` +
`${v.functionsWithFnRecovered}/${cases.length} functions.`,
);
lines.push('');
lines.push(
'Interpretation: ambiguityRate is the fraction of statement-precise proven labels the ' +
'NAME match cannot disambiguate (>=2 reached callees share the leaf name). On a v3+ ' +
'index it collapses to ~0 because the id bridge already discriminates same-named ' +
'callees — that ~0 is itself proof. fpEliminated is the REALIZED collision FP the id ' +
'bridge proved away on these exact slices; fnRecovered is the realized import-alias FN ' +
'it recovered. On a pre-v3 index (no calleeIds) both are 0 (id set == name set).',
);
process.stdout.write(lines.join('\n') + '\n');
} finally {
await backend.dispose().catch(() => {});
}
}
if (path.resolve(process.argv[1] ?? '') === fileURLToPath(import.meta.url)) {
run().catch((err) => {
process.stderr.write(`[impact-pdg-name-collision] ERROR: ${err?.stack || err}\n`);
process.exit(1);
});
}

View file

@ -0,0 +1,457 @@
/**
* Real-code performance and quality proxy probe for impact modes.
*
* This complements `measure.mjs`, which is the ground-truth accuracy gate over
* curated fixtures. A real repository does not have an AIS annotation set, so
* this probe does NOT claim accuracy. It checks whether unified `mode:'pdg'`
* preserves the established callgraph symbol reach on a real index, how much it
* costs, and how honest its PDG evidence/degraded signals are.
*/
import fs from 'node:fs';
import path from 'node:path';
import { performance } from 'node:perf_hooks';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(__dirname, '..', '..');
export const DEFAULT_REAL_CODE_CASES = [
{
name: 'cli-format-impact-upstream',
target: 'formatImpactResult',
file_path: 'gitnexus/src/cli/eval-server.ts',
kind: 'Function',
direction: 'upstream',
line: 208,
},
{
name: 'cli-impact-command-downstream',
target: 'impactCommand',
file_path: 'gitnexus/src/cli/tool.ts',
kind: 'Function',
direction: 'downstream',
// Block-start line of the coalesced backend-call statement group. The CFG
// coalesces lines 162-192 into one BasicBlock, so an anchor mid-block (e.g.
// 173) lands on no block start and degrades to pdg-no-block-at-line. Seed the
// block's start line so the intra slice is exercised on a real statement.
line: 162,
},
{
name: 'pdg-engine-downstream',
target: 'runImpactPDG',
file_path: 'gitnexus/src/mcp/local/pdg-impact.ts',
kind: 'Function',
direction: 'downstream',
// Block-start line of the function's opening coalesced statement group
// (the destructure + budget setup spanning 912+). Mid-block lines like 952
// resolve to no block start; 912 seeds a real, statement-rich intra slice.
line: 912,
},
{
name: 'pdg-dispatch-upstream',
target: '_impactImpl',
file_path: 'gitnexus/src/mcp/local/local-backend.ts',
kind: 'Method',
direction: 'upstream',
line: 4427,
},
{
name: 'pdg-compose-downstream',
target: 'composeUnifiedPdgImpactResult',
file_path: 'gitnexus/src/mcp/local/local-backend.ts',
kind: 'Method',
direction: 'downstream',
line: 4850,
},
];
function readOption(argv, name, fallback = undefined) {
const eq = argv.find((arg) => arg.startsWith(`--${name}=`));
if (eq) return eq.slice(name.length + 3);
const idx = argv.indexOf(`--${name}`);
if (idx >= 0 && idx + 1 < argv.length) return argv[idx + 1];
return fallback;
}
function hasFlag(argv, name) {
return argv.includes(`--${name}`);
}
export function median(xs) {
if (xs.length === 0) return null;
const sorted = [...xs].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 === 1 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
}
export function percentile(xs, pct) {
if (xs.length === 0) return null;
const sorted = [...xs].sort((a, b) => a - b);
const idx = Math.min(sorted.length - 1, Math.max(0, Math.ceil((pct / 100) * sorted.length) - 1));
return sorted[idx];
}
function round(value, digits = 3) {
if (value === null || value === undefined || Number.isNaN(value)) return null;
const scale = 10 ** digits;
return Math.round(value * scale) / scale;
}
function fmt(value, digits = 1) {
return value === null || value === undefined ? 'n/a' : Number(value).toFixed(digits);
}
export function symbolKeysFromByDepth(byDepth) {
const keys = new Set();
for (const items of Object.values(byDepth ?? {})) {
for (const item of items ?? []) {
if (!item || typeof item !== 'object') continue;
if (typeof item.id === 'string' && item.id.length > 0) {
keys.add(item.id);
continue;
}
const name = typeof item.name === 'string' ? item.name : '(unknown)';
const filePath = typeof item.filePath === 'string' ? item.filePath : '(unknown)';
keys.add(`${name}@${filePath}`);
}
}
return keys;
}
export function compareSymbolSets(reference, candidate) {
const ref = new Set(reference);
const cand = new Set(candidate);
const overlap = [...ref].filter((key) => cand.has(key));
const referenceOnly = [...ref].filter((key) => !cand.has(key)).sort();
const candidateOnly = [...cand].filter((key) => !ref.has(key)).sort();
const unionSize = new Set([...ref, ...cand]).size;
return {
referenceSize: ref.size,
candidateSize: cand.size,
overlapSize: overlap.length,
recallVsReference: ref.size === 0 ? null : overlap.length / ref.size,
precisionVsReference: cand.size === 0 ? null : overlap.length / cand.size,
jaccard: unionSize === 0 ? null : overlap.length / unionSize,
referenceOnly,
candidateOnly,
};
}
function sumEvidenceCounts(results) {
const counts = {};
for (const result of results) {
const evidenceCounts =
result?.pdgInterprocedural?.evidenceCounts ??
result?.pdgEvidence?.interproceduralEvidenceCounts ??
{};
for (const [key, value] of Object.entries(evidenceCounts)) {
counts[key] = (counts[key] ?? 0) + Number(value ?? 0);
}
}
return counts;
}
function readCases(caseFile) {
if (!caseFile) return DEFAULT_REAL_CODE_CASES;
const resolved = path.resolve(process.cwd(), caseFile);
const parsed = JSON.parse(fs.readFileSync(resolved, 'utf8'));
const cases = Array.isArray(parsed) ? parsed : parsed.cases;
if (!Array.isArray(cases) || cases.length === 0) {
throw new Error(`case file ${resolved} must contain a non-empty array or { "cases": [...] }`);
}
return cases;
}
async function timedImpact(backend, params) {
const started = performance.now();
const result = await backend.callTool('impact', params);
return { result, ms: performance.now() - started };
}
async function measureCase(backend, testCase, options) {
const baseParams = {
repo: options.repo,
target: testCase.target,
file_path: testCase.file_path,
kind: testCase.kind,
direction: testCase.direction ?? 'upstream',
maxDepth: options.depth,
includeTests: options.includeTests,
limit: options.limit,
};
const callgraphTimes = [];
const pdgTimes = [];
let callgraphResult = null;
let pdgResult = null;
for (let i = 0; i < options.repeat; i++) {
const callgraph = await timedImpact(backend, { ...baseParams, mode: 'callgraph' });
callgraphTimes.push(callgraph.ms);
callgraphResult = callgraph.result;
const pdg = await timedImpact(backend, {
...baseParams,
mode: 'pdg',
...(Number.isInteger(testCase.line) ? { line: testCase.line } : {}),
});
pdgTimes.push(pdg.ms);
pdgResult = pdg.result;
}
const callgraphKeys = symbolKeysFromByDepth(callgraphResult?.byDepth ?? {});
const pdgInterByDepth =
pdgResult?.interproceduralByDepth ?? pdgResult?.pdgInterprocedural?.byDepth ?? {};
const pdgInterKeys = symbolKeysFromByDepth(pdgInterByDepth);
const symbolAgreement = compareSymbolSets(callgraphKeys, pdgInterKeys);
const evidenceCounts =
pdgResult?.pdgInterprocedural?.evidenceCounts ??
pdgResult?.pdgEvidence?.interproceduralEvidenceCounts ??
{};
return {
name: testCase.name ?? testCase.target,
target: testCase.target,
filePath: testCase.file_path,
kind: testCase.kind,
direction: baseParams.direction,
line: Number.isInteger(testCase.line) ? testCase.line : null,
latencyMs: {
callgraph: {
median: round(median(callgraphTimes)),
p95: round(percentile(callgraphTimes, 95)),
samples: callgraphTimes.map((v) => round(v)),
},
pdg: {
median: round(median(pdgTimes)),
p95: round(percentile(pdgTimes, 95)),
samples: pdgTimes.map((v) => round(v)),
},
pdgOverCallgraphMedian:
median(callgraphTimes) && median(callgraphTimes) > 0
? round(median(pdgTimes) / median(callgraphTimes))
: null,
},
callgraph: {
error: callgraphResult?.error ?? null,
impactedCount: callgraphResult?.impactedCount ?? 0,
risk: callgraphResult?.risk ?? null,
epistemic: callgraphResult?.epistemic ?? null,
partial: Boolean(callgraphResult?.partial),
symbolCount: callgraphKeys.size,
},
pdg: {
error: pdgResult?.error ?? null,
pdgLayer: pdgResult?.pdgLayer ?? 'ready',
epistemic: pdgResult?.epistemic ?? null,
partial: Boolean(pdgResult?.partial || pdgResult?.pdgInterprocedural?.partial),
impactedCount: pdgResult?.impactedCount ?? 0,
affectedStatementCount: pdgResult?.affectedStatementCount ?? 0,
blockCount: pdgResult?.blockCount ?? 0,
interproceduralSymbolCount: pdgInterKeys.size,
evidence: pdgResult?.pdgInterprocedural?.evidence ?? pdgResult?.pdgEvidence?.interprocedural,
evidenceCounts,
},
symbolAgreement,
};
}
export function summarizeCases(cases) {
const ratios = cases
.map((c) => c.latencyMs.pdgOverCallgraphMedian)
.filter((v) => v !== null && v !== undefined);
const callgraphMedians = cases
.map((c) => c.latencyMs.callgraph.median)
.filter((v) => v !== null && v !== undefined);
const pdgMedians = cases
.map((c) => c.latencyMs.pdg.median)
.filter((v) => v !== null && v !== undefined);
const comparable = cases.filter((c) => c.symbolAgreement.recallVsReference !== null);
const recalls = comparable.map((c) => c.symbolAgreement.recallVsReference);
const precisions = comparable
.map((c) => c.symbolAgreement.precisionVsReference)
.filter((v) => v !== null && v !== undefined);
const degradedCases = cases.filter((c) => c.pdg.pdgLayer !== 'ready');
const errorCases = cases.filter((c) => c.callgraph.error || c.pdg.error);
const partialCases = cases.filter((c) => c.callgraph.partial || c.pdg.partial);
const noBlockAtLineCases = cases.filter((c) => c.pdg.epistemic === 'pdg-no-block-at-line');
const evidenceCounts = sumEvidenceCounts(cases.map((c) => ({ pdgInterprocedural: c.pdg })));
const totalBridgeSymbols = Object.values(evidenceCounts).reduce((a, b) => a + Number(b ?? 0), 0);
return {
performance: {
callgraphMedianMs: round(median(callgraphMedians)),
pdgMedianMs: round(median(pdgMedians)),
pdgP95Ms: round(percentile(cases.map((c) => c.latencyMs.pdg.p95).filter(Boolean), 95)),
pdgOverCallgraphMedian: round(median(ratios)),
},
qualityProxy: {
comparableCases: comparable.length,
meanSymbolRecallVsCallgraph: recalls.length
? round(recalls.reduce((a, b) => a + b, 0) / recalls.length)
: null,
minSymbolRecallVsCallgraph: recalls.length ? round(Math.min(...recalls)) : null,
meanSymbolPrecisionVsCallgraph: precisions.length
? round(precisions.reduce((a, b) => a + b, 0) / precisions.length)
: null,
degradedCaseCount: degradedCases.length,
errorCaseCount: errorCases.length,
partialCaseCount: partialCases.length,
noBlockAtLineCaseCount: noBlockAtLineCases.length,
evidenceCounts,
unprovenBridgeRatio:
totalBridgeSymbols > 0
? round((evidenceCounts['unproven-bridge'] ?? 0) / totalBridgeSymbols)
: null,
},
};
}
export function evaluateCheckGates(report, env = process.env) {
const failures = [];
const minRecall = Number(env.GN_REAL_CODE_PDG_MIN_SYMBOL_RECALL ?? 0.95);
const maxMedianMs = Number(env.GN_REAL_CODE_PDG_MAX_MEDIAN_MS ?? 5000);
const quality = report.summary.qualityProxy;
const perf = report.summary.performance;
if (report.cases.length === 0) failures.push('no real-code cases were measured');
if (quality.errorCaseCount > 0)
failures.push(`${quality.errorCaseCount} case(s) returned errors`);
if (quality.degradedCaseCount > 0) {
failures.push(`${quality.degradedCaseCount} case(s) reported a degraded PDG layer`);
}
if (
quality.minSymbolRecallVsCallgraph !== null &&
quality.minSymbolRecallVsCallgraph < minRecall
) {
failures.push(
`min PDG symbol recall vs callgraph ${quality.minSymbolRecallVsCallgraph} < ${minRecall}`,
);
}
if (perf.pdgMedianMs !== null && perf.pdgMedianMs > maxMedianMs) {
failures.push(`PDG median latency ${perf.pdgMedianMs}ms > ${maxMedianMs}ms`);
}
return failures;
}
function renderText(report, failures) {
const lines = [];
const perf = report.summary.performance;
const quality = report.summary.qualityProxy;
lines.push('=== impact-PDG real-code performance/quality probe ===');
lines.push(
`repo ${report.repo} | cases ${report.cases.length} | repeat ${report.repeat} | includeTests=${report.includeTests}`,
);
lines.push('');
lines.push(
`Latency: callgraph median ${fmt(perf.callgraphMedianMs)}ms, ` +
`pdg median ${fmt(perf.pdgMedianMs)}ms, pdg p95 ${fmt(perf.pdgP95Ms)}ms, ` +
`median overhead ${fmt(perf.pdgOverCallgraphMedian, 2)}x`,
);
lines.push(
`Quality proxy: min PDG symbol recall vs callgraph ${fmt(
quality.minSymbolRecallVsCallgraph,
3,
)}, mean recall ${fmt(quality.meanSymbolRecallVsCallgraph, 3)}, ` +
`mean precision ${fmt(quality.meanSymbolPrecisionVsCallgraph, 3)}`,
);
lines.push(
`Signals: degraded=${quality.degradedCaseCount}, errors=${quality.errorCaseCount}, ` +
`partial=${quality.partialCaseCount}, no-block-at-line=${quality.noBlockAtLineCaseCount}, ` +
`unprovenBridgeRatio=${fmt(quality.unprovenBridgeRatio, 3)}`,
);
lines.push(`Evidence counts: ${JSON.stringify(quality.evidenceCounts)}`);
lines.push('');
lines.push('Per case:');
for (const c of report.cases) {
lines.push(
` ${c.name}: cg ${fmt(c.latencyMs.callgraph.median)}ms/${c.callgraph.symbolCount} symbols, ` +
`pdg ${fmt(c.latencyMs.pdg.median)}ms/${c.pdg.interproceduralSymbolCount} inter-symbols, ` +
`statements=${c.pdg.affectedStatementCount}, recall=${fmt(
c.symbolAgreement.recallVsReference,
3,
)}, precision=${fmt(c.symbolAgreement.precisionVsReference, 3)}, ` +
`evidence=${c.pdg.evidence ?? 'n/a'}`,
);
if (c.callgraph.error || c.pdg.error || c.pdg.pdgLayer !== 'ready') {
lines.push(
` status: callgraphError=${c.callgraph.error ?? 'none'} pdgError=${
c.pdg.error ?? 'none'
} pdgLayer=${c.pdg.pdgLayer}`,
);
}
if (c.symbolAgreement.referenceOnly.length > 0) {
lines.push(
` callgraph-only symbols: ${c.symbolAgreement.referenceOnly.slice(0, 5).join(', ')}`,
);
}
}
lines.push('');
lines.push(
'Interpretation: this real-code probe measures latency and quality proxies, not accuracy. ' +
'The curated fixture harness remains the AIS-backed accuracy gate.',
);
if (failures.length > 0) {
lines.push('');
for (const failure of failures) lines.push(`[impact-pdg-real-code --check] FAIL: ${failure}`);
}
return lines.join('\n');
}
async function run() {
const argv = process.argv.slice(2);
const repo = readOption(argv, 'repo', 'GitNexus');
const repeat = Math.max(1, Number(readOption(argv, 'repeat', '3')));
const depth = Math.max(1, Number(readOption(argv, 'depth', '3')));
const limit = Math.max(1, Number(readOption(argv, 'limit', '100')));
const includeTests = readOption(argv, 'include-tests', 'true') !== 'false';
const caseFile = readOption(argv, 'case-file');
const json = hasFlag(argv, 'json');
const check = hasFlag(argv, 'check');
const cases = readCases(caseFile);
const { LocalBackend } = await import(
path.join(REPO_ROOT, 'src', 'mcp', 'local', 'local-backend.ts')
);
const backend = new LocalBackend();
const initialized = await backend.init();
if (!initialized)
throw new Error('no indexed repositories found; run gitnexus analyze --pdg first');
try {
const measured = [];
for (const testCase of cases) {
measured.push(
await measureCase(backend, testCase, { repo, repeat, depth, limit, includeTests }),
);
}
const report = {
repo,
repeat,
depth,
limit,
includeTests,
generatedAt: new Date().toISOString(),
note: 'Real-code probe: latency plus quality proxies only. Accuracy requires AIS-backed fixtures.',
cases: measured,
summary: summarizeCases(measured),
};
const failures = evaluateCheckGates(report);
if (json) {
process.stdout.write(JSON.stringify({ ...report, checkFailures: failures }, null, 2) + '\n');
} else {
process.stdout.write(renderText(report, failures) + '\n');
}
if (check && failures.length > 0) process.exit(1);
} finally {
await backend.dispose().catch(() => {});
}
}
if (path.resolve(process.argv[1] ?? '') === fileURLToPath(import.meta.url)) {
run().catch((err) => {
process.stderr.write(`[impact-pdg-real-code] ERROR: ${err?.stack || err}\n`);
process.exit(1);
});
}

View file

@ -199,7 +199,11 @@ This project is indexed by GitNexus as **${projectName}**${noStats ? '' : ` (${s
## Always Do
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run \`impact({target: "symbolName", direction: "upstream"})\` and report the blast radius (direct callers, affected processes, risk level) to the user.
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run \`impact({target: "symbolName", direction: "upstream"})\` and report the blast radius (direct callers, affected processes, risk level) to the user.${
hasPdg
? ` For unified PDG impact, add \`mode: "pdg"\` with optional \`line: <N>\` — it returns statement-level \`affectedStatements\` over CDG + REACHING_DEF and inter-procedural symbols in \`interproceduralByDepth\`/\`byDepth\`; no-layer/degraded PDG results are UNKNOWN-risk notes (\`--pdg\` layer).`
: ''
}
- **MUST run \`detect_changes()\` before committing** to verify your changes only affect expected symbols and execution flows. For regression review, compare against the default branch: \`detect_changes({scope: "compare", base_ref: ${JSON.stringify(markdownSafeBranch(defaultBranch))}})\`.
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
- When exploring unfamiliar code, use \`query({search_query: "concept"})\` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.

View file

@ -30,6 +30,7 @@
*/
import http from 'http';
import crypto from 'node:crypto';
import { isIPv4, isIPv6 } from 'node:net';
import { writeSync } from 'node:fs';
import {
@ -178,6 +179,18 @@ export function formatContextResult(result: any): string {
return lines.join('\n').trim();
}
function formatTruncationSuffix(result: {
truncatedBy?: unknown;
truncatedByReasons?: unknown;
}): string {
const label = Array.isArray(result.truncatedByReasons)
? result.truncatedByReasons.join(', ')
: typeof result.truncatedBy === 'string'
? result.truncatedBy
: '';
return label ? ` (by ${label})` : '';
}
export function formatImpactResult(result: any): string {
if (result.error) {
const suggestion = result.suggestion ? `\nSuggestion: ${result.suggestion}` : '';
@ -194,6 +207,28 @@ export function formatImpactResult(result: any): string {
// mirroring formatContextResult, so the real impact under whichever symbol the
// caller meant is visible on the text surface, not just in the JSON.
if (result.status === 'ambiguous') {
if (result.mode === 'pdg') {
const shown = result.candidates?.length ?? 0;
const totalCandidates = result.totalCandidates ?? shown;
const countPhrase =
totalCandidates > shown
? `${totalCandidates} symbols (showing ${shown})`
: `${totalCandidates} symbols`;
const lines = [
`${target?.name || '?'}: AMBIGUOUS — ${countPhrase} share this name. ` +
`PDG impact was not computed until the target is disambiguated. ` +
`Use --uid, file_path, or kind for one authoritative PDG result.`,
];
if (result.message) lines.push(String(result.message));
for (const c of result.candidates || []) {
const score = typeof c.score === 'number' ? ` score ${c.score}` : '';
lines.push(
` ${c.kind} ${c.name} → ${c.filePath}:${c.line || '?'}${score} (uid: ${c.uid})`,
);
}
return lines.join('\n');
}
// #2129 review F11 — report the FULL match count (`totalCandidates`), not the
// truncated `candidates[]` length; note when the candidate list is capped.
const shown = result.candidates?.length ?? 0;
@ -219,6 +254,204 @@ export function formatImpactResult(result: any): string {
return lines.join('\n');
}
// ─── PDG mode (mode:'pdg') ────────────────────────────────────────────
// KTD8 presentation half. PDG results are intra-procedural Program
// Dependence Graph blast radii: the single collapsed `byDepth[1]` bucket
// has NO call-hop depth meaning (block-hops ≠ call-hops), so we must NOT
// reuse the callgraph "depth N / WILL BREAK (direct)" framing, the
// callgraph DI/dynamic-dispatch lower-bound copy, or the confident
// "isolated" zero. A degraded / no-body PDG result is INCONCLUSIVE, not
// safe-to-refactor — it gets the explicit caveat + remediation, never an
// empty blast radius. Detect on `mode:'pdg'` (every PDG return path —
// findings, degradation, no-body, no-dependence — carries it). Ambiguous
// PDG results carry `status:'ambiguous'` and are handled above; they never
// reach here.
if (result.mode === 'pdg') {
const name = target?.name || '?';
const appendPdgInterproceduralSymbols = (lines: string[]): boolean => {
const byDepth =
result.interproceduralByDepth || result.pdgInterprocedural?.byDepth || result.byDepth || {};
const byDepthCounts =
result.interproceduralByDepthCounts ||
result.pdgInterprocedural?.byDepthCounts ||
result.byDepthCounts ||
{};
const depthKeys = Array.from(
new Set([...Object.keys(byDepthCounts), ...Object.keys(byDepth)]),
)
.map((d) => Number(d))
.filter((d) => Number.isFinite(d))
.sort((a, b) => a - b);
const hasReach = depthKeys.some((depth) => {
const items = byDepth[depth] || byDepth[String(depth)] || [];
const count = byDepthCounts[depth] ?? byDepthCounts[String(depth)] ?? items.length;
return count > 0;
});
if (!hasReach) return false;
const totalSymbols =
result.pdgInterprocedural?.impactedCount ??
(typeof result.impactedCount === 'number' ? result.impactedCount : 0);
lines.push('');
lines.push(`Inter-procedural symbol reach (${totalSymbols}):`);
for (const depth of depthKeys) {
const items = byDepth[depth] || byDepth[String(depth)] || [];
const count = byDepthCounts[depth] ?? byDepthCounts[String(depth)] ?? items.length;
if (count <= 0) continue;
lines.push(` d=${depth} (${count})`);
const shown = Math.min(items.length, 12);
for (const item of items.slice(0, shown)) {
const flags: string[] = [];
if (item.unresolved) flags.push('unresolved');
if (item.ambiguous) flags.push('ambiguous');
const flagStr = flags.length ? ` [${flags.join(', ')}]` : '';
lines.push(` ${item.type || ''} ${item.name} → ${item.filePath}${flagStr}`);
}
if (count > shown) lines.push(` ... and ${count - shown} more`);
}
return true;
};
// (1) Degradation — the PDG layer (or a sub-layer) is absent/unreadable.
// `pdgLayer` is the non-'ready' state from `pdgLayerStatus`. Print the
// honest remediation, NOT a zero/empty blast radius.
if (result.pdgLayer) {
const subLayer = result.missingSubLayer
? ` (missing sub-layer: ${result.missingSubLayer})`
: '';
return (
`${name}: PDG impact unavailable — the index has no usable PDG layer ` +
`[${result.pdgLayer}]${subLayer}. This is NOT "no impact". ` +
`Re-index with \`gitnexus analyze --pdg\` to build the control/data ` +
`dependence layer, or use \`--mode callgraph\` for the call-graph blast radius.` +
(result.note ? `\n${result.note}` : '')
);
}
// (2) No-body symbol (KTD6) — interface / type alias / abstract / ambient
// member / one-line declaration with no CFG. Show the caveat, never
// "isolated / no dependencies".
if (result.epistemic === 'no-pdg-body') {
const noBodyLines = [
`${name}: local PDG slice not applicable to this symbol — it has no PDG body ` +
`(no control/data dependence edges; e.g. an interface, type alias, ` +
`abstract/ambient member, or a one-line declaration). This is NOT a ` +
`confident "no impact".`,
];
appendPdgInterproceduralSymbols(noBodyLines);
if (result.note) noBodyLines.push(result.note);
return noBodyLines.join('\n');
}
// (2b) STATEMENT-ANCHORED SLICE (mode:'pdg' + line). When `criterionLine` is
// present the result is a statement slice: the seeded line plus the list of
// dependent statements (`affectedStatements: {line,filePath,text}[]`). Render
// those statements directly — this IS the useful output of statement mode —
// rather than the symbol-projection bucket below. Empty cases:
// - `pdg-no-block-at-line`: the line is blank / a comment / outside the
// body (no statement block) — print the steering note.
// - empty `affectedStatements` with `pdg-intra-procedural`: the line has no
// dependents in this direction — print the steering note.
// Each non-empty case also surfaces truncation honestly.
if (typeof result.criterionLine === 'number') {
const slice: any[] = Array.isArray(result.affectedStatements)
? result.affectedStatements
: [];
const count =
typeof result.affectedStatementCount === 'number'
? result.affectedStatementCount
: slice.length;
// File anchor for the heading — the seeded statement's file (every slice
// statement shares the function's file). Fall back to the target's file.
const anchorFile = slice[0]?.filePath || target?.filePath || name;
if (count === 0 || slice.length === 0) {
// No statement block at the line, or no dependents in this direction.
// Print the honest note (pdg-no-block-at-line or the no-dependence note)
// verbatim — never an empty "isolated" headline.
const emptySliceLines = [
`No statements ${direction}-dependent on ${anchorFile}:${result.criterionLine}.`,
];
if (result.truncated) {
const by = formatTruncationSuffix(result);
emptySliceLines.push(
`⚠️ Truncated${by} — the dependence slice was bounded; deeper PDG-dependent statements may exist.`,
);
}
appendPdgInterproceduralSymbols(emptySliceLines);
if (result.note) emptySliceLines.push(result.note);
return emptySliceLines.join('\n');
}
const slLines: string[] = [];
slLines.push(
`Statements ${direction}-dependent on ${anchorFile}:${result.criterionLine} (${count}):`,
);
for (const s of slice) {
const text = typeof s.text === 'string' ? s.text : '';
slLines.push(` L${s.line}: ${text}`);
}
// Truncation honesty — the slice may be a lower bound (depth or per-step
// LIMIT bound). Surface it the same way the symbol render does.
if (result.truncated) {
const by = formatTruncationSuffix(result);
slLines.push(
`⚠️ Truncated${by} — the dependence slice was bounded; deeper PDG-dependent statements may exist.`,
);
}
appendPdgInterproceduralSymbols(slLines);
if (result.note) {
slLines.push('');
slLines.push(`ℹ️ ${result.note}`);
}
return slLines.join('\n').trim();
}
const pdgLines: string[] = [];
if (!appendPdgInterproceduralSymbols(pdgLines)) {
pdgLines.push(
`${name} (${direction}): no inter-procedural symbols reached. ` +
`The local PDG statement slice may still report affectedStatements when seeded with line:<N>.`,
);
}
// The assembled note carries the local-PDG framing plus the unified
// inter-procedural symbol-reach contract; surface it verbatim so the CLI
// reader sees the same honesty the JSON consumer does.
if (result.note) {
pdgLines.push('');
pdgLines.push(`ℹ️ ${result.note}`);
} else {
pdgLines.push('');
pdgLines.push(
'ℹ️ Program Dependence Graph result — statement reach is reported in affectedStatements and inter-procedural symbol reach in interproceduralByDepth/byDepth.',
);
}
// Honest incompleteness signals (block-attribution + truncation).
if (result.ambiguousProjectionCount > 0) {
pdgLines.push(
`⚠️ ${result.ambiguousProjectionCount} block(s) could not be attributed to a ` +
`unique owning symbol (same-line functions) — all colliding symbols are shown.`,
);
}
if (result.unresolvedBlockCount > 0) {
pdgLines.push(
`⚠️ ${result.unresolvedBlockCount} dependence block(s) map to no owning ` +
`Function/Method/Constructor (top-level statement / closure) — surfaced under their file.`,
);
}
if (result.truncated) {
const by = formatTruncationSuffix(result);
pdgLines.push(
`⚠️ Truncated${by} — the dependence traversal was bounded; deeper PDG impacts may exist.`,
);
}
return pdgLines.join('\n').trim();
}
if (total === 0) {
// #1858 — "isolated" is a confident claim. If an interface / indirection
// boundary is on the path, the true count is a lower bound, not zero;
@ -376,7 +609,7 @@ function formatToolResult(toolName: string, result: any): string {
// Guide the agent to the logical next tool call.
// Critical for tool chaining: query → context → impact → fix.
function getNextStepHint(toolName: string): string {
export function getNextStepHint(toolName: string, result?: any): string {
switch (toolName) {
case 'query':
return '\n---\nNext: Pick a symbol above and run gitnexus-context "<name>" to see all its callers, callees, and execution flows.';
@ -385,6 +618,15 @@ function getNextStepHint(toolName: string): string {
return '\n---\nNext: To check what breaks if you change this, run gitnexus-impact "<name>" upstream';
case 'impact':
if (
result?.error ||
result?.status === 'ambiguous' ||
result?.mode === 'pdg' ||
result?.pdgLayer ||
typeof result?.criterionLine === 'number'
) {
return '';
}
return '\n---\nNext: Review d=1 items first (WILL BREAK). Read the source with cat to understand the code, then make your fix.';
case 'cypher':
@ -454,6 +696,14 @@ export async function evalServerCommand(options?: EvalServerOptions): Promise<vo
}, idleTimeoutSec * 1000);
}
// Startup-generated shutdown token: a `POST /shutdown` must present it in the
// X-Shutdown-Token header. The local agent that launches the server reads it
// from the GITNEXUS_EVAL_SERVER_SHUTDOWN_TOKEN line on fd 1 (next to the READY
// signal); a client on another VM under `--host 0.0.0.0` cannot guess it, so it
// can no longer kill the server. (SIGINT/SIGTERM and the idle timeout still
// shut down locally without a token.)
const shutdownToken = crypto.randomBytes(24).toString('hex');
const server = http.createServer(async (req, res) => {
resetIdleTimer();
@ -468,6 +718,12 @@ export async function evalServerCommand(options?: EvalServerOptions): Promise<vo
// Shutdown
if (req.method === 'POST' && req.url === '/shutdown') {
if (req.headers['x-shutdown-token'] !== shutdownToken) {
res.setHeader('Content-Type', 'application/json');
res.writeHead(403);
res.end(JSON.stringify({ error: 'forbidden: missing or invalid X-Shutdown-Token' }));
return;
}
res.setHeader('Content-Type', 'application/json');
res.writeHead(200);
res.end(JSON.stringify({ status: 'shutting_down' }));
@ -483,6 +739,14 @@ export async function evalServerCommand(options?: EvalServerOptions): Promise<vo
const toolMatch = req.url?.match(/^\/tool\/(\w+)$/);
if (req.method === 'POST' && toolMatch) {
const toolName = toolMatch[1];
if (!EVAL_SERVER_TOOLS.has(toolName)) {
res.setHeader('Content-Type', 'text/plain');
res.writeHead(400);
res.end(
`Error: unsupported tool '${toolName}'. Supported: ${[...EVAL_SERVER_TOOLS].sort().join(', ')}`,
);
return;
}
const body = await readBody(req);
let args: Record<string, any> = {};
@ -500,7 +764,7 @@ export async function evalServerCommand(options?: EvalServerOptions): Promise<vo
// Call tool, format result as text, append next-step hint
const result = await backend.callTool(toolName, args);
const formatted = formatToolResult(toolName, result);
const hint = getNextStepHint(toolName);
const hint = getNextStepHint(toolName, result);
res.setHeader('Content-Type', 'text/plain');
res.writeHead(200);
@ -611,6 +875,8 @@ export async function evalServerCommand(options?: EvalServerOptions): Promise<vo
try {
// Use fd 1 directly — LadybugDB captures process.stdout (#324)
writeSync(1, `GITNEXUS_EVAL_SERVER_READY:${displayHost}:${boundPort}\n`);
// The launching agent reads this to authorize POST /shutdown.
writeSync(1, `GITNEXUS_EVAL_SERVER_SHUTDOWN_TOKEN:${shutdownToken}\n`);
} catch {
// stdout may not be available (e.g., broken pipe)
}
@ -629,6 +895,21 @@ export async function evalServerCommand(options?: EvalServerOptions): Promise<vo
process.on('SIGTERM', shutdown);
}
/**
* Tools the eval-server exposes over HTTP — the read-only query surface the
* banner advertises. `LocalBackend.callTool` ALSO dispatches write-side / heavier
* tools (rename, shape_check, tool_map, …); the allowlist keeps a stray
* `POST /tool/<name>` from reaching those through this Docker/eval-harness server.
*/
export const EVAL_SERVER_TOOLS: ReadonlySet<string> = new Set([
'query',
'context',
'impact',
'cypher',
'detect_changes',
'list_repos',
]);
export const MAX_BODY_SIZE = 1024 * 1024; // 1MB
function readBody(req: http.IncomingMessage): Promise<string> {

View file

@ -338,6 +338,15 @@ program
.command('impact [target]')
.description('Blast radius analysis: what breaks if you change a symbol')
.option('-d, --direction <dir>', 'upstream (dependants) or downstream (dependencies)', 'upstream')
.option(
'--mode <mode>',
'Engine: callgraph (default) or pdg (opt-in, intra-procedural; needs analyze --pdg)',
'callgraph',
)
.option(
'--line <number>',
'1-based source line — PDG-only statement anchor (--mode pdg): slice the dependence from the statement at this line and show what depends on it',
)
.option('-r, --repo <name>', 'Target repository')
.option('--branch <name>', 'Scope to a specific branch index (multi-branch repos)')
.option('-u, --uid <uid>', 'Direct symbol UID (zero-ambiguity lookup)')

View file

@ -124,6 +124,8 @@ export async function impactCommand(
target?: string,
options?: {
direction?: string;
mode?: string;
line?: string;
repo?: string;
branch?: string;
uid?: string;
@ -162,12 +164,23 @@ export async function impactCommand(
const rawOffset = parseInt(options?.offset ?? '', 10);
const parsedLimit = Number.isFinite(rawLimit) ? rawLimit : undefined;
const parsedOffset = Number.isFinite(rawOffset) ? rawOffset : undefined;
// `--line` is a PDG-only statement anchor (1-based source line). Parse it to
// an integer when provided and thread it ONLY when present, so the backend's
// line-without-pdg / non-positive-integer validation fires on the real value
// rather than on a silently-dropped flag. A non-numeric `--line` parses to
// NaN, which the backend rejects as a non-positive integer (loud, not silent).
const parsedLine = options?.line !== undefined ? parseInt(options.line, 10) : undefined;
const result = await backend.callTool('impact', {
target: target || undefined,
target_uid: options?.uid,
file_path: options?.file,
kind: options?.kind,
direction: options?.direction || 'upstream',
// Forward the engine selector; backend validates the enum (callgraph/pdg)
// and treats the default 'callgraph' identically to an omitted mode.
mode: options?.mode,
// PDG-only statement anchor — forwarded only when --line was given.
...(parsedLine !== undefined ? { line: parsedLine } : {}),
maxDepth: options?.depth ? parseInt(options.depth, 10) : undefined,
includeTests: options?.includeTests ?? false,
repo: options?.repo,

View file

@ -339,7 +339,11 @@ function extractProcessNames(impact: unknown): string[] {
return o.affected_processes.map((p) => String(p.name ?? '')).filter(Boolean);
}
function mergeRisk(localRisk: string, cross: CrossRepoImpact[]): string {
// Exported so the U4 PDG-result interchangeability contract (KTD8) can assert
// permanently that a PDG `risk:'UNKNOWN'` never coalesces to a confident `LOW`.
// No behavior change — `'UNKNOWN'` was already handled correctly at the
// `(localRisk === 'LOW' || localRisk === 'UNKNOWN')` branch below.
export function mergeRisk(localRisk: string, cross: CrossRepoImpact[]): string {
const highConf = cross.some((c) => c.contract.confidence >= 0.85);
if (localRisk === 'CRITICAL') return 'CRITICAL';
if (cross.length >= 3) return 'CRITICAL';

View file

@ -6,6 +6,7 @@ import type { ContractExtractor, CypherExecutor } from '../contract-extractor.js
import type { ExtractedContract, RepoHandle } from '../types.js';
import { readSafe } from './fs-utils.js';
import { parseSourceSafe } from '../../tree-sitter/safe-parse.js';
import { logger } from '../../logger.js';
import {
getPluginForFile,
HTTP_SCAN_GLOB,
@ -40,13 +41,16 @@ import {
// ─── Graph-assisted queries ──────────────────────────────────────────
const HANDLES_ROUTE_QUERY = `
// Exported so integration tests can run the exact production query against a
// real LadybugDB (guards the Route.method column contract — see
// route-method-roundtrip.test.ts).
export const HANDLES_ROUTE_QUERY = `
MATCH (handlerFile:File)-[r:CodeRelation {type: 'HANDLES_ROUTE'}]->(route:Route)
RETURN handlerFile.id AS fileId, handlerFile.filePath AS filePath,
route.name AS routePath, route.id AS routeId,
route.method AS routeMethod,
route.responseKeys AS responseKeys,
r.reason AS routeSource`;
const FETCHES_QUERY = `
MATCH (callerFile:File)-[r:CodeRelation {type: 'FETCHES'}]->(route:Route)
RETURN callerFile.id AS fileId, callerFile.filePath AS filePath,
@ -324,7 +328,16 @@ export class HttpRouteExtractor implements ContractExtractor {
let rows: Record<string, unknown>[];
try {
rows = await db(HANDLES_ROUTE_QUERY);
} catch {
} catch (err) {
// A failure here silently disables the entire graph-assisted HTTP
// provider path (the source-scan fallback still runs and masks most
// of the damage), so surface it at debug level to make a total
// outage observable instead of invisible.
logger.debug(
`[http-route-extractor] HANDLES_ROUTE query failed; graph providers skipped: ${
err instanceof Error ? err.message : String(err)
}`,
);
return [];
}
@ -332,7 +345,14 @@ export class HttpRouteExtractor implements ContractExtractor {
const filePath = String(row.filePath ?? '');
const routePath = String(row.routePath ?? '');
const routeSource = String(row.routeSource ?? row.routeReason ?? '');
let method = methodFromRouteReason(routeSource);
// Prefer the HTTP verb persisted on the Route node by the ingestion
// routes phase (Spring/Laravel framework routes and decorator routes
// carry it). Fall back to parsing it out of the edge reason for
// older indexes or filesystem routes that never stored a method.
const graphMethod = String(row.routeMethod ?? '')
.trim()
.toUpperCase();
let method = (graphMethod || null) ?? methodFromRouteReason(routeSource);
// Look up handler name (and backfill method if missing) from the
// plugin's scan of the handler file. This replaces the old
@ -458,7 +478,12 @@ export class HttpRouteExtractor implements ContractExtractor {
let rows: Record<string, unknown>[];
try {
rows = await db(FETCHES_QUERY);
} catch {
} catch (err) {
logger.debug(
`[http-route-extractor] FETCHES query failed; graph consumers skipped: ${
err instanceof Error ? err.message : String(err)
}`,
);
return [];
}
for (const row of rows) {

View file

@ -62,7 +62,14 @@ const isGraphWide = (label: string): boolean => label === 'Community' || label =
* A→C edge. These are always extracted (and the orchestrator delete-alls them
* first, like Community/Process) so they rebuild from the fresh graph.
*/
const isGraphWideRelType = (type: string): boolean => type === 'TAINT_PATH';
// `CALL_SUMMARY` (PDG FU-C) is intra-procedural (a callee's RETURN-VALUE ASCENT
// depends only on its OWN body), but the orchestrator delete-alls it on an
// incremental `--pdg` writeback to keep the emit path single — so it must be
// re-included from the FULL fresh graph (which the emit phase recomputes every
// run) or an unchanged function's summary would be lost. Cheap: one self-loop
// edge per return-flowing function.
const isGraphWideRelType = (type: string): boolean =>
type === 'TAINT_PATH' || type === 'CALL_SUMMARY';
/**
* Build a Map<nodeId, filePath> for every File-bound node in the graph.

View file

@ -20,7 +20,7 @@
*/
import type { KnowledgeGraph } from '../../graph/types.js';
import { generateId } from '../../../lib/utils.js';
import { computeReachingDefs } from './reaching-defs.js';
import { computeReachingDefs, type ReachingDefsSolver } from './reaching-defs.js';
import { computeControlDependence } from './control-dependence.js';
import {
computePostDominators,
@ -28,7 +28,37 @@ import {
NO_IPDOM,
} from './post-dominators.js';
import { augmentForPostDom } from './synthetic-escape.js';
import type { BindingEntry, FunctionCfg } from './types.js';
import { DEFAULT_PDG_MAX_SITES_PER_STATEMENT } from './visitors/call-site-harvest.js';
import { calleeIdPosKey } from '../scope-resolution/graph-bridge/callee-id-sink.js';
import { encodeReachingDefReasonPairs } from './reaching-def-reason-codec.js';
import type { BasicBlockData, BindingEntry, FunctionCfg } from './types.js';
/**
* Reserved token placed in `BasicBlock.callees` when a statement's call sites
* were truncated at {@link DEFAULT_PDG_MAX_SITES_PER_STATEMENT}: the recorded
* callee list is then INCOMPLETE, so over-cap callees are absent. `*` is not a
* valid identifier leaf, so it cannot collide with a real callee name. The
* impact bridge treats a slice containing this sentinel as "callees unknown" and
* keeps reach callgraph-equal (proven), rather than falsely labeling an
* absent-but-real callee `unproven-bridge`.
*/
export const CALLEES_TRUNCATED_SENTINEL = '*';
/**
* Inner separator for the `BasicBlock.calleeIds` cell (resolved callee symbol
* ids). A TAB is used — NOT a space — because resolved ids embed `filePath` and
* C++ overload shape tags with multi-word primitive types (e.g. `unsigned char`,
* `long double`), so an id can legitimately contain a space; a space-joined cell
* then fragments on read and silently drops inter-procedural reach to that
* callee (#2227 tri-review). A tab cannot appear in a tree-sitter-derived id
* token (paths/identifiers/type tokens are tab-free) and round-trips intact
* through `escapeCSVField` (tab is in its preserved set) and the RFC-4180 COPY
* reader (every cell is quoted). Producer ({@link calleeIdsOfBlock}) and
* consumer (`splitCalleeIds`) import this single constant so they cannot drift.
* The sibling `callees` (leaf-name) cell stays space-joined — leaf names are
* bare identifiers and never contain a space.
*/
export const CALLEE_ID_SEP = '\t';
/**
* Default per-function CFG edge cap. A pathological generated function could
@ -255,11 +285,94 @@ export const hasEmitSafeFacts = (cfg: FunctionCfg): boolean => {
* 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.
*/
/**
* Space-joined, sorted, de-duplicated leaf callee names invoked directly in a
* block (`call`/`new` sites; the leaf of a dotted path — `child_process.exec` ⇒
* `exec`). This is the persisted substrate for statement-precise inter-procedural
* impact: a callee reached from a function is "proven" to be impacted by a
* changed statement iff its name appears in the callees of a block in that
* statement's dependence slice. `sites` is harvested only for TS/JS under `--pdg`
* (and absent on synthetic ENTRY/EXIT), so the field is empty elsewhere and the
* bridge degrades to the prior (callgraph-equal) behavior. Space-joined because
* leaf names are identifiers (no spaces) and the field is itself one CSV cell.
*/
export function calleesOfBlock(block: BasicBlockData): string {
const names = new Set<string>();
for (const stmt of block.statements ?? []) {
// A statement whose recorded sites reached the per-statement cap may have
// dropped over-cap callees (the harvester stops at the cap). Flag the block
// callee-unknown so the impact bridge keeps it callgraph-equal rather than
// under-proving an absent-but-real callee.
if ((stmt.sites?.length ?? 0) >= DEFAULT_PDG_MAX_SITES_PER_STATEMENT) {
names.add(CALLEES_TRUNCATED_SENTINEL);
}
for (const site of stmt.sites ?? []) {
if (site.kind === 'member-read') continue;
const callee = site.callee;
if (!callee) continue;
const leaf = callee.slice(callee.lastIndexOf('.') + 1);
if (leaf) names.add(leaf);
}
}
return [...names].sort().join(' ');
}
/**
* Tab-joined ({@link CALLEE_ID_SEP}), sorted, de-duplicated RESOLVED callee symbol ids invoked
* directly in a block — the SOUND parallel to {@link calleesOfBlock}'s leaf
* names (#2227 follow-up plan U3, KTD1/KTD2/KTD7). Each block site's call-site
* anchor `at` (U1) is joined by EXACT position to the per-file resolved-id map
* `fileMap` (U2's `(line,col) → Set<calleeId>`), so a callee reached from a
* function is proven impacted by a changed statement iff its resolved id — not
* just its leaf NAME — appears in a slice block's `calleeIds`. This eliminates
* the same-leaf-name collision (false-proven) and import-alias (false-unproven)
* the name predicate suffers on overloading languages.
*
* The site partitioning is inherited verbatim from {@link calleesOfBlock}: the
* SAME `member-read`-skip and the SAME per-statement site cap (R7) — a capped
* statement adds {@link CALLEES_TRUNCATED_SENTINEL} so the bridge keeps the
* block callee-unknown for ids too (callgraph-equal rather than under-proving).
* Because `at` is the SAME anchor the CALLS resolution keyed `atRange` on
* (KTD7), the join lands on exactly the sites the name harvest partitioned,
* including the nested-function exclusion (so a single-line inline closure's
* inner call never leaks its id into the outer block).
*
* `fileMap` is the resolved-id map for THIS file (`calleeIdAccumulator.get(
* filePath)` in run.ts). Absent (pdg off, or a file with no captured CALLS) ⇒
* `''` — the bridge then degrades to the leaf-name fallback (R3). A site whose
* `at` is absent (pre-U1 channel) or whose position is not in the map
* contributes no id (graceful, never throws).
*/
export function calleeIdsOfBlock(
block: BasicBlockData,
fileMap: ReadonlyMap<string, ReadonlySet<string>> | undefined,
): string {
if (fileMap === undefined) return '';
const ids = new Set<string>();
for (const stmt of block.statements ?? []) {
// Mirror calleesOfBlock's cap signal: an over-cap statement dropped sites,
// so the id list is INCOMPLETE — flag the block callee-unknown (R7).
if ((stmt.sites?.length ?? 0) >= DEFAULT_PDG_MAX_SITES_PER_STATEMENT) {
ids.add(CALLEES_TRUNCATED_SENTINEL);
}
for (const site of stmt.sites ?? []) {
if (site.kind === 'member-read') continue;
const at = site.at;
if (!at) continue;
const resolved = fileMap.get(calleeIdPosKey(at[0], at[1]));
if (resolved === undefined) continue;
for (const id of resolved) ids.add(id);
}
}
return [...ids].sort().join(CALLEE_ID_SEP);
}
export function emitFileCfgs(
graph: KnowledgeGraph,
cfgs: readonly FunctionCfg[],
maxEdgesPerFunction: number = DEFAULT_MAX_CFG_EDGES_PER_FUNCTION,
onWarn?: (message: string) => void,
calleeIdMap?: ReadonlyMap<string, ReadonlySet<string>>,
): CfgEmitResult {
const result: CfgEmitResult = { blocks: 0, edges: 0, droppedEdges: 0, cappedFunctions: 0 };
const cap = maxEdgesPerFunction > 0 ? maxEdgesPerFunction : Infinity;
@ -277,6 +390,16 @@ export function emitFileCfgs(
startLine: b.startLine,
endLine: b.endLine,
text: b.text,
// Space-joined leaf callee names invoked in this block — the
// statement-precise inter-procedural reach substrate. Harvested from
// the per-statement `sites` (already on the side channel); dropping
// them here is what made the impact-mode bridge labeling degenerate.
callees: calleesOfBlock(b),
// Space-joined RESOLVED callee symbol ids — the SOUND parallel to
// `callees`, joined from the U2 map by each site's exact `at`
// position (#2227 follow-up U3). Absent map (pdg off / no captures)
// ⇒ `''`, and the bridge falls back to the leaf-name match (R3).
calleeIds: calleeIdsOfBlock(b, calleeIdMap),
},
});
result.blocks++;
@ -361,6 +484,11 @@ export function emitFileReachingDefs(
cfgs: readonly FunctionCfg[],
maxEdgesPerFunction: number = DEFAULT_PDG_MAX_REACHING_DEF_EDGES_PER_FUNCTION,
onWarn?: (message: string) => void,
// U12: a per-file memoized solver lets the RD-emit / harvest / taint passes
// share the SAME per-function fixpoint (this caller is its own cache bucket —
// it passes maxBlockVisits, the harvest/taint callers do not). Defaults to the
// plain solver so existing callers are unaffected.
solve: ReachingDefsSolver = computeReachingDefs,
): ReachingDefEmitResult {
const result: ReachingDefEmitResult = {
edges: 0,
@ -385,7 +513,7 @@ export function emitFileReachingDefs(
);
continue;
}
const r = computeReachingDefs(cfg, {
const r = solve(cfg, {
maxFacts,
maxBlockVisits: cfg.blocks.length * DEFAULT_PDG_MAX_REACHING_DEF_BLOCK_REVISITS,
});
@ -412,18 +540,47 @@ export function emitFileReachingDefs(
}
// Dedup to (defBlock, useBlock, binding) — facts arrive sorted, so the
// deduped order (and therefore cap truncation) is deterministic.
const seen = new Set<string>();
const deduped: { defBlock: number; useBlock: number; bindingIdx: number }[] = [];
// deduped order (and therefore cap truncation) is deterministic. ONE edge per
// group (the edge COUNT is unchanged — substrate/bench safe), but the FU-B-2
// annotation AGGREGATES the FULL ordered list of (defLine, useLine) pairs for
// that group into the persisted `reason`. The first fact of a group (facts
// sort by def block, def stmt, use block, use stmt, binding) keeps the group's
// emit position; every subsequent fact of the SAME (block-pair, binding)
// appends its line pair to that group's list. Carrying the full list (not just
// the first pair) is what makes a SAME-BINDING reassignment chain recoverable:
// `acc = f(acc); acc = g(acc)` coalesces into one self-block whose
// `acc@N->acc@N+1` and `acc@N+1->acc@N+2` steps share the one group — a
// first-pair-only annotation could chain N->N+1 but never reach N+2. Dedup of
// exact-duplicate pairs within a group keeps the list compact (a `x = x + 1`
// self-fact never re-adds the same pair).
const groupIndex = new Map<string, number>();
const deduped: {
defBlock: number;
useBlock: number;
bindingIdx: number;
pairs: { defLine: number; useLine: number }[];
}[] = [];
for (const f of r.facts) {
const key = `${f.def.blockIndex}:${f.use.blockIndex}:${f.bindingIdx}`;
if (seen.has(key)) continue;
seen.add(key);
deduped.push({
defBlock: f.def.blockIndex,
useBlock: f.use.blockIndex,
bindingIdx: f.bindingIdx,
});
const at = groupIndex.get(key);
const pair = { defLine: f.def.line, useLine: f.use.line };
if (at === undefined) {
groupIndex.set(key, deduped.length);
deduped.push({
defBlock: f.def.blockIndex,
useBlock: f.use.blockIndex,
bindingIdx: f.bindingIdx,
pairs: [pair],
});
continue;
}
const list = deduped[at].pairs;
// Skip an exact-duplicate (defLine, useLine) — a self-referential statement
// (`x = x + 1`) emits the same line pair more than once; the list only needs
// each distinct step once for the projection walk.
if (!list.some((p) => p.defLine === pair.defLine && p.useLine === pair.useLine)) {
list.push(pair);
}
}
let emittedForFn = 0;
@ -476,7 +633,16 @@ export function emitFileReachingDefs(
sourceId,
targetId,
confidence: 1.0,
reason: binding.name, // plain source-level name (M0/S1 verdict) — queryable
// FU-B-2: the source-level binding name (M0/S1 verdict — name FIRST so
// `pdg_query` flows stays queryable) PLUS a compact versioned annotation
// carrying the FULL ordered list of def/use source LINE pairs for this
// (block-pair, binding) group. For a self-edge (defBlock === useBlock)
// this captures the intra-block def@L→use@L' chain — including a
// SAME-BINDING reassignment chain (`acc@24->acc@25->acc@26`) — that the
// block-granular projection lost; the statement projection (pdg-impact.ts)
// walks the list forward to fixpoint to recover the coalesced block's
// interior statements.
reason: encodeReachingDefReasonPairs(binding.name, edge.pairs),
});
result.edges++;
emittedForFn++;

View file

@ -0,0 +1,162 @@
/**
* REACHING_DEF reason codec (PDG FU-B-2) — the ONE shared encoder/decoder for
* the source-level annotation carried on a persisted `REACHING_DEF` edge's
* `reason` column.
*
* A `REACHING_DEF` edge is `(defBlock:BasicBlock)->(useBlock:BasicBlock)` for one
* binding. The persisted columns (`from,to,type,confidence,reason,step`) are
* DEDUPED to `(defBlock, useBlock, bindingIdx)` (emit.ts), so the persisted
* edge cannot, by itself, recover the def→use chain WITHIN a coalesced
* straight-line BasicBlock: lines 7-9 of `chainCompute` collapse to one block
* and `a@7 -> b@8 -> c@9` become block-self edges with no line information. This
* codec ANNOTATES each edge with the ORDERED LIST of (defLine, useLine) source
* lines for that (block-pair, binding) group — the full set of def→use steps the
* solver produced for it — so the statement-granular intra-block chain is
* recoverable at projection time (pdg-impact.ts) WITHOUT widening the dedup key:
* the edge COUNT is unchanged (still one edge per group), only the `reason`
* carries the pair LIST (RD/taint substrate + cfg-bench budgets protected — the
* cfg-bench canon does not include the persisted `reason`).
*
* Carrying the FULL list (not just the FIRST pair) is what makes a SAME-BINDING
* reassignment chain recoverable: `acc = f(acc); acc = g(acc); acc = h(acc)`
* coalesces into one block, and ALL of `acc@24->acc@25`, `acc@25->acc@26`,
* `acc@26->acc@27` share the one `(self-block, self-block, accIdx)` group. A
* first-pair-only annotation could chain `24->25` but never reach `26`; the full
* list lets the projection walk the whole chain to fixpoint.
*
* ## Wire format (version `1`)
*
* ```
* <name> (legacy / pre-FU-B-2 — bare name)
* <name>|1:<d1>:<u1> (FU-B-2, single pair)
* <name>|1:<d1>:<u1>;<d2>:<u2>;... (FU-B-2, ordered pair LIST)
* ```
*
* The binding NAME comes FIRST, verbatim, so the established read paths keep
* working with a trivial change: `pdg_query` mode:'flows' filters the variable
* by `r.reason = $variable OR r.reason STARTS WITH $variable|` and projects the
* name via {@link decodeReachingDefReason}. Source identifiers never contain `|`
* (the structural separator), so the name is unambiguously the substring before
* the first `|`; an un-annotated reason has no `|` and decodes to itself with no
* line info. Within the annotation, `;` separates pairs and `:` separates the
* version + the two lines of each pair. `<defLine>`/`<useLine>` are 1-based
* decimal source lines.
*
* ## Delimiter / round-trip discipline (mirrors call-summary-codec KTD6)
*
* Every structural character (`|`, `:`, `;`, the version digit, decimal digits)
* is printable ASCII, so the encoding survives `escapeCSVField ∘ sanitizeUTF8`
* (csv-generator.ts) byte-exact. The decoder NEVER throws — anything not a
* well-formed version-`1` annotation degrades to "name only, no pairs" (the sound
* default: the projection then falls back to block-start granularity exactly as
* before FU-B-2). A malformed individual pair within an otherwise-well-formed
* list is dropped; the well-formed pairs are kept.
*/
/** One-character format version prefix. Bump on any wire-format change. */
export const REACHING_DEF_REASON_CODEC_VERSION = '1';
/**
* Structural separator between the binding name and the versioned annotation.
* Source identifiers cannot contain it, so the name is the substring before the
* first occurrence (and a name with no occurrence is a legacy bare-name reason).
*/
const NAME_SEP = '|';
/** Separator between consecutive (defLine:useLine) pairs in the annotation. */
const PAIR_SEP = ';';
/** One def→use source-line step within a coalesced block's self chain. */
export interface DefUseLinePair {
/** 1-based def source line. */
readonly defLine: number;
/** 1-based use source line. */
readonly useLine: number;
}
/** A decoded REACHING_DEF reason. `pairs` is empty for a legacy (un-annotated)
* reason. `defLine`/`useLine` mirror the FIRST pair for back-compat consumers. */
export interface DecodedReachingDefReason {
/** The source-level binding name (always present — the legacy payload). */
readonly name: string;
/**
* The ordered list of (defLine, useLine) steps the FU-B-2 annotation carries.
* Empty for a legacy / malformed / un-annotated reason.
*/
readonly pairs: readonly DefUseLinePair[];
/** 1-based def source line of the FIRST pair (back-compat; absent if none). */
readonly defLine?: number;
/** 1-based use source line of the FIRST pair (back-compat; absent if none). */
readonly useLine?: number;
}
/** Whether a (defLine, useLine) pair is a well-formed 1-based-or-0 integer pair. */
function isValidPair(defLine: number, useLine: number): boolean {
return Number.isInteger(defLine) && Number.isInteger(useLine) && defLine >= 0 && useLine >= 0;
}
/**
* Encode a binding name + its ordered (defLine, useLine) step list into the
* versioned `reason` wire string. Deterministic; never throws. Malformed /
* negative pairs are dropped (defensive — the solver always passes 1-based
* integers); if NO valid pair survives the result degrades to the bare name
* (legacy form) so a malformed annotation never fabricates bad lines. The name
* is written verbatim FIRST (see the module doc), so a name that —
* pathologically — already contains `|` would be re-decoded with a truncated
* name; binding names are source identifiers, which never contain `|`, so this
* cannot occur for real input (and would only lose line precision, never corrupt
* the substrate).
*/
export function encodeReachingDefReasonPairs(
name: string,
pairs: ReadonlyArray<DefUseLinePair>,
): string {
const valid = pairs.filter((p) => isValidPair(p.defLine, p.useLine));
if (valid.length === 0) return name;
const body = valid.map((p) => `${p.defLine}:${p.useLine}`).join(PAIR_SEP);
return `${name}${NAME_SEP}${REACHING_DEF_REASON_CODEC_VERSION}:${body}`;
}
/**
* Single-pair convenience over {@link encodeReachingDefReasonPairs} — kept for
* call sites and tests that carry exactly one def→use step.
*/
export function encodeReachingDefReason(name: string, defLine: number, useLine: number): string {
return encodeReachingDefReasonPairs(name, [{ defLine, useLine }]);
}
/**
* Decode a REACHING_DEF `reason` wire string into its binding name + (when
* present) the FU-B-2 def/use source-line pair LIST. Never throws — a non-string,
* an un-annotated bare name, or a malformed annotation all yield `{ name, pairs:
* [] }` (the sound default: the consumer falls back to block-start granularity).
* A well-formed `<name>|1:<d1>:<u1>;<d2>:<u2>;...` yields every well-formed pair
* (a single malformed pair is dropped, the rest kept); `defLine`/`useLine` mirror
* the first pair for back-compat consumers.
*/
export function decodeReachingDefReason(reason: unknown): DecodedReachingDefReason {
const raw = typeof reason === 'string' ? reason : '';
const sep = raw.indexOf(NAME_SEP);
if (sep === -1) return { name: raw, pairs: [] };
const name = raw.slice(0, sep);
const annotation = raw.slice(sep + 1);
// Annotation is `<version>:<d1>:<u1>;<d2>:<u2>;...`. Split off the version
// prefix once: the first colon ends the version token; the remainder is the
// `;`-separated pair body.
const firstColon = annotation.indexOf(':');
if (firstColon === -1 || annotation.slice(0, firstColon) !== REACHING_DEF_REASON_CODEC_VERSION) {
return { name, pairs: [] };
}
const body = annotation.slice(firstColon + 1);
const pairs: DefUseLinePair[] = [];
for (const chunk of body.split(PAIR_SEP)) {
const parts = chunk.split(':');
if (parts.length !== 2) continue; // malformed pair — drop it, keep the rest
const defLine = Number(parts[0]);
const useLine = Number(parts[1]);
if (!isValidPair(defLine, useLine)) continue;
pairs.push({ defLine, useLine });
}
if (pairs.length === 0) return { name, pairs: [] };
return { name, pairs, defLine: pairs[0].defLine, useLine: pairs[0].useLine };
}

View file

@ -243,6 +243,37 @@ export function computeReachingDefs(cfg: FunctionCfg, limits?: ReachingDefsLimit
return solveReachingDefs(cfg, limits, computeInSetsAuto);
}
/** A reaching-defs solver — {@link computeReachingDefs} or a memoized wrapper. */
export type ReachingDefsSolver = (cfg: FunctionCfg, limits?: ReachingDefsLimits) => FunctionDefUse;
/**
* Per-file memoized reaching-defs solver (#2227 tri-review, U12). Under `--pdg`
* the SAME per-function RD fixpoint was solved 3–4× per analyze (RD emit +
* call-summary harvest + taint + summary harvest). Cache by (cfg identity,
* limits) so each DISTINCT solve runs once: the RD-emit bucket (passes
* `maxBlockVisits`) and the harvest/taint bucket (does not) stay byte-identical
* to their inline solves because the limits are part of the key. Lazy — solves
* on first request, so the taint zero-match fast path still skips its solve.
* Create one per FILE and drop it after the file to bound the per-function
* `facts` arrays (100k+ objects on a huge function) from going whole-repo.
*/
export function createMemoizedReachingDefs(): ReachingDefsSolver {
const cache = new Map<FunctionCfg, Map<string, FunctionDefUse>>();
return (cfg, limits) => {
const key = `${limits?.maxFacts ?? ''}|${limits?.maxBlockVisits ?? ''}`;
let byKey = cache.get(cfg);
if (byKey === undefined) {
byKey = new Map();
cache.set(cfg, byKey);
}
const hit = byKey.get(key);
if (hit !== undefined) return hit;
const result = computeReachingDefs(cfg, limits);
byKey.set(key, result);
return result;
};
}
/**
* Dense GEN/KILL monotone worklist — the original (#2082 M2) reaching-defs
* solver. As of #2201 it plays two roles: (1) the production dispatcher

View file

@ -40,6 +40,20 @@ export interface BindingEntry {
* `name@module` in edge ids instead of `name:line:col`.
*/
readonly synthetic?: boolean;
/**
* For `kind: 'param'` bindings only: the 0-based ENCLOSING TOP-LEVEL FORMAL
* position this binding belongs to — the index a call site's argument position
* joins against (PDG FU-C). For a simple identifier formal this equals the
* param's ordinal; for a DESTRUCTURED/REST formal every inner name carries the
* SAME formal index (`function f({a, b}, c)` ⇒ a:0, b:0, c:1), so a downstream
* positional consumer never mistakes the destructured-object formal for a later
* simple formal. Set by the per-language `declareParams`; OMITTED when the
* producer does not (yet) supply it — a consumer that needs a sound formal
* position MUST treat a param binding without `formalIndex` as unknown and fall
* back conservatively (never attribute a flattened ordinal to a formal slot).
* Omit-when-absent (pre-upgrade durable channels stay valid; JSON-plain).
*/
readonly formalIndex?: number;
}
/**
@ -126,6 +140,35 @@ export interface SiteRecord {
* included; dynamic `req[key]` is never recorded — documented KTD10 FN).
*/
readonly property?: string;
/**
* Call-site anchor source position `[line (1-based), column (0-based)]` for
* call/new sites only — member-read sites omit it (the resolved-id join only
* consumes call/new). Recorded by the harvester at the call/new node where it
* reads the callee, so the later resolved-callee-id join inherits this
* harvester's exact (nested-function-excluded — see line 150) site
* partitioning (#2227 follow-up plan KTD1).
*
* ANCHOR ALIGNMENT (plan KTD7 — load-bearing): this MUST be the SAME position
* the CALLS-edge resolution keys its `atRange` on, because a downstream unit
* joins the two by EXACT position. That anchor is the WHOLE call/new
* expression node's start — `nodeToCapture('@reference.call.*', node)` in the
* scope-extractor anchors `@reference.call.free/.member/.constructor` on the
* `call_expression`/`new_expression` (TS) / `method_invocation`/
* `object_creation_expression` (Java) node itself (the callee identifier /
* member property is the `@reference.name` SUB-tag, never the anchor — see
* `anchorCaptureFor` + `KNOWN_SUB_TAGS` in scope-extractor.ts, and
* `atRange: anchor.range` at scope-extractor.ts:1030). So for a bare call
* `foo(x)`, a member call `arr.map(x)`, and a namespaced/chained call
* `a.b.c(x)` alike, `at` is the start of the enclosing call/new expression
* node — the harvester's `visitCall`/`visitNew` receives exactly that node and
* records `[node.startPosition.row + 1, node.startPosition.column]`. (For a
* member call the call expression starts at the receiver, e.g. `arr` in
* `arr.map(x)`, and the CALLS anchor starts there too — they match.)
*
* Omit-when-absent (pre-upgrade durable channels stay valid; JSON-plain; NOT
* named `nodeId` per the reviver hazard above).
*/
readonly at?: readonly [number, number];
}
/**

View file

@ -459,7 +459,9 @@ export class CCppHarvester extends ScopeTreeHarvester {
private visitCall(node: SyntaxNode, acc: FactAccumulator, kind: 'call' | 'new'): void {
const calleeNode = node.childForFieldName(kind === 'new' ? 'type' : 'function');
const argsNode = node.childForFieldName('arguments');
const siteIdx = acc.openCallSite(kind);
// `node` IS the call/new expression — the SAME node the scope-extractor
// anchors `@reference.call.*` (its `atRange`) on (KTD7).
const siteIdx = acc.openCallSite(kind, [node.startPosition.row + 1, node.startPosition.column]);
acc.pushFrame(siteIdx);
let calleePath: string | undefined;
if (calleeNode) {

View file

@ -29,6 +29,7 @@
* NOTE: nothing serialized here may carry a field named `nodeId` — the durable
* parsedfile-store reviver dedups objects keyed on that field name.
*/
import type { SyntaxNode } from '../../utils/ast-helpers.js';
import type { SiteArgOccurrence, SiteRecord, StatementFacts } from '../types.js';
/** Mutable build-time view of a {@link SiteRecord}. */
@ -44,6 +45,8 @@ interface MutableSite {
requireArg?: string;
object?: number;
property?: string;
/** Call-site anchor position — see {@link SiteRecord.at}. Call/new only. */
at?: [number, number];
}
/**
@ -214,8 +217,14 @@ export class CallSiteFactAccumulator {
* Returns the new site index, or -1 when the per-statement site cap is hit
* (the caller threads -1 through `pushFrame`/`setSite*`, all of which no-op on
* a sentinel index — see {@link DEFAULT_PDG_MAX_SITES_PER_STATEMENT}).
*
* `at` is the call/new node's anchor position `[line (1-based), col (0-based)]`
* — the SAME position the CALLS-edge resolution keys on (see
* {@link SiteRecord.at} for the KTD7 alignment); the harvester passes its
* `visitCall`/`visitNew` node's `startPosition` so the downstream resolved-id
* join lands by exact position.
*/
openCallSite(kind: 'call' | 'new'): number {
openCallSite(kind: 'call' | 'new', at?: readonly [number, number]): number {
if (this.sites.length >= DEFAULT_PDG_MAX_SITES_PER_STATEMENT) {
this._sitesTruncated = true;
return -1;
@ -223,6 +232,7 @@ export class CallSiteFactAccumulator {
const site: MutableSite = { kind };
const parent = this.innermostArgPosition();
if (parent) site.parent = parent;
if (at) site.at = [at[0], at[1]];
this.sites.push(site);
return this.sites.length - 1;
}
@ -372,3 +382,64 @@ const finalizeSite = (site: MutableSite): SiteRecord => {
}
return site as SiteRecord;
};
/**
* Per-grammar hooks the shared {@link finalizeChain} terminal needs but cannot
* name itself (it carries no tree-sitter literals — see the file header). Each
* harvester supplies the two callbacks bound to its own `this`.
*/
export interface ChainTerminalHooks {
/** Resolve a binding-target node to its function-table binding index. */
resolve(node: SyntaxNode): number;
/**
* Walk a NON-identifier chain root for its uses + nested sites (the terminal's
* `else` branch — `self.x.f()`, `foo().bar`, a tuple index, etc.).
*/
walkRoot(node: SyntaxNode): void;
}
/**
* Shared `walkChain` TERMINAL (#2227 follow-up, plan KTD5/U8) — the byte-identical
* post-unwind block the Go / Kotlin / Swift / Rust / Python harvesters all ran
* after walking their grammar-specific access chain (`selector_expression` /
* `navigation_expression` / `field_expression` / `attribute`) into an
* `accesses: string[]` list and a resolved root node `cur`.
*
* It records the chain-root identifier as a use, emits at most ONE member-read
* site — the INNERMOST access — when the root is an identifier (suppressed by
* `skipFinalRead` when that access IS the callee, carried by the dotted path
* instead), and builds the dotted path `[root, ...accesses].join('.')`. The only
* per-grammar bit is the root identifier node type, supplied via `isRootIdType`
* (`'identifier'` for Go/Rust/Python, `'simple_identifier'` for Kotlin/Swift);
* the `resolve` / `walkRoot` callbacks bind the harvester's own methods. The
* `addUse` / `addMemberRead` machinery is on the accumulator itself, so it is
* called directly (no callback). Behavior is identical to the inlined terminals
* this replaces — the per-language harvest tests are the characterization lock.
*/
export function finalizeChain(
acc: CallSiteFactAccumulator,
cur: SyntaxNode,
accesses: readonly string[],
skipFinalRead: boolean,
isRootIdType: (type: string) => boolean,
hooks: ChainTerminalHooks,
): { path?: string; rootIdx?: number } {
let rootIdx: number | undefined;
let rootSegment: string | undefined;
if (isRootIdType(cur.type) && cur.text !== '_') {
rootIdx = hooks.resolve(cur);
acc.addUse(rootIdx);
rootSegment = cur.text;
} else {
hooks.walkRoot(cur);
}
const innermost = accesses[0];
if (rootIdx !== undefined && innermost && !(skipFinalRead && accesses.length === 1)) {
acc.addMemberRead(rootIdx, innermost);
}
const path =
rootSegment !== undefined && accesses.every((a) => a !== '')
? [rootSegment, ...accesses].join('.')
: undefined;
return { path, rootIdx };
}

View file

@ -466,7 +466,9 @@ export class CsharpHarvester extends ScopeTreeHarvester {
private visitCall(node: SyntaxNode, acc: FactAccumulator, kind: 'call' | 'new'): void {
const calleeNode = node.childForFieldName(kind === 'new' ? 'type' : 'function');
const argsNode = node.childForFieldName('arguments');
const siteIdx = acc.openCallSite(kind);
// `node` IS the call/object-creation expression — the SAME node the
// scope-extractor anchors `@reference.call.*` (its `atRange`) on (KTD7).
const siteIdx = acc.openCallSite(kind, [node.startPosition.row + 1, node.startPosition.column]);
acc.pushFrame(siteIdx);
let calleePath: string | undefined;
if (calleeNode) {

View file

@ -1,11 +1,46 @@
/**
* Dart def/use harvester (#2195) — the Dart analogue of
* {@link import('./kotlin-harvest.js').KotlinHarvester} and the Swift / Python /
* Rust harvesters. Like them it harvests NO call-site `sites[]` (the call-site
* taint substrate is a later step): it emits only the per-function binding table
* ({@link BindingEntry}[]) plus {@link StatementFacts} (defs / uses / mayDefs) via
* a local {@link FactAccumulator} with no site machinery, so the produced facts
* never carry a `sites` key.
* {@link import('./python-harvest.js').PythonHarvester} and the C-family
* harvesters. Like Python it harvests per-function binding tables
* ({@link BindingEntry}[]) plus {@link StatementFacts} (defs / uses / mayDefs)
* AND a taint {@link import('../types.js').SiteRecord} per call / `new` (callee
* path, receiver, per-arg occurrence entries, result defs, spread marker, and an
* `at` anchor) via the shared {@link CallSiteFactAccumulator} — the same site
* substrate the C-family / Go / TS / Python harvesters emit.
*
* DART HAS NO `call_expression` NODE (verified by a real parse — see below). A
* call is a FLAT SIBLING RUN under a container (`expression_statement`,
* `argument`, an `initialized_variable_definition`'s `value` field,
* `await_expression`, …): a chain HEAD (`identifier` / `this` / `super` / a
* parenthesized expr) immediately followed by one or more `selector` siblings.
* A `selector` whose inner is an `argument_part` is the CALL marker (the prefix
* up to it is the callee); a `selector` whose inner is an
* `unconditional_assignable_selector` / `conditional_assignable_selector`
* (`.name` / `?.name`) is a member access. So `foo(a, b)` parses as
* `identifier foo` + `selector (a, b)`; `obj.method(x)` as `identifier obj` +
* `selector .method` + `selector (x)`; `a.b.c()` as `a` + `.b` + `.c` + `()`.
* A `new Foo(…)` IS a single `new_expression` node (`type_identifier` +
* `arguments`) — the only `kind: 'new'` shape. An UpperCamelCase bare call
* `Foo(…)` is an IMPLICIT constructor by Dart convention but is structurally a
* free call (`identifier` + `selector(argument_part)`), so it stays
* `kind: 'call'` (matching the scope-extractor, which tags it
* `@reference.call.constructor` on the same callee identifier — see below).
*
* ANCHOR ALIGNMENT (plan KTD7 — load-bearing): a call site's `at` MUST be the
* SAME `[line (1-based), col (0-based)]` the Dart CALLS resolution keys its
* `atRange` on, because a downstream unit joins the two by EXACT position. Dart
* has no whole-call node, so the scope-extractor anchors the CALLS reference NOT
* on a call expression but on the callee NAME identifier
* (`captures.ts emitSelectorReference`):
* - a FREE / implicit-constructor call `foo(…)` / `Foo(…)` →
* `@reference.call.free` / `.constructor` anchored on the callee `identifier`
* (`prev`), so `at` = that identifier's start.
* - a MEMBER call `obj.method(…)` → `@reference.call.member` anchored on the
* method-name `identifier` (`nameId`, inside the `.method` selector), so
* `at` = the method-name identifier's start — NOT the receiver `obj`.
* (A `new_expression` is NOT captured for CALLS by the Dart scope-resolution
* today, so a `new` site's `at` simply finds no resolved id — graceful, never a
* mis-join. A cascade `a..m(…)` resolves as a FREE call on its method name.)
*
* Runs in the parse worker next to the Dart CFG visitor. Output is the binding
* table the {@link import('../cfg-builder.js').CfgBuilder} stamps onto the CFG,
@ -78,7 +113,13 @@
*/
import type { SyntaxNode } from '../../utils/ast-helpers.js';
import type { BindingEntry, StatementFacts } from '../types.js';
import { DefUseAccumulator as FactAccumulator } from './call-site-harvest.js';
import { CallSiteFactAccumulator as FactAccumulator } from './call-site-harvest.js';
/** Selector inners that are a `.name` / `?.name` member access (not a call). */
const ASSIGNABLE_SELECTOR_TYPES = new Set([
'unconditional_assignable_selector',
'conditional_assignable_selector',
]);
/** Node types that own a nested CFG — their subtrees are opaque to harvesting. */
const NESTED_FUNCTION_TYPES = new Set(['function_expression', 'function_body']);
@ -95,6 +136,14 @@ export class DartHarvester {
private readonly fnId: number;
/** >0 while walking a conditionally-evaluated subexpression — defs become may-defs. */
private conditionalDepth = 0;
/**
* Chain-head / `new_expression` node id → binding indices its single-target
* result is assigned to (`var x = f()` / `x = g()` ⇒ `[x]`). Populated just
* before the value walk reaches the call (see {@link registerResultDefs}) and
* consumed by {@link visitChainCall} / {@link visitNew}. Mirrors the Python /
* Go harvesters' `resultDefTargets`.
*/
private readonly resultDefTargets = new Map<number, number[]>();
/**
* @param fnNode The function-bearing node: a `function_body` (whose previous
@ -394,9 +443,16 @@ export class DartHarvester {
this.use(node, acc);
return;
case 'initialized_variable_definition': {
const value = node.childForFieldName('value');
if (value) this.walkValue(value, acc);
const name = node.childForFieldName('name');
// The `value` field can REPEAT across a Dart postfix run (`= g` `(y)`):
// collect every `value`-tagged child as one chain and walk them together
// so a member/free call across the run is harvested as one site.
const valueRun = this.fieldRun(node, 'value');
// Register result-defs BEFORE the value walk so the call the value walk
// reaches carries them — single identifier target only (`var x = f()`);
// a trailing comma-separated `b = 2` declarator attaches nothing.
if (name && valueRun.length > 0) this.registerRunResultDefs(valueRun, [name]);
if (valueRun.length > 0) this.walkRun(valueRun, acc);
if (name) this.def(name, acc);
// Trailing comma-separated bindings (`var a = 1, b = 2;`): each
// `initialized_identifier` is an `identifier` + its own value expr.
@ -413,18 +469,18 @@ export class DartHarvester {
case 'assignment_expression': {
const lvalue = node.childForFieldName('left');
const op = node.childForFieldName('operator');
const value = node.childForFieldName('right');
if (value) this.walkValue(value, acc);
// A `right` field can repeat (an identifier + trailing selectors): walk
// every named child after the operator that isn't the lvalue.
for (const c of node.namedChildren) {
if (c === lvalue) continue;
if (c.type === 'assignable_expression') continue;
if (c === value) continue;
this.walkValue(c, acc);
// The `right` field can REPEAT across a postfix run (`= obj` `.m` `(y)`):
// collect every `right`-tagged child and group the run so a call across
// it is one site.
const rhs = this.fieldRun(node, 'right');
const scalar = lvalue ? this.scalarAssignTarget(lvalue) : undefined;
// A plain `x = <call>` attaches `resultDefs: [x]` (compound `+=` does not —
// the prior value flows in too).
if (scalar && op?.text === '=' && rhs.length > 0) {
this.registerRunResultDefs(rhs, [scalar]);
}
if (rhs.length > 0) this.walkRun(rhs, acc);
if (lvalue) {
const scalar = this.scalarAssignTarget(lvalue);
if (scalar) {
this.def(scalar, acc);
if (op && op.text !== '=') this.use(scalar, acc); // compound assign reads too
@ -466,8 +522,10 @@ export class DartHarvester {
return;
}
case 'selector': {
// `.name` / `(...args)` — a member-access suffix name is not a scalar
// binding; walk the argument part for uses but skip the bare property id.
// FALLBACK: a `selector` reached OUTSIDE a postfix run (normal chains are
// grouped + harvested by `walkChildren`/`walkRun`). `.name` / `(...args)`
// — a member-access suffix name is not a scalar binding; walk the argument
// part for uses but skip the bare property id. No site is emitted here.
for (const c of node.namedChildren) {
if (
c.type === 'unconditional_assignable_selector' ||
@ -480,36 +538,20 @@ export class DartHarvester {
return;
}
case 'logical_and_expression':
case 'logical_or_expression': {
// `a && b` / `a || b` — the right operand is conditionally evaluated.
const operands = node.namedChildren.filter((c) => !COMMENT_TYPES.has(c.type));
if (operands.length > 0) this.walkValue(operands[0], acc);
for (let i = 1; i < operands.length; i++) {
const rhs = operands[i];
this.conditional(() => this.walkValue(rhs, acc));
}
case 'logical_or_expression':
// `a && b` / `a || b` — the right operand is conditionally evaluated. The
// operand may be a flattened postfix run (`a && g(x)` ⇒ `a`, `g`,
// `selector`), so group runs and demote everything after the operator.
this.walkBinaryConditional(node, acc, new Set(['&&', '||']));
return;
}
case 'if_null_expression': {
case 'if_null_expression':
// `a ?? b` — the right operand only evaluates when the left is null.
const operands = node.namedChildren.filter((c) => !COMMENT_TYPES.has(c.type));
if (operands.length > 0) this.walkValue(operands[0], acc);
for (let i = 1; i < operands.length; i++) {
const rhs = operands[i];
this.conditional(() => this.walkValue(rhs, acc));
}
this.walkBinaryConditional(node, acc, new Set(['??']));
return;
}
case 'conditional_expression': {
case 'conditional_expression':
// `c ? a : b` — the condition runs always; both arms are conditional.
const operands = node.namedChildren.filter((c) => !COMMENT_TYPES.has(c.type));
if (operands.length > 0) this.walkValue(operands[0], acc);
for (let i = 1; i < operands.length; i++) {
const arm = operands[i];
this.conditional(() => this.walkValue(arm, acc));
}
this.walkBinaryConditional(node, acc, new Set(['?', ':']));
return;
}
case 'switch_expression': {
// `switch (x) { p1 => a, p2 => b }` (Dart 3): the subject runs always;
// each arm (pattern + value) is conditional, so a def inside an arm value
@ -524,6 +566,11 @@ export class DartHarvester {
}
return;
}
case 'new_expression':
// `new Foo(args)` — the only single-node call shape Dart has. Constructor
// site (`kind: 'new'`).
this.visitNew(node, acc);
return;
case 'inferred_type':
case 'final_builtin':
case 'type_identifier':
@ -531,13 +578,380 @@ export class DartHarvester {
// Binding keyword / type position — no scalar value uses.
return;
default:
for (let i = 0; i < node.namedChildCount; i++) {
const c = node.namedChild(i);
if (c) this.walkValue(c, acc);
}
// A container (`expression_statement`, `argument`, `await_expression`,
// `cascade_section`'s parent, …) whose children may form Dart postfix
// call/access RUNS (`identifier` + `selector*`). Group runs so a call is
// harvested as one site; non-run children walk normally.
this.walkChildren(node.namedChildren, acc);
}
}
// ── Dart postfix call/access chains (#2227 follow-up) ─────────────────────
/**
* Walk a container's named children, coalescing each Dart postfix RUN — a
* chain HEAD immediately followed by one or more `selector` (and/or a
* `cascade_section`) siblings — into a single {@link walkRun} so a member /
* free call across the run is harvested as ONE call site. A child that does
* not start a run walks via {@link walkValue} as before.
*/
private walkChildren(children: readonly SyntaxNode[], acc: FactAccumulator): void {
const named = children.filter((c) => !COMMENT_TYPES.has(c.type));
let i = 0;
while (i < named.length) {
const head = named[i];
// A run continues over immediately-following `selector` / `cascade_section`
// siblings (the postfix suffixes applied to `head`).
let end = i + 1;
while (end < named.length && this.isSuffix(named[end])) end++;
if (end > i + 1) {
this.walkRun(named.slice(i, end), acc);
} else {
this.walkValue(head, acc);
}
i = end;
}
}
/** A postfix-run suffix node: a `.name`/`(args)` `selector` or a `..m()` cascade. */
private isSuffix(node: SyntaxNode): boolean {
return node.type === 'selector' || node.type === 'cascade_section';
}
/**
* Walk a binary / ternary expression whose operands are FLATTENED across the
* node's children (`a ?? g(x)` ⇒ children `a`, `??`, `g`, `selector`). The
* children before the FIRST boundary operator (`boundaries`) run
* unconditionally; everything after a boundary is conditionally evaluated (a
* may-def context). Each segment is grouped via {@link walkChildren} so a
* postfix call split across children (`g` + `selector`) is one site.
*/
private walkBinaryConditional(
node: SyntaxNode,
acc: FactAccumulator,
boundaries: ReadonlySet<string>,
): void {
const segments: SyntaxNode[][] = [[]];
for (let i = 0; i < node.childCount; i++) {
const c = node.child(i);
if (!c || COMMENT_TYPES.has(c.type)) continue;
if (!c.isNamed && boundaries.has(c.text)) {
segments.push([]);
continue;
}
if (c.isNamed) segments[segments.length - 1].push(c);
}
// First segment unconditional; the rest are conditional arms / right operands.
if (segments[0].length > 0) this.walkChildren(segments[0], acc);
for (let i = 1; i < segments.length; i++) {
const seg = segments[i];
if (seg.length > 0) this.conditional(() => this.walkChildren(seg, acc));
}
}
/**
* Walk one postfix run `[head, suffix*]`. A lone node (no suffixes) is just a
* value walk. A run with suffixes is a Dart call/access chain: each `selector`
* whose inner is an `argument_part` is a call applied to the prefix; an
* assignable `.name`/`?.name` selector is a member access; a `cascade_section`
* with an `argument_part` is a free call on its method name.
*/
private walkRun(run: readonly SyntaxNode[], acc: FactAccumulator): void {
if (run.length === 1) {
this.walkValue(run[0], acc);
return;
}
const head = run[0];
const suffixes = run.slice(1);
const hasCascade = suffixes.some((s) => s.type === 'cascade_section');
// A cascade target (`a..m()`) is read once; its cascade calls are FREE calls
// on the method name (matching the scope-extractor's cascade classification).
if (hasCascade) {
this.walkValue(head, acc);
for (const s of suffixes) {
if (s.type === 'cascade_section') this.visitCascade(s, acc);
else this.walkValue(s, acc);
}
return;
}
// Plain postfix chain: walk left→right, opening a call site at each
// `selector(argument_part)`. The callee path / receiver come from the prefix.
// Only the LAST call in the run receives the binding result (`var x =
// a.b().c()` ⇒ x is `.c()`'s result, not `.b()`'s).
const lastCallIdx = this.lastCallSelectorIndex(run);
let chainStartUseRecorded = false;
let rootIdx: number | undefined;
let rootSegment: string | undefined;
const accesses: string[] = []; // dotted member segments since the last call
for (let i = 0; i < run.length; i++) {
const node = run[i];
if (node === head) {
const resolvedHead = this.chainHead(head);
if (resolvedHead) {
rootIdx = this.resolve(resolvedHead);
rootSegment = resolvedHead.text;
} else {
// A non-identifier head (parenthesized expr, literal) — walk it for
// uses; it has no static root segment.
this.walkValue(head, acc);
}
continue;
}
// node is a `selector`.
const inner = node.namedChild(0);
if (inner?.type === 'argument_part') {
// Call marker — `prefix(args)`. Record the chain root use (once). For a
// FREE call the head IS the callee NAME — a statement-level use but NOT a
// value occurrence in any enclosing argument (`exec(escape(x))` must not
// put `escape` into exec's arg 0). For a MEMBER call the head is the
// RECEIVER (a real value that launders taint) — a normal occurrence use.
if (rootIdx !== undefined && !chainStartUseRecorded) {
if (accesses.length === 0) acc.addUseWithoutOccurrence(rootIdx);
else acc.addUse(rootIdx);
chainStartUseRecorded = true;
}
// The accesses since the last call form the callee tail; the FINAL access
// IS the callee (carried by the path, `skipFinalRead`), but an inner
// access is a member READ — `a.b.c()` reads `a.b` then calls `.c` (mirror
// the Go / Python `walkChain` innermost member-read). A single access
// (`obj.method()`) is just the callee — no read.
if (rootIdx !== undefined && accesses.length >= 2) {
acc.addMemberRead(rootIdx, accesses[0]);
}
const anchor = this.callAnchor(run, i, head);
this.visitChainCall(node, inner, acc, {
rootIdx,
callee: this.calleePath(rootSegment, accesses),
anchor,
isMember: accesses.length > 0,
isLastCall: i === lastCallIdx,
});
// After a call the result is opaque — subsequent accesses have no static
// root (the call return value), so drop the path.
accesses.length = 0;
rootSegment = undefined;
rootIdx = undefined;
continue;
}
if (inner && ASSIGNABLE_SELECTOR_TYPES.has(inner.type)) {
// `.name` / `?.name` member access — extends the dotted path. A trailing
// access NOT followed by a call is a value-position member READ (record
// the root use + at most one member-read site at the innermost access).
const seg = this.selectorName(inner);
if (seg) accesses.push(seg.text);
const isLastAndNotCall = i === run.length - 1;
if (isLastAndNotCall && rootIdx !== undefined) {
if (!chainStartUseRecorded) {
acc.addUse(rootIdx);
chainStartUseRecorded = true;
}
// Innermost access is a member read (`a.b` in `a.b.c` value position).
if (accesses.length >= 1) acc.addMemberRead(rootIdx, accesses[0]);
}
continue;
}
// An index `[i]` / other selector — walk its inner for uses.
this.walkValue(node, acc);
}
// A chain that ended without any call but had a root (`a.b` as a whole value
// read) still records its root use even when the access loop didn't (e.g. a
// single non-call selector run reached here without a call).
if (rootIdx !== undefined && !chainStartUseRecorded) acc.addUse(rootIdx);
}
/** The chain root binding node — an `identifier` head (not `this`/`super`/literal). */
private chainHead(head: SyntaxNode): SyntaxNode | undefined {
return head.type === 'identifier' && head.text !== '_' ? head : undefined;
}
/** The bound name identifier of an assignable selector inner (`.name` ⇒ `name`). */
private selectorName(inner: SyntaxNode): SyntaxNode | undefined {
for (let i = inner.namedChildCount - 1; i >= 0; i--) {
const c = inner.namedChild(i);
if (c?.type === 'identifier') return c;
}
return undefined;
}
/** Dotted callee path `root.a.b` (or undefined when the root is not an identifier). */
private calleePath(
rootSegment: string | undefined,
accesses: readonly string[],
): string | undefined {
if (rootSegment === undefined) return undefined;
return [rootSegment, ...accesses].join('.');
}
/**
* The `at` anchor for a call `selector` at `run[i]`, byte-aligned with the
* Dart CALLS `atRange` (see file header): a FREE / implicit-constructor call
* (the call selector immediately follows the chain head) anchors on the head
* identifier; a MEMBER call anchors on the method-NAME identifier of the
* preceding `.method` selector.
*/
private callAnchor(run: readonly SyntaxNode[], i: number, head: SyntaxNode): [number, number] {
const prev = run[i - 1];
if (prev && prev.type === 'selector') {
const inner = prev.namedChild(0);
const nameId = inner ? this.selectorName(inner) : undefined;
if (nameId) return [nameId.startPosition.row + 1, nameId.startPosition.column];
}
// Free / constructor call — anchor on the chain head (the callee identifier).
return [head.startPosition.row + 1, head.startPosition.column];
}
/**
* Open + populate a call site for a Dart postfix `prefix(args)` call. The
* callee NAME is a statement-level use (recorded as the chain root above, or
* here for a bare free call), NOT a value occurrence in any enclosing argument.
*/
private visitChainCall(
selector: SyntaxNode,
argPart: SyntaxNode,
acc: FactAccumulator,
info: {
rootIdx: number | undefined;
callee: string | undefined;
anchor: [number, number];
isMember: boolean;
isLastCall: boolean;
},
): void {
const siteIdx = acc.openCallSite('call', info.anchor);
acc.pushFrame(siteIdx);
if (info.callee !== undefined) acc.setSiteCallee(siteIdx, info.callee);
// A member call (`obj.m(x)`, `a.b.c(x)`) launders taint through its receiver
// root; a bare free call (`foo(x)`) has no receiver.
if (info.isMember && info.rootIdx !== undefined) acc.setSiteReceiver(siteIdx, info.rootIdx);
// Only the run's terminal call receives the binding result (its `.parent` is
// the run's shared `initialized_variable_definition` / `assignment_expression`).
if (info.isLastCall) {
const resultDefs = this.resultDefTargets.get(selector.parent?.id ?? -1);
if (resultDefs !== undefined) acc.setSiteResultDefs(siteIdx, resultDefs);
}
this.walkArguments(argPart, siteIdx, acc);
acc.popFrame();
}
/** Index in `run` of the LAST call-marker selector (`selector(argument_part)`). */
private lastCallSelectorIndex(run: readonly SyntaxNode[]): number {
for (let i = run.length - 1; i >= 0; i--) {
const n = run[i];
if (n.type === 'selector' && n.namedChild(0)?.type === 'argument_part') return i;
}
return -1;
}
/** A single `new Foo(args)` constructor site (`kind: 'new'`). */
private visitNew(node: SyntaxNode, acc: FactAccumulator): void {
const typeId = node.namedChildren.find((c) => c.type === 'type_identifier');
const argsNode = node.namedChildren.find((c) => c.type === 'arguments');
// `new_expression` is NOT captured for CALLS by the Dart scope-resolution, so
// this `at` finds no resolved id — anchor on the node start for consistency.
const siteIdx = acc.openCallSite('new', [
node.startPosition.row + 1,
node.startPosition.column,
]);
acc.pushFrame(siteIdx);
if (typeId) acc.setSiteCallee(siteIdx, typeId.text); // the type is not a scalar binding
const resultDefs = this.resultDefTargets.get(node.id);
if (resultDefs !== undefined) acc.setSiteResultDefs(siteIdx, resultDefs);
if (argsNode) this.walkArgumentsNode(argsNode, siteIdx, acc);
acc.popFrame();
}
/** A cascade call `a..method(args)` — a FREE call on the method name. */
private visitCascade(cascade: SyntaxNode, acc: FactAccumulator): void {
const cascadeSelector = cascade.namedChildren.find((c) => c.type === 'cascade_selector');
const argPart = cascade.namedChildren.find((c) => c.type === 'argument_part');
// A property cascade (`..field = x`, no argument_part) is not a call — walk
// its non-selector children for uses and stop.
if (!argPart) {
for (const c of cascade.namedChildren) {
if (c.type === 'cascade_selector') continue;
this.walkValue(c, acc);
}
return;
}
const nameId = cascadeSelector
? (this.selectorName(cascadeSelector) ?? cascadeSelector)
: undefined;
const anchor: [number, number] = nameId
? [nameId.startPosition.row + 1, nameId.startPosition.column]
: [cascade.startPosition.row + 1, cascade.startPosition.column];
const siteIdx = acc.openCallSite('call', anchor);
acc.pushFrame(siteIdx);
if (nameId) acc.setSiteCallee(siteIdx, nameId.text);
this.walkArguments(argPart, siteIdx, acc);
acc.popFrame();
}
/** Walk a `selector → argument_part → arguments` for per-arg occurrences. */
private walkArguments(argPart: SyntaxNode, siteIdx: number, acc: FactAccumulator): void {
const args = argPart.namedChildren.find((c) => c.type === 'arguments');
if (args) this.walkArgumentsNode(args, siteIdx, acc);
}
/** Walk an `arguments` node, tagging each positional / named arg's occurrence position. */
private walkArgumentsNode(args: SyntaxNode, siteIdx: number, acc: FactAccumulator): void {
let pos = 0;
for (let i = 0; i < args.namedChildCount; i++) {
const arg = args.namedChild(i);
if (!arg || COMMENT_TYPES.has(arg.type)) continue;
if (arg.type === 'argument' || arg.type === 'named_argument') {
acc.setFrameArg(pos);
// The argument value may be a flattened postfix run (`escape(x)` ⇒
// `escape` + `selector(x)`) — group via `walkChildren` so a NESTED call is
// its own parent-linked site (the via-tagged sanitizer-interposition
// substrate). A `named_argument` (`k: v`) records only the VALUE
// occurrence — the `label` name (`k`) is dropped.
const valueChildren = arg.namedChildren.filter((c) => c.type !== 'label');
this.walkChildren(valueChildren, acc);
pos++;
} else {
// A spread `...xs` argument variant, if the grammar surfaces one.
acc.setFrameArg(pos);
acc.setSiteSpread(siteIdx, pos);
this.walkValue(arg, acc);
pos++;
}
}
}
/**
* Register result-defs for a single-target binding whose value RUN's terminal
* call / `new` should carry `[x]`: `var x = f()` / `var x = obj.m()` /
* `var x = new Foo()` / `x = g(y)`. Keyed so the run's call selector (whose
* `.parent` is the run's shared parent — the `initialized_variable_definition`
* or `assignment_expression`) AND a single `new_expression` run-node both hit.
*/
private registerRunResultDefs(run: readonly SyntaxNode[], targets: readonly SyntaxNode[]): void {
const defs: number[] = [];
for (const target of targets) {
if (target.type !== 'identifier' || target.text === '_') continue;
defs.push(this.resolve(target));
}
if (defs.length === 0) return;
// A postfix-chain run: its call selector keys on the run's shared parent.
const parentId = run[0]?.parent?.id;
if (parentId !== undefined) this.resultDefTargets.set(parentId, defs);
// A single `new Foo(…)` value: `visitNew` keys on the `new_expression` itself.
if (run.length === 1 && run[0].type === 'new_expression') {
this.resultDefTargets.set(run[0].id, defs);
}
}
/** The `field`-tagged children of `node` (a Dart postfix run flattens here). */
private fieldRun(node: SyntaxNode, field: string): SyntaxNode[] {
const run: SyntaxNode[] = [];
for (let i = 0; i < node.childCount; i++) {
const c = node.child(i);
if (!c || COMMENT_TYPES.has(c.type)) continue;
if (node.fieldNameForChild?.(i) === field) run.push(c);
}
return run;
}
/**
* The bare `identifier` of an `assignable_expression` lvalue WHEN it is a
* scalar target (`x = …`), or undefined when it is a member / subscript write

View file

@ -67,7 +67,7 @@
*/
import type { SyntaxNode } from '../../utils/ast-helpers.js';
import type { BindingEntry, StatementFacts } from '../types.js';
import { CallSiteFactAccumulator } from './call-site-harvest.js';
import { CallSiteFactAccumulator, finalizeChain } from './call-site-harvest.js';
import { ScopeTreeHarvester, type Scope, type FactAccumulator } from './scope-tree-harvest.js';
/** Node types that own a nested CFG — their subtrees are opaque to harvesting. */
@ -571,7 +571,12 @@ export class GoHarvester extends ScopeTreeHarvester {
private visitCall(node: SyntaxNode, acc: FactAccumulator): void {
const calleeNode = node.childForFieldName('function');
const argsNode = node.childForFieldName('arguments');
const siteIdx = acc.openCallSite('call');
// `node` IS the call_expression — the SAME node the scope-extractor anchors
// `@reference.call.*` (its `atRange`) on (KTD7).
const siteIdx = acc.openCallSite('call', [
node.startPosition.row + 1,
node.startPosition.column,
]);
acc.pushFrame(siteIdx);
let calleePath: string | undefined;
if (calleeNode) {
@ -640,24 +645,11 @@ export class GoHarvester extends ScopeTreeHarvester {
break;
}
}
let rootIdx: number | undefined;
let rootSegment: string | undefined;
if (cur.type === 'identifier' && cur.text !== '_') {
rootIdx = this.resolve(cur);
acc.addUse(rootIdx);
rootSegment = cur.text;
} else {
this.walkValue(cur, acc);
}
const innermost = accesses[0];
if (rootIdx !== undefined && innermost && !(skipFinalRead && accesses.length === 1)) {
acc.addMemberRead(rootIdx, innermost);
}
const path =
rootSegment !== undefined && accesses.every((a) => a !== '')
? [rootSegment, ...accesses].join('.')
: undefined;
return { path, rootIdx };
// The shared terminal: root-use record + innermost member-read + path-join.
return finalizeChain(acc, cur, accesses, skipFinalRead, (t) => t === 'identifier', {
resolve: (n) => this.resolve(n),
walkRoot: (n) => this.walkValue(n, acc),
});
}
}

View file

@ -414,7 +414,12 @@ export class JavaHarvester extends ScopeTreeHarvester {
const objectNode = node.childForFieldName('object');
const nameNode = node.childForFieldName('name');
const argsNode = node.childForFieldName('arguments');
const siteIdx = acc.openCallSite('call');
// `node` IS the method_invocation — the SAME node the scope-extractor
// anchors `@reference.call.*` (its `atRange`) on (KTD7).
const siteIdx = acc.openCallSite('call', [
node.startPosition.row + 1,
node.startPosition.column,
]);
acc.pushFrame(siteIdx);
let receiverPath: string | undefined;
if (objectNode) {
@ -444,7 +449,12 @@ export class JavaHarvester extends ScopeTreeHarvester {
private visitNew(node: SyntaxNode, acc: FactAccumulator): void {
const typeNode = node.childForFieldName('type');
const argsNode = node.childForFieldName('arguments');
const siteIdx = acc.openCallSite('new');
// `node` IS the object_creation_expression — the SAME node the
// scope-extractor anchors `@reference.call.constructor` (its `atRange`) on.
const siteIdx = acc.openCallSite('new', [
node.startPosition.row + 1,
node.startPosition.column,
]);
acc.pushFrame(siteIdx);
if (typeNode) {
// The type name is not a scalar binding — record it only as the callee

View file

@ -1,12 +1,42 @@
/**
* Kotlin def/use harvester (#2195) — the Kotlin analogue of
* {@link import('./swift-harvest.js').SwiftHarvester} and the C-family / Go /
* Rust / Python harvesters. Like the Swift / Python / Rust harvesters it harvests
* NO call-site `sites[]` (the call-site taint substrate is a later step): it emits
* only the per-function binding table ({@link BindingEntry}[]) plus
* {@link StatementFacts} (defs / uses / mayDefs) via a local
* {@link FactAccumulator} with no site machinery, so the produced facts never
* carry a `sites` key.
* Rust / Python harvesters. Like the Go / Python / Dart harvesters it harvests
* the per-function binding table ({@link BindingEntry}[]) plus
* {@link StatementFacts} (defs / uses / mayDefs) AND a taint
* {@link import('../types.js').SiteRecord} per call (callee path, receiver,
* per-arg occurrence entries, result defs, spread marker, and an `at` anchor)
* via the shared {@link CallSiteFactAccumulator} — the same site substrate the
* C-family / Go / TS / Python / Dart harvesters emit (#2227 follow-up).
*
* KOTLIN CALL SHAPE (verified by a real parse — see below). A call is a
* `call_expression` whose LAST child is a `call_suffix` (holding the
* `value_arguments` and/or a trailing `annotated_lambda`); the callee is the
* preceding expression — a bare `simple_identifier` (`foo()`) for a FREE call,
* or a `navigation_expression` (`obj.method` / `a?.b` via `navigation_suffix`)
* for a MEMBER call. A chained call `a.b.c()` nests `navigation_expression`s;
* the receiver is the chain ROOT binding. Kotlin constructor calls look like
* ordinary calls (no `new`), so every site is `kind: 'call'` (the CALLS query
* classifies a capitalized/known-type callee as `@reference.call.constructor`,
* but the harvester only needs callee + receiver + `at` right — `kind` is not
* joined). Named args (`name = value`) record the VALUE occurrence and drop the
* name (like Python / Dart).
*
* ANCHOR ALIGNMENT (plan KTD7 — load-bearing): a call site's `at` MUST be the
* SAME `[line (1-based), col (0-based)]` the Kotlin CALLS resolution keys its
* `atRange` on, because a downstream unit joins the two by EXACT position. The
* Kotlin scope query (query.ts) anchors `@reference.call.free` and
* `@reference.call.member` on the WHOLE `call_expression` node (the
* `@reference.name` simple_identifier and the `@reference.receiver` are SUB-tags,
* excluded from the anchor by `KNOWN_SUB_TAGS` + the broadest-span rule in
* `anchorCaptureFor`; `atRange: anchor.range` at scope-extractor.ts:1030). So for
* a free call `foo(x)`, a member call `obj.method(x)`, and a chained call
* `a.b.c(x)` alike, `at` is the start of the enclosing `call_expression` node —
* which, for a member/chained call, starts at the RECEIVER (`obj`/`a`), exactly
* where the CALLS anchor starts too. This is the Go/Python whole-call-node model,
* NOT the Dart callee-name model. The harvester's `visitCall` receives exactly
* the `call_expression` node and records `[node.startPosition.row + 1,
* node.startPosition.column]`.
*
* Runs in the parse worker next to the Kotlin CFG visitor. Output is the binding
* table the {@link import('../cfg-builder.js').CfgBuilder} stamps onto the CFG,
@ -80,7 +110,7 @@
*/
import type { SyntaxNode } from '../../utils/ast-helpers.js';
import type { BindingEntry, StatementFacts } from '../types.js';
import { DefUseAccumulator as FactAccumulator } from './call-site-harvest.js';
import { CallSiteFactAccumulator as FactAccumulator, finalizeChain } from './call-site-harvest.js';
/** Node types that own a nested CFG — their subtrees are opaque to harvesting. */
const NESTED_FUNCTION_TYPES = new Set([
@ -99,6 +129,13 @@ export class KotlinHarvester {
private readonly fnId: number;
/** >0 while walking a conditionally-evaluated subexpression — defs become may-defs. */
private conditionalDepth = 0;
/**
* `call_expression` node id → binding indices its single-target result is
* assigned to (`val x = f()` / `x = g()` ⇒ `[x]`). Populated just before the
* value walk reaches the call (see {@link registerResultDefs}) and consumed by
* {@link visitCall}. Mirrors the Go / Python / Dart harvesters' `resultDefTargets`.
*/
private readonly resultDefTargets = new Map<number, number[]>();
constructor(private readonly fnNode: SyntaxNode) {
this.fnId = fnNode.id;
@ -431,6 +468,14 @@ export class KotlinHarvester {
(c) => c.type === 'variable_declaration' || c.type === 'multi_variable_declaration',
);
const value = this.propertyValue(node);
// Register result-defs BEFORE the value walk so the call site (reached
// during the walk) carries them — single `variable_declaration` binder
// only (`val x = f()`); a `multi_variable_declaration` destructuring
// (`val (a, b) = p`) attaches nothing.
if (value && binder?.type === 'variable_declaration') {
const id = binder.namedChildren.find((c) => c.type === 'simple_identifier');
if (id) this.registerResultDefs(value, [id]);
}
if (value) this.walkValue(value, acc);
if (binder?.type === 'variable_declaration') this.defVariableDeclaration(binder, acc);
else if (binder?.type === 'multi_variable_declaration') {
@ -444,9 +489,16 @@ export class KotlinHarvester {
const lvalue = node.namedChildren.find((c) => c.type === 'directly_assignable_expression');
const op = this.assignmentOperator(node);
const value = this.assignmentValue(node);
const scalar = lvalue ? this.unwrapAssignable(lvalue) : undefined;
// Plain `x = f(a)` attaches `resultDefs: [x]` (a compound `x += f(a)`
// does not — the prior value flows in too; a member/index lvalue is not
// a scalar def).
if (value && op === '=' && scalar?.type === 'simple_identifier') {
this.registerResultDefs(value, [scalar]);
}
if (value) this.walkValue(value, acc);
if (lvalue) {
const lv = this.unwrapAssignable(lvalue);
const lv = scalar ?? this.unwrapAssignable(lvalue);
if (lv.type === 'simple_identifier') {
this.def(lv, acc);
if (op !== '=') this.use(lv, acc); // compound assign reads too
@ -471,11 +523,18 @@ export class KotlinHarvester {
}
return;
}
case 'call_expression':
// #2227 follow-up: explicit case (previously default-descended) — same
// uses, plus a taint-site record. Kotlin has no `new` (constructor calls
// are plain `call_expression`s). Defs/uses stay byte-identical.
this.visitCall(node, acc);
return;
case 'navigation_expression': {
// `a.b` / `a?.b` — value read of the chain root only; the suffix name is
// not a scalar binding.
const target = node.namedChild(0);
if (target) this.walkValue(target, acc);
// not a scalar binding. Records the chain-root use (identical to the old
// descent) plus at most ONE member-read site (the innermost access),
// mirroring the Go / Python harvesters' value-position `walkChain`.
this.walkChain(node, acc, false);
return;
}
case 'conjunction_expression':
@ -572,4 +631,180 @@ export class KotlinHarvester {
}
return n;
}
// ── taint-site harvest (#2227 follow-up) ─────────────────────────────────
/** Strip `parenthesized_expression` wrappers around a value (`(f())`). */
private unwrapValue(node: SyntaxNode): SyntaxNode {
let n = node;
let hops = 8;
while (n.type === 'parenthesized_expression' && hops-- > 0) {
const inner = n.namedChild(0);
if (!inner) break;
n = inner;
}
return n;
}
/**
* When `value`'s root (after stripping parens) is a `call_expression`, remember
* that call site should carry `resultDefs` — the binding indices of `targets`
* (def-position identifiers). Consumed by {@link visitCall} once the value walk
* reaches the node. Single-target only (the caller restricts to a plain
* identifier binder); the blank target (`_`) binds nothing and is skipped.
*/
private registerResultDefs(value: SyntaxNode, targets: readonly SyntaxNode[]): void {
const root = this.unwrapValue(value);
if (root.type !== 'call_expression') return;
const defs: number[] = [];
for (const target of targets) {
if (target.type !== 'simple_identifier' || target.text === '_') continue;
defs.push(this.resolve(target));
}
if (defs.length > 0) this.resultDefTargets.set(root.id, defs);
}
/**
* The callee node of a `call_expression` — the first named child that is NOT
* the trailing `call_suffix` (a bare `simple_identifier` for a free call, or a
* `navigation_expression` for a member/chained call).
*/
private calleeOf(call: SyntaxNode): SyntaxNode | undefined {
for (let i = 0; i < call.namedChildCount; i++) {
const c = call.namedChild(i);
if (c && c.type !== 'call_suffix' && !COMMENT_TYPES.has(c.type)) return c;
}
return undefined;
}
/**
* Explicit `call_expression` handler. Records a call site (callee path,
* receiver, per-arg occurrence entries, result defs, spread marker) while
* reproducing EXACTLY the uses the old default descent recorded (callee chain
* root + arguments). Kotlin has no `new` — every site is `kind: 'call'`.
*/
private visitCall(node: SyntaxNode, acc: FactAccumulator): void {
const calleeNode = this.calleeOf(node);
// `node` IS the `call_expression` — the SAME node the scope query anchors
// `@reference.call.free/.member` on (its `atRange`), so the resolved-id join
// lands by exact position (see file header ANCHOR ALIGNMENT).
const siteIdx = acc.openCallSite('call', [
node.startPosition.row + 1,
node.startPosition.column,
]);
acc.pushFrame(siteIdx);
if (calleeNode) {
const callee = this.unwrapValue(calleeNode);
if (callee.type === 'simple_identifier') {
// A bare free call — the callee NAME is a statement-level use but NOT a
// value occurrence in any enclosing argument.
if (callee.text !== '_') acc.addUseWithoutOccurrence(this.resolve(callee));
acc.setSiteCallee(siteIdx, callee.text);
} else if (callee.type === 'navigation_expression') {
// skipFinalRead: the final `.name` IS the callee, carried by the path.
const chain = this.walkChain(callee, acc, true);
if (chain.path !== undefined) acc.setSiteCallee(siteIdx, chain.path);
if (chain.rootIdx !== undefined) acc.setSiteReceiver(siteIdx, chain.rootIdx);
} else {
// Call-rooted chains (`f().g()`), indexing (`m[k]()`), parenthesized
// callables — the walk still records uses and nested sites; the callee
// path is not statically known.
this.walkValue(callee, acc);
}
}
const resultDefs = this.resultDefTargets.get(node.id);
if (resultDefs !== undefined) acc.setSiteResultDefs(siteIdx, resultDefs);
const suffix = node.namedChildren.find((c) => c.type === 'call_suffix');
if (suffix) this.walkArguments(suffix, siteIdx, acc);
acc.popFrame();
}
/**
* Walk a `call_suffix`'s `value_arguments`, tagging each positional / named /
* spread argument's occurrence position. A trailing `annotated_lambda` is a
* nested function body — opaque (its `lambda_literal` is excluded by
* {@link NESTED_FUNCTION_TYPES}), so it is not an argument occurrence here.
*/
private walkArguments(suffix: SyntaxNode, siteIdx: number, acc: FactAccumulator): void {
const args = suffix.namedChildren.find((c) => c.type === 'value_arguments');
if (!args) return;
let pos = 0;
for (let i = 0; i < args.namedChildCount; i++) {
const arg = args.namedChild(i);
if (!arg || arg.type !== 'value_argument') continue;
acc.setFrameArg(pos);
const value = this.argumentValue(arg);
if (value?.type === 'spread_expression') {
// `f(*xs)` — a spread. Mark the first spread position so the matcher
// degrades soundly; the inner value still walks for occurrences.
acc.setSiteSpread(siteIdx, pos);
const inner = value.namedChild(0);
if (inner) this.walkValue(inner, acc);
} else if (value) {
this.walkValue(value, acc);
}
pos++;
}
}
/**
* The value expression of a `value_argument` — for a named argument
* (`name = value`) the leading `simple_identifier` name is dropped (it is a
* parameter name, not a use), and only the value after `=` is returned; a
* positional argument's value is its sole non-comment named child.
*/
private argumentValue(arg: SyntaxNode): SyntaxNode | undefined {
// A named arg carries an anon `=` token; the value is the named child after
// it (skipping the leading `simple_identifier` name).
let sawEq = false;
for (let i = 0; i < arg.childCount; i++) {
const c = arg.child(i);
if (!c) continue;
if (!c.isNamed && c.type === '=') {
sawEq = true;
continue;
}
if (sawEq && c.isNamed && !COMMENT_TYPES.has(c.type)) return c;
}
// Positional arg — the first non-comment named child.
for (let i = 0; i < arg.namedChildCount; i++) {
const c = arg.namedChild(i);
if (c && c.type !== 'annotation' && !COMMENT_TYPES.has(c.type)) return c;
}
return undefined;
}
/**
* `navigation_expression` chain walk shared by value position and callee
* position. Records the chain-root identifier as a use (identical to the old
* default descent) plus at most ONE member-read site — the INNERMOST access —
* when the root is an identifier; `skipFinalRead` suppresses it when that
* access is the callee (carried by the dotted path instead). Mirrors the Go /
* Python harvesters' `walkChain`.
*/
private walkChain(
node: SyntaxNode,
acc: FactAccumulator,
skipFinalRead: boolean,
): { path?: string; rootIdx?: number } {
const accesses: string[] = [];
let cur: SyntaxNode = this.unwrapValue(node);
for (;;) {
if (cur.type === 'navigation_expression') {
const suffix = cur.namedChildren.find((c) => c.type === 'navigation_suffix');
const name = suffix?.namedChildren.find((c) => c.type === 'simple_identifier');
accesses.unshift(name?.text ?? '');
const operand = cur.namedChild(0);
if (!operand) break;
cur = this.unwrapValue(operand);
} else {
break;
}
}
// The shared terminal: root-use record + innermost member-read + path-join.
return finalizeChain(acc, cur, accesses, skipFinalRead, (t) => t === 'simple_identifier', {
resolve: (n) => this.resolve(n),
walkRoot: (n) => this.walkValue(n, acc),
});
}
}

View file

@ -553,7 +553,12 @@ export class PhpHarvester {
shape: 'function' | 'member' | 'scoped',
): void {
const argsNode = node.childForFieldName('arguments');
const siteIdx = acc.openCallSite('call');
// `node` IS the call expression — the SAME node the scope-extractor anchors
// `@reference.call.*` (its `atRange`) on (KTD7).
const siteIdx = acc.openCallSite('call', [
node.startPosition.row + 1,
node.startPosition.column,
]);
acc.pushFrame(siteIdx);
if (shape === 'function') {
@ -603,7 +608,12 @@ export class PhpHarvester {
/** Explicit `object_creation_expression` (`new Foo($x)`) handler. */
private visitNew(node: SyntaxNode, acc: FactAccumulator): void {
const argsNode = node.childForFieldName('arguments');
const siteIdx = acc.openCallSite('new');
// `node` IS the object_creation_expression — the SAME node the
// scope-extractor anchors `@reference.call.constructor` (its `atRange`) on.
const siteIdx = acc.openCallSite('new', [
node.startPosition.row + 1,
node.startPosition.column,
]);
acc.pushFrame(siteIdx);
// The class name is the first `name`/`qualified_name` child (not a binding).
const className = node.namedChildren.find(

View file

@ -11,9 +11,14 @@
* per-statement variable definition/use facts that ride the side channel for the
* reaching-defs / CDG solvers. Output is the per-function binding table
* ({@link BindingEntry}[]) plus {@link StatementFacts} the visitor attaches to
* blocks as it walks. NO `sites[]` are harvested here — the call-site taint
* substrate is a later step (this unit emits only bindings + defs/uses + mayDefs
* via the local {@link FactAccumulator}, which has no site machinery at all).
* blocks as it walks. Each `call` ALSO records a taint {@link SiteRecord} (callee
* path, receiver, per-arg occurrence entries, result defs, spread marker, and an
* `at` anchor) via the shared {@link CallSiteFactAccumulator} — the same site
* substrate the C-family / Go / TS harvesters emit. Python has NO `new`
* expression (constructors are plain `call`s), so every site is `kind: 'call'`.
* The `at` anchor is the `call` node's start position, byte-aligned with the
* `@reference.call.*` CALLS-edge anchor (which also captures the whole `call`
* node), so the downstream resolved-callee-id join lands by exact position.
*
* Every node type and field literal below was grammar-validated against
* tree-sitter-python (0.23.x) via the introspection probe before use (mandatory
@ -92,7 +97,7 @@
*/
import type { SyntaxNode } from '../../utils/ast-helpers.js';
import type { BindingEntry, StatementFacts } from '../types.js';
import { DefUseAccumulator as FactAccumulator } from './call-site-harvest.js';
import { CallSiteFactAccumulator as FactAccumulator, finalizeChain } from './call-site-harvest.js';
/** Node types that own a nested CFG — their subtrees are opaque to harvesting. */
const NESTED_FUNCTION_TYPES = new Set(['function_definition', 'lambda']);
@ -110,6 +115,13 @@ export class PythonHarvester {
private readonly fnId: number;
/** >0 while walking a conditionally-evaluated subexpression — defs become may-defs. */
private conditionalDepth = 0;
/**
* `call` node id → binding indices its single-target result is assigned to
* (`x = f()` ⇒ `[x]`). Populated just before the value walk reaches the call
* (see {@link registerResultDefs}) and consumed by {@link visitCall}. Mirrors
* the Go harvester's `resultDefTargets`.
*/
private readonly resultDefTargets = new Map<number, number[]>();
constructor(private readonly fnNode: SyntaxNode) {
this.fnId = fnNode.id;
@ -542,6 +554,10 @@ export class PythonHarvester {
case 'assignment': {
const left = node.childForFieldName('left');
const right = node.childForFieldName('right');
// Register result-defs BEFORE walking the value so the nested call site
// (reached during the value walk) carries them — single identifier
// target only (`x = f()`); a tuple/attribute LHS attaches nothing.
if (left?.type === 'identifier' && right) this.registerResultDefs(right, [left]);
if (right) this.walkValue(right, acc);
if (left) this.defTargets(left, acc);
return;
@ -565,6 +581,7 @@ export class PythonHarvester {
// walrus `(n := v)` — `n` is a def, `v` a use.
const name = node.childForFieldName('name');
const value = node.childForFieldName('value');
if (name?.type === 'identifier' && value) this.registerResultDefs(value, [name]);
if (value) this.walkValue(value, acc);
if (name?.type === 'identifier') this.def(name, acc);
return;
@ -590,9 +607,10 @@ export class PythonHarvester {
}
case 'attribute': {
// `a.b` — value read of the operand root only; the attribute name is not
// a scalar binding.
const obj = node.childForFieldName('object');
if (obj) this.walkValue(obj, acc);
// a scalar binding. Records the chain-root use plus at most ONE
// member-read site (the innermost identifier-rooted access), mirroring
// the Go harvester's value-position `walkChain`.
this.walkChain(node, acc, false);
return;
}
case 'subscript': {
@ -603,15 +621,12 @@ export class PythonHarvester {
if (sub) this.walkValue(sub, acc);
return;
}
case 'call': {
// Reproduce default-descent uses: callee chain + arguments. No site
// record (taint substrate is a later step).
const fn = node.childForFieldName('function');
const args = node.childForFieldName('arguments');
if (fn) this.walkValue(fn, acc);
if (args) this.walkValue(args, acc);
case 'call':
// #2227 follow-up: explicit case (previously default-descended) — same
// uses, plus a taint-site record. Python has no `new` (constructor calls
// are plain `call`s). Defs/uses stay byte-identical.
this.visitCall(node, acc);
return;
}
case 'list_comprehension':
case 'set_comprehension':
case 'dictionary_comprehension':
@ -657,4 +672,127 @@ export class PythonHarvester {
}
if (body) this.walkValue(body, acc);
}
// ── taint-site harvest (#2227 follow-up) ─────────────────────────────────
/**
* When `value`'s root (after stripping parens) is a `call`, remember that
* call site should carry `resultDefs` — the binding indices of `targets`
* (def-position identifiers). Consumed by {@link visitCall} once the value
* walk reaches the node. Single-target only (the caller restricts to a plain
* identifier LHS); the blank identifier (`_`) binds nothing and is skipped.
*/
private registerResultDefs(value: SyntaxNode, targets: readonly SyntaxNode[]): void {
const root = this.unwrapValue(value);
if (root.type !== 'call') return;
const defs: number[] = [];
for (const target of targets) {
if (target.type !== 'identifier' || target.text === '_') continue;
defs.push(this.resolve(target));
}
if (defs.length > 0) this.resultDefTargets.set(root.id, defs);
}
/** Strip `parenthesized_expression` wrappers around a value (`(f())`). */
private unwrapValue(node: SyntaxNode): SyntaxNode {
let n = node;
let hops = 8;
while (n.type === 'parenthesized_expression' && hops-- > 0) {
const inner = n.namedChild(0);
if (!inner) break;
n = inner;
}
return n;
}
/**
* Explicit `call` handler. Records a call site (callee path, receiver, per-arg
* occurrence entries, result defs, spread marker) while reproducing EXACTLY
* the uses the old default descent recorded (callee chain root + arguments).
* Python has no `new` — every site is `kind: 'call'`.
*/
private visitCall(node: SyntaxNode, acc: FactAccumulator): void {
const calleeNode = node.childForFieldName('function');
const argsNode = node.childForFieldName('arguments');
// `node` IS the `call` — the SAME node the scope-extractor anchors
// `@reference.call.free/.member` on (its `atRange`), so the resolved-id join
// lands by exact position.
const siteIdx = acc.openCallSite('call', [
node.startPosition.row + 1,
node.startPosition.column,
]);
acc.pushFrame(siteIdx);
if (calleeNode) {
const callee = this.unwrapValue(calleeNode);
if (callee.type === 'identifier') {
if (callee.text !== '_') acc.addUseWithoutOccurrence(this.resolve(callee));
acc.setSiteCallee(siteIdx, callee.text);
} else if (callee.type === 'attribute') {
// skipFinalRead: the final `.attr` IS the callee, carried by the path.
const chain = this.walkChain(callee, acc, true);
if (chain.path !== undefined) acc.setSiteCallee(siteIdx, chain.path);
if (chain.rootIdx !== undefined) acc.setSiteReceiver(siteIdx, chain.rootIdx);
} else {
// Call-rooted chains (`a().b()`), subscripts (`d[k]()`) — the walk still
// records uses and nested sites; the callee path is not statically known.
this.walkValue(callee, acc);
}
}
const resultDefs = this.resultDefTargets.get(node.id);
if (resultDefs !== undefined) acc.setSiteResultDefs(siteIdx, resultDefs);
if (argsNode) {
let pos = 0;
for (let i = 0; i < argsNode.namedChildCount; i++) {
const arg = argsNode.namedChild(i);
if (!arg || arg.type === 'comment') continue;
if (arg.type === 'list_splat' || arg.type === 'dictionary_splat') {
// `f(*args)` / `f(**kw)` — a spread. Mark the first spread position so
// the matcher degrades soundly; the inner value still walks.
acc.setFrameArg(pos);
acc.setSiteSpread(siteIdx, pos);
const inner = arg.namedChild(0);
if (inner) this.walkValue(inner, acc);
} else {
// Positional or `keyword_argument` — the `keyword_argument` case in
// `walkValue` walks only its `value`, so the key name is not a use.
acc.setFrameArg(pos);
this.walkValue(arg, acc);
}
pos++;
}
}
acc.popFrame();
}
/**
* `attribute` chain walk shared by value position and callee position. Records
* the chain-root identifier as a use (identical to the old default descent)
* plus at most ONE member-read site — the INNERMOST access — when the root is
* an identifier; `skipFinalRead` suppresses it when that access is the callee
* (carried by the dotted path instead). Mirrors the Go harvester's `walkChain`.
*/
private walkChain(
node: SyntaxNode,
acc: FactAccumulator,
skipFinalRead: boolean,
): { path?: string; rootIdx?: number } {
const accesses: string[] = [];
let cur: SyntaxNode = this.unwrapValue(node);
for (;;) {
if (cur.type === 'attribute') {
const field = cur.childForFieldName('attribute');
accesses.unshift(field?.text ?? '');
const operand = cur.childForFieldName('object');
if (!operand) break;
cur = this.unwrapValue(operand);
} else {
break;
}
}
// The shared terminal: root-use record + innermost member-read + path-join.
return finalizeChain(acc, cur, accesses, skipFinalRead, (t) => t === 'identifier', {
resolve: (n) => this.resolve(n),
walkRoot: (n) => this.walkValue(n, acc),
});
}
}

View file

@ -3,11 +3,46 @@
* {@link import('./python-harvest.js').PythonHarvester} (the closest structural
* sibling: implicit/keyword-delimited blocks, statement-modifier forms, a
* begin/rescue/else/ensure exception model, and `case`/`when` + `case`/`in`
* pattern matching). Like the Python harvester, this unit emits ONLY the
* pattern matching). Like the Python / Kotlin harvesters, this unit emits the
* per-function binding table ({@link BindingEntry}[]) plus {@link StatementFacts}
* (defs / uses / mayDefs) — NO call-site `sites[]` are harvested (the taint
* substrate is a later step), so it uses a local {@link FactAccumulator} with no
* site machinery at all and the emitted facts carry no `sites` key.
* (defs / uses / mayDefs) AND a taint {@link import('../types.js').SiteRecord}
* per call (callee path, receiver, per-arg occurrence entries, result defs,
* spread marker, and an `at` anchor) via the shared
* {@link CallSiteFactAccumulator} — the same site substrate the C-family / Go /
* TS / Python / Kotlin / Dart harvesters emit.
*
* RUBY CALL SHAPE (verified by a real parse — see below). EVERY call in Ruby is
* a single `call` node (fields `receiver`?/`method`/`arguments`?): a free call
* `foo(a)`, an implicit-receiver paren-less command `puts x` / `attr_accessor :x`,
* a member call `obj.method(x)`, a safe-navigation call `obj&.m()`, AND a chained
* `a.b.c` (nested `call` receivers) are all `call` nodes. There is NO separate
* `command` / `command_call` node in this vendored grammar — paren-less commands
* normalize to `call` with a `method` + `arguments` and no `receiver`. Ruby has
* NO `new` keyword (`Foo.new` is an ordinary member `call` with method `new`), so
* every site is `kind: 'call'`. A receiver-only no-args `call` (`obj.field`,
* `a.b`) is grammatically INDISTINGUISHABLE from a paren-less zero-arg member
* call, and the Ruby CALLS query tags it `@reference.call.member` too — so it is
* harvested as a `kind: 'call'` site (NOT a member-read), keeping the harvest
* byte-aligned with what the resolver assigns a callee id. Named (symbol-keyed)
* args (`f(k: v)` ⇒ a `pair` of `hash_key_symbol` + value) record the VALUE
* occurrence and drop the key (like Python / Kotlin / Dart). A `do … end` / `{ }`
* block is a nested function (opaque) — it is NOT an argument occurrence (the
* `block` field is never walked here).
*
* ANCHOR ALIGNMENT (plan KTD7 — load-bearing): a call site's `at` MUST be the
* SAME `[line (1-based), col (0-based)]` the Ruby CALLS resolution keys its
* `atRange` on, because a downstream unit joins the two by EXACT position. The
* Ruby scope query (query.ts) anchors `@reference.call.free` and
* `@reference.call.member` on the WHOLE `call` node (the `@reference.name` method
* identifier and `@reference.receiver` are SUB-tags, excluded from the anchor by
* `KNOWN_SUB_TAGS` + the broadest-span rule in `anchorCaptureFor`). So for a free
* call `foo(x)`, an implicit command `puts x`, a member call `obj.method(x)`, and
* a chained call `a.b.c` alike, `at` is the start of the `call` node — which, for
* a member/chained call, starts at the RECEIVER (`obj`/`a`), exactly where the
* CALLS anchor starts too. This is the Go/Python/Kotlin whole-call-node model,
* NOT the Dart callee-name model. The harvester records
* `[node.startPosition.row + 1, node.startPosition.column]` of the `call` node.
* (Verified byte-exact against the real Ruby query for every shape above.)
*
* Runs in the parse worker next to the Ruby CFG visitor.
*
@ -76,7 +111,7 @@
*/
import type { SyntaxNode } from '../../utils/ast-helpers.js';
import type { BindingEntry, StatementFacts } from '../types.js';
import { DefUseAccumulator as FactAccumulator } from './call-site-harvest.js';
import { CallSiteFactAccumulator as FactAccumulator } from './call-site-harvest.js';
/** Node types that own a nested CFG — their subtrees are opaque to harvesting. */
const NESTED_FUNCTION_TYPES = new Set([
@ -111,6 +146,13 @@ export class RubyHarvester {
private readonly fnId: number;
/** >0 while walking a conditionally-evaluated subexpression — defs become may-defs. */
private conditionalDepth = 0;
/**
* `call` node id → binding indices its single-target result is assigned to
* (`x = f()` ⇒ `[x]`). Populated just before the value walk reaches the call
* (see {@link registerResultDefs}) and consumed by {@link visitCall}. Mirrors
* the Python / Kotlin / Go harvesters' `resultDefTargets`.
*/
private readonly resultDefTargets = new Map<number, number[]>();
constructor(private readonly fnNode: SyntaxNode) {
this.fnId = fnNode.id;
@ -428,6 +470,11 @@ export class RubyHarvester {
case 'assignment': {
const left = node.childForFieldName('left');
const right = node.childForFieldName('right');
// Register result-defs BEFORE walking the value so the nested call site
// (reached during the value walk) carries them — single plain-identifier
// target only (`x = f()`); a multi-target / `@ivar` / index LHS attaches
// nothing (the per-target mapping is ambiguous).
if (left?.type === 'identifier' && right) this.registerResultDefs(right, [left]);
if (right) this.walkValue(right, acc);
if (left) this.defTargets(left, acc);
return;
@ -462,16 +509,14 @@ export class RubyHarvester {
}
return;
}
case 'call': {
// `recv.meth(args)` / `meth(args)` — the receiver root + arguments are
// uses (the method name is not a scalar binding). A nested block child is
// its OWN function CFG (opaque here).
const receiver = node.childForFieldName('receiver');
const args = node.childForFieldName('arguments');
if (receiver) this.walkValue(receiver, acc);
if (args) this.walkValue(args, acc);
case 'call':
// `recv.meth(args)` / `meth(args)` / `puts x` / `obj.field` — every Ruby
// call shape. Records a taint site (callee path, receiver, per-arg
// occurrences, result defs, spread) plus the SAME uses the old default
// descent recorded (receiver chain root + arguments). A nested block
// child is its OWN function CFG (opaque — the `block` field is not walked).
this.visitCall(node, acc);
return;
}
default:
for (let i = 0; i < node.namedChildCount; i++) {
const c = node.namedChild(i);
@ -479,4 +524,155 @@ export class RubyHarvester {
}
}
}
// ── taint-site harvest ────────────────────────────────────────────────────
/**
* When `value` is a `call`, remember that call site should carry `resultDefs`
* — the binding indices of `targets` (def-position identifiers). Consumed by
* {@link visitCall} once the value walk reaches the node. Single plain-identifier
* target only (the caller restricts to it); the blank target (`_`) binds nothing
* and is skipped.
*/
private registerResultDefs(value: SyntaxNode, targets: readonly SyntaxNode[]): void {
if (value.type !== 'call') return;
const defs: number[] = [];
for (const target of targets) {
if (target.type !== 'identifier' || target.text === '_') continue;
defs.push(this.resolve(target));
}
if (defs.length > 0) this.resultDefTargets.set(value.id, defs);
}
/**
* Explicit `call` handler. Records a call site (callee path, receiver, per-arg
* occurrence entries, result defs, spread marker) while reproducing EXACTLY the
* uses the old default descent recorded (receiver chain root + arguments). Ruby
* has no `new` — every site is `kind: 'call'`. The `at` anchor is the `call`
* node's start, byte-aligned with the `@reference.call.free/.member` CALLS
* anchor (which captures the whole `call` node), so the resolved-id join lands
* by exact position (see file header ANCHOR ALIGNMENT).
*/
private visitCall(node: SyntaxNode, acc: FactAccumulator): void {
const receiver = node.childForFieldName('receiver');
const method = node.childForFieldName('method');
const args = node.childForFieldName('arguments');
const siteIdx = acc.openCallSite('call', [
node.startPosition.row + 1,
node.startPosition.column,
]);
acc.pushFrame(siteIdx);
if (receiver) {
// Member / chained call (`obj.method`, `a.b.c`, `obj&.m`). Walk the receiver
// chain to its root binding (the receiver use + any mid-chain member reads)
// and build the dotted callee path `root.…​.method`.
const chain = this.walkReceiverChain(receiver, acc);
if (chain.rootIdx !== undefined) acc.setSiteReceiver(siteIdx, chain.rootIdx);
const path = this.calleePath(chain.path, method);
if (path !== undefined) acc.setSiteCallee(siteIdx, path);
} else if (method) {
// Free / implicit-receiver call (`foo(a)`, `puts x`, `attr_accessor :x`).
// The method NAME is a statement-level use (a known local resolves to its
// binding, an unknown method to a `module` synthetic) but NOT a value
// occurrence in any enclosing argument.
if (method.text !== '_') acc.addUseWithoutOccurrence(this.resolve(method));
acc.setSiteCallee(siteIdx, method.text);
}
const resultDefs = this.resultDefTargets.get(node.id);
if (resultDefs !== undefined) acc.setSiteResultDefs(siteIdx, resultDefs);
if (args) this.walkArguments(args, siteIdx, acc);
acc.popFrame();
}
/**
* Walk a member-call receiver, returning the chain ROOT binding (recorded as a
* use) and the dotted prefix path (`a.b` in `a.b.c()`), plus a member-read site
* for each NON-final access in the chain. A receiver that is itself a `call`
* (Ruby nests `a.b.c` as `call(call(a,b),c)`) recurses; a bare identifier root
* is the receiver binding; a `self` / non-identifier root has no static path.
*/
private walkReceiverChain(
receiver: SyntaxNode,
acc: FactAccumulator,
): { rootIdx?: number; path?: string } {
// Collect the access names along the receiver chain (outermost-last), and the
// chain root node, by unwinding nested `call` receivers.
const accesses: string[] = [];
let cur = receiver;
for (;;) {
if (cur.type === 'call' && cur.childForFieldName('arguments') === null) {
// A no-args member `call` in receiver position is a member ACCESS
// (`a.b` in `a.b.c`) — its method name extends the path; recurse on its
// own receiver. A receiver `call` WITH arguments is an opaque call result
// (`foo(a).bar` — handled by the `else` below as a nested call site).
const m = cur.childForFieldName('method');
const inner = cur.childForFieldName('receiver');
accesses.unshift(m?.text ?? '');
if (!inner) break;
cur = inner;
continue;
}
break;
}
let rootIdx: number | undefined;
let rootSegment: string | undefined;
if (cur.type === 'identifier' && cur.text !== '_') {
rootIdx = this.resolve(cur);
acc.addUse(rootIdx);
rootSegment = cur.text;
} else {
// `self` / `@ivar` / a call-rooted receiver (`foo(a).bar`) / literal — walk
// for uses + nested sites; no static root segment.
this.walkValue(cur, acc);
}
// The INNERMOST access (`a.b` in `a.b.c()`) is a value-position member read;
// the trailing access (the receiver's own method) is part of the callee path,
// not a separate read.
if (rootIdx !== undefined && accesses.length >= 1) {
acc.addMemberRead(rootIdx, accesses[0]);
}
const path =
rootSegment !== undefined && accesses.every((a) => a !== '')
? [rootSegment, ...accesses].join('.')
: undefined;
return { rootIdx, path };
}
/** Dotted callee path `prefix.method` (or undefined when the prefix is opaque). */
private calleePath(prefix: string | undefined, method: SyntaxNode | null): string | undefined {
const m = method?.text;
if (m === undefined || m.length === 0) return undefined;
if (prefix === undefined) return undefined;
return `${prefix}.${m}`;
}
/**
* Walk an `argument_list`, tagging each positional / keyword / splat argument's
* occurrence position. A `pair` (`k: v`) records only the VALUE occurrence (the
* `hash_key_symbol` key is not a use). A `splat_argument` (`*xs`) /
* `hash_splat_argument` (`**kw`) marks the first spread position so the matcher
* degrades soundly; its inner value still walks. A `block_argument` (`&blk`)
* passes a block — its inner value is a use occurrence.
*/
private walkArguments(args: SyntaxNode, siteIdx: number, acc: FactAccumulator): void {
let pos = 0;
for (let i = 0; i < args.namedChildCount; i++) {
const arg = args.namedChild(i);
if (!arg || arg.type === 'comment') continue;
acc.setFrameArg(pos);
if (arg.type === 'splat_argument' || arg.type === 'hash_splat_argument') {
acc.setSiteSpread(siteIdx, pos);
const inner = arg.namedChild(0);
if (inner) this.walkValue(inner, acc);
} else if (arg.type === 'pair') {
// `k: v` — only the value is an occurrence; the symbol key is not a use.
const value = arg.childForFieldName('value') ?? arg.namedChild(arg.namedChildCount - 1);
if (value) this.walkValue(value, acc);
} else {
// Positional arg, `block_argument` (`&blk`), or a nested expression.
this.walkValue(arg, acc);
}
pos++;
}
}
}

View file

@ -1,11 +1,82 @@
/**
* Rust def/use harvester (#2195 U7) — the Rust analogue of
* {@link import('./typescript-harvest.js').TsHarvester} and the C-family /
* Go / Python harvesters. Like the Python harvester it harvests NO call-site
* `sites[]` (the call-site taint substrate is a later step): it emits only the
* per-function binding table ({@link BindingEntry}[]) plus {@link StatementFacts}
* (defs / uses / mayDefs) via a local {@link FactAccumulator} with no site
* machinery, so the produced facts never carry a `sites` key.
* Go / Python / Swift / Kotlin / Dart harvesters. Like the Swift / Kotlin / Go /
* Python / Dart harvesters it harvests the per-function binding table
* ({@link BindingEntry}[]) plus {@link StatementFacts} (defs / uses / mayDefs)
* AND a taint {@link import('../types.js').SiteRecord} per call (callee path,
* receiver, per-arg occurrence entries, result defs, and an `at` anchor) via the
* shared {@link CallSiteFactAccumulator} — the same site substrate the
* C-family / Go / TS / Kotlin / Python / Dart / Swift harvesters emit, so Rust
* BasicBlocks get `callees` + `calleeIds`.
*
* RUST CALL SHAPE (verified by a real parse — see the probe table below). Rust
* has ONE call node, `call_expression { function, arguments }`, whose `function`
* field takes three shapes:
* 1. a bare `identifier` (`foo(x)`) — a FREE call; callee path = the name.
* 2. a `field_expression { value, field }` (`a.method(x)`) — a METHOD call
* (the `.` access). The dotted path is `a.method` (leaf `method`); the
* receiver is the chain ROOT binding (`a`). Chained `a.b.c()` nests
* `field_expression`s (path `a.b.c`, root `a`, mid-chain read `a.b`).
* 3. a `scoped_identifier { path, name }` (`Foo::bar(x)` / `a::b::c(x)`) — an
* associated-fn / path call. The path is joined with `.` (NOT `::`) so the
* LEAF after the last separator is the tail (`Foo::bar` ⇒ `Foo.bar` ⇒ leaf
* `bar`), exactly matching the Rust CALLS query, which tags this
* `@reference.call.free` with `@reference.name` = the tail `name:
* (identifier)` (`bar`), and matching how {@link
* import('../emit.js').calleesOfBlock} extracts the leaf via
* `callee.slice(callee.lastIndexOf('.') + 1)`. The receiver is set only when
* the path ROOT is a bound local (`a::b::c` with `a` a local ⇒ receiver `a`);
* a type/module root (`Foo`, `crate`) is not a value binding, so no receiver.
* 4. a `generic_function { function, type_arguments }` (`foo::<T>(x)`) — the
* turbofish form; `visitCall` unwraps the `function` field and recurses, so
* `foo::<T>(x)` records the same site as `foo(x)`.
* A `try_expression` (`foo()?`) wraps a `call_expression` — the inner call walks
* normally.
*
* STRUCT LITERALS ARE HARVESTED AS `kind: 'new'` (U4). A struct-literal
* expression `Point { x: 1 }` is a `struct_expression { name, body }`, NOT a
* `call_expression`. The Rust CALLS query tags it `@reference.call.constructor`
* (resolving to a constructor id), so `visitStruct` opens a `kind: 'new'` site
* whose callee path is the struct TYPE name (`mymod::Point` ⇒ dotted `mymod.Point`
* ⇒ leaf `Point`, the SAME tail the `@reference.name` capture resolves) and whose
* `at` is the `struct_expression` start (== the broadest-span
* `@reference.call.constructor` anchor — verified byte-equal for plain / scoped /
* turbofish / scoped+turbofish forms). The `name` field shapes are
* `type_identifier` (`Point`), `scoped_type_identifier` (`mymod::Point`), and
* `generic_type_with_turbofish` (`Foo::<T>` / `mymod::Bar::<T>`); all start at the
* same column as the `struct_expression`, so the anchor aligns. The struct's
* type/module head is no value binding ⇒ no receiver. Field-init VALUES
* (`field_initializer` `value`, shorthand `y`, base `..rest`) walk for
* uses/occurrences; field NAMES are not uses.
*
* MACROS ARE NOT HARVESTED. `println!(...)` / `vec!(...)` are `macro_invocation`
* nodes (a `macro` ident + a `token_tree`), NOT `call_expression`s. The Rust
* CALLS query tags them `@reference.macro` (a DISJOINT namespace resolved via the
* MacroRegistry to Macro defs, never a fn of the same name) — NOT
* `@reference.call.*`. So no resolved callee-id is keyed at a macro's position,
* and opening a call site there would put a leaf (`println`) into `callees` that
* the resolution side never produces — a spurious, unjoinable callee. We
* therefore record NO site for a macro (its argument identifiers still walk for
* uses via the default token-tree descent), keeping `callees` aligned with the
* CALLS resolution.
*
* ANCHOR ALIGNMENT (plan KTD7 — load-bearing): a call site's `at` MUST be the
* SAME `[line (1-based), col (0-based)]` the Rust CALLS resolution keys its
* `atRange` on, because a downstream unit joins the two by EXACT position. The
* Rust scope query (captures.ts) anchors `@reference.call.free` (free + scoped),
* `@reference.call.member`, and `@reference.call.constructor` on the WHOLE
* `call_expression` node (the `@reference.name` identifier / `field_identifier`
* and the `@reference.receiver` are SUB-tags in `KNOWN_SUB_TAGS`, excluded by the
* broadest-span rule in `anchorCaptureFor`; `atRange: anchor.range` at
* scope-extractor.ts:1030). So for a free call `foo(x)`, a method call
* `a.method(x)`, a path call `Foo::bar(x)`, and a chained call `a.b.c(x)` alike,
* `at` is the start of the enclosing `call_expression` node — which, for a
* method/chained call, starts at the RECEIVER (`a`), and for a path call at the
* head segment (`Foo`), exactly where the CALLS anchor starts too. This is the
* Swift / Go / Python / Kotlin whole-call-node model, NOT the Dart callee-name
* model. `visitCall` receives exactly the `call_expression` node and records
* `[node.startPosition.row + 1, node.startPosition.column]`.
*
* Runs in the parse worker next to the Rust CFG visitor. Output is the binding
* table the {@link import('../cfg-builder.js').CfgBuilder} stamps onto the CFG,
@ -80,7 +151,7 @@
*/
import type { SyntaxNode } from '../../utils/ast-helpers.js';
import type { BindingEntry, StatementFacts } from '../types.js';
import { DefUseAccumulator as FactAccumulator } from './call-site-harvest.js';
import { CallSiteFactAccumulator as FactAccumulator, finalizeChain } from './call-site-harvest.js';
/** Node types that own a nested CFG — their subtrees are opaque to harvesting. */
const NESTED_FUNCTION_TYPES = new Set(['function_item', 'closure_expression']);
@ -101,6 +172,13 @@ export class RustHarvester {
private readonly fnId: number;
/** >0 while walking a conditionally-evaluated subexpression — defs become may-defs. */
private conditionalDepth = 0;
/**
* `call_expression` node id → binding indices its single-target result is
* assigned to (`let x = f()` / `x = g()` ⇒ `[x]`). Populated just before the
* value walk reaches the call (see {@link registerResultDefs}) and consumed by
* {@link visitCall}. Mirrors the Swift / Kotlin / Go / Python harvesters' map.
*/
private readonly resultDefTargets = new Map<number, number[]>();
constructor(private readonly fnNode: SyntaxNode) {
this.fnId = fnNode.id;
@ -524,6 +602,10 @@ export class RustHarvester {
const value = node.childForFieldName('value');
const pat = node.childForFieldName('pattern');
const alt = node.childForFieldName('alternative'); // `let … else { … }`
// Register result-defs BEFORE the value walk so the call the walk reaches
// carries them — single plain-identifier pattern only (`let x = f()`); a
// destructuring `let (a, b) = …` attaches nothing (ambiguous mapping).
if (value && pat && pat.type === 'identifier') this.registerResultDefs(value, [pat]);
if (value) this.walkValue(value, acc);
if (alt) this.walkValue(alt, acc);
if (pat) this.defPattern(pat, acc);
@ -539,6 +621,9 @@ export class RustHarvester {
case 'assignment_expression': {
const left = node.childForFieldName('left');
const right = node.childForFieldName('right');
// A plain `x = f(a)` attaches `resultDefs: [x]`; a field/index lvalue does
// not (no scalar target).
if (right && left && left.type === 'identifier') this.registerResultDefs(right, [left]);
if (right) this.walkValue(right, acc);
if (left) {
if (left.type === 'identifier') {
@ -575,11 +660,27 @@ export class RustHarvester {
}
return;
}
case 'call_expression':
// A Rust call (`foo(a)`, `a.method(x)`, `Foo::bar(x)`, `foo::<T>(x)`).
// Records a taint site (callee path, receiver, per-arg occurrences, result
// defs) while reproducing the uses the old default descent recorded. Rust
// has no `new` — every site is `kind: 'call'`.
this.visitCall(node, acc);
return;
case 'struct_expression':
// A Rust struct literal (`Point { x: 1 }`, `mymod::Point { .. }`,
// `Foo::<T> { .. }`). The Rust CALLS query tags it
// `@reference.call.constructor`, so it resolves to a constructor id — we
// record a `kind: 'new'` site (callee = the struct type path) so that id
// joins into `calleeIds`. Field-init VALUES walk for uses/occurrences.
this.visitStruct(node, acc);
return;
case 'field_expression': {
// `a.b` — value read of the chain root only; the field name is not a
// scalar binding.
const value = node.childForFieldName('value');
if (value) this.walkValue(value, acc);
// `a.b` value read — the chain-root identifier is a use plus at most one
// member-read site (the innermost access); the field name is not a scalar
// binding. Mirrors the Swift / Kotlin / Go value-position member-read
// semantics.
this.walkChain(node, acc, false);
return;
}
default:
@ -589,4 +690,290 @@ export class RustHarvester {
}
}
}
// ── taint-site harvest ───────────────────────────────────────────────────
/**
* When `value`'s root (after unwrapping a `try_expression`) is a
* `call_expression`, remember that call site should carry `resultDefs` — the
* binding indices of `targets` (def-position identifiers). Consumed by
* {@link visitCall} once the value walk reaches the node. Single-target only;
* the blank target (`_`) binds nothing.
*/
private registerResultDefs(value: SyntaxNode, targets: readonly SyntaxNode[]): void {
const root = this.unwrapValue(value);
if (root.type !== 'call_expression') return;
const defs: number[] = [];
for (const target of targets) {
if (target.type !== 'identifier' || target.text === '_') continue;
defs.push(this.resolve(target));
}
if (defs.length > 0) this.resultDefTargets.set(root.id, defs);
}
/** Strip a `try_expression` (`expr?`) / `await_expression` wrapper around a value. */
private unwrapValue(node: SyntaxNode): SyntaxNode {
let n = node;
let hops = 4;
while ((n.type === 'try_expression' || n.type === 'await_expression') && hops-- > 0) {
const inner = n.namedChild(0);
if (!inner) break;
n = inner;
}
return n;
}
/**
* Open + populate a call site for a Rust `call_expression`. `node` IS the
* `call_expression` — the SAME node the scope query anchors `@reference.call.*`
* on (its `atRange`), so the resolved-id join lands by exact position (see file
* header ANCHOR ALIGNMENT). A `call_expression` is always `kind: 'call'`; struct
* literals (`kind: 'new'`) are harvested separately by {@link visitStruct}.
*/
private visitCall(node: SyntaxNode, acc: FactAccumulator): void {
const calleeNode = node.childForFieldName('function');
const argsNode = node.childForFieldName('arguments');
const siteIdx = acc.openCallSite('call', [
node.startPosition.row + 1,
node.startPosition.column,
]);
acc.pushFrame(siteIdx);
if (calleeNode) this.harvestCallee(calleeNode, siteIdx, acc);
const resultDefs = this.resultDefTargets.get(node.id);
if (resultDefs !== undefined) acc.setSiteResultDefs(siteIdx, resultDefs);
if (argsNode) {
let pos = 0;
for (let i = 0; i < argsNode.namedChildCount; i++) {
const arg = argsNode.namedChild(i);
if (!arg || arg.type === 'line_comment' || arg.type === 'block_comment') continue;
acc.setFrameArg(pos);
this.walkValue(arg, acc);
pos++;
}
}
acc.popFrame();
}
/**
* Open + populate a `kind: 'new'` site for a Rust `struct_expression`
* (`Point { x: 1 }`, `mymod::Point { .. }`, `Foo::<T> { .. }`). `node` IS the
* `struct_expression` — the SAME node the Rust scope query anchors
* `@reference.call.constructor` on (its `atRange`), so the resolved
* constructor-id join lands by exact position. The `name` field of a
* `struct_expression` is a `type_identifier` (`Point`), a
* `scoped_type_identifier` (`mymod::Point`), or a `generic_type_with_turbofish`
* (`Foo::<T>` / `mymod::Bar::<T>`); all three start at the SAME column as the
* enclosing `struct_expression` (verified by a real parse), so the broadest-span
* `@reference.call.constructor` anchor == the `struct_expression` start.
*
* The callee path joins the `::`-segments of the type with `.` (NOT `::`) so the
* LEAF after the last separator is the tail (`mymod::Point` ⇒ `mymod.Point` ⇒
* leaf `Point`), exactly the tail the CALLS query's `@reference.name` capture
* resolves and the tail {@link import('../emit.js').calleesOfBlock} extracts via
* `lastIndexOf('.')`. A type/module path head is never a value binding, so no
* receiver (mirrors {@link harvestScopedCallee}). The field-init VALUES
* (`field_initializer` `value`, shorthand `y`, base `..rest`) walk for
* uses/occurrences; field NAMES are not uses.
*/
private visitStruct(node: SyntaxNode, acc: FactAccumulator): void {
const nameNode = node.childForFieldName('name');
const bodyNode = node.childForFieldName('body');
const siteIdx = acc.openCallSite('new', [
node.startPosition.row + 1,
node.startPosition.column,
]);
acc.pushFrame(siteIdx);
const path = nameNode ? this.structTypePath(nameNode) : undefined;
if (path !== undefined) acc.setSiteCallee(siteIdx, path);
if (bodyNode) {
let pos = 0;
for (let i = 0; i < bodyNode.namedChildCount; i++) {
const field = bodyNode.namedChild(i);
if (!field) continue;
if (field.type === 'field_initializer') {
// `x: VALUE` — the field NAME is not a use; only VALUE is walked.
const value = field.childForFieldName('value');
if (value) {
acc.setFrameArg(pos);
this.walkValue(value, acc);
pos++;
}
} else if (field.type === 'shorthand_field_initializer') {
// `y` shorthand — the identifier IS a value use of the local `y`.
const id = field.namedChild(0);
if (id) {
acc.setFrameArg(pos);
this.walkValue(id, acc);
pos++;
}
} else if (field.type === 'base_field_initializer') {
// `..rest` functional-update base — `rest` is a value use.
const baseExpr = field.namedChild(0);
if (baseExpr) {
acc.setFrameArg(pos);
this.walkValue(baseExpr, acc);
pos++;
}
}
}
}
acc.popFrame();
}
/**
* Build the dotted type path of a `struct_expression`'s `name` field. The name
* is a `type_identifier` (`Point`), a `scoped_type_identifier`
* (`mymod::Point` — `path` + tail `name` type_identifier), or a
* `generic_type_with_turbofish` (`Foo::<T>` / `mymod::Bar::<T>` — its `type`
* field is a `type_identifier` or a `scoped_identifier`; the turbofish
* `type_arguments` are dropped). Segments join with `.` so the leaf is the type
* tail (matching the CALLS `@reference.name` tail capture). Returns `undefined`
* when no segments could be read (defensive — keeps a mis-anchored site from
* carrying a bogus callee).
*/
private structTypePath(nameNode: SyntaxNode): string | undefined {
const segments: string[] = [];
const collect = (n: SyntaxNode): void => {
const t = n.type;
if (t === 'type_identifier' || t === 'identifier') {
segments.push(n.text);
return;
}
if (t === 'generic_type_with_turbofish') {
// `Foo::<T>` / `mymod::Bar::<T>` — descend the `type` field; the
// `type_arguments` are not part of the resolved type path.
const typeNode = n.childForFieldName('type');
if (typeNode) collect(typeNode);
return;
}
if (t === 'scoped_type_identifier' || t === 'scoped_identifier') {
// `mymod::Point` / `mymod::Bar` — head `path` then the tail `name`.
const pathNode = n.childForFieldName('path');
if (pathNode) collect(pathNode);
const tail = n.childForFieldName('name');
if (tail) collect(tail);
return;
}
};
collect(nameNode);
return segments.length > 0 && segments.every((s) => s !== '') ? segments.join('.') : undefined;
}
/**
* Record the callee path + receiver for a `call_expression`'s `function` node.
* Free `identifier` (`foo`), method `field_expression` (`a.method`, receiver
* root `a`), path `scoped_identifier` (`Foo::bar` ⇒ dotted `Foo.bar`, leaf
* `bar`), and the turbofish `generic_function` (`foo::<T>` — unwrap the
* `function` field and recurse). Anything else (a call-rooted chain `f()()`,
* a parenthesized callable) walks for uses with no static callee path.
*/
private harvestCallee(calleeNode: SyntaxNode, siteIdx: number, acc: FactAccumulator): void {
const callee = this.unwrapValue(calleeNode);
if (callee.type === 'identifier') {
// A bare free call — the callee NAME is a statement-level use but NOT a
// value occurrence in any enclosing argument.
if (callee.text !== '_') acc.addUseWithoutOccurrence(this.resolve(callee));
acc.setSiteCallee(siteIdx, callee.text);
return;
}
if (callee.type === 'field_expression') {
// skipFinalRead: the final `.field` IS the callee, carried by the path.
const chain = this.walkChain(callee, acc, true);
if (chain.path !== undefined) acc.setSiteCallee(siteIdx, chain.path);
if (chain.rootIdx !== undefined) acc.setSiteReceiver(siteIdx, chain.rootIdx);
return;
}
if (callee.type === 'scoped_identifier') {
const scoped = this.harvestScopedCallee(callee, acc);
if (scoped.path !== undefined) acc.setSiteCallee(siteIdx, scoped.path);
if (scoped.rootIdx !== undefined) acc.setSiteReceiver(siteIdx, scoped.rootIdx);
return;
}
if (callee.type === 'generic_function') {
// `foo::<T>(x)` — the turbofish wraps the real callee in `function`.
const inner = callee.childForFieldName('function');
if (inner) this.harvestCallee(inner, siteIdx, acc);
else this.walkValue(callee, acc);
return;
}
// Call-rooted chains (`f()()`), parenthesized callables — walk for uses; no
// static callee path.
this.walkValue(callee, acc);
}
/**
* Walk a `scoped_identifier` (`Foo::bar`, `a::b::c`) callee. The `::`-segments
* are joined with `.` (NOT `::`) so the LEAF after the last separator is the
* tail (`Foo::bar` ⇒ `Foo.bar` ⇒ leaf `bar`), matching the Rust CALLS query's
* `@reference.name` tail capture and {@link
* import('../emit.js').calleesOfBlock}'s `lastIndexOf('.')` leaf rule. The
* receiver is set only when the head segment is a bound LOCAL (`a::b::c` with
* `a` a local); a type / module head (`Foo`, `crate`) is no value binding.
*/
private harvestScopedCallee(
node: SyntaxNode,
acc: FactAccumulator,
): { path?: string; rootIdx?: number } {
const segments: string[] = [];
let cur: SyntaxNode = node;
for (;;) {
if (cur.type === 'scoped_identifier') {
const name = cur.childForFieldName('name');
segments.unshift(name?.text ?? '');
const path = cur.childForFieldName('path');
if (!path) break;
cur = path;
} else {
// The head segment — a bare `identifier` (`a` / `Foo` / `crate`) or a
// `crate`/`self`/`super`/`metavariable` keyword node.
segments.unshift(cur.text);
break;
}
}
let rootIdx: number | undefined;
// Only a head segment that is a bound LOCAL is a receiver (taint substrate);
// a type / module path head launders no value.
if (cur.type === 'identifier' && cur.text !== '_' && this.table.has(cur.text)) {
rootIdx = this.resolve(cur);
acc.addUse(rootIdx);
}
const path = segments.every((s) => s !== '') ? segments.join('.') : undefined;
return { path, rootIdx };
}
/**
* `field_expression` chain walk shared by value position and callee position.
* Records the chain-root identifier as a use plus at most ONE member-read site
* — the INNERMOST access — when the root is an identifier; `skipFinalRead`
* suppresses it when that access is the callee (carried by the dotted path
* instead). Mirrors the Swift / Kotlin / Go / Python harvesters' walkChain. A
* non-identifier root (`self`/literal/call) launders no static path/receiver
* but its uses + nested sites are still walked.
*/
private walkChain(
node: SyntaxNode,
acc: FactAccumulator,
skipFinalRead: boolean,
): { path?: string; rootIdx?: number } {
const accesses: string[] = [];
let cur: SyntaxNode = node;
for (;;) {
if (cur.type === 'field_expression') {
const field = cur.childForFieldName('field');
accesses.unshift(field?.text ?? '');
const value = cur.childForFieldName('value');
if (!value) break;
cur = value;
} else {
break;
}
}
// The shared terminal: root-use record + innermost member-read + path-join.
// The non-identifier root (`self.x.f()`, `foo().bar`, a tuple index) walks for
// uses + nested sites.
return finalizeChain(acc, cur, accesses, skipFinalRead, (t) => t === 'identifier', {
resolve: (n) => this.resolve(n),
walkRoot: (n) => this.walkValue(n, acc),
});
}
}

View file

@ -1,12 +1,49 @@
/**
* Swift def/use harvester (#2195) — the Swift analogue of
* {@link import('./typescript-harvest.js').TsHarvester} and the C-family / Go /
* Rust / Python harvesters. Like the Python / Rust harvesters it harvests NO
* call-site `sites[]` (the call-site taint substrate is a later step): it emits
* only the per-function binding table ({@link BindingEntry}[]) plus
* {@link StatementFacts} (defs / uses / mayDefs) via a local
* {@link FactAccumulator} with no site machinery, so the produced facts never
* carry a `sites` key.
* Rust / Kotlin / Python harvesters. Like the Kotlin / Go / Python / Dart
* harvesters it harvests the per-function binding table ({@link BindingEntry}[])
* plus {@link StatementFacts} (defs / uses / mayDefs) AND a taint
* {@link import('../types.js').SiteRecord} per call (callee path, receiver,
* per-arg occurrence entries, result defs, and an `at` anchor) via the shared
* {@link CallSiteFactAccumulator} — the same site substrate the C-family / Go /
* TS / Kotlin / Python / Dart harvesters emit.
*
* SWIFT CALL SHAPE (verified by a real parse — see below; structurally identical
* to Kotlin). A call is a `call_expression` whose LAST child is a `call_suffix`
* (holding the `value_arguments` and/or a trailing closure `lambda_literal`); the
* callee is the preceding expression — a bare `simple_identifier` (`foo(...)`)
* for a FREE call, or a `navigation_expression` (`obj.method` / `a?.b`, fields
* `target`/`suffix`→`navigation_suffix`→`suffix`:`simple_identifier`) for a
* MEMBER call. A chained call `a.b.c()` nests `navigation_expression`s; the
* receiver is the chain ROOT binding (`self`/literal roots launder no taint —
* no receiver). Swift has no `new` — an init call `Foo(...)` is an ordinary
* `call_expression` with a `simple_identifier` callee, so every site is
* `kind: 'call'` (the CALLS query re-tags an UpperCamelCase callee
* `@reference.call.constructor`, but the harvester only needs callee + receiver
* + `at` right — `kind` is not joined). A `value_argument` carries its value in
* the `value` field; a labeled arg's `value_argument_label` (`name:`) is dropped,
* so only the value occurrence is recorded (an `&inout` value walks its target
* for the use). Trailing closures (`xs.map { … }`) are a nested `lambda_literal`
* — opaque (in {@link NESTED_FUNCTION_TYPES}), NOT an argument occurrence.
*
* ANCHOR ALIGNMENT (plan KTD7 — load-bearing): a call site's `at` MUST be the
* SAME `[line (1-based), col (0-based)]` the Swift CALLS resolution keys its
* `atRange` on, because a downstream unit joins the two by EXACT position. The
* Swift scope query (query.ts) anchors `@reference.call.free`,
* `@reference.call.member`, and `@reference.call.constructor` on the WHOLE
* `call_expression` node (the `@reference.name` simple_identifier and the
* `@reference.receiver` are SUB-tags, excluded from the anchor by
* `KNOWN_SUB_TAGS` + the broadest-span rule in `anchorCaptureFor`; the
* constructor re-tag at `captures.ts` reuses the same call_expression node, and
* `atRange: anchor.range` at scope-extractor.ts:1030). So for a free call
* `foo(x)`, a member call `obj.method(x)`, a chained call `a.b.c(x)`, and an init
* call `Foo(x)` alike, `at` is the start of the enclosing `call_expression` node
* — which, for a member/chained call, starts at the RECEIVER (`obj`/`a`), exactly
* where the CALLS anchor starts too. This is the Kotlin/Go/Python whole-call-node
* model, NOT the Dart callee-name model. `visitCall` receives exactly the
* `call_expression` node and records `[node.startPosition.row + 1,
* node.startPosition.column]`.
*
* Runs in the parse worker next to the Swift CFG visitor. Output is the binding
* table the {@link import('../cfg-builder.js').CfgBuilder} stamps onto the CFG,
@ -78,7 +115,7 @@
*/
import type { SyntaxNode } from '../../utils/ast-helpers.js';
import type { BindingEntry, StatementFacts } from '../types.js';
import { DefUseAccumulator as FactAccumulator } from './call-site-harvest.js';
import { CallSiteFactAccumulator as FactAccumulator, finalizeChain } from './call-site-harvest.js';
/** Node types that own a nested CFG — their subtrees are opaque to harvesting. */
const NESTED_FUNCTION_TYPES = new Set([
@ -96,6 +133,13 @@ export class SwiftHarvester {
private readonly fnId: number;
/** >0 while walking a conditionally-evaluated subexpression — defs become may-defs. */
private conditionalDepth = 0;
/**
* `call_expression` node id → binding indices its single-target result is
* assigned to (`let x = f()` / `x = g()` ⇒ `[x]`). Populated just before the
* value walk reaches the call (see {@link registerResultDefs}) and consumed by
* {@link visitCall}. Mirrors the Kotlin / Go / Python harvesters' map.
*/
private readonly resultDefTargets = new Map<number, number[]>();
constructor(private readonly fnNode: SyntaxNode) {
this.fnId = fnNode.id;
@ -465,14 +509,20 @@ export class SwiftHarvester {
case 'property_declaration': {
// Walk each `value` for uses, then def each `name` pattern's leaves.
const names: SyntaxNode[] = [];
const values: SyntaxNode[] = [];
for (let i = 0; i < node.childCount; i++) {
const field = node.fieldNameForChild(i);
const child = node.child(i);
if (!child) continue;
if (field === 'value') this.walkValue(child, acc);
if (field === 'value' || field === 'computed_value') values.push(child);
else if (field === 'name') names.push(child);
else if (field === 'computed_value') this.walkValue(child, acc);
}
// Register result-defs BEFORE the value walk so the call the walk reaches
// carries them — single-name `let x = f()` only (a destructuring or
// multi-binder `let (a, b) = …` / `let p = 1, q = 2` attaches nothing).
const single = this.singlePatternBinder(names);
if (single && values.length === 1) this.registerResultDefs(values[0], [single]);
for (const value of values) this.walkValue(value, acc);
for (const pat of names) this.defPattern(pat, acc);
return;
}
@ -480,12 +530,16 @@ export class SwiftHarvester {
const target = node.childForFieldName('target');
const result = node.childForFieldName('result');
const op = node.childForFieldName('operator')?.text ?? '=';
const lv = target ? this.unwrapAssignable(target) : undefined;
const scalar = lv && lv.type === 'simple_identifier' ? lv : undefined;
// A plain `x = <call>` attaches `resultDefs: [x]`; a compound `+=` does
// not (the prior value flows in too).
if (scalar && op === '=' && result) this.registerResultDefs(result, [scalar]);
if (result) this.walkValue(result, acc);
if (target) {
const lv = this.unwrapAssignable(target);
if (lv.type === 'simple_identifier') {
this.def(lv, acc);
if (op !== '=') this.use(lv, acc); // compound assign reads too
if (lv) {
if (scalar) {
this.def(scalar, acc);
if (op !== '=') this.use(scalar, acc); // compound assign reads too
} else {
// `self.x = …`, `a[i] = …` — root is a use only (not a scalar def).
this.walkValue(lv, acc);
@ -493,11 +547,18 @@ export class SwiftHarvester {
}
return;
}
case 'call_expression':
// A Swift call (`foo(a)`, `obj.method(x)`, `a.b.c()`, `Foo(...)`). Records
// a taint site (callee path, receiver, per-arg occurrences, result defs)
// while reproducing the uses the old default descent recorded. Swift has
// no `new` — every site is `kind: 'call'`.
this.visitCall(node, acc);
return;
case 'navigation_expression': {
// `a.b` — value read of the chain root only; the suffix name is not a
// scalar binding.
const target = node.childForFieldName('target');
if (target) this.walkValue(target, acc);
// `a.b` value read — the chain-root identifier is a use plus at most one
// member-read site (the innermost access); the suffix name is not a scalar
// binding. Mirrors the Kotlin / Go value-position member-read semantics.
this.walkChain(node, acc, false);
return;
}
case 'try_expression': {
@ -548,4 +609,145 @@ export class SwiftHarvester {
}
return n;
}
// ── taint-site harvest ───────────────────────────────────────────────────
/**
* The sole `bound_identifier` binder of a single `name` pattern, or undefined
* when there are multiple `name` patterns or the pattern is a tuple / wildcard
* destructuring (`let (a, b) = …`). Used to gate single-target result-defs.
*/
private singlePatternBinder(names: readonly SyntaxNode[]): SyntaxNode | undefined {
if (names.length !== 1) return undefined;
const pat = names[0];
const bound = pat.childForFieldName?.('bound_identifier');
if (bound && bound.type === 'simple_identifier' && bound.text !== '_') return bound;
if (pat.type === 'simple_identifier' && pat.text !== '_') return pat;
return undefined;
}
/**
* When `value`'s root (after unwrapping) is a `call_expression`, remember that
* call site should carry `resultDefs` — the binding indices of `targets`
* (def-position identifiers). Consumed by {@link visitCall} once the value walk
* reaches the node. Single-target only; the blank target (`_`) binds nothing.
*/
private registerResultDefs(value: SyntaxNode, targets: readonly SyntaxNode[]): void {
const root = this.unwrapAssignable(value);
if (root.type !== 'call_expression') return;
const defs: number[] = [];
for (const target of targets) {
if (target.type !== 'simple_identifier' || target.text === '_') continue;
defs.push(this.resolve(target));
}
if (defs.length > 0) this.resultDefTargets.set(root.id, defs);
}
/**
* The callee node of a `call_expression` — the first named child that is NOT
* the trailing `call_suffix` (a bare `simple_identifier` for a free / init
* call, or a `navigation_expression` for a member / chained call).
*/
private calleeOf(call: SyntaxNode): SyntaxNode | undefined {
for (let i = 0; i < call.namedChildCount; i++) {
const c = call.namedChild(i);
if (c && c.type !== 'call_suffix') return c;
}
return undefined;
}
/**
* Open + populate a call site for a Swift `call_expression`. `node` IS the
* `call_expression` — the SAME node the scope query anchors `@reference.call.*`
* on (its `atRange`), so the resolved-id join lands by exact position (see file
* header ANCHOR ALIGNMENT). Swift has no `new`, so every site is `kind: 'call'`.
*/
private visitCall(node: SyntaxNode, acc: FactAccumulator): void {
const calleeNode = this.calleeOf(node);
const siteIdx = acc.openCallSite('call', [
node.startPosition.row + 1,
node.startPosition.column,
]);
acc.pushFrame(siteIdx);
if (calleeNode) {
const callee = this.unwrapAssignable(calleeNode);
if (callee.type === 'simple_identifier') {
// A bare free / init call — the callee NAME is a statement-level use but
// NOT a value occurrence in any enclosing argument.
if (callee.text !== '_') acc.addUseWithoutOccurrence(this.resolve(callee));
acc.setSiteCallee(siteIdx, callee.text);
} else if (callee.type === 'navigation_expression') {
// skipFinalRead: the final `.name` IS the callee, carried by the path.
const chain = this.walkChain(callee, acc, true);
if (chain.path !== undefined) acc.setSiteCallee(siteIdx, chain.path);
if (chain.rootIdx !== undefined) acc.setSiteReceiver(siteIdx, chain.rootIdx);
} else {
// Call-rooted chains (`f()()`), parenthesized callables, `self.x`-rooted —
// the walk still records uses and nested sites; no static callee path.
this.walkValue(callee, acc);
}
}
const resultDefs = this.resultDefTargets.get(node.id);
if (resultDefs !== undefined) acc.setSiteResultDefs(siteIdx, resultDefs);
const suffix = node.namedChildren.find((c) => c.type === 'call_suffix');
if (suffix) this.walkArguments(suffix, acc);
acc.popFrame();
}
/**
* Walk a `call_suffix`'s `value_arguments`, tagging each positional / labeled
* argument's occurrence position. A trailing closure (`lambda_literal`) is a
* nested function body — opaque (excluded by {@link NESTED_FUNCTION_TYPES}), so
* it is not an argument occurrence here.
*/
private walkArguments(suffix: SyntaxNode, acc: FactAccumulator): void {
const args = suffix.namedChildren.find((c) => c.type === 'value_arguments');
if (!args) return;
let pos = 0;
for (let i = 0; i < args.namedChildCount; i++) {
const arg = args.namedChild(i);
if (!arg || arg.type !== 'value_argument') continue;
acc.setFrameArg(pos);
// A labeled arg (`name: value`) records only the VALUE occurrence — the
// `value_argument_label` is dropped. An `&inout` value walks its `target`
// identifier for the use. Swift has no call-site spread operator.
const value = arg.childForFieldName('value');
if (value) this.walkValue(value, acc);
pos++;
}
}
/**
* `navigation_expression` chain walk shared by value position and callee
* position. Records the chain-root identifier as a use plus at most ONE
* member-read site — the INNERMOST access — when the root is an identifier;
* `skipFinalRead` suppresses it when that access is the callee (carried by the
* dotted path instead). Mirrors the Kotlin / Go / Python harvesters' walkChain.
* A non-identifier root (`self`/literal/call) launders no static path/receiver.
*/
private walkChain(
node: SyntaxNode,
acc: FactAccumulator,
skipFinalRead: boolean,
): { path?: string; rootIdx?: number } {
const accesses: string[] = [];
let cur: SyntaxNode = this.unwrapAssignable(node);
for (;;) {
if (cur.type === 'navigation_expression') {
const suffix = cur.childForFieldName('suffix');
const name = suffix?.childForFieldName('suffix');
accesses.unshift(name?.text ?? '');
const operand = cur.childForFieldName('target');
if (!operand) break;
cur = this.unwrapAssignable(operand);
} else {
break;
}
}
// The shared terminal: root-use record + innermost member-read + path-join.
return finalizeChain(acc, cur, accesses, skipFinalRead, (t) => t === 'simple_identifier', {
resolve: (n) => this.resolve(n),
walkRoot: (n) => this.walkValue(n, acc),
});
}
}

View file

@ -186,6 +186,7 @@ export class TsHarvester {
kind: BindingEntry['kind'],
scope: Scope,
hoistToRoot: boolean,
formalIndex?: number,
): void {
const target = hoistToRoot ? this.root : scope;
const name = nameNode.text;
@ -200,6 +201,10 @@ export class TsHarvester {
declLine: nameNode.startPosition.row + 1,
declColumn: nameNode.startPosition.column,
kind,
// Carry the enclosing formal position for params so the PDG call-summary
// consumer joins by FORMAL slot, never the flattened binding ordinal. A
// destructured/rest formal hands the SAME index to every inner name.
...(formalIndex !== undefined ? { formalIndex } : {}),
});
}
@ -207,48 +212,60 @@ export class TsHarvester {
const params = fnNode.childForFieldName('parameters') ?? fnNode.childForFieldName('parameter');
if (!params) return;
if (params.type === 'identifier') {
this.declare(params, 'param', this.root, true); // `x => …` single-param arrow
this.declare(params, 'param', this.root, true, 0); // `x => …` single-param arrow ⇒ formal 0
return;
}
// Each NAMED child of the parameter list is one top-level formal — its index
// here is the 0-based formal position. Destructured/rest formals fan out to
// several inner names, but ALL inherit this one formal index, so the PDG
// call-summary never misattributes an inner name to a later formal's slot.
let formalIndex = 0;
for (let i = 0; i < params.namedChildCount; i++) {
const p = params.namedChild(i);
if (!p) continue;
// TS wraps each param (required_parameter/optional_parameter, field
// `pattern`); plain JS puts the pattern directly in formal_parameters.
const pattern = p.childForFieldName('pattern') ?? p;
this.declarePattern(pattern, 'param', this.root, true);
this.declarePattern(pattern, 'param', this.root, true, formalIndex);
formalIndex++;
}
}
/** Declare every name bound by a (possibly destructuring) pattern. */
/**
* Declare every name bound by a (possibly destructuring) pattern. When
* `formalIndex` is supplied (param patterns), EVERY name the pattern binds
* carries that one enclosing-formal position (the recursion never reassigns
* it), so `function f({a, b}, c)` records a:0, b:0, c:1.
*/
private declarePattern(
node: SyntaxNode,
kind: BindingEntry['kind'],
scope: Scope,
hoistToRoot: boolean,
formalIndex?: number,
): void {
switch (node.type) {
case 'identifier':
case 'shorthand_property_identifier_pattern':
this.declare(node, kind, scope, hoistToRoot);
this.declare(node, kind, scope, hoistToRoot, formalIndex);
return;
case 'rest_pattern':
case 'object_pattern':
case 'array_pattern':
for (let i = 0; i < node.namedChildCount; i++) {
const c = node.namedChild(i);
if (c) this.declarePattern(c, kind, scope, hoistToRoot);
if (c) this.declarePattern(c, kind, scope, hoistToRoot, formalIndex);
}
return;
case 'pair_pattern': {
const value = node.childForFieldName('value');
if (value) this.declarePattern(value, kind, scope, hoistToRoot);
if (value) this.declarePattern(value, kind, scope, hoistToRoot, formalIndex);
return;
}
case 'assignment_pattern':
case 'object_assignment_pattern': {
const left = node.childForFieldName('left');
if (left) this.declarePattern(left, kind, scope, hoistToRoot);
if (left) this.declarePattern(left, kind, scope, hoistToRoot, formalIndex);
return;
}
default:
@ -256,7 +273,7 @@ export class TsHarvester {
for (let i = 0; i < node.namedChildCount; i++) {
const c = node.namedChild(i);
if (c && !TYPE_CONTEXT_TYPES.has(c.type)) {
this.declarePattern(c, kind, scope, hoistToRoot);
this.declarePattern(c, kind, scope, hoistToRoot, formalIndex);
}
}
}
@ -716,7 +733,9 @@ export class TsHarvester {
private visitCall(node: SyntaxNode, acc: FactAccumulator, kind: 'call' | 'new'): void {
const calleeNode = node.childForFieldName(kind === 'new' ? 'constructor' : 'function');
const argsNode = node.childForFieldName('arguments');
const siteIdx = acc.openCallSite(kind);
// `node` IS the call_expression/new_expression — the SAME node the
// scope-extractor anchors `@reference.call.*` (its `atRange`) on (KTD7).
const siteIdx = acc.openCallSite(kind, [node.startPosition.row + 1, node.startPosition.column]);
acc.pushFrame(siteIdx);
let calleePath: string | undefined;
if (calleeNode) {
@ -862,6 +881,8 @@ interface MutableSite {
requireArg?: string;
object?: number;
property?: string;
/** Call-site anchor position — see {@link SiteRecord.at}. Call/new only. */
at?: [number, number];
}
/**
@ -945,11 +966,17 @@ class FactAccumulator {
return [...this.defs.slice(snap[0]), ...this.mayDefs.slice(snap[1])];
}
/** Open a call/new site; parent = innermost enclosing argument position. */
openCallSite(kind: 'call' | 'new'): number {
/**
* Open a call/new site; parent = innermost enclosing argument position. `at`
* is the call/new node's anchor position `[line (1-based), col (0-based)]` —
* the SAME position the CALLS-edge resolution keys on (KTD7; see
* {@link SiteRecord.at}).
*/
openCallSite(kind: 'call' | 'new', at?: readonly [number, number]): number {
const site: MutableSite = { kind };
const parent = this.innermostArgPosition();
if (parent) site.parent = parent;
if (at) site.at = [at[0], at[1]];
this.sites.push(site);
return this.sites.length - 1;
}

View file

@ -0,0 +1,73 @@
/**
* Phase: callSummaries (PDG FU-C, U-C3)
*
* The whole-program CALL_SUMMARY materialisation pass — the dependence-engine
* SIBLING of `taintSummaries`. Runs AFTER scope-resolution (where the resolved
* `CALLS` graph lives in `ctx.graph` and the per-function RETURN-VALUE ASCENT
* summaries were harvested in-phase) and emits one `CALL_SUMMARY` self-loop edge
* per harvested callee. A later consumer phase (NOT this task) decodes the
* bitset to ascend a callee's return effect into the caller continuation.
*
* Opt-in: registered with `enabledWhen: (o) => o.pdg === true`. A default
* `analyze` run never includes it, so the graph is byte-identical and emits ZERO
* CALL_SUMMARY edges. No always-on phase depends on it.
*
* @deps scopeResolution, pruneLocalSymbols
* @reads scopeResolution output (callSummaries)
* @writes graph (CALL_SUMMARY self-loop edges)
*/
import type { PipelinePhase, PipelineContext, PhaseResult } from './types.js';
import { getPhaseOutput } from './types.js';
import type { ScopeResolutionOutput } from '../scope-resolution/pipeline/phase.js';
import {
emitCallSummaries,
DEFAULT_PDG_MAX_CALL_SUMMARY_EDGES,
} from '../taint/call-summary-emit.js';
import { logger } from '../../logger.js';
export interface CallSummariesOutput {
/** Per-callee summaries fed to the emit. */
summaries: number;
/** CALL_SUMMARY edges persisted. */
edgesEmitted: number;
}
const EMPTY: CallSummariesOutput = { summaries: 0, edgesEmitted: 0 };
export const callSummariesPhase: PipelinePhase<CallSummariesOutput> = {
name: 'callSummaries',
deps: ['scopeResolution', 'pruneLocalSymbols'],
async execute(
ctx: PipelineContext,
deps: ReadonlyMap<string, PhaseResult<unknown>>,
): Promise<CallSummariesOutput> {
const scope = getPhaseOutput<ScopeResolutionOutput>(deps, 'scopeResolution');
const summaries = scope.callSummaries;
if (summaries.length === 0) return EMPTY;
const maxEdges = ctx.options?.pdgMaxCallSummaryEdges ?? DEFAULT_PDG_MAX_CALL_SUMMARY_EDGES;
const emit = emitCallSummaries(ctx.graph, summaries, { maxEdges }, (m) => logger.warn(m));
if (emit.edgesDropped > 0) {
logger.warn(
`[call-summary] capped: ${emit.edgesDropped} CALL_SUMMARY edge(s) dropped by the ` +
`per-run cap (${maxEdges}) — raise pdgMaxCallSummaryEdges if intentional`,
);
}
if (emit.skippedMissingEndpoint > 0) {
logger.debug(
`[call-summary] ${emit.skippedMissingEndpoint} summary/summaries skipped (callee node ` +
`missing from graph)`,
);
}
if (emit.edgesEmitted > 0) {
logger.debug(
`[call-summary] ${summaries.length} summaries → ${emit.edgesEmitted} CALL_SUMMARY edge(s)`,
);
}
return { summaries: summaries.length, edgesEmitted: emit.edgesEmitted };
},
};

View file

@ -22,6 +22,7 @@ export {
} from '../scope-resolution/pipeline/phase.js';
export { pruneLocalSymbolsPhase, type PruneLocalSymbolsOutput } from './prune-local-symbols.js';
export { taintSummariesPhase, type TaintSummariesOutput } from './taint-summaries.js';
export { callSummariesPhase, type CallSummariesOutput } from './call-summaries.js';
export { mroPhase, type MROOutput } from './mro.js';
export { communitiesPhase, type CommunitiesOutput } from './communities.js';
export { processesPhase, type ProcessesOutput } from './processes.js';

View file

@ -42,6 +42,15 @@ const EXPO_NAV_PATTERNS = [
export interface RouteEntry {
filePath: string;
source: string;
/**
* HTTP verb for this route when ingestion knows it structurally
* (Spring/Laravel framework routes and decorator routes carry
* `httpMethod`; filesystem-derived routes — Next.js/Expo/PHP file
* routes — do not, so this stays undefined for them). Persisted onto
* the Route node so downstream contract extraction can read the verb
* from the graph instead of re-parsing the handler source.
*/
method?: string;
}
export interface RoutesOutput {
@ -135,6 +144,33 @@ function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* Canonicalize a route's HTTP verb for persistence on the Route node.
* Returns an upper-cased standard method, or `undefined` when the value
* is not a real HTTP verb. Laravel `Route::resource` / `apiResource`
* surface `httpMethod` values like `resource` / `apiResource` (they
* expand to several verbs at runtime), so they must not be stored as a
* method — leaving them `undefined` keeps the column clean and lets the
* contract extractor fall back to its source-scan path for those routes.
*/
const VALID_HTTP_METHODS = new Set([
'GET',
'POST',
'PUT',
'PATCH',
'DELETE',
'HEAD',
'OPTIONS',
'TRACE',
'CONNECT',
]);
export function normalizeRouteMethod(raw: string | null | undefined): string | undefined {
if (typeof raw !== 'string') return undefined;
const verb = raw.trim().toUpperCase();
return VALID_HTTP_METHODS.has(verb) ? verb : undefined;
}
export const routesPhase: PipelinePhase<RoutesOutput> = {
name: 'routes',
deps: ['parse'],
@ -213,6 +249,7 @@ export const routesPhase: PipelinePhase<RoutesOutput> = {
addRoute(routeUrl, {
filePath: route.filePath,
source: 'framework-route',
method: normalizeRouteMethod(route.httpMethod),
});
if (route.routeName && !namedRouteRegistry.has(route.routeName)) {
namedRouteRegistry.set(route.routeName, routeUrl);
@ -223,6 +260,7 @@ export const routesPhase: PipelinePhase<RoutesOutput> = {
addRoute(url, {
filePath: dr.filePath,
source: `decorator-${dr.decoratorName}`,
method: normalizeRouteMethod(dr.httpMethod),
});
}
@ -232,7 +270,7 @@ export const routesPhase: PipelinePhase<RoutesOutput> = {
handlerContents = await readFileContents(ctx.repoPath, handlerPaths);
for (const [routeURL, entry] of routeRegistry) {
const { filePath: handlerPath, source: routeSource } = entry;
const { filePath: handlerPath, source: routeSource, method: routeMethod } = entry;
const content = handlerContents.get(handlerPath);
const { responseKeys, errorKeys } = content
@ -251,6 +289,7 @@ export const routesPhase: PipelinePhase<RoutesOutput> = {
properties: {
name: routeURL,
filePath: handlerPath,
...(routeMethod ? { method: routeMethod } : {}),
...(responseKeys ? { responseKeys } : {}),
...(errorKeys ? { errorKeys } : {}),
...(middleware && middleware.length > 0 ? { middleware } : {}),

View file

@ -33,6 +33,7 @@ import {
scopeResolutionPhase,
pruneLocalSymbolsPhase,
taintSummariesPhase,
callSummariesPhase,
mroPhase,
communitiesPhase,
processesPhase,
@ -121,6 +122,11 @@ export interface PipelineOptions {
/** Per-run `TAINT_PATH` edge cap (#2084 review P1-3). `undefined` ⇒
* `DEFAULT_PDG_MAX_INTERPROC_EDGES` (1000); `0` ⇒ no cap. */
pdgMaxInterprocEdges?: number;
/** Per-run `CALL_SUMMARY` edge cap (PDG FU-C, U-C3). `undefined` ⇒
* `DEFAULT_PDG_MAX_CALL_SUMMARY_EDGES` (0 = unlimited); `0` ⇒ no cap.
* Programmatic only, no CLI flag (KTD8) — same discipline as the other
* pdg caps. */
pdgMaxCallSummaryEdges?: number;
/**
* Streaming/chunked PDG graph emit (#2202). When true, the BasicBlock +
* intra-file PDG-edge layer (CFG / REACHING_DEF / CDG / POST_DOMINATE /
@ -267,6 +273,7 @@ export function buildPhaseList(options?: PipelineOptions): PipelinePhase[] {
// pdg-gated phase. Off ⇒ absent ⇒ byte-identical graph. No always-on
// phase depends on it (a filtered-out dep would throw in getPhaseOutput).
.register(taintSummariesPhase, { enabledWhen: (o) => o.pdg === true })
.register(callSummariesPhase, { enabledWhen: (o) => o.pdg === true })
.register(mroPhase, { enabledWhen: (o) => !o.skipGraphPhases })
.register(communitiesPhase, { enabledWhen: (o) => !o.skipGraphPhases })
.register(processesPhase, { enabledWhen: (o) => !o.skipGraphPhases })

View file

@ -0,0 +1,106 @@
/**
* Resolved-callee-id capture sink (#2227 follow-up plan U2).
*
* During Phase-4 scope-resolution CALLS-edge emission, each resolved call
* site's `(callSiteLine, callSiteCol) → resolvedCalleeId` mapping is
* accumulated here — across ALL THREE CALLS emit paths
* (`emitReceiverBoundCalls` via `tryEmitEdge`/`tryEmitEdgeWithExplicitTargetId`,
* and the inline `graph.addRelationship` in `emitFreeCallFallback` and
* `emitReferencesViaLookup`), each BEFORE its dedup (KTD6/R8). A later unit
* (U3) joins this map to CFG `BasicBlock`s by exact call-site position and
* emits a `BasicBlock.calleeIds` set.
*
* KEY ALIGNMENT (plan KTD7 — load-bearing): the key is the call/new
* expression node's start position — `line` 1-based (`startPosition.row + 1`),
* `col` 0-based (`startPosition.column`). This MUST equal the U1
* `SiteRecord.at` so the U3 position join lands. The CALLS resolution exposes
* the same node's range via `site.atRange` (`atRange: anchor.range`,
* scope-extractor.ts:1030), whose `startLine`/`startCol` are built by
* `nodeToCapture` as `row + 1` / `column` (1-based line, 0-based col — see the
* `Range` doc in gitnexus-shared). So a capture keyed on
* `(atRange.startLine, atRange.startCol)` is byte-equal to U1's `at` — no
* normalization needed.
*
* Gating (R4): the concrete sink is created in `run.ts` only when
* `input.pdg === true`; otherwise `undefined` is threaded through, so off-mode
* does zero work and emits byte-identical output.
*
* Multi-target dispatch (R2/KTD8): one site → multiple emit calls → the `Set`
* accumulates every resolved target. Capture is per-emit-call, so the
* candidate set is complete and a real target is never dropped.
*/
/** Encoded position key: `${line}:${col}` (1-based line, 0-based col). */
export type CalleeIdPosKey = string;
/** Build the position key from a call-site anchor. Single source of truth so
* producer (this sink) and consumer (U3's CFG join) encode positions
* identically. */
export function calleeIdPosKey(line: number, col: number): CalleeIdPosKey {
return `${line}:${col}`;
}
/**
* Write-side contract handed to the three CALLS emitters. Each resolved CALLS
* edge feeds one `add` BEFORE its dedup, keyed on the call-site anchor.
*/
export interface CalleeIdSink {
/**
* Record that the call site at `(line, col)` in `filePath` resolved to
* `calleeId`. Idempotent per `(filePath, line, col, calleeId)` — the
* underlying value is a `Set`, so repeat targets collapse.
*/
add(filePath: string, line: number, col: number, calleeId: string): void;
}
/**
* Read-side accessor consumed by U3's CFG-emit join. Returns the per-file
* `posKey → Set<calleeId>` map (or `undefined` when the file produced no
* captures). Kept separate from the write interface so the emitters only see
* the narrow `add` surface.
*/
export interface CalleeIdMapView {
/** Per-file position→ids map, or `undefined` if nothing was captured for it. */
get(filePath: string): ReadonlyMap<CalleeIdPosKey, ReadonlySet<string>> | undefined;
/**
* Release a file's captured map once its CFG emit has consumed it (R6). The
* three CALLS passes fully precede the CFG-emit loop and each file is read
* exactly once, so releasing after consumption bounds the accumulator to one
* file's call sites instead of holding the whole repo's for the full phase.
*/
delete(filePath: string): void;
}
/** The concrete accumulator: a write sink that also exposes the read view. */
export interface CalleeIdAccumulator extends CalleeIdSink, CalleeIdMapView {}
/**
* Create the concrete nested-`Map` accumulator. Call ONLY when
* `input.pdg === true` (else thread `undefined` for byte-identity / zero
* overhead — R4).
*/
export function createCalleeIdAccumulator(): CalleeIdAccumulator {
const byFile = new Map<string, Map<CalleeIdPosKey, Set<string>>>();
return {
add(filePath: string, line: number, col: number, calleeId: string): void {
let byPos = byFile.get(filePath);
if (byPos === undefined) {
byPos = new Map<CalleeIdPosKey, Set<string>>();
byFile.set(filePath, byPos);
}
const key = calleeIdPosKey(line, col);
let ids = byPos.get(key);
if (ids === undefined) {
ids = new Set<string>();
byPos.set(key, ids);
}
ids.add(calleeId);
},
get(filePath: string): ReadonlyMap<CalleeIdPosKey, ReadonlySet<string>> | undefined {
return byFile.get(filePath);
},
delete(filePath: string): void {
byFile.delete(filePath);
},
};
}

View file

@ -20,6 +20,17 @@ import type { KnowledgeGraph } from '../../../graph/types.js';
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
import type { GraphNodeLookup } from '../graph-bridge/node-lookup.js';
import { resolveCallerGraphId, resolveDefGraphId } from '../graph-bridge/ids.js';
import type { CalleeIdSink } from './callee-id-sink.js';
/**
* Optional resolved-callee-id capture context (#2227 follow-up U2). Threaded
* in only under `--pdg` (else `undefined` → zero overhead, byte-identity R4).
* `filePath` is NOT on the `site` param, so it rides here alongside the sink.
*/
export interface CalleeIdCaptureCtx {
readonly sink: CalleeIdSink;
readonly filePath: string;
}
/**
* Map a `Reference.kind` to a graph edge type. `import-use` is dropped
@ -74,6 +85,7 @@ export function tryEmitEdge(
seen: Set<string>,
confidence = 0.85,
collapseByCallerTarget = false,
calleeCapture?: CalleeIdCaptureCtx,
): boolean {
// Inheritance edges are emitted directly by `preEmitInheritanceEdges` (which
// owns the enclosing-class caller and the EXTENDS-vs-IMPLEMENTS type), so this
@ -85,6 +97,19 @@ export function tryEmitEdge(
if (targetGraphId === undefined) return false;
if (edgeType === undefined) return false;
// Resolved-callee-id capture (#2227 U2/KTD6/R8): record this CALLS site's
// resolved target BEFORE the dedup `seen` check, so collapsed same-target
// multi-line calls are still captured per site. Keyed on `site.atRange`
// (1-based line / 0-based col — byte-equal to U1's SiteRecord.at).
if (calleeCapture !== undefined && edgeType === 'CALLS') {
calleeCapture.sink.add(
calleeCapture.filePath,
site.atRange.startLine,
site.atRange.startCol,
targetGraphId,
);
}
// CALLS edges may collapse to `(caller, target)` granularity when
// the provider opts in (C# matches legacy DAG behavior this way).
// Write/read ACCESSES keep per-site dedup so multiple writes to the
@ -134,12 +159,24 @@ export function tryEmitEdgeWithExplicitTargetId(
seen: Set<string>,
confidence = 0.85,
collapseByCallerTarget = false,
calleeCapture?: CalleeIdCaptureCtx,
): boolean {
const callerGraphId = resolveCallerGraphId(site.inScope, scopes, nodeLookup, site.atRange);
const edgeType = mapReferenceKindToEdgeType(site.kind as Reference['kind']);
if (callerGraphId === undefined) return false;
if (edgeType === undefined) return false;
// Resolved-callee-id capture (#2227 U2/KTD6/R8) — before dedup, see
// `tryEmitEdge`. The explicit target id IS the resolved callee id.
if (calleeCapture !== undefined && edgeType === 'CALLS') {
calleeCapture.sink.add(
calleeCapture.filePath,
site.atRange.startLine,
site.atRange.startCol,
targetGraphId,
);
}
const useCollapsed = collapseByCallerTarget && edgeType === 'CALLS';
const dedupKey = useCollapsed
? `${edgeType}:${callerGraphId}->${targetGraphId}`

View file

@ -24,6 +24,7 @@ import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexe
import { resolveCallerGraphId, resolveDefGraphId } from '../graph-bridge/ids.js';
import { mapReferenceKindToEdgeType } from '../graph-bridge/edges.js';
import type { GraphNodeLookup } from '../graph-bridge/node-lookup.js';
import type { CalleeIdSink } from '../graph-bridge/callee-id-sink.js';
/**
* Optional opaque skip key — providers may pre-emit edges (e.g. via
@ -40,6 +41,10 @@ export function emitReferencesViaLookup(
referenceIndex: { readonly bySourceScope: ReadonlyMap<ScopeId, readonly Reference[]> },
nodeLookup: GraphNodeLookup,
skipSites?: ReferenceSiteSkipSet,
/** Resolved-callee-id capture sink (#2227 U2). Threaded in only under
* `--pdg`; `undefined` ⇒ zero overhead, byte-identity (R4). Captured at the
* CALLS emit below BEFORE this loop's `seen` dedup (KTD6/R8). */
calleeIdSink?: CalleeIdSink,
): { emitted: number; skipped: number } {
let emitted = 0;
let skipped = 0;
@ -80,6 +85,15 @@ export function emitReferencesViaLookup(
continue;
}
// Resolved-callee-id capture (#2227 U2/KTD6/R8): record this CALLS site's
// resolved target BEFORE the `seen` dedup, keyed on `ref.atRange`
// (byte-equal to U1's SiteRecord.at: 1-based line / 0-based col). Only
// CALLS feeds the bridge; ACCESSES/USES/EXTENDS are skipped. `fromFilePath`
// is the call-site (caller) file — the same file U1 stamps the site on.
if (calleeIdSink !== undefined && edgeType === 'CALLS' && fromFilePath !== undefined) {
calleeIdSink.add(fromFilePath, ref.atRange.startLine, ref.atRange.startCol, targetGraphId);
}
const dedupKey = `${edgeType}:${callerGraphId}->${targetGraphId}:${ref.atRange.startLine}:${ref.atRange.startCol}`;
if (seen.has(dedupKey)) continue;
seen.add(dedupKey);

View file

@ -35,6 +35,7 @@ import type {
ResolutionSuppressionReason,
} from '../resolution-outcome.js';
import { resolveCallerGraphId, resolveDefGraphId } from '../graph-bridge/ids.js';
import type { CalleeIdSink } from '../graph-bridge/callee-id-sink.js';
import {
findAllCallableBindingsInScope,
findCallableBindingInScope,
@ -88,6 +89,11 @@ export function emitFreeCallFallback(
* candidate (monotonicity). */
readonly constraintCompatibility?: ScopeResolver['constraintCompatibility'];
readonly recordResolutionOutcome?: ResolutionOutcomeRecorder;
/** Resolved-callee-id capture sink (#2227 U2). Threaded in only under
* `--pdg`; `undefined` ⇒ zero overhead, byte-identity (R4). Captured at
* the CALLS emit below BEFORE the collapsed `seen` dedup (KTD6) so
* same-target multi-line calls are still recorded per site. */
readonly calleeIdSink?: CalleeIdSink;
} = {},
): number {
let emitted = 0;
@ -422,6 +428,18 @@ export function emitFreeCallFallback(
// means we don't add a new edge — so `emit-references` skips its
// potentially-wrong fallback for the same site.
handledSites.add(siteKey(parsed.filePath, site));
// Resolved-callee-id capture (#2227 U2/KTD6/R8): record this CALLS site's
// resolved target BEFORE the collapsed `seen` dedup. The free-call dedup
// key drops the line (one edge per caller→target), so capturing after
// `seen.has` would lose every same-target call past the first — capture
// here, per site, keyed on `site.atRange` (byte-equal to U1's
// SiteRecord.at: 1-based line / 0-based col).
options.calleeIdSink?.add(
parsed.filePath,
site.atRange.startLine,
site.atRange.startCol,
tgtGraphId,
);
const relId = `rel:CALLS:${callerGraphId}->${tgtGraphId}`;
if (seen.has(relId)) continue;
seen.add(relId);

View file

@ -54,7 +54,12 @@ import {
findValueBindingInScope,
isClassLike,
} from '../scope/walkers.js';
import { tryEmitEdge, tryEmitEdgeWithExplicitTargetId } from '../graph-bridge/edges.js';
import {
tryEmitEdge,
tryEmitEdgeWithExplicitTargetId,
type CalleeIdCaptureCtx,
} from '../graph-bridge/edges.js';
import type { CalleeIdSink } from '../graph-bridge/callee-id-sink.js';
import { resolveCompoundReceiverClass } from '../passes/compound-receiver.js';
import { resolveDefGraphId } from '../graph-bridge/ids.js';
import {
@ -148,6 +153,10 @@ export function emitReceiverBoundCalls(
model: SemanticModel,
options: {
readonly recordResolutionOutcome?: ResolutionOutcomeRecorder;
/** Resolved-callee-id capture sink (#2227 U2). Threaded in only under
* `--pdg`; `undefined` ⇒ zero overhead, byte-identity (R4). Per-file
* capture contexts are built from this + `parsed.filePath` in the loop. */
readonly calleeIdSink?: CalleeIdSink;
} = {},
): number {
let emitted = 0;
@ -200,6 +209,7 @@ export function emitReceiverBoundCalls(
primaryMemberDef: SymbolDefinition,
site: ParsedFile['referenceSites'][number],
confidence: number,
calleeCapture: CalleeIdCaptureCtx | undefined,
): number => {
if (ownerDef.type !== 'Interface') return 0;
const impls = implementorsByInterfaceDefId.get(ownerDef.nodeId);
@ -225,6 +235,7 @@ export function emitReceiverBoundCalls(
seen,
confidence,
collapse,
calleeCapture,
);
if (ok) n++;
}
@ -233,6 +244,13 @@ export function emitReceiverBoundCalls(
for (const parsed of parsedFiles) {
const namespaceTargets = collectNamespaceTargets(parsed, scopes);
// Per-file resolved-callee-id capture context (#2227 U2). Built once per
// file; `undefined` when the sink is absent (pdg off) so the `tryEmitEdge`
// capture is a no-op and emission stays byte-identical (R4).
const calleeCapture: CalleeIdCaptureCtx | undefined =
options.calleeIdSink !== undefined
? { sink: options.calleeIdSink, filePath: parsed.filePath }
: undefined;
for (const site of parsed.referenceSites) {
if (site.kind !== 'call' && site.kind !== 'read' && site.kind !== 'write') continue;
@ -330,6 +348,7 @@ export function emitReceiverBoundCalls(
seen,
0.85,
collapse,
calleeCapture,
);
if (ok) emitted++;
// Always mark handled when the site was resolved, even
@ -417,6 +436,7 @@ export function emitReceiverBoundCalls(
seen,
0.85,
collapse,
calleeCapture,
);
if (ok) emitted++;
// Always mark handled when the site was resolved, even
@ -497,6 +517,7 @@ export function emitReceiverBoundCalls(
seen,
confidence,
collapse,
calleeCapture,
);
if (ok) emitted++;
handledSites.add(siteKey);
@ -593,6 +614,7 @@ export function emitReceiverBoundCalls(
seen,
confidence,
collapse,
calleeCapture,
);
if (ok) emitted++;
handledSites.add(siteKey);
@ -630,6 +652,7 @@ export function emitReceiverBoundCalls(
seen,
0.85,
collapse,
calleeCapture,
);
if (ok) emitted++;
handledSites.add(siteKey);
@ -692,6 +715,7 @@ export function emitReceiverBoundCalls(
seen,
0.85,
collapse,
calleeCapture,
);
if (ok) emitted++;
handledSites.add(siteKey);
@ -773,6 +797,7 @@ export function emitReceiverBoundCalls(
seen,
confidence,
collapse,
calleeCapture,
);
if (ok) emitted++;
handledSites.add(siteKey);
@ -831,6 +856,11 @@ export function emitReceiverBoundCalls(
memberDef,
memberDef.filePath !== parsed.filePath ? 'import-resolved' : 'global',
seen,
// Explicit defaults so the trailing capture ctx (#2227 U2) can
// be threaded without changing dedup/confidence behavior.
0.85,
false,
calleeCapture,
);
if (ok) {
emitted++;
@ -939,6 +969,7 @@ export function emitReceiverBoundCalls(
seen,
0.85,
collapse,
calleeCapture,
);
if (ok) emitted++;
// Always mark handled when the site was resolved, even
@ -1029,6 +1060,7 @@ export function emitReceiverBoundCalls(
seen,
confidence,
collapse,
calleeCapture,
);
if (ok) emitted++;
handledSites.add(siteKey);
@ -1145,12 +1177,20 @@ export function emitReceiverBoundCalls(
seen,
confidence,
collapse,
calleeCapture,
);
if (ok) emitted++;
// Interface dispatch: when the primary owner is an
// Interface, emit secondary CALLS edges to every
// implementing class's same-named method.
emitted += emitInterfaceDispatchFor(ownerDef, memberName, memberDef, site, confidence);
emitted += emitInterfaceDispatchFor(
ownerDef,
memberName,
memberDef,
site,
confidence,
calleeCapture,
);
// Always mark handled when the site was resolved, even
// if the edge was deduplicated (collapse mode), so
// `emitReferencesViaLookup` doesn't re-emit from the
@ -1242,6 +1282,7 @@ export function emitReceiverBoundCalls(
seen,
confidence,
collapse,
calleeCapture,
);
if (ok) emitted++;
handledSites.add(siteKey);

View file

@ -43,6 +43,7 @@ import {
} from '../../../../storage/parsedfile-store.js';
import type { ResolutionOutcome } from '../resolution-outcome.js';
import type { FunctionSummary } from '../../taint/summary-model.js';
import type { CallSummary } from '../../taint/call-summary-model.js';
import { buildFunctionNodeIndex } from '../../taint/summary-harvest-driver.js';
import { PdgEmitSink, type PdgEmitManifest } from '../../../lbug/pdg-emit-sink.js';
import { resolveNativeSafeStorageDir } from '../../../lbug/lbug-config.js';
@ -74,6 +75,13 @@ export interface ScopeResolutionOutput {
* The `taintSummaries` phase composes these over the `CALLS` graph.
*/
readonly functionSummaries: readonly FunctionSummary[];
/**
* Per-function RETURN-VALUE ASCENT summaries harvested in the pdg window
* (PDG FU-C, U-C2), across all languages. Empty unless `--pdg`. The
* `callSummaries` phase materialises one `CALL_SUMMARY` self-loop edge per
* entry once the resolved call graph is known.
*/
readonly callSummaries: readonly CallSummary[];
/**
* Streamed PDG-emit COPY manifest (#2202). Present only when streaming was on
* (full rebuild + `--pdg` + enabled): the BasicBlock node CSV + per-pair PDG
@ -92,6 +100,7 @@ const NOOP_OUTPUT: ScopeResolutionOutput = Object.freeze({
resolutionOutcomes: [],
perLanguage: new Map(),
functionSummaries: [],
callSummaries: [],
});
export const scopeResolutionPhase: PipelinePhase<ScopeResolutionOutput> = {
@ -165,6 +174,9 @@ export const scopeResolutionPhase: PipelinePhase<ScopeResolutionOutput> = {
// M4 (#2084 U1): per-function taint summaries accumulated across every
// language pass; the cross-function fixpoint phase reads this output.
const functionSummaries: FunctionSummary[] = [];
// FU-C (U-C2): per-function RETURN-VALUE ASCENT summaries accumulated across
// every language pass; the `callSummaries` emit phase reads this output.
const callSummaries: CallSummary[] = [];
const perLanguage = new Map<
SupportedLanguages,
{
@ -510,6 +522,7 @@ export const scopeResolutionPhase: PipelinePhase<ScopeResolutionOutput> = {
processedScopeFiles += langFileCount;
anyRan = true;
functionSummaries.push(...stats.functionSummaries);
callSummaries.push(...stats.callSummaries);
totalFiles += stats.filesProcessed;
totalImports += stats.importsEmitted;
totalRefs += stats.referenceEdgesEmitted;
@ -572,6 +585,7 @@ export const scopeResolutionPhase: PipelinePhase<ScopeResolutionOutput> = {
resolutionOutcomes,
perLanguage,
functionSummaries,
callSummaries,
pdgEmitManifest,
};
},

View file

@ -45,6 +45,7 @@ import {
DEFAULT_PDG_MAX_CDG_EDGES_PER_FUNCTION,
REACHING_DEF_FACTS_PER_EDGE_CAP,
} from '../../cfg/emit.js';
import { createMemoizedReachingDefs } from '../../cfg/reaching-defs.js';
import {
emitFileTaint,
DEFAULT_PDG_MAX_TAINT_FINDINGS_PER_FUNCTION,
@ -58,7 +59,9 @@ import {
harvestFileSummaries,
type FunctionNodeIndex,
} from '../../taint/summary-harvest-driver.js';
import { harvestFileCallSummaries } from '../../taint/summary-harvest-driver.js';
import type { FunctionSummary } from '../../taint/summary-model.js';
import type { CallSummary } from '../../taint/call-summary-model.js';
import type { FunctionCfg } from '../../cfg/types.js';
import { resolveDefGraphId } from '../graph-bridge/ids.js';
import { buildPopulatedMethodDispatch } from '../graph-bridge/method-dispatch.js';
@ -66,6 +69,10 @@ import { propagateImportedReturnTypes } from '../passes/imported-return-types.js
import { emitReceiverBoundCalls } from '../passes/receiver-bound-calls.js';
import { emitFreeCallFallback } from '../passes/free-call-fallback.js';
import { emitReferencesViaLookup } from '../graph-bridge/references-to-edges.js';
import {
createCalleeIdAccumulator,
type CalleeIdAccumulator,
} from '../graph-bridge/callee-id-sink.js';
import { emitImportEdges } from '../graph-bridge/imports-to-edges.js';
import type { ScopeResolver } from '../contract/scope-resolver.js';
import { findEnclosingClassDef, resolveInheritanceBaseInScope } from '../scope/walkers.js';
@ -411,6 +418,15 @@ interface RunScopeResolutionStats {
* fixpoint phase composes them over the complete `CALLS` graph.
*/
readonly functionSummaries: readonly FunctionSummary[];
/**
* Per-function RETURN-VALUE ASCENT summaries harvested in the pdg window
* (PDG FU-C, U-C2). Empty unless `input.pdg === true`. Keyed by resolved
* `Function`/`Method`/`Constructor` node id; the whole-program CALL_SUMMARY
* emit phase materialises one self-loop edge per entry once the call graph is
* known. Unlike {@link functionSummaries} this needs NO taint model — it is
* pure data-dependence — so it is harvested for every `--pdg` language.
*/
readonly callSummaries: readonly CallSummary[];
}
export function runScopeResolution(
@ -529,6 +545,7 @@ export function runScopeResolution(
referenceSkipped: 0,
resolutionOutcomes,
functionSummaries: [],
callSummaries: [],
};
}
@ -701,6 +718,13 @@ export function runScopeResolution(
// ── Phase 4: emit graph edges (LOAD-BEARING ORDER — see I1) ────────────
input.onProgress?.('linking symbols', files.length, files.length);
const handledSites = new Set<string>(preEmittedInheritanceSites);
// Resolved-callee-id capture accumulator (#2227 U2). Created ONLY under
// `--pdg` — `undefined` otherwise so the three emitters do zero work and emit
// byte-identical output (R4). Populated below at all three CALLS emit paths
// (each before its dedup, KTD6/R8); consumed by the CFG-emit join (U3) at
// `emitFileCfgs` below to produce `BasicBlock.calleeIds`.
const calleeIdAccumulator: CalleeIdAccumulator | undefined =
input.pdg === true ? createCalleeIdAccumulator() : undefined;
const receiverExtras = emitReceiverBoundCalls(
graph,
indexes,
@ -712,6 +736,7 @@ export function runScopeResolution(
readonlyModel,
{
recordResolutionOutcome,
calleeIdSink: calleeIdAccumulator,
},
);
const unresolvedReceiverExtras =
@ -744,6 +769,7 @@ export function runScopeResolution(
conversionOnlyArgTypePrefixes: provider.conversionOnlyArgTypePrefixes,
constraintCompatibility: provider.constraintCompatibility,
recordResolutionOutcome,
calleeIdSink: calleeIdAccumulator,
},
);
const { emitted, skipped } = emitReferencesViaLookup(
@ -752,6 +778,7 @@ export function runScopeResolution(
referenceIndex,
postHeritageNodeLookup,
handledSites,
calleeIdAccumulator,
);
const importsEmitted = emitImportEdges(
graph,
@ -788,6 +815,11 @@ export function runScopeResolution(
// so the return (below the pdg block) can read it; empty on non-pdg runs.
const harvestedSummaries: FunctionSummary[] = [];
let summaryUnresolved = 0;
// FU-C (U-C2): per-function RETURN-VALUE ASCENT summaries harvested in the
// pdg window for the whole-program CALL_SUMMARY emit phase. Function-scoped
// (read by the return below the pdg block); empty on non-pdg runs.
const harvestedCallSummaries: CallSummary[] = [];
let callSummaryUnresolved = 0;
// M3 (#2083 U4): accumulated taint time (match + taint-side solve +
// propagate + TAINTED/SANITIZES emit), a sibling of `pdgMs` for the same
// reason — it interleaves per file inside `emit=`, so only an accumulator
@ -854,10 +886,10 @@ export function runScopeResolution(
// is built ONCE (whole-graph scan) and reused across every file; summaries
// accumulate here and ride out on the stats for the cross-function fixpoint
// phase. Only built when the language has a registered taint model.
const fnNodeIndex =
taintSpec !== undefined
? (input.prebuiltFunctionNodeIndex ?? buildFunctionNodeIndex(graph))
: undefined;
// Built whenever pdg is on (NOT gated on taintSpec): the FU-C call-summary
// harvest needs it for EVERY language (it is pure data-dependence, no taint
// model), and the taint summary harvest reuses it when taintSpec is present.
const fnNodeIndex = input.prebuiltFunctionNodeIndex ?? buildFunctionNodeIndex(graph);
for (const pf of emitParsedFiles) {
const cfgs = pf.cfgSideChannel;
// Defensive: cfgSideChannel is opaque (`unknown`) and crosses the cache /
@ -892,6 +924,10 @@ export function runScopeResolution(
);
}
if (wellFormed.length === 0) continue;
// U3 hook (#2227): the resolved-callee-id map for this file is
// `calleeIdAccumulator?.get(pf.filePath)` — joined here by exact
// call-site position to emit `BasicBlock.calleeIds`. Captured above at
// the three CALLS emit paths (U2); wired into `emitFileCfgs` by U3.
const emitted = emitFileCfgs(
pdgTarget,
wellFormed,
@ -900,16 +936,31 @@ export function runScopeResolution(
// 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),
// U3 (#2227): the resolved-callee-id map for this file (captured at the
// three CALLS emit paths in U2), joined by exact call-site position to
// emit `BasicBlock.calleeIds`. `undefined` when pdg is off (the
// accumulator is only created under `input.pdg === true`).
calleeIdAccumulator?.get(pf.filePath),
);
cfgBlocks += emitted.blocks;
cfgEdges += emitted.edges;
cfgDroppedEdges += emitted.droppedEdges;
// R6 (#2227 tri-review-2): release this file's captured id map now that
// emitFileCfgs has consumed it — the CALLS passes fully precede this loop
// and each file is read exactly once, so this bounds the accumulator to one
// file's call sites instead of holding the whole repo's for the phase.
calleeIdAccumulator?.delete(pf.filePath);
// M2 (#2082 U4): reaching definitions over the same validated CFGs.
// In-memory facts are computed per function and dropped after the
// bounded (defBlock, useBlock, binding) projection is persisted —
// M3 recomputes via the same pure solver in-phase (KTD8). Timing is
// PROF-gated like every other checkpoint here (zero cost when off).
// U12: one memoized RD solver per file, shared by the RD-emit + call-
// summary + taint + summary passes, so the per-function fixpoint runs once
// per (limits) bucket instead of 3–4× (#2227 tri-review). File-scoped: it
// is re-created each iteration, so its per-function facts drop with the file.
const rdSolve = createMemoizedReachingDefs();
const t0 = PROF ? performance.now() : 0;
const rd = emitFileReachingDefs(
pdgTarget,
@ -917,6 +968,7 @@ export function runScopeResolution(
input.pdgMaxReachingDefEdgesPerFunction ??
DEFAULT_PDG_MAX_REACHING_DEF_EDGES_PER_FUNCTION,
(message) => logger.warn(message), // unconditional — R7, both layers
rdSolve,
);
if (PROF) pdgMs += performance.now() - t0;
rdEdges += rd.edges;
@ -941,6 +993,21 @@ export function runScopeResolution(
cdgDropped += cdg.droppedEdges;
cdgSkippedUnsound += cdg.skippedUnsoundFunctions;
// FU-C (U-C2): RETURN-VALUE ASCENT summaries over the SAME validated
// CFGs, inside the SAME per-file try. Independent of taint — runs for
// EVERY `--pdg` language (pure data-dependence, no source/sink model).
// Reuses the same RD fact cap the RD/taint solves use (coverage parity).
const callHarvest = harvestFileCallSummaries(
fnNodeIndex,
wellFormed,
taintLimits.maxFacts && taintLimits.maxFacts > 0
? taintLimits.maxFacts
: DEFAULT_PDG_MAX_REACHING_DEF_FACTS_PER_FUNCTION,
rdSolve,
);
harvestedCallSummaries.push(...callHarvest.summaries);
callSummaryUnresolved += callHarvest.unresolved;
// M3 (#2083 U4): taint over the SAME validated CFGs, inside the SAME
// per-file try (a taint throw costs this file's taint layer only —
// its CFG/REACHING_DEF edges above are already in the graph). Skipped
@ -954,6 +1021,7 @@ export function runScopeResolution(
taintSpec,
taintLimits,
(message) => logger.warn(message), // unconditional — R4/R6
rdSolve,
);
if (PROF) taintMs += performance.now() - t1;
taintTotals.analyzed += taint.functionsAnalyzed;
@ -987,6 +1055,7 @@ export function runScopeResolution(
taintLimits.maxFacts && taintLimits.maxFacts > 0
? taintLimits.maxFacts
: DEFAULT_PDG_MAX_REACHING_DEF_FACTS_PER_FUNCTION,
rdSolve,
);
harvestedSummaries.push(...harvest.summaries);
summaryUnresolved += harvest.unresolved;
@ -1090,6 +1159,16 @@ export function runScopeResolution(
: ''),
);
}
// FU-C (U-C2): call-summary harvest volume + anchor-resolution diagnostics.
if (harvestedCallSummaries.length > 0 || callSummaryUnresolved > 0) {
logger.debug(
`[call-summary] lang=${provider.language}: ${harvestedCallSummaries.length} function ` +
`return-ascent summary/summaries harvested` +
(callSummaryUnresolved > 0
? `, ${callSummaryUnresolved} CFG anchor(s) unresolved (same-line collision or missing node)`
: ''),
);
}
}
if (PROF) {
@ -1120,5 +1199,6 @@ export function runScopeResolution(
referenceSkipped: skipped,
resolutionOutcomes,
functionSummaries: harvestedSummaries,
callSummaries: harvestedCallSummaries,
};
}

View file

@ -0,0 +1,151 @@
/**
* Call-summary reason codec (PDG FU-C, U-C2) — the ONE shared encoder/decoder
* for the bitset carried on persisted `CALL_SUMMARY` edges.
*
* A `CALL_SUMMARY` edge is a self-loop on a Function/Method/Constructor node
* recording that callee's RETURN-VALUE ASCENT: for each formal-parameter index,
* whether that parameter flows to the function's return value. The producer
* (the whole-program emit phase) writes this; a later consumer phase reads it to
* ascend a callee's return effect into the caller continuation. Two hand-rolled
* copies of a wire format drift — both sides MUST import from here (the same
* discipline `path-codec.ts` documents).
*
* ## Wire format (version `1`)
*
* ```
* 1|r:<hexbitset>[|<reserved-segment>…]
* ```
*
* - One-character version prefix ({@link CALL_SUMMARY_CODEC_VERSION}), then `|`,
* then a `r:` (return) segment whose payload is the param→return bitset as a
* lowercase hex string (LSB = formal index 0). Bit `i` set ⇒ formal parameter
* `i` flows to the return value. An empty/zero bitset is the absence of any
* return-flow (a sound EMPTY summary — never a false claim).
* - FORWARD COMPATIBILITY: the format reserves space for future facts via
* additional trailing `|<tag>:<payload>` segments (planned: `o:` out-params,
* `e:` exception ascent — both deferred: out-params need an alias model,
* exception ascent needs try/catch CDG). The decoder accepts and ignores any
* trailing segment whose tag it does not understand, so a future writer's
* output stays decodable by today's reader (and vice-versa: today's reader
* only requires the `r:` segment). Reserved tags MUST stay disjoint from `r`.
*
* ## Delimiter / round-trip discipline (mirrors path-codec KTD6)
*
* Every structural character (`|`, `:`, the version digit, hex digits `[0-9a-f]`)
* is printable ASCII, so the encoding survives `escapeCSVField ∘ sanitizeUTF8`
* (csv-generator.ts) byte-exact (pinned by the round-trip test). The hex payload
* is a non-negative integer rendered via BigInt, so the codec handles functions
* with arbitrarily many formal parameters without overflow.
*
* The decoder NEVER throws — anything malformed yields a typed failure, exactly
* like `decodeTaintPath`. A decode failure on the consumer side means "no usable
* ascent fact", which is the sound default (never claim a false return-flow).
*/
/** One-character format version prefix. Bump on any wire-format change. */
export const CALL_SUMMARY_CODEC_VERSION = '1';
/** Return-flow segment tag (`r:<hexbitset>`). */
const RETURN_TAG = 'r';
/** Hex payload charset (lowercase). */
const HEX = /^[0-9a-f]+$/;
/** A decoded call summary's facts. Forward-compatible: future facts add fields. */
export interface DecodedCallSummary {
readonly ok: true;
readonly version: string;
/**
* Sorted, de-duplicated formal-parameter indices that flow to the return
* value (ascending). Empty ⇒ no return-flow recorded (sound EMPTY summary).
*/
readonly returnFlowParams: readonly number[];
}
/** Typed parse failure — the decoder never throws. */
export interface CallSummaryDecodeFailure {
readonly ok: false;
readonly error: string;
}
export type CallSummaryDecodeResult = DecodedCallSummary | CallSummaryDecodeFailure;
/**
* Pack a set of return-flowing formal-parameter indices into a bitset (LSB =
* index 0). Negative or non-integer indices are ignored (defensive; the
* harvester only ever passes non-negative integers). Returns a `BigInt`.
*/
function packReturnBitset(returnFlowParams: Iterable<number>): bigint {
let bits = 0n;
for (const idx of returnFlowParams) {
if (!Number.isInteger(idx) || idx < 0) continue;
bits |= 1n << BigInt(idx);
}
return bits;
}
/**
* Encode the param→return ascent into the versioned `reason` wire string.
* Deterministic; never throws. `returnFlowParams` is the set of formal indices
* that flow to the return value (order/duplication irrelevant — the bitset
* canonicalises). An empty set encodes as `r:0` (an explicit empty summary).
*/
export function encodeCallSummary(returnFlowParams: Iterable<number>): string {
const bits = packReturnBitset(returnFlowParams);
return `${CALL_SUMMARY_CODEC_VERSION}|${RETURN_TAG}:${bits.toString(16)}`;
}
/**
* Decode a `CALL_SUMMARY` reason wire string into its ascent facts. Returns a
* typed failure for anything that is not a well-formed version-`1` summary —
* never throws. Unknown trailing segments (future facts) are accepted and
* ignored (forward compatibility).
*/
export function decodeCallSummary(reason: unknown): CallSummaryDecodeResult {
if (typeof reason !== 'string' || reason.length === 0) {
return { ok: false, error: 'empty or non-string reason' };
}
// Read the version as the substring BEFORE the first '|' (NOT a single char):
// the wire format reserves multi-digit future versions, so a `12|…` writer must
// degrade to a clean 'unsupported version' typed-failure here, never silently
// parse as version '1' with a stray '2' segment. No '|' ⇒ the whole reason is a
// bare token with no body, which is malformed.
const firstSep = reason.indexOf('|');
if (firstSep === -1) {
return { ok: false, error: 'malformed body: expected a segment separator after the version' };
}
const version = reason.slice(0, firstSep);
if (version !== CALL_SUMMARY_CODEC_VERSION) {
return { ok: false, error: `unsupported call-summary version '${version}'` };
}
const segments = reason.slice(firstSep + 1).split('|');
let returnBits: bigint | undefined;
for (const seg of segments) {
const sep = seg.indexOf(':');
if (sep === -1) {
return { ok: false, error: `malformed segment '${seg}' (expected '<tag>:<payload>')` };
}
const tag = seg.slice(0, sep);
const payload = seg.slice(sep + 1);
if (tag === RETURN_TAG) {
if (!HEX.test(payload)) {
return { ok: false, error: `invalid return bitset '${payload}'` };
}
returnBits = BigInt(`0x${payload}`);
}
// Unknown tag (reserved future fact): accept + ignore for forward compat.
}
if (returnBits === undefined) {
return { ok: false, error: "missing required 'r:' return segment" };
}
// Unpack the bitset into ascending formal indices.
const returnFlowParams: number[] = [];
let bits = returnBits;
let idx = 0;
while (bits > 0n) {
if ((bits & 1n) === 1n) returnFlowParams.push(idx);
bits >>= 1n;
idx++;
}
return { ok: true, version, returnFlowParams };
}

View file

@ -0,0 +1,99 @@
/**
* Call-summary emission (PDG FU-C, U-C3) — materialise `CALL_SUMMARY`.
*
* Persists each per-callee {@link CallSummary} as ONE `CALL_SUMMARY` self-loop
* edge on the callee Function/Method/Constructor node, with the param→return
* ASCENT bitset encoded in `reason` via the SHARED {@link call-summary-codec}
* (never a second hand-rolled wire format). A later consumer phase decodes it to
* ascend a callee's return effect into the caller continuation.
*
* The self-loop shape matches the schema (Function→Function / Method→Method /
* Constructor→Constructor pairs already exist from the M0 TAINT_PATH work — zero
* new schema pairs). Like `TAINT_PATH`/`TAINTED`, `CALL_SUMMARY` stays OUT of
* `VALID_RELATION_TYPES` and the web schema — it is an internal PDG-engine edge.
*
* Boundedness mirrors the M4 interproc emit driver: dedup by the deterministic
* edge id, an optional per-run cap, and unconditional truncate-and-warn.
*/
import type { KnowledgeGraph } from '../../graph/types.js';
import { encodeCallSummary } from './call-summary-codec.js';
import type { CallSummary } from './call-summary-model.js';
/** Confidence stamped on `CALL_SUMMARY` edges. A summary is a context-
* insensitive whole-parameter abstraction — a coarser signal than a resolved
* `CALLS` edge, so kept below 1.0 (mirrors the interproc TAINT_PATH posture). */
export const CALL_SUMMARY_CONFIDENCE = 0.6;
/** Default per-run cap on emitted `CALL_SUMMARY` edges. `0` ⇒ unlimited. */
export const DEFAULT_PDG_MAX_CALL_SUMMARY_EDGES = 0;
export interface CallSummaryEmitLimits {
/** Max `CALL_SUMMARY` edges per run (post-dedup). `undefined`/0 ⇒ unlimited. */
readonly maxEdges?: number;
}
export interface CallSummaryEmitResult {
/** CALL_SUMMARY edges persisted. */
edgesEmitted: number;
/** Summaries dropped by the per-run cap. */
edgesDropped: number;
/** Summaries skipped because the callee node was missing from the graph. */
skippedMissingEndpoint: number;
}
/**
* Persist per-callee summaries as `CALL_SUMMARY` self-loop edges. `summaries` is
* assumed deterministically ordered (the harvest sorts `returnFlowParams`).
* Never throws on valid input.
*/
export function emitCallSummaries(
graph: KnowledgeGraph,
summaries: readonly CallSummary[],
limits?: CallSummaryEmitLimits,
onWarn?: (message: string) => void,
): CallSummaryEmitResult {
const result: CallSummaryEmitResult = {
edgesEmitted: 0,
edgesDropped: 0,
skippedMissingEndpoint: 0,
};
const maxEdges = limits?.maxEdges && limits.maxEdges > 0 ? limits.maxEdges : Infinity;
const seen = new Set<string>();
for (const summary of summaries) {
if (result.edgesEmitted >= maxEdges) {
result.edgesDropped++;
continue;
}
const node = graph.getNode(summary.fnId);
if (!node) {
result.skippedMissingEndpoint++;
continue;
}
// One self-loop edge per callee; dedup by the callee id (the harvest already
// produces at most one summary per resolved fnId, but a same-line anchor
// could in principle map two CFGs to one id — the Set keeps it idempotent).
const id = `rel:CALL_SUMMARY:${summary.fnId}`;
if (seen.has(id)) continue;
seen.add(id);
graph.addRelationship({
id,
sourceId: summary.fnId,
targetId: summary.fnId,
type: 'CALL_SUMMARY',
confidence: CALL_SUMMARY_CONFIDENCE,
reason: encodeCallSummary(summary.returnFlowParams),
});
result.edgesEmitted++;
}
if (result.edgesDropped > 0) {
onWarn?.(
`[call-summary] ${result.edgesDropped} CALL_SUMMARY edge(s) dropped by the ` +
`per-run cap (${maxEdges})`,
);
}
return result;
}

View file

@ -0,0 +1,210 @@
/**
* Per-function dependence-SUMMARY harvest (PDG FU-C, U-C2).
*
* Pure, deterministic derivation of one function's RETURN-VALUE ASCENT — which
* formal-parameter indices flow to the function's return value — from the SAME
* substrate the M2/M3 passes consume: the reaching-definition facts
* (`computeReachingDefs`) over the function's CFG. No graph, no I/O, no logger;
* mirrors the {@link harvestFunctionSummary} (taint) contract so snapshot tests
* and the version stamp stay stable. Runs IN-PHASE inside the scope-resolution
* pdg window where the RD facts are materialised (reusing them — zero new
* worker/CFG work, so NO parse-cache pdg:N bump).
*
* ## Return-site identification (language-agnostic, soundness-first)
*
* Return statements are identified STRUCTURALLY via the M2 edge-kind invariant:
* the SOURCE block of every CFG edge of kind `return` terminates in the return
* jump, so that block's LAST statement is the `return <expr>` — its `uses` are
* the returned bindings. A `return;` with no value has empty uses (contributes
* nothing). For languages whose visitor models IMPLICIT returns (arrow-function
* expression bodies, Python last-expression), the CFG emits a `return` edge to
* EXIT whose source block's last statement carries the returned expression's
* `uses`, so those flow through the same path with no language-specific code.
*
* SOUNDNESS = never claim a false return-flow: when a function has NO `return`
* CFG edge (a language/shape with no robust exit notion modelled, or a void
* function), `returnUseStmtKeys` is empty and the harvest emits an EMPTY summary
* — the absence of a fact, never a wrong one.
*
* ## Param → return reachability
*
* Each formal parameter is seeded as a value at its entry def point(s); forward
* reachability over the def→use facts marks the param's index as return-flowing
* the moment a tainted binding it produced (under the M3 statement-level floor:
* a statement using a value taints all of its defs/mayDefs) is among a
* return-use statement's `uses`. The recorded edge is from an ACTUAL binding
* occurrence in a return's uses — never the floor — keeping the recorded fact
* precise even though onward propagation over-approximates.
*
* ## Formal-position soundness — destructured / rest params
*
* The consumer reads `returnFlowParams` POSITIONALLY (call-site arg position →
* same-index formal → bitset), so each recorded index MUST be the 0-based
* ENCLOSING FORMAL position, never the flattened binding ordinal. A
* destructured/rest formal binds several names: `function f({a, b}, c)` flattens
* to bindings a, b, c, whose ORDINALS are 0, 1, 2 — but the formal positions are
* 0, 0, 1. Recording an ordinal would misattribute `b`'s return-flow to formal
* `c` (a FALSE return-flow claim, not a miss). To stay sound we key every
* recorded index on {@link BindingEntry.formalIndex} (the producer-supplied
* enclosing-formal position, identical for every inner name of one formal).
*
* CONSERVATIVE FALLBACK: a producer that does not yet supply `formalIndex` on
* its param bindings leaves the harvest unable to prove the ordinal equals the
* formal slot, so the harvest emits an EMPTY summary for that function — a
* documented MISS (loses ascent), NEVER a false claim. Functions whose every
* param binding carries `formalIndex` get the precise formal positions.
*/
import type { FunctionCfg } from '../cfg/types.js';
import { pointKey, type FunctionDefUse, type ProgramPoint } from '../cfg/reaching-defs.js';
/** The own-facts portion of a call summary (fnId/anchor added by the caller). */
export interface HarvestedCallSummaryFacts {
readonly paramCount: number;
/** Sorted, de-duplicated formal-parameter indices that flow to the return. */
readonly returnFlowParams: readonly number[];
}
export interface CallSummaryHarvestResult {
/** `computed` — facts derived; `coverage-gap` — the RD solver was not
* `computed`, so no summary is produced (consistent with the taint harvest). */
readonly status: 'computed' | 'coverage-gap';
readonly gapReason?: FunctionDefUse['status'];
readonly facts: HarvestedCallSummaryFacts;
}
const EMPTY_FACTS: HarvestedCallSummaryFacts = { paramCount: 0, returnFlowParams: [] };
/** A value flowing forward, tagged with the param seed it came from. */
interface SeedValue {
readonly bindingIdx: number;
readonly point: ProgramPoint;
/** Param index (≥0) this value originates from. */
readonly paramIdx: number;
}
/**
* Harvest the RETURN-VALUE ASCENT facts for one function. PRECONDITION: `cfg`
* is `isEmitSafeCfg`-filtered and `defUse` was computed from it (the caller
* gates exactly as the taint harvest path does).
*/
export function harvestCallSummary(
cfg: FunctionCfg,
defUse: FunctionDefUse,
): CallSummaryHarvestResult {
if (defUse.status !== 'computed') {
return { status: 'coverage-gap', gapReason: defUse.status, facts: EMPTY_FACTS };
}
const bindings = defUse.bindings;
// ── param bindings → ENCLOSING FORMAL position ────────────────────────────
// SOUNDNESS (FU-C): the consumer joins `returnFlowParams` positionally against
// call-site arg positions, so each index MUST be the 0-based enclosing formal
// position — `BindingEntry.formalIndex`, which a destructured/rest formal hands
// identically to every inner name. The flattened binding ORDINAL is NOT a safe
// substitute (`function f({a, b}, c)` ⇒ b's ordinal 1 collides with formal c).
const paramBindings = bindings
.map((b, idx) => ({ b, idx }))
.filter((e) => e.b.kind === 'param')
.sort((a, b) => a.b.declLine - b.b.declLine || a.b.declColumn - b.b.declColumn);
// CONSERVATIVE FALLBACK: build binding-index → enclosing-formal-position only
// while every param binding supplies `formalIndex` (narrowed per-entry, no
// assertion). If ANY lacks it, the ordinal-vs-formal mapping is unprovable, so
// the summary degrades to EMPTY below (a documented MISS, never a false claim).
const paramCount = paramBindings.length;
const paramFormalOf = new Map<number, number>();
let missingFormalIndex = false;
for (const e of paramBindings) {
const formalIndex = e.b.formalIndex;
if (formalIndex === undefined) {
missingFormalIndex = true;
break;
}
paramFormalOf.set(e.idx, formalIndex);
}
// ── return points: source block of every `return` CFG edge ────────────────
// The M2 edge-kind invariant: a `return` edge's SOURCE block terminates in the
// return jump, so its LAST statement is `return <expr>` — its `uses` are the
// returned bindings. (`return;` with no value has empty uses.) No `return`
// edge ⇒ empty set ⇒ EMPTY summary (sound — never a false return-flow claim).
const returnUseStmtKeys = new Set<string>();
for (const e of cfg.edges) {
if (e.kind !== 'return') continue;
const block = cfg.blocks[e.from];
const stmts = block?.statements;
if (!stmts || stmts.length === 0) continue;
returnUseStmtKeys.add(`${e.from}:${stmts.length - 1}`);
}
// Fast exit: no params, no return sites, or an unprovable formal mapping
// (conservative fallback) ⇒ nothing safely flows to the return.
if (paramCount === 0 || returnUseStmtKeys.size === 0 || missingFormalIndex) {
return { status: 'computed', facts: { paramCount, returnFlowParams: [] } };
}
const stmtAt = (p: ProgramPoint) => cfg.blocks[p.blockIndex]?.statements?.[p.stmtIndex];
// ── def→use index ─────────────────────────────────────────────────────────
const factsByDef = new Map<string, { bindingIdx: number; use: ProgramPoint }[]>();
for (const f of defUse.facts) {
const key = `${f.bindingIdx}:${pointKey(f.def)}`;
const list = factsByDef.get(key);
const entry = { bindingIdx: f.bindingIdx, use: f.use };
if (list) list.push(entry);
else factsByDef.set(key, [entry]);
}
// ── seeds: each param at its entry def point(s) ────────────────────────────
const queue: SeedValue[] = [];
const visited = new Set<string>();
const enqueue = (v: SeedValue): void => {
const key = `${v.paramIdx}:${v.bindingIdx}:${pointKey(v.point)}`;
if (visited.has(key)) return;
visited.add(key);
queue.push(v);
};
for (const { idx } of paramBindings) {
// 0-based ENCLOSING formal position — guaranteed present (the missing-formal
// case returned EMPTY above), but guard rather than assert to stay `any`-free.
const paramIdx = paramFormalOf.get(idx);
if (paramIdx === undefined) continue;
for (const f of defUse.facts) {
if (f.bindingIdx === idx && f.def.blockIndex === cfg.entryIndex) {
enqueue({ bindingIdx: idx, point: f.def, paramIdx });
}
}
}
// ── forward reachability ──────────────────────────────────────────────────
const returnFlow = new Set<number>();
let head = 0;
while (head < queue.length) {
const v = queue[head++];
const b = v.bindingIdx;
for (const fact of factsByDef.get(`${b}:${pointKey(v.point)}`) ?? []) {
const useStmt = stmtAt(fact.use);
if (!useStmt) continue;
const useKey = `${fact.use.blockIndex}:${fact.use.stmtIndex}`;
// (1) return reach: the param's value is among a return-use's `uses`.
if (returnUseStmtKeys.has(useKey) && useStmt.uses.includes(b)) {
returnFlow.add(v.paramIdx);
}
// (2) onward floor: this statement's defs/mayDefs carry the value onward.
for (const d of [...useStmt.defs, ...(useStmt.mayDefs ?? [])]) {
enqueue({
bindingIdx: d,
point: {
blockIndex: fact.use.blockIndex,
stmtIndex: fact.use.stmtIndex,
line: useStmt.line,
},
paramIdx: v.paramIdx,
});
}
}
}
const returnFlowParams = [...returnFlow].sort((a, b) => a - b);
return { status: 'computed', facts: { paramCount, returnFlowParams } };
}

View file

@ -0,0 +1,49 @@
/**
* Per-callee dependence SUMMARY model (PDG FU-C, U-C2).
*
* A {@link CallSummary} is the compact, context-insensitive abstraction of one
* function's RETURN-VALUE ASCENT: which formal-parameter indices flow to the
* function's return value. It is the data-dependence twin of the M4
* {@link FunctionSummary} (taint), but for the *slicing* engine rather than the
* taint engine — a later consumer phase uses it to ascend a callee's return
* effect into the caller continuation (the documented no-ascent false negative).
*
* ## Scope (first cut — RETURN-VALUE ONLY)
*
* WHOLE-PARAMETER granularity. Ports are `param i` → `return`. Out-params /
* mutated args (need an alias model) and exception ascent (need try/catch CDG)
* are DEFERRED — the {@link call-summary-codec} reserves wire-format space for
* them so they can land without a cache-namespace bump.
*
* ## Plain-data discipline
*
* A summary is a JSON-plain value type (no functions, class instances, Maps, or
* Symbols) so it survives `RunScopeResolutionStats` → `ScopeResolutionOutput`
* threading unchanged — the same `Cloneable` constraint the CFG side channel and
* the taint {@link FunctionSummary} obey.
*/
/** Source-relative parameter index (0-based, declaration order). */
export type ParamIndex = number;
/**
* The dependence abstraction of one function. The resolved Function/Method/
* Constructor graph node id this summary describes, plus the set of formal
* parameters whose value flows to the return.
*/
export interface CallSummary {
/** The resolved `Function`/`Method`/`Constructor` graph node id. */
readonly fnId: string;
/** Repo-relative source path (carried for diagnostics + the anchor join). */
readonly filePath: string;
/** 1-based function start line (mirrors `FunctionCfg.functionStartLine`). */
readonly startLine: number;
/** Number of declared formal parameters (port arity). */
readonly paramCount: number;
/**
* Sorted, de-duplicated formal-parameter indices that flow to the function's
* return value (ascending). Empty ⇒ no parameter reaches the return (a sound
* EMPTY summary — never a false claim).
*/
readonly returnFlowParams: readonly ParamIndex[];
}

View file

@ -71,7 +71,12 @@ import {
bindingKey,
DEFAULT_PDG_MAX_REACHING_DEF_FACTS_PER_FUNCTION,
} from '../cfg/emit.js';
import { computeReachingDefs, pointKey, type ProgramPoint } from '../cfg/reaching-defs.js';
import {
computeReachingDefs,
pointKey,
type ProgramPoint,
type ReachingDefsSolver,
} from '../cfg/reaching-defs.js';
import type { BindingEntry, FunctionCfg } from '../cfg/types.js';
import { hasTaintSafeSites } from './site-safety.js';
import { buildTaintImportIndex, matchFunctionSites } from './match.js';
@ -156,6 +161,10 @@ export function emitFileTaint(
spec: SourceSinkSanitizerSpec,
limits?: TaintEmitLimits,
onWarn?: (message: string) => void,
// U12: shared per-file memoized solver (harvest/taint bucket — no maxBlockVisits).
// The zero-match fast path below still skips the solve entirely; only MATCHED
// functions request it, hitting the cache the call-summary harvest warmed.
solve: ReachingDefsSolver = computeReachingDefs,
): TaintEmitResult {
const result: TaintEmitResult = {
functionsAnalyzed: 0,
@ -204,7 +213,7 @@ export function emitFileTaint(
continue;
}
const defUse = computeReachingDefs(cfg, { maxFacts });
const defUse = solve(cfg, { maxFacts });
const flows = computeTaintFlows(cfg, defUse, matches, { maxFindingsPerFunction, maxHops });
if (flows.status === 'coverage-gap') {
// R4: skipped entirely, counted by reason; aggregate-warned by the

View file

@ -30,18 +30,29 @@
import type { ParsedImport, GraphNode } from 'gitnexus-shared';
import type { KnowledgeGraph } from '../../graph/types.js';
import { computeReachingDefs } from '../cfg/reaching-defs.js';
import { computeReachingDefs, type ReachingDefsSolver } from '../cfg/reaching-defs.js';
import { DEFAULT_PDG_MAX_REACHING_DEF_FACTS_PER_FUNCTION } from '../cfg/emit.js';
import type { FunctionCfg } from '../cfg/types.js';
import { buildTaintImportIndex, matchFunctionSites } from './match.js';
import type { SourceSinkSanitizerSpec } from './source-sink-config.js';
import { harvestFunctionSummary } from './summary-harvest.js';
import { ownFactsDigest, summaryVersion, type FunctionSummary } from './summary-model.js';
import { harvestCallSummary } from './call-summary-harvest.js';
import type { CallSummary } from './call-summary-model.js';
/** `cfg.functionStartLine` (1-based) − this = the node's 0-based `startLine`. */
export const NODE_TO_CFG_LINE_OFFSET = 1;
/** Node labels that can own a CFG / be a `CALLS` endpoint. */
/**
* Node labels that can own a CFG / be a `CALLS` endpoint AND receive a
* return-value-ascent summary. `Constructor` is INTENTIONALLY excluded: a
* constructor's "return" is the freshly-allocated instance, not a user-flowed
* value, so a formal→return ascent is not meaningful for it. A Constructor CFG
* therefore resolves to no functionish node (counted `unresolved`) and emits no
* CALL_SUMMARY edge — a sound recall miss, never a false ascent. The impact
* consumer may still DESCEND into a Constructor; it just never learns a
* constructor's return-flow. (#2227 tri-review.)
*/
const FUNCTIONISH_LABELS = new Set(['Function', 'Method']);
/**
@ -99,6 +110,8 @@ export function harvestFileSummaries(
parsedImports: readonly ParsedImport[],
spec: SourceSinkSanitizerSpec,
maxFacts: number = DEFAULT_PDG_MAX_REACHING_DEF_FACTS_PER_FUNCTION,
// U12: shared per-file memoized solver (harvest/taint bucket — no maxBlockVisits).
solve: ReachingDefsSolver = computeReachingDefs,
): FileSummaryResult {
const importIndex = buildTaintImportIndex(parsedImports);
const summaries: FunctionSummary[] = [];
@ -111,7 +124,7 @@ export function harvestFileSummaries(
unresolved++;
continue;
}
const defUse = computeReachingDefs(cfg, { maxFacts });
const defUse = solve(cfg, { maxFacts });
const matches = matchFunctionSites(cfg, spec, importIndex);
const harvested = harvestFunctionSummary(cfg, defUse, matches);
if (harvested.status !== 'computed') {
@ -145,3 +158,66 @@ export function harvestFileSummaries(
return { summaries, unresolved, gaps };
}
export interface FileCallSummaryResult {
readonly summaries: readonly CallSummary[];
/** CFGs whose anchor resolved to no unique graph node (collision / missing). */
readonly unresolved: number;
/** CFGs whose reaching-defs were not `computed` (no summary produced). */
readonly gaps: number;
}
/**
* Harvest per-function RETURN-VALUE ASCENT summaries (PDG FU-C, U-C2) for one
* file's emit-safe CFGs — the dependence-engine SIBLING of
* {@link harvestFileSummaries}. `cfgs` MUST already be `isEmitSafeCfg`-filtered.
* Pure aside from the read-only graph lookup; never throws on valid input.
*
* Unlike the taint harvest, this needs NO source/sink model — return-value
* ascent is purely data-dependence over the RD facts — so it runs for every
* `--pdg` language (not just those with a registered taint spec). It reuses the
* SAME per-function RD facts (recomputed via the same pure solver + cap the RD
* emit used; the persisted REACHING_DEF projection is a lossy subset, so the
* harvest re-derives in-phase exactly as the taint harvest does — no new
* worker/CFG work, no parse-cache bump).
*/
export function harvestFileCallSummaries(
fnIndex: FunctionNodeIndex,
cfgs: readonly FunctionCfg[],
maxFacts: number = DEFAULT_PDG_MAX_REACHING_DEF_FACTS_PER_FUNCTION,
// U12: shared per-file memoized solver (harvest/taint bucket — no maxBlockVisits).
solve: ReachingDefsSolver = computeReachingDefs,
): FileCallSummaryResult {
const summaries: CallSummary[] = [];
let unresolved = 0;
let gaps = 0;
for (const cfg of cfgs) {
const fnId = resolveFnId(fnIndex, cfg);
if (fnId === undefined) {
unresolved++;
continue;
}
const defUse = solve(cfg, { maxFacts });
const harvested = harvestCallSummary(cfg, defUse);
if (harvested.status !== 'computed') {
gaps++;
continue;
}
const facts = harvested.facts;
// Skip functions with NO return-flow at all — an empty summary records no
// ascent fact, so persisting it would only bloat the edge set. (The consumer
// treats an absent CALL_SUMMARY as "no known ascent", identical to an empty
// one.) A 0-param function or a void function therefore emits no edge.
if (facts.returnFlowParams.length === 0) continue;
summaries.push({
fnId,
filePath: cfg.filePath,
startLine: cfg.functionStartLine,
paramCount: facts.paramCount,
returnFlowParams: facts.returnFlowParams,
});
}
return { summaries, unresolved, gaps };
}

View file

@ -261,11 +261,16 @@ export const buildRelRow = (rel: GraphRelationship): string =>
* No `name` column; blocks are identified by id + source span. Shared by the
* whole-graph emit pass and the streaming PDG emit sink (issue #2202) so the
* two paths produce byte-identical BasicBlock rows by construction. */
export const BASICBLOCK_CSV_HEADER = 'id,filePath,startLine,endLine,text';
export const BASICBLOCK_CSV_HEADER = 'id,filePath,startLine,endLine,text,callees,calleeIds';
/** Build the escaped CSV row (no trailing newline) for one BasicBlock node.
* Single source of the BasicBlock row bytes — used by `streamAllCSVsToDisk`
* and by the streaming `PdgEmitSink` (issue #2202). */
* and by the streaming `PdgEmitSink` (issue #2202). `callees` is a comma-free
* (space-joined) list of the leaf callee names invoked in the block — the
* statement-precise inter-procedural reach substrate (the field is itself a CSV
* cell, so the inner separator must NOT be a comma). `calleeIds` is the SOUND
* parallel to `callees`: the space-joined RESOLVED callee symbol ids for the
* block (#2227 follow-up), likewise a comma-free cell. */
export const buildBasicBlockRow = (node: GraphNode): string =>
[
escapeCSVField(node.id),
@ -273,6 +278,8 @@ export const buildBasicBlockRow = (node: GraphNode): string =>
escapeCSVNumber(node.properties.startLine, -1),
escapeCSVNumber(node.properties.endLine, -1),
escapeCSVField(node.properties.text || ''),
escapeCSVField(String(node.properties.callees ?? '')),
escapeCSVField(String(node.properties.calleeIds ?? '')),
].join(',');
export interface StreamedCSVResult {
@ -374,7 +381,7 @@ export const streamAllCSVsToDisk = async (
// Route nodes for API endpoint mapping
const routeWriter = new BufferedCSVWriter(
path.join(csvDir, 'route.csv'),
'id,name,filePath,responseKeys,errorKeys,middleware',
'id,name,filePath,responseKeys,errorKeys,middleware,method',
);
// Tool nodes for MCP tool definitions
@ -553,6 +560,7 @@ export const streamAllCSVsToDisk = async (
escapeCSVField(keysStr),
escapeCSVField(errorKeysStr),
escapeCSVField(middlewareStr),
escapeCSVField(String(node.properties.method ?? '')),
].join(','),
);
break;

Some files were not shown because too many files have changed in this diff Show more