mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-09 03:17:54 +00:00
Merge branch 'main' into codex/windows-update-doctor
This commit is contained in:
commit
fabca76c32
158 changed files with 23347 additions and 288 deletions
71
.github/workflows/impact-pdg-mutation-report.yml
vendored
Normal file
71
.github/workflows/impact-pdg-mutation-report.yml
vendored
Normal 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
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`."
|
||||
|
|
|
|||
798
gitnexus/bench/impact-pdg/README.md
Normal file
798
gitnexus/bench/impact-pdg/README.md
Normal 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).
|
||||
21
gitnexus/bench/impact-pdg/baselines.json
Normal file
21
gitnexus/bench/impact-pdg/baselines.json
Normal 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."
|
||||
}
|
||||
361
gitnexus/bench/impact-pdg/blast-radius.mjs
Normal file
361
gitnexus/bench/impact-pdg/blast-radius.mjs
Normal 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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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."
|
||||
}
|
||||
|
|
@ -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."
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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."
|
||||
}
|
||||
|
|
@ -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."
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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."
|
||||
}
|
||||
|
|
@ -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."
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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."
|
||||
}
|
||||
|
|
@ -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."
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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."
|
||||
}
|
||||
|
|
@ -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."
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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."
|
||||
}
|
||||
|
|
@ -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."
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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."
|
||||
}
|
||||
|
|
@ -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."
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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."
|
||||
}
|
||||
|
|
@ -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."
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)."
|
||||
}
|
||||
|
|
@ -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."
|
||||
}
|
||||
|
|
@ -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`
|
||||
}
|
||||
|
|
@ -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."
|
||||
}
|
||||
|
|
@ -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."
|
||||
}
|
||||
|
|
@ -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`
|
||||
}
|
||||
|
|
@ -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)."
|
||||
}
|
||||
|
|
@ -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."
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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."
|
||||
}
|
||||
|
|
@ -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."
|
||||
}
|
||||
|
|
@ -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`
|
||||
}
|
||||
|
|
@ -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."
|
||||
}
|
||||
|
|
@ -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."
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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`)."
|
||||
}
|
||||
|
|
@ -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."
|
||||
}
|
||||
|
|
@ -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`
|
||||
}
|
||||
|
|
@ -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)."
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
45
gitnexus/bench/impact-pdg/gate-mutation-recall.mjs
Normal file
45
gitnexus/bench/impact-pdg/gate-mutation-recall.mjs
Normal 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);
|
||||
}
|
||||
1390
gitnexus/bench/impact-pdg/measure.mjs
Normal file
1390
gitnexus/bench/impact-pdg/measure.mjs
Normal file
File diff suppressed because it is too large
Load diff
498
gitnexus/bench/impact-pdg/metrics.mjs
Normal file
498
gitnexus/bench/impact-pdg/metrics.mjs
Normal 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;
|
||||
}
|
||||
541
gitnexus/bench/impact-pdg/mutation-oracle.mjs
Normal file
541
gitnexus/bench/impact-pdg/mutation-oracle.mjs
Normal 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;
|
||||
}
|
||||
599
gitnexus/bench/impact-pdg/name-collision.mjs
Normal file
599
gitnexus/bench/impact-pdg/name-collision.mjs
Normal 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);
|
||||
});
|
||||
}
|
||||
457
gitnexus/bench/impact-pdg/real-code.mjs
Normal file
457
gitnexus/bench/impact-pdg/real-code.mjs
Normal 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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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)')
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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++;
|
||||
|
|
|
|||
162
gitnexus/src/core/ingestion/cfg/reaching-def-reason-codec.ts
Normal file
162
gitnexus/src/core/ingestion/cfg/reaching-def-reason-codec.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
},
|
||||
};
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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}`
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
151
gitnexus/src/core/ingestion/taint/call-summary-codec.ts
Normal file
151
gitnexus/src/core/ingestion/taint/call-summary-codec.ts
Normal 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 };
|
||||
}
|
||||
99
gitnexus/src/core/ingestion/taint/call-summary-emit.ts
Normal file
99
gitnexus/src/core/ingestion/taint/call-summary-emit.ts
Normal 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;
|
||||
}
|
||||
210
gitnexus/src/core/ingestion/taint/call-summary-harvest.ts
Normal file
210
gitnexus/src/core/ingestion/taint/call-summary-harvest.ts
Normal 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 } };
|
||||
}
|
||||
49
gitnexus/src/core/ingestion/taint/call-summary-model.ts
Normal file
49
gitnexus/src/core/ingestion/taint/call-summary-model.ts
Normal 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[];
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Reference in a new issue