@clear-capabilities/agentic-security-scanner 0.183.1 → 0.185.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/bin/agentic-security.js +88 -0
  3. package/dist/{2a8456e0800aeee871b6.json → 0b301d45b231f144a1b4.json} +27 -9
  4. package/dist/1128.index.js +908 -0
  5. package/dist/1336.index.js +261 -112
  6. package/dist/1665.index.js +828 -0
  7. package/dist/2923.index.js +17 -9
  8. package/dist/9382.index.js +23 -2
  9. package/dist/9579.index.js +11 -4
  10. package/dist/agentic-security.mjs +15 -15
  11. package/dist/agentic-security.mjs.sha256 +1 -1
  12. package/dist/frontend/src/shell.js +12 -2
  13. package/package.json +27 -9
  14. package/src/context-intelligence/controls.js +15 -0
  15. package/src/context-intelligence/reachability.js +8 -2
  16. package/src/dataflow/CLAUDE.md +43 -1
  17. package/src/dataflow/backward.js +42 -7
  18. package/src/dataflow/cross-service-taint.js +83 -1
  19. package/src/dataflow/demand-query.js +929 -0
  20. package/src/dataflow/dep-fingerprint.js +718 -0
  21. package/src/dataflow/engine.js +48 -6
  22. package/src/dataflow/ifds-precise.js +22 -0
  23. package/src/dataflow/incremental-replay.js +118 -0
  24. package/src/dataflow/incremental-session.js +257 -0
  25. package/src/dataflow/incremental.js +237 -35
  26. package/src/dataflow/index.js +25 -94
  27. package/src/dataflow/summaries.js +72 -6
  28. package/src/dataflow/tabulation.js +80 -24
  29. package/src/discovery/hunter.js +6 -2
  30. package/src/discovery/index.js +5 -2
  31. package/src/engine.js +63 -3
  32. package/src/graph/CLAUDE.md +31 -0
  33. package/src/graph/adapters/artifact.js +81 -0
  34. package/src/graph/adapters/crossrepo.js +75 -0
  35. package/src/graph/adapters/dataflow.js +76 -0
  36. package/src/graph/adapters/provenance.js +92 -0
  37. package/src/graph/adapters/semantic.js +89 -0
  38. package/src/graph/crosswalk-builders.js +152 -0
  39. package/src/graph/crosswalk.js +146 -0
  40. package/src/graph/index.js +15 -0
  41. package/src/graph/observation.js +57 -0
  42. package/src/graph/refs.js +105 -0
  43. package/src/graph/registry.js +129 -0
  44. package/src/graph/relations.js +81 -0
  45. package/src/graph/scc.js +241 -0
  46. package/src/graph/view.js +254 -0
  47. package/src/language/haskell-disclosure.js +13 -1
  48. package/src/lineage/CLAUDE.md +71 -8
  49. package/src/lineage/DESIGN_GRAPH_BUILDER.md +1 -1
  50. package/src/lineage/DESIGN_PATH_PROVENANCE.md +6 -0
  51. package/src/lineage/boundary-contract.js +273 -0
  52. package/src/lineage/completion.js +205 -0
  53. package/src/lineage/continuation.js +140 -0
  54. package/src/lineage/control-credit.js +313 -0
  55. package/src/lineage/correlation.js +199 -0
  56. package/src/lineage/coverage.js +8 -1
  57. package/src/lineage/cross-repo-link.js +22 -0
  58. package/src/lineage/cut-planner.js +548 -0
  59. package/src/lineage/driver.js +219 -66
  60. package/src/lineage/engine.js +166 -21
  61. package/src/lineage/export-json.js +5 -0
  62. package/src/lineage/graph-builder.js +84 -7
  63. package/src/lineage/haskell-view.js +59 -0
  64. package/src/lineage/index.js +3 -0
  65. package/src/lineage/nix-view.js +35 -0
  66. package/src/lineage/observation-adapters.js +17 -9
  67. package/src/lineage/owned-build.js +77 -0
  68. package/src/lineage/owned-worker-entry.js +22 -0
  69. package/src/lineage/owned-worker.js +95 -0
  70. package/src/lineage/path-evidence.js +123 -0
  71. package/src/lineage/path-predicates.js +529 -0
  72. package/src/lineage/path-query.js +189 -20
  73. package/src/lineage/path-store.js +191 -17
  74. package/src/lineage/scc-solver.js +186 -0
  75. package/src/lineage/smt-orchestrator.js +138 -0
  76. package/src/lineage/source-seeding.js +36 -2
  77. package/src/lineage/summaries.js +66 -4
  78. package/src/lineage/value-versions.js +130 -0
  79. package/src/lineage/witness-selection.js +95 -0
  80. package/src/pipeline/scan-health.js +19 -0
  81. package/src/posture/CLAUDE.md +13 -0
  82. package/src/posture/evidence-bundle.js +1 -1
  83. package/src/posture/witness-bundle.js +573 -0
  84. package/src/posture/witness-runtime.js +276 -0
  85. package/src/report/index.js +2 -0
