@clear-capabilities/agentic-security-scanner 0.184.0 → 0.186.1
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.
- package/CHANGELOG.md +37 -0
- package/bin/agentic-security.js +111 -1
- package/dist/1128.index.js +912 -0
- package/dist/1665.index.js +828 -0
- package/dist/2584.index.js +1 -1
- package/dist/4171.index.js +2 -2
- package/dist/5752.index.js +466 -0
- package/dist/7405.index.js +1 -1
- package/dist/8158.index.js +1713 -0
- package/dist/8798.index.js +1 -1
- package/dist/9382.index.js +2 -2
- package/dist/9579.index.js +1 -1
- package/dist/agentic-security.mjs +29 -29
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/{dad58d1113a12c23a142.json → ca62ee93079be4c24eee.json} +29 -12
- package/dist/frontend/src/shell.js +12 -2
- package/package.json +29 -12
- package/src/dataflow/CLAUDE.md +46 -1
- package/src/dataflow/access-paths.js +12 -2
- package/src/dataflow/backward.js +42 -7
- package/src/dataflow/cross-service-taint.js +83 -1
- package/src/dataflow/demand-query.js +1168 -0
- package/src/dataflow/dep-fingerprint.js +718 -0
- package/src/dataflow/engine.js +48 -6
- package/src/dataflow/exploit-prover.js +6 -2
- package/src/dataflow/ifds-precise.js +22 -0
- package/src/dataflow/incremental-replay.js +118 -0
- package/src/dataflow/incremental-session.js +257 -0
- package/src/dataflow/incremental.js +237 -35
- package/src/dataflow/index.js +25 -94
- package/src/dataflow/ir-cache.js +268 -0
- package/src/dataflow/privacy-taint.js +7 -0
- package/src/dataflow/summaries.js +72 -6
- package/src/dataflow/tabulation.js +80 -24
- package/src/engine.js +113 -8
- package/src/graph/CLAUDE.md +31 -0
- package/src/graph/adapters/artifact.js +81 -0
- package/src/graph/adapters/crossrepo.js +75 -0
- package/src/graph/adapters/dataflow.js +76 -0
- package/src/graph/adapters/provenance.js +92 -0
- package/src/graph/adapters/semantic.js +89 -0
- package/src/graph/crosswalk-builders.js +152 -0
- package/src/graph/crosswalk.js +146 -0
- package/src/graph/index.js +15 -0
- package/src/graph/observation.js +57 -0
- package/src/graph/refs.js +105 -0
- package/src/graph/registry.js +129 -0
- package/src/graph/relations.js +81 -0
- package/src/graph/scc.js +241 -0
- package/src/graph/view.js +254 -0
- package/src/ir/CLAUDE.md +4 -0
- package/src/ir/callgraph.js +36 -8
- package/src/ir/index.js +18 -4
- package/src/ir/parser-js.js +10 -0
- package/src/language/CLAUDE.md +13 -0
- package/src/language/nix-option-types.js +170 -0
- package/src/language/nix-script-taint.js +46 -3
- package/src/language/nix-secrets.js +20 -2
- package/src/lineage/CLAUDE.md +72 -8
- package/src/lineage/DESIGN_GRAPH_BUILDER.md +1 -1
- package/src/lineage/DESIGN_PATH_PROVENANCE.md +6 -0
- package/src/lineage/boundary-contract.js +273 -0
- package/src/lineage/completion.js +205 -0
- package/src/lineage/continuation.js +140 -0
- package/src/lineage/control-credit.js +313 -0
- package/src/lineage/correlation.js +199 -0
- package/src/lineage/coverage.js +8 -1
- package/src/lineage/cross-repo-link.js +22 -0
- package/src/lineage/cut-planner.js +548 -0
- package/src/lineage/driver.js +219 -66
- package/src/lineage/engine.js +166 -21
- package/src/lineage/export-json.js +5 -0
- package/src/lineage/graph-builder.js +84 -7
- package/src/lineage/haskell-view.js +59 -0
- package/src/lineage/index.js +3 -0
- package/src/lineage/nix-view.js +35 -0
- package/src/lineage/owned-build.js +77 -0
- package/src/lineage/owned-worker-entry.js +22 -0
- package/src/lineage/owned-worker.js +95 -0
- package/src/lineage/path-evidence.js +123 -0
- package/src/lineage/path-predicates.js +529 -0
- package/src/lineage/path-query.js +189 -20
- package/src/lineage/path-store.js +191 -17
- package/src/lineage/scc-solver.js +186 -0
- package/src/lineage/smt-orchestrator.js +174 -0
- package/src/lineage/smt-z3-backend.js +294 -0
- package/src/lineage/source-seeding.js +36 -2
- package/src/lineage/summaries.js +66 -4
- package/src/lineage/value-versions.js +130 -0
- package/src/lineage/witness-selection.js +95 -0
- package/src/pipeline/scan-health.js +18 -0
- package/src/posture/CLAUDE.md +17 -0
- package/src/posture/business-logic.js +16 -7
- package/src/posture/dag-commands.js +442 -0
- package/src/posture/evidence-bundle.js +1 -1
- package/src/posture/finding-defaults.js +19 -0
- package/src/posture/git-history.js +91 -1
- package/src/posture/witness-bundle.js +573 -0
- package/src/posture/witness-runtime.js +276 -0
- package/src/report/index.js +2 -0
- package/src/sast/CLAUDE.md +2 -0
- package/src/sast/_call-args.js +70 -0
- package/src/sast/init-scope.js +87 -0
- package/src/sast/listen-bind-address.js +49 -0
- package/src/sast/redirect-target.js +106 -0
- package/src/sast/reflected-input.js +76 -0
- package/src/sast/send-file-root.js +27 -0
- package/src/sast/sensitive-data-logging.js +38 -1
- package/dist/119.index.js +0 -197
- package/dist/128.index.js +0 -129
- package/dist/1293.index.js +0 -5689
- package/dist/1336.index.js +0 -16182
- package/dist/1351.index.js +0 -133
- package/dist/1387.index.js +0 -271
- package/dist/1437.index.js +0 -23
- package/dist/1445.index.js +0 -110
- package/dist/1505.index.js +0 -151
- package/dist/1557.index.js +0 -293
- package/dist/1621.index.js +0 -311
- package/dist/1751.index.js +0 -160
- package/dist/1765.index.js +0 -727
- package/dist/1861.index.js +0 -690
- package/dist/1906.index.js +0 -433
- package/dist/208.index.js +0 -1631
- package/dist/2223.index.js +0 -159
- package/dist/2238.index.js +0 -295
- package/dist/2530.index.js +0 -152
- package/dist/2565.index.js +0 -116
- package/dist/2574.index.js +0 -127
- package/dist/2620.index.js +0 -345
- package/dist/2773.index.js +0 -98
- package/dist/2775.index.js +0 -32
- package/dist/2966.index.js +0 -171
- package/dist/2972.index.js +0 -87
- package/dist/2991.index.js +0 -131
- package/dist/3063.index.js +0 -833
- package/dist/3075.index.js +0 -227
- package/dist/3121.index.js +0 -297
- package/dist/3314.index.js +0 -263
- package/dist/3480.index.js +0 -1822
- package/dist/3481.index.js +0 -24
- package/dist/3506.index.js +0 -268
- package/dist/3533.index.js +0 -591
- package/dist/3653.index.js +0 -450
- package/dist/3740.index.js +0 -722
- package/dist/3822.index.js +0 -131
- package/dist/3868.index.js +0 -292
- package/dist/3918.index.js +0 -192
- package/dist/4090.index.js +0 -288
- package/dist/4113.index.js +0 -1286
- package/dist/4121.index.js +0 -1190
- package/dist/4142.index.js +0 -690
- package/dist/4168.index.js +0 -509
- package/dist/4281.index.js +0 -993
- package/dist/4336.index.js +0 -436
- package/dist/4387.index.js +0 -532
- package/dist/4413.index.js +0 -302
- package/dist/4476.index.js +0 -4510
- package/dist/4491.index.js +0 -133
- package/dist/4538.index.js +0 -185
- package/dist/4684.index.js +0 -319
- package/dist/4740.index.js +0 -165
- package/dist/4780.index.js +0 -209
- package/dist/4802.index.js +0 -225
- package/dist/4812.index.js +0 -379
- package/dist/4835.index.js +0 -126
- package/dist/494.index.js +0 -226
- package/dist/4991.index.js +0 -549
- package/dist/5199.index.js +0 -305
- package/dist/5216.index.js +0 -109
- package/dist/5439.index.js +0 -301
- package/dist/5443.index.js +0 -97
- package/dist/5496.index.js +0 -377
- package/dist/5738.index.js +0 -240
- package/dist/5801.index.js +0 -588
- package/dist/5817.index.js +0 -714
- package/dist/5858.index.js +0 -223
- package/dist/5933.index.js +0 -143
- package/dist/601.index.js +0 -23
- package/dist/6173.index.js +0 -273
- package/dist/6291.index.js +0 -1871
- package/dist/6312.index.js +0 -666
- package/dist/634.index.js +0 -16182
- package/dist/644.index.js +0 -87
- package/dist/6508.index.js +0 -283
- package/dist/6560.index.js +0 -645
- package/dist/6576.index.js +0 -422
- package/dist/6579.index.js +0 -271
- package/dist/6655.index.js +0 -3221
- package/dist/6745.index.js +0 -305
- package/dist/6818.index.js +0 -21642
- package/dist/6932.index.js +0 -1190
- package/dist/6972.index.js +0 -167
- package/dist/6995.index.js +0 -163
- package/dist/7249.index.js +0 -191
- package/dist/7372.index.js +0 -1197
- package/dist/7546.index.js +0 -541
- package/dist/76.index.js +0 -448
- package/dist/7879.index.js +0 -163
- package/dist/8025.index.js +0 -130
- package/dist/8054.index.js +0 -248
- package/dist/808.index.js +0 -1330
- package/dist/8097.index.js +0 -201
- package/dist/8126.index.js +0 -191
- package/dist/8178.index.js +0 -78
- package/dist/8382.index.js +0 -93
- package/dist/8408.index.js +0 -261
- package/dist/8540.index.js +0 -174
- package/dist/8635.index.js +0 -802
- package/dist/8688.index.js +0 -410
- package/dist/8699.index.js +0 -779
- package/dist/8891.index.js +0 -457
- package/dist/8916.index.js +0 -885
- package/dist/9146.index.js +0 -170
- package/dist/9168.index.js +0 -841
- package/dist/9292.index.js +0 -369
- package/dist/9422.index.js +0 -866
- package/dist/9440.index.js +0 -453
- package/dist/9479.index.js +0 -152
- package/dist/9481.index.js +0 -220
- package/dist/9539.index.js +0 -100
- package/dist/9547.index.js +0 -103
- package/dist/9692.index.js +0 -90
- package/dist/9800.index.js +0 -462
- package/dist/9899.index.js +0 -138
- package/dist/9981.index.js +0 -206
- package/dist/999.index.js +0 -117
- package/dist/9999.index.js +0 -1698
package/src/dataflow/engine.js
CHANGED
|
@@ -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 +=
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
|
@@ -22,8 +22,13 @@
|
|
|
22
22
|
//
|
|
23
23
|
// We do NOT attempt to prove ARBITRARY satisfiability — the v2 use case.
|
|
24
24
|
|
|
25
|
+
import { createRequire } from 'node:module';
|
|
25
26
|
import { provablyMatches, safeSanitizerOutputRegex } from './string-domain.js';
|
|
26
27
|
|
|
28
|
+
// Optional package, loaded at first use through a runtime require so a bundler keeps it external and a
|
|
29
|
+
// build without it fails here (caught below), never at start-up.
|
|
30
|
+
const require = createRequire(import.meta.url);
|
|
31
|
+
|
|
27
32
|
// CWE → canonical exploit input. Conservative payloads safe to display.
|
|
28
33
|
const EXPLOIT_INPUTS = {
|
|
29
34
|
'CWE-89': `1' OR '1'='1`, // SQL injection
|
|
@@ -93,8 +98,7 @@ async function _maybeLoadZ3() {
|
|
|
93
98
|
if (_z3Loaded) return _z3;
|
|
94
99
|
_z3Loaded = true;
|
|
95
100
|
try {
|
|
96
|
-
|
|
97
|
-
_z3 = mod || null;
|
|
101
|
+
_z3 = require('z3-solver') || null;
|
|
98
102
|
} catch { _z3 = null; }
|
|
99
103
|
return _z3;
|
|
100
104
|
}
|
|
@@ -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
|
+
}
|