@@ -2130,6 +2130,8 @@ function _unionAnnotationTaint(fn, entrySet) {
2130
2130
  if (seeds && seeds.size && Array.isArray(fn.params) && _REQ_PARAM.test(fn.params[0] || '')) {
2131
2131
  entrySet = new Set([...entrySet, ...[...seeds].map((f) => `${fn.params[0]}.${f}`)]);
2132
2132
  }
2133
+ // DAG-08: access paths seeded by a bound boundary contract (lineage/boundary-contract.js). Absent unless a contract bound to this function.
2134
+ if (Array.isArray(fn.boundarySeeds) && fn.boundarySeeds.length) entrySet = new Set([...entrySet, ...fn.boundarySeeds]);
2133
2135
  if (!fn.paramAnnotations || !fn.paramAnnotations.length) return entrySet;
2134
2136
  const extra = matchAnnotationParams(fn.paramAnnotations, fn.file);
2135
2137
  if (!extra.size) return entrySet;
@@ -2359,9 +2361,9 @@ function stateEq(a, b) {
2359
2361
  // that runs out used to leave a result that looked complete; now `runTaintEngine`'s return value carries `analysisIncomplete`, and the scan
2360
2362
  // reports it as `deepStatus.incomplete`.
2361
2363
  let _incomplete = new Map();
2362
- function _noteIncomplete(reason, detail) {
2364
+ function _noteIncomplete(reason, detail, n = 1) {
2363
2365
  const cur = _incomplete.get(reason);
2364
- if (cur) cur.count += 1; else _incomplete.set(reason, { reason, count: 1, example: String(detail ?? '').slice(0, 160) });
2366
+ if (cur) cur.count += n; else _incomplete.set(reason, { reason, count: n, example: String(detail ?? '').slice(0, 160) });
2365
2367
  }
2366
2368
  export const INCOMPLETE_REASONS = Object.freeze({
2367
2369
  'summary-budget': 'the interprocedural summary budget ran out; later callees were treated as having no effect',
@@ -2369,6 +2371,10 @@ export const INCOMPLETE_REASONS = Object.freeze({
2369
2371
  'function-limit': 'more functions than the per-scan function limit; the rest were not analysed',
2370
2372
  'higher-order-limit': 'more callback invocations than the per-function limit; the rest were not analysed',
2371
2373
  'analysis-error': 'the analysis of a function threw and its result was discarded',
2374
+ // DAG-07 R07.3
2375
+ 'context-cap-fallback': 'a call under a tainted argument shape was answered with the empty-entry summary because the per-function context cap was reached',
2376
+ 'recursion-nonconverged': 'a recursive summary was still changing when its refinement rounds ran out',
2377
+ 'incremental-empty-entry-reuse': 'a summary restored from a previous scan stood in for a tainted call context',
2372
2378
  });
2373
2379
 
2374
2380
  export function runTaintEngine(perFileIR, callGraph, opts = {}) {
@@ -2401,6 +2407,9 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
2401
2407
  // v0.69 — opts.summaryCache lets the caller (runDeepAnalysis with
2402
2408
  // incremental mode) hand in a pre-seeded cache from persisted state.
2403
2409
  const summaryCache = opts.summaryCache || new SummaryCache();
2410
+ // DAG-05: cross-scan reuse. `_replay` (incremental-replay.js) records what each function
2411
+ // contributed and, for a function the dependency plan proved clean, replays it.
2412
+ const _replay = opts._replay || null;
2404
2413
 
2405
2414
  // Deterministic ordering (Sentinel-parity §9.2): sort functions by qid so
2406
2415
  // cache-cold runs produce the same finding sequence run-over-run.
@@ -2408,6 +2417,7 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
2408
2417
  a.qid < b.qid ? -1 : a.qid > b.qid ? 1 : 0
2409
2418
  );
2410
2419
  _buildRegexNames(fnList);
2420
+ if (_replay) _replay.setMainLoop(new Set(fnList.slice(0, fnLimit).map((f) => f.qid))); // DAG-05
2411
2421
  _buildRequestFieldSeeds(fnList, callGraph, opts, deadlineMs);
2412
2422
  // PRD W0.1 (SARD_80_F1_EXECUTION_PRD.md) — the main per-function loop below
2413
2423
  // silently `break`s once `n > fnLimit` (default 5000), which for a single
@@ -2438,6 +2448,7 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
2438
2448
  // unbounded blowup). v0.66 — the inner ctx now records mutatedParams
2439
2449
  // via _mutatedParamsOut so cross-function param mutation propagates.
2440
2450
  const MAX_FP_ITERS = 3;
2451
+ let _fpConverged = false; // DAG-05: did the pre-pass reach a fixed point within MAX_FP_ITERS
2441
2452
  // qid -> this function's own exit-state (every tainted access path at
2442
2453
  // return, from the LAST pre-pass iteration only — see the class-field
2443
2454
  // cross-taint pass below for why this must be a real, already-observed
@@ -2452,6 +2463,13 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
2452
2463
  let changedThisIter = false;
2453
2464
  for (const fn of fnList) {
2454
2465
  if (Date.now() > deadlineMs) break;
2466
+ // DAG-05 begin: a function proved clean is not re-walked; its recorded exit state and findings stand in.
2467
+ if (_replay && _replay.canSkip(fn.qid)) {
2468
+ exitStateByQid.set(fn.qid, _replay.exitState(fn.qid));
2469
+ if (it === 0) _replayFindings(fn.qid);
2470
+ continue;
2471
+ }
2472
+ // DAG-05 end
2455
2473
  const entry = new Set();
2456
2474
  const key = fn.qid + '::empty';
2457
2475
  const existing = summaryCache.get(fn.qid, entry);
@@ -2506,8 +2524,11 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
2506
2524
  // write, silently delivering 2 rounds of fixed-point refinement instead
2507
2525
  // of the MAX_FP_ITERS=3 this code and dataflow/CLAUDE.md both promise.
2508
2526
  // `changedThisIter` tracks actual value changes instead.
2509
- if (!changedThisIter) break;
2527
+ if (!changedThisIter) { _fpConverged = true; break; }
2510
2528
  }
2529
+ // DAG-05 begin
2530
+ if (_replay) { _replay.recordExitStates(exitStateByQid); _replay.noteConverged(_fpConverged); }
2531
+ // DAG-05 end
2511
2532
  // Class-field cross-taint pass (SARD_80_F1_SCANNER_PRD.md — Juliet flow
2512
2533
  // variants 45/65-68: a method writes a tainted value to a static or
2513
2534
  // instance field and a SIBLING method of the same class reads it into a
@@ -2836,6 +2857,9 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
2836
2857
  if (++n > fnLimit) { _noteIncomplete('function-limit', `limit ${fnLimit}`); break; }
2837
2858
  if (Date.now() > deadlineMs) { _noteIncomplete('deadline', 'main function loop'); break; } // global timeout
2838
2859
  if (onProgress) onProgress({ current: n, total: _progressTotal });
2860
+ // DAG-05 begin: replayed in the pre-pass; idempotent here for a pre-pass cut short.
2861
+ if (_replay && _replay.canSkip(fn.qid)) { _replayFindings(fn.qid); continue; }
2862
+ // DAG-05 end
2839
2863
  // Module-level functions: analyze with an empty entry state. The function
2840
2864
  // discovers its own sources from req.body/process.env/etc. as it walks.
2841
2865
  const callContext = {
@@ -2934,6 +2958,7 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
2934
2958
  }
2935
2959
  Object.defineProperty(all, '_summaryCache', { value: summaryCache, enumerable: false });
2936
2960
  if (summaryCache._budgetHits) _noteIncomplete('summary-budget', `${summaryCache._budgetHits} summary requests after ${summaryCache._maxIter} computations`);
2961
+ if (typeof summaryCache.completionNotes === 'function') for (const n of summaryCache.completionNotes()) _noteIncomplete(n.reason, n.example, n.count);
2937
2962
  Object.defineProperty(all, 'analysisIncomplete', { value: [..._incomplete.values()].sort((a, b) => (a.reason < b.reason ? -1 : 1)), enumerable: false });
2938
2963
  _collapseNestedHsSinks(all);
2939
2964
  return all;
@@ -2963,10 +2988,13 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
2963
2988
  // this fix.
2964
2989
  const file = f.file || attributedFn.file;
2965
2990
  const key = `${f.sinkId}:${file}:${f.line}${f.siteKey ? ':' + f.siteKey : ''}`;
2966
- if (seen.has(key)) continue;
2991
+ // DAG-05: a duplicate is still part of what THIS function contributed (the first
2992
+ // collector may be a function that later changes), so it is recorded; it is not re-emitted.
2993
+ const _dup = seen.has(key);
2994
+ if (_dup && !_replay) continue;
2967
2995
  seen.add(key);
2968
2996
  const fn = attributedFn;
2969
- all.push({
2997
+ const _mapped = {
2970
2998
  id: `ir-taint:${file}:${f.line}:${f.sinkId}${f.siteKey ? ':' + f.siteKey : ''}`,
2971
2999
  ...(f.siteKey ? { siteKey: f.siteKey } : {}),
2972
3000
  file,
@@ -3049,7 +3077,21 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
3049
3077
  chain: (f.trace || []).map(t => ({
3050
3078
  file: fn.file, line: t.line, label: t.sourceLabel, provenance: t.provenance || null,
3051
3079
  })),
3052
- });
3080
+ };
3081
+ if (_replay) _replay.record(attributedFn.qid, key, _mapped);
3082
+ if (!_dup) all.push(_mapped);
3083
+ }
3084
+ }
3085
+
3086
+ // DAG-05: emit a replayed function's recorded findings through the same
3087
+ // de-duplication the collector applies, so a replayed finding and a recomputed
3088
+ // one for the same sink are one finding.
3089
+ function _replayFindings(qid) {
3090
+ for (const { key, obj } of _replay.findingsFor(qid)) {
3091
+ if (seen.has(key)) continue;
3092
+ seen.add(key);
3093
+ all.push(obj);
3094
+ _replay.noteReused(obj.id);
3053
3095
  }
3054
3096
  }
3055
3097
 
@@ -158,6 +158,28 @@ export function backwardSlice(callGraph, finding, opts = {}) {
158
158
  return out.reverse(); // source-first
159
159
  }
160
160
 
161
+ /**
162
+ * Same walk as backwardSlice, plus a completion record in the shared
163
+ * vocabulary (DAG-02 R02.5): a chain cut at MAX_SLICE_DEPTH is a prefix of the
164
+ * real predecessor chain, so it reports `partial` with the depth limit as its
165
+ * reason and can never license a negative claim. backwardSlice itself is
166
+ * unchanged.
167
+ */
168
+ export function backwardSliceWithCompletion(callGraph, finding, opts = {}) {
169
+ const slice = backwardSlice(callGraph, finding, opts);
170
+ const capped = slice.some((s) => s && s.reason === 'slice-depth-cap');
171
+ return {
172
+ slice,
173
+ completion: {
174
+ status: capped ? 'partial' : 'complete',
175
+ reasons: capped ? ['slice-depth-limit'] : [],
176
+ unexploredFrontierCount: capped ? 1 : 0,
177
+ visited: slice.length,
178
+ limits: { maxDepth: MAX_SLICE_DEPTH },
179
+ },
180
+ };
181
+ }
182
+
161
183
  // ── Persistent cross-scan summary cache ────────────────────────────────────
162
184
 
163
185
  function _cachePath(scanRoot) {
@@ -0,0 +1,118 @@
1
+ // DAG-05: the engine-facing half of cross-scan reuse.
2
+ //
3
+ // `ReplayHooks` is handed to `runTaintEngine` as `opts._replay`. It does two jobs:
4
+ //
5
+ // * RECORD (every run): for each function, the findings it contributed to the
6
+ // result (every pass, after the same de-duplication key the engine uses) and
7
+ // its pre-pass exit state, so the next scan has something to reuse.
8
+ // * REPLAY (warm run): for a function the plan proved clean, hand the engine its
9
+ // recorded exit state and findings instead of re-walking its body.
10
+ //
11
+ // A function is replayable only when every one of these holds: the dependency
12
+ // plan says clean (own body, resolution, the whole closure and its cycle are
13
+ // unchanged), a complete record exists for it, and the record came from an
14
+ // analysis that converged. Anything less and the engine analyses it as before.
15
+
16
+ import { jsonSafeFinding } from './incremental.js';
17
+
18
+ const MAX_EXIT_PATHS = 4000;
19
+
20
+ export class ReplayHooks {
21
+ /**
22
+ * @param {object} o
23
+ * @param {Set<string>} [o.clean] qids that may be replayed (empty or absent: record only)
24
+ * @param {Map<string,object>} [o.records] prior records: qid -> { exit:[...], findings:[{key,obj}] }
25
+ * @param {string} [o.generation] generation id of the cache the records came from
26
+ * @param {Map<string,string>} [o.closure] qid -> closure fingerprint (for provenance)
27
+ */
28
+ constructor({ clean, records, generation, closure } = {}) {
29
+ this.clean = clean || new Set();
30
+ this.records = records || new Map();
31
+ this.generation = generation || null;
32
+ this.closure = closure || new Map();
33
+ this._found = new Map(); // qid -> Map(key -> obj) recorded this run
34
+ this._unrecordable = new Set(); // qids whose findings could not be serialised
35
+ this._exits = new Map(); // qid -> [path] recorded this run
36
+ this.converged = false;
37
+ this.mainLoop = null; // Set<qid>: the functions this run's main loop covers (the first fnLimit by qid)
38
+ this.replayed = new Set(); // qids actually skipped this run
39
+ this.reusedFindingIds = new Set();
40
+ }
41
+
42
+ /**
43
+ * The engine's main loop covers only the first `fnLimit` functions by qid, so a record
44
+ * made while a function was outside it lacks that loop's findings. Replay needs the same
45
+ * membership now as when the record was made.
46
+ */
47
+ setMainLoop(qids) { this.mainLoop = qids; }
48
+
49
+ canSkip(qid) {
50
+ if (!this.clean.has(qid)) return false;
51
+ const r = this.records.get(qid);
52
+ if (!r) return false;
53
+ return !this.mainLoop || r.inMain === this.mainLoop.has(qid);
54
+ }
55
+
56
+ exitState(qid) {
57
+ const r = this.records.get(qid);
58
+ return new Set((r && r.exit) || []);
59
+ }
60
+
61
+ /** Recorded findings for a replayed function, cloned so the engine may mutate them. */
62
+ findingsFor(qid) {
63
+ const r = this.records.get(qid);
64
+ if (!r) return [];
65
+ this.replayed.add(qid);
66
+ const out = [];
67
+ for (const { key, obj } of r.findings || []) {
68
+ const c = JSON.parse(JSON.stringify(obj));
69
+ out.push({ key, obj: c });
70
+ }
71
+ return out;
72
+ }
73
+
74
+ noteReused(id) { this.reusedFindingIds.add(id); }
75
+
76
+ /** Called from the engine's finding collector for every mapped finding. */
77
+ record(qid, key, obj) {
78
+ if (!qid) return;
79
+ let m = this._found.get(qid);
80
+ if (!m) { m = new Map(); this._found.set(qid, m); }
81
+ if (m.has(key)) return;
82
+ const c = jsonSafeFinding(obj);
83
+ if (c === null) { this._unrecordable.add(qid); return; }
84
+ m.set(key, c);
85
+ }
86
+
87
+ recordExitStates(exitByQid) {
88
+ for (const [qid, set] of exitByQid) {
89
+ const arr = [...set].filter((p) => typeof p === 'string').sort();
90
+ this._exits.set(qid, arr);
91
+ }
92
+ }
93
+
94
+ noteConverged(v) { this.converged = !!v; }
95
+
96
+ /**
97
+ * The records to persist for the next scan: a clean function carries its prior
98
+ * record forward, every other function gets what was recorded this run. A
99
+ * function with an unserialisable finding or an oversized exit state gets no
100
+ * record, which makes it always recompute.
101
+ */
102
+ buildRecords(qids) {
103
+ const out = {};
104
+ for (const qid of qids) {
105
+ if (this.replayed.has(qid)) {
106
+ const r = this.records.get(qid);
107
+ if (r) out[qid] = r;
108
+ continue;
109
+ }
110
+ if (this._unrecordable.has(qid)) continue;
111
+ const exit = this._exits.get(qid);
112
+ if (!exit || exit.length > MAX_EXIT_PATHS) continue;
113
+ const found = this._found.get(qid) || new Map();
114
+ out[qid] = { exit, inMain: this.mainLoop ? this.mainLoop.has(qid) : true, findings: [...found].map(([key, obj]) => ({ key, obj })) };
115
+ }
116
+ return out;
117
+ }
118
+ }
@@ -0,0 +1,257 @@
1
+ // DAG-05: one incremental session per deep-analysis run.
2
+ //
3
+ // begin read the persisted state, fingerprint the current project, decide warm
4
+ // or cold (and say why), restore the typed contexts of clean functions
5
+ // finish stamp cache provenance on the findings, persist the new generation
6
+ // atomically, and describe what was reused versus recomputed
7
+ //
8
+ // The decision is made from CONTENT (dep-fingerprint.js). Any doubt is cold:
9
+ // an unreadable, corrupt, incomplete or incompatible cache, a changed global
10
+ // input, an unsupported option, a prior analysis that was cut short or did not
11
+ // converge. A cold run is a full analysis and reports `mode: 'cold'` with the
12
+ // reason, so a missing speed-up is never silent and a wrong answer never
13
+ // results from a cache that could not be trusted.
14
+
15
+ import * as fs from 'node:fs';
16
+ import * as path from 'node:path';
17
+ import * as crypto from 'node:crypto';
18
+ import { fileURLToPath } from 'node:url';
19
+
20
+ import { SummaryCache } from './summaries.js';
21
+ import {
22
+ readIncrementalState, validateIncrementalState, hashFileContent, diffFileHashes,
23
+ serializeContexts, restoreContexts, commitIncrementalState, CONTEXT_SCHEMA,
24
+ } from './incremental.js';
25
+ import { ReplayHooks } from './incremental-replay.js';
26
+ import {
27
+ buildDependencyModel, globalComponents, planIncremental, countByReason, diffComponents, DEP_GRAPH_SCHEMA,
28
+ } from './dep-fingerprint.js';
29
+ import { loadProjectStubs } from '../ir/type-stubs.js';
30
+
31
+ const REPLAY_SCHEMA = 'replay/1';
32
+
33
+ // Options whose effect on a function's result is not captured by the function's own
34
+ // fingerprint, so reuse under them is refused rather than guessed.
35
+ const UNSUPPORTED_FOR_REPLAY = [
36
+ ['AGENTIC_SECURITY_POINTS_TO', 'a whole-program points-to graph feeds every function'],
37
+ ['AGENTIC_SECURITY_RHS', 'reachability slicing restricts which functions are analysed'],
38
+ ['AGENTIC_SECURITY_KCFA_CALLSTRING', 'call-string contexts key summaries by caller'],
39
+ ['AGENTIC_SECURITY_BACKWARD_SLICE', 'the backward slicer needs live IR references a stored finding does not have'],
40
+ ['AGENTIC_SECURITY_SYMEXEC', 'the exploit prover needs live IR references a stored finding does not have'],
41
+ ];
42
+ // Reasons that make a finished run's records untrustworthy for reuse.
43
+ const BLOCKING_INCOMPLETE = new Set(['deadline', 'analysis-error', 'summary-budget']);
44
+
45
+ let _scannerVersion = null;
46
+ function scannerVersion() {
47
+ if (_scannerVersion) return _scannerVersion;
48
+ try {
49
+ const here = path.dirname(fileURLToPath(import.meta.url));
50
+ for (const rel of ['../../package.json', '../package.json', './package.json']) {
51
+ try { const v = JSON.parse(fs.readFileSync(path.resolve(here, rel), 'utf8')).version; if (v) { _scannerVersion = v; return v; } } catch { /* next */ }
52
+ }
53
+ } catch { /* fall through */ }
54
+ _scannerVersion = 'unknown';
55
+ return _scannerVersion;
56
+ }
57
+
58
+ /** Reverse call graph (callee -> callers) from the dependency model's own edges. */
59
+ function callersFrom(model) {
60
+ const callers = {};
61
+ for (const [qid, f] of Object.entries(model.functions)) {
62
+ for (const c of f.callees) (callers[c] ||= []).push(qid);
63
+ }
64
+ return callers;
65
+ }
66
+
67
+ /**
68
+ * Start a session. Returns null when incremental analysis is not enabled or the
69
+ * caller supplied no scan root / file contents (nothing could be persisted).
70
+ */
71
+ export function beginIncrementalSession({ enabled, opts, perFileIR, callGraph, cha, env = process.env, now = Date.now }) {
72
+ if (!enabled || !opts || !opts.scanRoot || !opts.fileContents) return null;
73
+ const t0 = now();
74
+ const fileHashes = {};
75
+ for (const [fp, content] of Object.entries(opts.fileContents)) fileHashes[fp] = hashFileContent(content);
76
+ const global = globalComponents({
77
+ scanRoot: opts.scanRoot, fileContents: opts.fileContents, opts, env,
78
+ stubsProvider: opts._stubsProvider || ((root) => (root ? loadProjectStubs(root) : null)),
79
+ });
80
+ const rulesDigest = crypto.createHash('sha256').update(`${global.catalog}${global.sanitizers}`).digest('hex').slice(0, 16);
81
+ const version = { scanner: opts.scannerVersion || scannerVersion(), rules: opts.rulesDigest || `content:${rulesDigest}` };
82
+ const tRead = now();
83
+ const prior = readIncrementalState(opts.scanRoot);
84
+ const readMs = now() - tRead;
85
+ const t1 = now();
86
+ const reuse = prior.deps && prior.deps.global && prior.deps.global.analyzers === global.analyzers
87
+ ? { functions: prior.deps.functions, priorFiles: prior.files, curFiles: fileHashes } : null;
88
+ const model = buildDependencyModel({ perFileIR, callGraph, cha, fileContents: opts.fileContents, reuse });
89
+ const fingerprintMs = (t1 - t0) + (now() - t1);
90
+ const session = {
91
+ root: opts.scanRoot, version, global, model, fileHashes, prior, callGraph,
92
+ mode: 'cold', reason: null, plan: null, cache: null, hooks: null,
93
+ restored: { contexts: 0, rejected: 0 }, fingerprintMs, priorGeneration: prior.generation || null,
94
+ fileDiff: null, io: opts._incrementalIo || {}, timing: { fingerprintMs, readMs, planMs: 0, restoreMs: 0 },
95
+ };
96
+ const decide = () => {
97
+ const unsupported = UNSUPPORTED_FOR_REPLAY.find(([name]) => env[name] === '1');
98
+ if (unsupported) return `replay unsupported under ${unsupported[0]}: ${unsupported[1]}`;
99
+ if (!Object.keys(model.functions).length) return 'no analysable functions in this project, so there is nothing to reuse';
100
+ const valid = validateIncrementalState(prior, version);
101
+ if (!valid.valid) {
102
+ // name the content that moved when the version string alone does not
103
+ const moved = prior.deps && prior.deps.global ? diffComponents(prior.deps.global, global) : [];
104
+ return moved.length && /rule-pack/.test(valid.reason) ? `${valid.reason} (${moved.join(', ')})` : valid.reason;
105
+ }
106
+ if (!prior.deps) return 'prior state has no dependency model';
107
+ if (!prior.replay || prior.replay.schema !== REPLAY_SCHEMA) return 'prior state has no replay records';
108
+ if (!prior.replay.complete) return `prior analysis was incomplete (${(prior.replay.reasons || []).join(', ') || 'unspecified'})`;
109
+ if (!prior.replay.converged) return 'prior fixed point did not converge';
110
+ if (prior.deps.schema !== DEP_GRAPH_SCHEMA) return 'prior dependency graph has an incompatible schema';
111
+ return null;
112
+ };
113
+ const why = decide();
114
+ session.fileDiff = diffFileHashes(prior.files || {}, fileHashes);
115
+ if (why) {
116
+ session.reason = why;
117
+ session.hooks = new ReplayHooks();
118
+ return session;
119
+ }
120
+ const tPlan = now();
121
+ const records = new Map(Object.entries(prior.replay.records || {}));
122
+ const plan = planIncremental(
123
+ { global: prior.deps.global || {}, deps: prior.deps, hasRecord: (q) => records.has(q) },
124
+ { global, model },
125
+ );
126
+ if (plan.mode === 'cold') {
127
+ session.reason = plan.reason;
128
+ session.plan = plan;
129
+ session.hooks = new ReplayHooks();
130
+ return session;
131
+ }
132
+ session.timing.planMs = now() - tPlan;
133
+ const tRestore = now();
134
+ const cache = new SummaryCache();
135
+ const stats = restoreContexts(cache, { schema: CONTEXT_SCHEMA, contexts: prior.replay.contexts }, plan.clean);
136
+ // A clean function whose empty-entry context could not be restored cannot be replayed.
137
+ for (const q of [...plan.clean]) {
138
+ if (!cache.has(q, new Set())) { plan.clean.delete(q); plan.dirty.set(q, 'context-not-restorable'); }
139
+ }
140
+ session.timing.restoreMs = now() - tRestore;
141
+ const closure = new Map(Object.entries(model.functions).map(([q, f]) => [q, f.closure]));
142
+ session.mode = 'warm';
143
+ session.plan = plan;
144
+ session.cache = cache;
145
+ session.restored = stats;
146
+ session.hooks = new ReplayHooks({ clean: plan.clean, records, generation: prior.generation, closure });
147
+ return session;
148
+ }
149
+
150
+ /** Options to merge into the runTaintEngine call. */
151
+ export function engineOptionsFor(session) {
152
+ if (!session) return {};
153
+ return { _replay: session.hooks, ...(session.cache ? { summaryCache: session.cache } : {}) };
154
+ }
155
+
156
+ /**
157
+ * Finish: stamp provenance, persist the new generation, return the stats object
158
+ * to attach to the findings array.
159
+ */
160
+ export function finishIncrementalSession(session, findings, { now = Date.now } = {}) {
161
+ if (!session) return null;
162
+ const hooks = session.hooks;
163
+ const model = session.model;
164
+ // provenance on every finding
165
+ const gen = session.priorGeneration;
166
+ let reused = 0; let recomputed = 0;
167
+ for (const f of findings) {
168
+ if (hooks && hooks.reusedFindingIds.has(f.id)) {
169
+ f.cacheProvenance = { state: 'reused', generation: gen, closureFingerprint: model.functions[f._funcQid]?.closure ?? null };
170
+ reused++;
171
+ } else {
172
+ f.cacheProvenance = { state: 'recomputed' };
173
+ recomputed++;
174
+ }
175
+ }
176
+ const incomplete = Array.isArray(findings.analysisIncomplete) ? findings.analysisIncomplete : [];
177
+ const blocking = incomplete.filter((x) => BLOCKING_INCOMPLETE.has(x.reason)).map((x) => x.reason);
178
+ const nonConverged = incomplete.some((x) => x.reason === 'recursion-nonconverged');
179
+ const cache = findings._summaryCache;
180
+ const t0 = now();
181
+ let committed = false;
182
+ let commitError = null;
183
+ const persistPhases = {};
184
+ let lapT = now();
185
+ const lap = (n) => { const x = now(); persistPhases[n] = x - lapT; lapT = x; };
186
+ try {
187
+ const ctx = serializeContexts(cache);
188
+ lap('serialize-contexts');
189
+ const records = hooks.buildRecords(Object.keys(model.functions));
190
+ lap('build-records');
191
+ const replay = {
192
+ schema: REPLAY_SCHEMA,
193
+ complete: blocking.length === 0,
194
+ reasons: [...new Set(blocking)].sort(),
195
+ converged: hooks.converged && !nonConverged,
196
+ contexts: ctx.contexts, contextsNotPersisted: ctx.skipped, records,
197
+ };
198
+ const deps = {
199
+ schema: DEP_GRAPH_SCHEMA, global: session.global, chaDigest: model.chaDigest,
200
+ functions: model.functions, fileSymbols: model.fileSymbols, sccs: model.sccs,
201
+ };
202
+ lap('assemble');
203
+ committed = commitIncrementalState(session.root, {
204
+ files: session.fileHashes,
205
+ // the typed contexts live in replay.json; summaries.json keeps the legacy reverse call graph only
206
+ summaries: {},
207
+ callers: callersFrom(model),
208
+ deps, replay,
209
+ }, session.version, session.io);
210
+ lap('commit');
211
+ if (!committed) commitError = 'state not written (state writes disabled, unwritable directory, or an interrupted write)';
212
+ } catch (e) { commitError = String((e && e.message) || e); }
213
+ const persistMs = now() - t0;
214
+
215
+ const plan = session.plan;
216
+ const cleanN = plan && plan.clean ? plan.clean.size : 0;
217
+ const replayed = hooks ? hooks.replayed.size : 0;
218
+ const fnTotal = Object.keys(model.functions).length;
219
+ return {
220
+ // legacy fields the existing tests and consumers read
221
+ seeded: session.restored.restored ?? 0, reusable: replayed, discardedLegacy: 0,
222
+ mode: session.mode,
223
+ reason: session.mode === 'cold' ? session.reason : null,
224
+ priorGeneration: gen,
225
+ provenance: {
226
+ reusedFunctions: replayed,
227
+ recomputedFunctions: Math.max(0, fnTotal - replayed),
228
+ reusedFindings: reused,
229
+ recomputedFindings: recomputed,
230
+ restoredContexts: session.restored.restored ?? 0,
231
+ contextsRejected: session.restored.rejected ?? 0,
232
+ },
233
+ invalidation: plan ? {
234
+ cleanFunctions: cleanN,
235
+ dirtyFunctions: plan.dirty.size,
236
+ dirtyByReason: countByReason(plan.dirty),
237
+ // the first 500 recomputed functions and why, so a reader can see what the edit reached
238
+ dirty: [...plan.dirty].sort((a, b) => (a[0] < b[0] ? -1 : 1)).slice(0, 500).map(([qid, reason]) => ({ qid, reason })),
239
+ removedSymbols: plan.removed,
240
+ removedFiles: plan.removedFiles || [],
241
+ movedOrRenamedSymbols: plan.renamed || [],
242
+ sccWidened: plan.widened || 0,
243
+ changedGlobalComponents: plan.changedComponents || [],
244
+ } : null,
245
+ files: { changed: session.fileDiff.changed.length, added: session.fileDiff.added.length, removed: session.fileDiff.removed.length, unchanged: session.fileDiff.unchanged.length },
246
+ model: { ...model.stats, fingerprintMs: session.fingerprintMs },
247
+ timing: session.timing,
248
+ persisted: { committed, error: commitError, ms: persistMs, phaseMs: persistPhases },
249
+ // DAG-07 R07.3 shape: reuse proven equivalent by fingerprint is complete; a cold fallback is a full analysis.
250
+ completion: {
251
+ status: 'complete',
252
+ reasons: [],
253
+ emptyEntryReusedForTaintedContexts: 0,
254
+ summariesWithTruncatedFindings: 0,
255
+ },
256
+ };
257
+ }