@clear-capabilities/agentic-security-scanner 0.184.0 → 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 (77) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/bin/agentic-security.js +87 -0
  3. package/dist/{dad58d1113a12c23a142.json → 0b301d45b231f144a1b4.json} +26 -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/9579.index.js +1 -1
  8. package/dist/agentic-security.mjs +15 -15
  9. package/dist/agentic-security.mjs.sha256 +1 -1
  10. package/dist/frontend/src/shell.js +12 -2
  11. package/package.json +26 -9
  12. package/src/dataflow/CLAUDE.md +43 -1
  13. package/src/dataflow/backward.js +42 -7
  14. package/src/dataflow/cross-service-taint.js +83 -1
  15. package/src/dataflow/demand-query.js +929 -0
  16. package/src/dataflow/dep-fingerprint.js +718 -0
  17. package/src/dataflow/engine.js +48 -6
  18. package/src/dataflow/ifds-precise.js +22 -0
  19. package/src/dataflow/incremental-replay.js +118 -0
  20. package/src/dataflow/incremental-session.js +257 -0
  21. package/src/dataflow/incremental.js +237 -35
  22. package/src/dataflow/index.js +25 -94
  23. package/src/dataflow/summaries.js +72 -6
  24. package/src/dataflow/tabulation.js +80 -24
  25. package/src/engine.js +51 -3
  26. package/src/graph/CLAUDE.md +31 -0
  27. package/src/graph/adapters/artifact.js +81 -0
  28. package/src/graph/adapters/crossrepo.js +75 -0
  29. package/src/graph/adapters/dataflow.js +76 -0
  30. package/src/graph/adapters/provenance.js +92 -0
  31. package/src/graph/adapters/semantic.js +89 -0
  32. package/src/graph/crosswalk-builders.js +152 -0
  33. package/src/graph/crosswalk.js +146 -0
  34. package/src/graph/index.js +15 -0
  35. package/src/graph/observation.js +57 -0
  36. package/src/graph/refs.js +105 -0
  37. package/src/graph/registry.js +129 -0
  38. package/src/graph/relations.js +81 -0
  39. package/src/graph/scc.js +241 -0
  40. package/src/graph/view.js +254 -0
  41. package/src/lineage/CLAUDE.md +71 -8
  42. package/src/lineage/DESIGN_GRAPH_BUILDER.md +1 -1
  43. package/src/lineage/DESIGN_PATH_PROVENANCE.md +6 -0
  44. package/src/lineage/boundary-contract.js +273 -0
  45. package/src/lineage/completion.js +205 -0
  46. package/src/lineage/continuation.js +140 -0
  47. package/src/lineage/control-credit.js +313 -0
  48. package/src/lineage/correlation.js +199 -0
  49. package/src/lineage/coverage.js +8 -1
  50. package/src/lineage/cross-repo-link.js +22 -0
  51. package/src/lineage/cut-planner.js +548 -0
  52. package/src/lineage/driver.js +219 -66
  53. package/src/lineage/engine.js +166 -21
  54. package/src/lineage/export-json.js +5 -0
  55. package/src/lineage/graph-builder.js +84 -7
  56. package/src/lineage/haskell-view.js +59 -0
  57. package/src/lineage/index.js +3 -0
  58. package/src/lineage/nix-view.js +35 -0
  59. package/src/lineage/owned-build.js +77 -0
  60. package/src/lineage/owned-worker-entry.js +22 -0
  61. package/src/lineage/owned-worker.js +95 -0
  62. package/src/lineage/path-evidence.js +123 -0
  63. package/src/lineage/path-predicates.js +529 -0
  64. package/src/lineage/path-query.js +189 -20
  65. package/src/lineage/path-store.js +191 -17
  66. package/src/lineage/scc-solver.js +186 -0
  67. package/src/lineage/smt-orchestrator.js +138 -0
  68. package/src/lineage/source-seeding.js +36 -2
  69. package/src/lineage/summaries.js +66 -4
  70. package/src/lineage/value-versions.js +130 -0
  71. package/src/lineage/witness-selection.js +95 -0
  72. package/src/pipeline/scan-health.js +18 -0
  73. package/src/posture/CLAUDE.md +13 -0
  74. package/src/posture/evidence-bundle.js +1 -1
  75. package/src/posture/witness-bundle.js +573 -0
  76. package/src/posture/witness-runtime.js +276 -0
  77. package/src/report/index.js +2 -0
@@ -0,0 +1,186 @@
1
+ // Recursive-component solver for the lineage driver (DAG-01, R01.3 to R01.5).
2
+ //
3
+ // Pure: no I/O, no clock of its own (a budget is an injected callback), and
4
+ // no import of any analysis engine. `driver.js` supplies the per-function
5
+ // analysis; tests supply synthetic transfer functions.
6
+ //
7
+ // ## Fact domain (R01.4)
8
+ //
9
+ // A function summary is a finite set of facts:
10
+ // - `ret:<dataElementId>` the return value carries the id
11
+ // - `mut:<paramPath>:<dataElementId>` the parameter access path
12
+ // `paramPath` is mutated to carry
13
+ // the id
14
+ // The id universe is the set of data-element ids that appear in the entry
15
+ // states of the analyzed contexts and the program text (finite per run). The
16
+ // path universe is the set of syntactic parameter access paths, additionally
17
+ // BOUNDED by `maxPathDepth` (default 6 segments): a deeper path is folded to
18
+ // its depth-bounded prefix, which only adds the id to a coarser path (an
19
+ // over-approximation) and is recorded as the widening reason
20
+ // `path-depth-cap`.
21
+ //
22
+ // ## Join and monotonicity
23
+ //
24
+ // The join is set union per fact family. The solver stores
25
+ // `next = join(previous, analyze(previous))`, so the per-function summary
26
+ // sequence is an ascending chain in a finite lattice whether or not the
27
+ // underlying analysis is itself monotone. An ascending chain over a finite
28
+ // lattice stabilizes, so the iteration terminates; `maxRounds` is a second,
29
+ // independent safety bound. A fixed point reached here is the least fixed
30
+ // point of the joined transfer function, which is exactly what a naive
31
+ // exhaustive iteration to stability computes (the reference solver in the
32
+ // tests).
33
+ //
34
+ // ## What converged means
35
+ //
36
+ // `converged: true` means a full round changed no member summary AND at least
37
+ // two rounds ran (round one is compared with the bottom seed, which is not an
38
+ // analysis result). It does not mean the program is safe: summaries
39
+ // under-approximate whatever the analysis does not model. If a budget stops
40
+ // the iteration, a round cap is hit, or a member is never analyzed, the
41
+ // result is `converged: false` with the remaining frontier listed, and the
42
+ // last iterate must be treated as partial, never as a conservative proof.
43
+
44
+ function cmp(a, b) { return a < b ? -1 : a > b ? 1 : 0; }
45
+
46
+ export const DEFAULT_MAX_PATH_DEPTH = 6;
47
+ export const DEFAULT_MAX_ROUNDS = 32;
48
+ export const DEFAULT_MAX_CHANGED_FACTS = 1000;
49
+
50
+ function foldPath(path, maxDepth) {
51
+ const segs = String(path).split('.');
52
+ if (segs.length <= maxDepth) return null;
53
+ return segs.slice(0, maxDepth).join('.');
54
+ }
55
+
56
+ // Joins two field-identity summaries. Returns `{ summary, reasons }`.
57
+ // `reasons` lists widening reasons applied by this join (deterministic order).
58
+ export function joinFieldSummaries(prev, next, opts = {}) {
59
+ const maxDepth = opts.maxPathDepth ?? DEFAULT_MAX_PATH_DEPTH;
60
+ const reasons = new Set();
61
+ const returnFlat = new Set(prev ? prev.returnFlat : []);
62
+ for (const id of next.returnFlat) returnFlat.add(id);
63
+
64
+ const mutatedParams = new Map();
65
+ const addMut = (path, ids) => {
66
+ const folded = foldPath(path, maxDepth);
67
+ const key = folded ?? path;
68
+ if (folded !== null) reasons.add('path-depth-cap');
69
+ let set = mutatedParams.get(key);
70
+ if (!set) { set = new Set(); mutatedParams.set(key, set); }
71
+ for (const id of ids) set.add(id);
72
+ };
73
+ if (prev) for (const [p, ids] of prev.mutatedParams) addMut(p, ids);
74
+ for (const [p, ids] of next.mutatedParams) addMut(p, ids);
75
+
76
+ const returnByPath = new Map();
77
+ for (const src of [prev && prev.returnByPath, next.returnByPath]) {
78
+ if (!src) continue;
79
+ for (const [p, ids] of src) {
80
+ const set = returnByPath.get(p) ?? new Set();
81
+ for (const id of ids) set.add(id);
82
+ returnByPath.set(p, set);
83
+ }
84
+ }
85
+ const summary = { returnFlat, returnByPath, mutatedParams, widenings: next.widenings ?? [] };
86
+ if (next.degradedReason) summary.degradedReason = next.degradedReason;
87
+ return { summary, reasons: [...reasons].sort(cmp) };
88
+ }
89
+
90
+ export function fieldFacts(summary) {
91
+ const out = [];
92
+ if (!summary) return out;
93
+ for (const id of summary.returnFlat) out.push(`ret:${id}`);
94
+ for (const [p, ids] of summary.mutatedParams) for (const id of ids) out.push(`mut:${p}:${id}`);
95
+ return out.sort(cmp);
96
+ }
97
+
98
+ export function bottomFieldSummary() {
99
+ return { returnFlat: new Set(), returnByPath: new Map(), mutatedParams: new Map(), widenings: [] };
100
+ }
101
+
102
+ function factsEqual(a, b) {
103
+ const fa = fieldFacts(a);
104
+ const fb = fieldFacts(b);
105
+ if (fa.length !== fb.length) return false;
106
+ for (let i = 0; i < fa.length; i++) if (fa[i] !== fb[i]) return false;
107
+ return true;
108
+ }
109
+
110
+ // Solves one recursive component.
111
+ //
112
+ // members sorted qids in the component
113
+ // analyze(qid) runs one analysis of `qid` against the currently
114
+ // published summaries and returns a RAW summary
115
+ // publish(qid, s) makes `s` the summary other members will read
116
+ // bottom(qid) initial summary (default: empty)
117
+ // beforeRound(round) hook, called before each round
118
+ // shouldStop() returns a reason string to stop, or a falsy value
119
+ // maxRounds, maxPathDepth, maxChangedFacts bounds
120
+ //
121
+ // Returns { converged, rounds, changedFacts, changedFactsTotal,
122
+ // changedFactsTruncated, wideningReasons, stoppedBy,
123
+ // remainingFrontier, summaries }.
124
+ export function solveRecursiveComponent(args) {
125
+ const members = [...args.members].sort(cmp);
126
+ const maxRounds = args.maxRounds ?? DEFAULT_MAX_ROUNDS;
127
+ const maxChanged = args.maxChangedFacts ?? DEFAULT_MAX_CHANGED_FACTS;
128
+ const bottom = args.bottom || (() => bottomFieldSummary());
129
+ const current = new Map();
130
+ for (const q of members) {
131
+ const b = bottom(q);
132
+ current.set(q, b);
133
+ args.publish(q, b);
134
+ }
135
+ const reasons = new Set();
136
+ const changedFacts = [];
137
+ let changedTotal = 0;
138
+ let rounds = 0;
139
+ let converged = false;
140
+ let stoppedBy = null;
141
+ let analyzedThisRound = new Set();
142
+
143
+ while (rounds < maxRounds) {
144
+ rounds++;
145
+ if (args.beforeRound) args.beforeRound(rounds);
146
+ let changedInRound = false;
147
+ analyzedThisRound = new Set();
148
+ for (const q of members) {
149
+ const stop = args.shouldStop ? args.shouldStop() : null;
150
+ if (stop) { stoppedBy = stop; break; }
151
+ const prev = current.get(q);
152
+ const raw = args.analyze(q);
153
+ analyzedThisRound.add(q);
154
+ const { summary: joined, reasons: rs } = joinFieldSummaries(prev, raw, { maxPathDepth: args.maxPathDepth });
155
+ for (const r of rs) reasons.add(r);
156
+ if (!factsEqual(prev, joined)) {
157
+ changedInRound = true;
158
+ const before = new Set(fieldFacts(prev));
159
+ for (const f of fieldFacts(joined)) {
160
+ if (before.has(f)) continue;
161
+ changedTotal++;
162
+ if (changedFacts.length < maxChanged) changedFacts.push({ round: rounds, qid: q, fact: f });
163
+ }
164
+ }
165
+ current.set(q, joined);
166
+ args.publish(q, joined);
167
+ }
168
+ if (stoppedBy) { rounds = analyzedThisRound.size === 0 && rounds > 0 ? rounds - 1 : rounds; break; }
169
+ if (!changedInRound && rounds >= 2) { converged = true; break; }
170
+ }
171
+ if (!converged && !stoppedBy && rounds >= maxRounds) {
172
+ reasons.add('round-cap');
173
+ stoppedBy = 'round-cap';
174
+ }
175
+ return {
176
+ converged,
177
+ rounds,
178
+ changedFacts,
179
+ changedFactsTotal: changedTotal,
180
+ changedFactsTruncated: changedTotal > changedFacts.length,
181
+ wideningReasons: [...reasons].sort(cmp),
182
+ stoppedBy,
183
+ remainingFrontier: converged ? [] : members.slice(),
184
+ summaries: current,
185
+ };
186
+ }
@@ -0,0 +1,138 @@
1
+ //
2
+ // smt-orchestrator.js: the optional solver path for witness conditions (DAG-04, R04.3 / R04.4).
3
+ //
4
+ // The bounded abstract domains in path-predicates.js decide first. A solver is consulted only when they
5
+ // say `unknown`, and only through this module, which makes the call:
6
+ // * awaited: the caller gets one settled result, never a dangling promise;
7
+ // * time-bounded: a deadline races the solver, and a solver that ignores it is still released;
8
+ // * cancellable: an AbortSignal from the caller and the module's own deadline both abort the solver's
9
+ // signal, and a late settlement from an abandoned solver is swallowed;
10
+ // * honest: no solver reports `unsupported`, a deadline reports `timeout`, a malformed or failing solver
11
+ // reports `unknown`. None of these ever drops or downgrades the finding. The abstract result is kept.
12
+ //
13
+ // No solver ships here and no dependency is added. `noSolver` is the default stub. A real solver is an
14
+ // object with `{ name, version?, available?, check(problem, { signal }) -> Promise<{ result }> }` where
15
+ // `result` is 'sat' | 'unsat' | 'unknown'. `problem` is the solver-neutral view from `toSolverProblem`.
16
+ // Satisfiability never implies exploitability.
17
+
18
+ import { evaluateWitnessConditions, toSolverProblem, PATH_PREDICATE_VERSION, SATISFIABILITY_NOTE } from './path-predicates.js';
19
+
20
+ export const SOLVER_DEFAULT_TIMEOUT_MS = 1000;
21
+ export const SOLVER_TIMEOUT_MAX_MS = 60_000;
22
+
23
+ /** The default: a solver that is not there. */
24
+ export const noSolver = Object.freeze({
25
+ name: 'none', version: null, available: false,
26
+ async check() { return { result: 'unsupported' }; },
27
+ });
28
+
29
+ const MAP = Object.freeze({ sat: 'feasible-model', unsat: 'infeasible-under-model', unknown: 'unknown' });
30
+
31
+ /** Render the problem as SMT-LIB 2 text for a solver adapter that wants text. Opaque predicates become comments. */
32
+ export function toSmtLib(problem) {
33
+ const lines = ['(set-logic ALL)'];
34
+ const sym = (s) => `|${String(s).replace(/\|/g, '_')}|`;
35
+ const num = (side) => (side.var ? sym(side.var) : String(side.const));
36
+ for (const v of problem.vars) lines.push(`(declare-const ${sym(v)} Int)`);
37
+ for (const a of problem.assertions) {
38
+ const alts = a.alternatives.map((alt) => {
39
+ const parts = alt.map((l) => {
40
+ if (l.t === 'num') {
41
+ const op = { '<': '<', '<=': '<=', '>': '>', '>=': '>=', '==': '=', '===': '=', '!=': 'distinct', '!==': 'distinct' }[l.op];
42
+ return `(${op} ${num(l.left)} ${num(l.right)})`;
43
+ }
44
+ if (l.t === 'eq') return `(${l.equal ? '=' : 'distinct'} ${sym(l.var)} ${JSON.stringify(l.value)})`;
45
+ if (l.t === 'bool') return l.truth ? 'true' : 'false';
46
+ return 'true';
47
+ });
48
+ return parts.length === 1 ? parts[0] : `(and ${parts.join(' ') || 'true'})`;
49
+ });
50
+ lines.push(`; ${a.ref}`);
51
+ lines.push(`(assert ${alts.length === 0 ? 'false' : alts.length === 1 ? alts[0] : `(or ${alts.join(' ')})`})`);
52
+ }
53
+ lines.push('(check-sat)');
54
+ return lines.join('\n');
55
+ }
56
+
57
+ function boundedTimeout(ms) {
58
+ if (ms === undefined) return SOLVER_DEFAULT_TIMEOUT_MS;
59
+ if (!Number.isFinite(ms) || ms < 0 || ms > SOLVER_TIMEOUT_MAX_MS) return null;
60
+ return Math.floor(ms);
61
+ }
62
+
63
+ /**
64
+ * Ask a solver about the collected conditions. Always resolves; never throws.
65
+ *
66
+ * @returns {Promise<{status:string, reasons:string[], solver:object, elapsedMs:number, semanticsVersion:string, limitations:string[]}>}
67
+ */
68
+ export async function checkWithSolver(collected, { solver = noSolver, timeoutMs, signal, now = Date.now } = {}) {
69
+ const started = now();
70
+ const info = { name: solver?.name ?? 'unknown', version: solver?.version ?? null };
71
+ const out = (status, reasons, extra = {}) => ({
72
+ status, reasons, solver: info, elapsedMs: now() - started, semanticsVersion: PATH_PREDICATE_VERSION,
73
+ satisfiabilityNote: SATISFIABILITY_NOTE,
74
+ limitations: ['A solver result is relative to the encoded conditions, not to the running program.', ...(extra.limitations ?? [])],
75
+ ...(extra.model ? { model: extra.model } : {}),
76
+ });
77
+ const tmo = boundedTimeout(timeoutMs);
78
+ if (tmo === null) return out('unknown', ['invalid-timeout']);
79
+ if (!solver || solver.available === false || typeof solver.check !== 'function') return out('unsupported', ['no-solver']);
80
+ if (signal?.aborted) return out('unknown', ['cancelled']);
81
+
82
+ const ac = new AbortController();
83
+ let timer; let onAbort;
84
+ const timeoutP = new Promise((resolve) => { timer = setTimeout(() => resolve({ __timeout: true }), tmo); });
85
+ const cancelP = new Promise((resolve) => {
86
+ if (!signal) return;
87
+ onAbort = () => resolve({ __cancelled: true });
88
+ signal.addEventListener('abort', onAbort, { once: true });
89
+ });
90
+ let solverP;
91
+ try {
92
+ solverP = Promise.resolve(solver.check(toSolverProblem(collected), { signal: ac.signal }));
93
+ } catch {
94
+ clearTimeout(timer);
95
+ return out('unknown', ['solver-error']);
96
+ }
97
+ // A solver that settles after we gave up must not become an unhandled rejection.
98
+ solverP.catch(() => {});
99
+ try {
100
+ const r = await Promise.race([solverP, timeoutP, cancelP]);
101
+ if (r && r.__timeout) { ac.abort(); return out('timeout', ['solver-deadline']); }
102
+ if (r && r.__cancelled) { ac.abort(); return out('unknown', ['cancelled']); }
103
+ const status = isObjectWithResult(r) ? MAP[r.result] : undefined;
104
+ if (r?.result === 'unsupported') return out('unsupported', ['solver-declined']);
105
+ if (!status) return out('unknown', ['solver-malformed']);
106
+ return out(status, [`solver-${r.result}`], r.result === 'sat' && r.model && typeof r.model === 'object' ? { model: r.model } : {});
107
+ } catch {
108
+ return out('unknown', ['solver-error']);
109
+ } finally {
110
+ clearTimeout(timer);
111
+ if (signal && onAbort) signal.removeEventListener('abort', onAbort);
112
+ }
113
+ }
114
+
115
+ function isObjectWithResult(r) { return typeof r === 'object' && r !== null && typeof r.result === 'string'; }
116
+
117
+ /**
118
+ * The full decision: abstract domains first, then the solver only when the abstract answer is `unknown`.
119
+ * The abstract answer stays on the result, so a timeout or an unsupported solver never erases what the
120
+ * cheap model already established, and the finding stays visible as unknown/partial.
121
+ */
122
+ export async function evaluateWitnessConditionsAsync(collected, o = {}) {
123
+ const abstract = evaluateWitnessConditions(collected, o);
124
+ if (abstract.status !== 'unknown') return { ...abstract, abstractStatus: abstract.status, solverConsulted: false };
125
+ const s = await checkWithSolver(collected, o);
126
+ if (s.status === 'unsupported') return { ...abstract, abstractStatus: abstract.status, solverConsulted: false, solver: s.solver, solverStatus: 'unsupported' };
127
+ if (s.status === 'timeout') {
128
+ return { ...abstract, status: 'timeout', reasons: [...abstract.reasons, 'solver-deadline'], abstractStatus: abstract.status, solverConsulted: true, solver: s.solver, solverStatus: 'timeout', elapsedMs: s.elapsedMs };
129
+ }
130
+ if (s.status === 'unknown') {
131
+ return { ...abstract, abstractStatus: abstract.status, solverConsulted: true, solver: s.solver, solverStatus: 'unknown', reasons: [...abstract.reasons, ...s.reasons], elapsedMs: s.elapsedMs };
132
+ }
133
+ return {
134
+ ...abstract, status: s.status, reasons: s.reasons, abstractStatus: abstract.status, solverConsulted: true, solver: s.solver, solverStatus: s.status,
135
+ limitations: [...abstract.limitations, ...s.limitations], elapsedMs: s.elapsedMs,
136
+ contradiction: s.status === 'infeasible-under-model' ? { kind: 'solver-unsat', refs: abstract.clauseRefs } : null,
137
+ };
138
+ }
@@ -125,6 +125,11 @@ function seedPathFor(expr, parentOf) {
125
125
  // from seed records, must not surface a literal `*` name to a user
126
126
  // without translating it into an honest "dynamic key" disclosure.
127
127
 
128
+ // DAG-07 R07.5: generous defaults so an ordinary project never reaches them;
129
+ // they exist so a pathological input cannot make the planner unbounded.
130
+ export const DEFAULT_MAX_SEEDS = 1000000;
131
+ export const DEFAULT_MAX_NODES = 20000000;
132
+
128
133
  // ── §3.3/§3.4: planSeeds ────────────────────────────────────────────────────
129
134
 
130
135
  /**
@@ -144,11 +149,29 @@ function seedPathFor(expr, parentOf) {
144
149
  * A matched expression with NO access path (`accessPathOf` → `null`) is
145
150
  * recorded in `unseedable[]` — never silently dropped (§3.3).
146
151
  */
147
- export function planSeeds(callGraph, { repository } = {}) {
152
+ export function planSeeds(callGraph, { repository, maxSeeds, maxNodes, signal, deadline } = {}) {
148
153
  const seeds = [];
149
154
  const unseedable = [];
155
+ // DAG-07 R07.1: a completion record for the seeding step. A function the
156
+ // planner did not reach has no seeds, which looks identical to "this
157
+ // function reads no sources"; the record is what tells them apart.
158
+ const seedCap = Number.isInteger(maxSeeds) && maxSeeds >= 0 ? maxSeeds : DEFAULT_MAX_SEEDS;
159
+ const nodeCap = Number.isInteger(maxNodes) && maxNodes >= 0 ? maxNodes : DEFAULT_MAX_NODES;
160
+ let functionsTotal = 0;
161
+ let functionsScanned = 0;
162
+ let nodesVisited = 0;
163
+ let stopReason = null;
164
+ const shouldStop = () => !!(signal && signal.aborted) || (Number.isFinite(deadline) && Date.now() > deadline);
150
165
  for (const fn of callGraph.functions.values()) {
166
+ functionsTotal += 1;
167
+ if (stopReason) continue;
168
+ if (shouldStop()) { stopReason = 'cancelled'; continue; }
169
+ if (seeds.length >= seedCap) { stopReason = 'seeding-limit'; continue; }
170
+ if (nodesVisited >= nodeCap) { stopReason = 'seeding-limit'; continue; }
171
+ functionsScanned += 1;
151
172
  for (const [nid, node] of Object.entries(fn.cfg?.nodes ?? {})) {
173
+ nodesVisited += 1;
174
+ if (seeds.length >= seedCap) { stopReason = 'seeding-limit'; break; }
152
175
  const parentOf = new Map();
153
176
  for (const root of exprRoots(node)) walkExpr(root, (e, p) => { if (p) parentOf.set(e, p); });
154
177
  for (const root of exprRoots(node)) {
@@ -182,7 +205,18 @@ export function planSeeds(callGraph, { repository } = {}) {
182
205
  }
183
206
  }
184
207
  }
185
- return { seeds, unseedable };
208
+ const notScanned = functionsTotal - functionsScanned;
209
+ const completion = {
210
+ status: stopReason === 'cancelled' ? 'cancelled' : stopReason ? 'partial' : 'complete',
211
+ reasons: stopReason ? [stopReason] : [],
212
+ functionsTotal,
213
+ functionsScanned,
214
+ functionsNotScanned: notScanned,
215
+ seeds: seeds.length,
216
+ unseedable: unseedable.length,
217
+ limits: { maxSeeds: seedCap, maxNodes: nodeCap, nodesVisited },
218
+ };
219
+ return { seeds, unseedable, completion };
186
220
  }
187
221
 
188
222
  // ── §3.5: seedEntryStateFactory — the driver.js opts.seedEntryState hook ──
@@ -1,5 +1,8 @@
1
1
  import { hashState, emptyState, addIdentity } from './field-identity.js';
2
- import { resolveExprIdentities, residualFlat, analyzeFunctionFieldIdentity } from './engine.js';
2
+ import {
3
+ resolveExprIdentities, residualFlat, analyzeFunctionFieldIdentity,
4
+ argumentContext, callInvocationId, calleeRecorder,
5
+ } from './engine.js';
3
6
  import { accessPathOf } from '../dataflow/access-paths.js';
4
7
  import { functionRecord } from '../ir/callgraph.js';
5
8
 
@@ -51,6 +54,13 @@ export class FieldIdentitySummaryCache {
51
54
  this._stack = new Set();
52
55
  this._contextsByQid = new Map();
53
56
  this._maxContextsPerFn = maxContextsPerFn;
57
+ // DAG-07 R07.3: degradations and unfinished refinements, recorded so a
58
+ // caller can tell a precise result from one that fell back. Append-only.
59
+ this._notes = [];
60
+ }
61
+
62
+ completionNotes() {
63
+ return this._notes.slice();
54
64
  }
55
65
 
56
66
  _key(qid, entryState) {
@@ -74,6 +84,21 @@ export class FieldIdentitySummaryCache {
74
84
  }
75
85
 
76
86
  compute(qid, entryState, analyzeFn) {
87
+ // While `qid` is marked by `refreshing()` (SCC solver only, driver.js): re-run `analyzeFn` for an
88
+ // entry that is ALREADY seeded in the cache, without the recursion-stub
89
+ // stack and without the bounded refinement loop. A self-call or a call
90
+ // to another member of the same component inside `analyzeFn` therefore
91
+ // reads the previous round's cached summary instead of a bottom stub.
92
+ // Never marked => behavior is byte-identical to before. The mark lives on
93
+ // the cache (not an argument) so wrappers around compute() stay valid.
94
+ if (this._refreshing && this._refreshing.get(qid) === hashState(entryState)) {
95
+ // Consume the mark: a nested call to the same context (a self-call)
96
+ // must take the normal path and hit the cached previous-round summary.
97
+ this._refreshing.delete(qid);
98
+ const refreshed = analyzeFn(entryState);
99
+ this.set(qid, entryState, refreshed);
100
+ return refreshed;
101
+ }
77
102
  // Ordering matches dataflow/summaries.js's own compute() exactly:
78
103
  // cache-hit checked FIRST, before the `_stack`-based recursion guard.
79
104
  // This is load-bearing, not incidental — B5's whole point is that a
@@ -130,6 +155,7 @@ export class FieldIdentitySummaryCache {
130
155
  // every later reader of the empty-entry context too.
131
156
  const base = this._cache.get(this._key(qid, emptyState())) ?? emptyFieldSummary();
132
157
  const fallback = { ...base, degradedReason: 'context-cap' };
158
+ this._notes.push({ qid, reason: 'context-cap' });
133
159
  this.set(qid, entryState, fallback);
134
160
  return fallback;
135
161
  }
@@ -219,12 +245,16 @@ export class FieldIdentitySummaryCache {
219
245
  // round) mirrors dataflow/summaries.js's own compute() exactly.
220
246
  if (this._hitRecursion) {
221
247
  const FP_MAX = 3;
248
+ let converged = false;
222
249
  for (let fp = 0; fp < FP_MAX; fp++) {
223
250
  const prev = summary;
224
251
  summary = analyzeFn(entryState);
225
- if (fieldSummaryEq(prev, summary)) break;
252
+ if (fieldSummaryEq(prev, summary)) { converged = true; break; }
226
253
  this.set(qid, entryState, summary);
227
254
  }
255
+ // R07.3/R01.5: still changing after the last round means the
256
+ // summary is an under-approximation, not a fixed point.
257
+ if (!converged) this._notes.push({ qid, reason: 'recursion-nonconverged', rounds: FP_MAX });
228
258
  }
229
259
  // Defensive strip, mirroring dataflow's own equivalent: analyzeFn's
230
260
  // real return shape here never actually carries `_recursive` under
@@ -238,6 +268,28 @@ export class FieldIdentitySummaryCache {
238
268
  }
239
269
  }
240
270
 
271
+ // Marks `qid` as being refreshed by the SCC solver for the duration of `fn`.
272
+ // Only the exact `entryState` context is refreshed; any other context of
273
+ // `qid` reached meanwhile (e.g. a call-site argument state) takes the normal
274
+ // guarded path, so recursion through it still terminates.
275
+ refreshing(qid, entryState, fn) {
276
+ (this._refreshing ||= new Map()).set(qid, hashState(entryState));
277
+ try { return fn(); } finally { this._refreshing.delete(qid); }
278
+ }
279
+
280
+ // Forgets every cached context of `qid` except those whose state hash is in
281
+ // `keepHashes` (a Set). Used by the SCC solver between rounds so a context
282
+ // computed against an earlier round's summaries is never reused.
283
+ dropContexts(qid, keepHashes = new Set()) {
284
+ const seen = this._contextsByQid.get(qid);
285
+ if (!seen) return;
286
+ for (const h of [...seen]) {
287
+ if (keepHashes.has(h)) continue;
288
+ this._cache.delete(`${qid}::${h}`);
289
+ seen.delete(h);
290
+ }
291
+ }
292
+
241
293
  size() {
242
294
  return this._cache.size;
243
295
  }
@@ -326,11 +378,11 @@ export function entryStateFromCall(paramNames, callArgs, callerState, ctx) {
326
378
  // call site) is load-bearing, not stylistic — it means no future second
327
379
  // caller of entryStateFromCall can reintroduce the hazard by passing the
328
380
  // full ctx through a different path.
329
- const argCtx = ctx?.recordHop ? { recordHop: ctx.recordHop } : undefined;
330
381
  let entryState = emptyState();
331
382
  const n = Math.min(paramNames.length, callArgs.length);
332
383
  for (let i = 0; i < n; i++) {
333
384
  const paramName = paramNames[i];
385
+ const argCtx = argumentContext(ctx, i);
334
386
  const resolved = resolveExprIdentities(callerState, callArgs[i], argCtx);
335
387
  const residual = residualFlat(resolved.flat, resolved.byPath);
336
388
  for (const id of residual) entryState = addIdentity(entryState, paramName, id);
@@ -409,6 +461,12 @@ export function createCallSummaryResolver(cache, lookupCallee) {
409
461
  fromPath: null, toPath: path, dataElementId: id,
410
462
  syntacticPath: null, widenReason: null, lossReason: null,
411
463
  peerScope: qid, peerContext: calleeContext,
464
+ ...(ctx.correlationVersion === 2 ? {
465
+ callRole: 'bind', invocationId: callInvocationId(ctx),
466
+ formalPosition: fn.params.indexOf(path.split('.')[0]),
467
+ relPath: path.includes('.') ? path.slice(path.indexOf('.') + 1) : '',
468
+ toVersion: 'entry',
469
+ } : null),
412
470
  });
413
471
  }
414
472
  }
@@ -444,7 +502,11 @@ export function createCallSummaryResolver(cache, lookupCallee) {
444
502
  // deepest call always wins). Passing a `context` field here would be
445
503
  // silently overwritten and is dead code.
446
504
  const calleeCtx = ctx?.recordHop
447
- ? { resolveCallSummary, recordHop: ctx.recordHop }
505
+ ? {
506
+ resolveCallSummary,
507
+ recordHop: ctx.correlationVersion === 2 ? calleeRecorder(ctx) : ctx.recordHop,
508
+ ...(ctx.correlationVersion === 2 ? { correlationVersion: 2 } : null),
509
+ }
448
510
  : { resolveCallSummary };
449
511
  const result = analyzeFunctionFieldIdentity(fn, es, calleeCtx);
450
512
  return summaryFromAnalysisResult(result);
@@ -0,0 +1,130 @@
1
+ //
2
+ // value-versions.js: SSA-like definition identities for the lineage engine
3
+ // (DAG-03, R03.3).
4
+ //
5
+ // A reaching-definitions pass over one function's CFG that answers: "which
6
+ // definitions of this state path can reach the entry of this CFG node?" The
7
+ // answer is a SET, so a merge keeps both alternatives (a phi) instead of
8
+ // pretending they co-occur or picking one, and a later strong write kills the
9
+ // earlier definition of that path and everything under it, whether or not
10
+ // the written value carried any identity (a clean literal is still a kill).
11
+ //
12
+ // Definition ids are the CFG node ids of the writing `assign`, plus the
13
+ // reserved id 'entry' for a value that was live on function entry (a
14
+ // parameter, a seeded source, anything no assignment has overwritten).
15
+ //
16
+ // The pass is purely structural. It does not read or change the field
17
+ // identity state, so it cannot alter an analysis result; it only labels the
18
+ // provenance halves the engine emits when correlation version 2 is on.
19
+ //
20
+ export const ENTRY_VERSION = 'entry';
21
+
22
+ function nodeEntries(nodes) {
23
+ if (nodes instanceof Map) return [...nodes.entries()];
24
+ return Object.entries(nodes ?? {});
25
+ }
26
+
27
+ function pathHasWildcard(path) {
28
+ return path.split('.').includes('*');
29
+ }
30
+
31
+ function definitePrefix(path) {
32
+ const segs = path.split('.');
33
+ const idx = segs.indexOf('*');
34
+ if (idx <= 0) return null;
35
+ return segs.slice(0, idx).join('.');
36
+ }
37
+
38
+ /** The definitions recorded for the longest self-or-ancestor key of `path` in `st`, or {entry}. */
39
+ function lookup(st, path) {
40
+ let p = path;
41
+ for (;;) {
42
+ const hit = st.get(p);
43
+ if (hit) return hit;
44
+ const i = p.lastIndexOf('.');
45
+ if (i === -1) return null;
46
+ p = p.slice(0, i);
47
+ }
48
+ }
49
+
50
+ function join(a, b) {
51
+ const out = new Map();
52
+ const keys = new Set([...a.keys(), ...b.keys()]);
53
+ for (const k of keys) {
54
+ const sa = a.get(k) ?? lookup(a, k) ?? new Set([ENTRY_VERSION]);
55
+ const sb = b.get(k) ?? lookup(b, k) ?? new Set([ENTRY_VERSION]);
56
+ out.set(k, new Set([...sa, ...sb]));
57
+ }
58
+ return out;
59
+ }
60
+
61
+ function equal(a, b) {
62
+ if (a.size !== b.size) return false;
63
+ for (const [k, sa] of a) {
64
+ const sb = b.get(k);
65
+ if (!sb || sb.size !== sa.size) return false;
66
+ for (const d of sa) if (!sb.has(d)) return false;
67
+ }
68
+ return true;
69
+ }
70
+
71
+ function transfer(nid, node, st) {
72
+ if (node.kind !== 'assign' || typeof node.target !== 'string') return st;
73
+ const out = new Map(st);
74
+ if (pathHasWildcard(node.target)) {
75
+ // A computed-key write is a WEAK update: it adds a definition to the
76
+ // container and kills nothing.
77
+ const container = definitePrefix(node.target);
78
+ if (container) {
79
+ const prior = out.get(container) ?? lookup(st, container) ?? new Set([ENTRY_VERSION]);
80
+ out.set(container, new Set([...prior, nid]));
81
+ }
82
+ return out;
83
+ }
84
+ const t = node.target;
85
+ for (const k of [...out.keys()]) {
86
+ if (k === t || k.startsWith(`${t}.`)) out.delete(k);
87
+ }
88
+ out.set(t, new Set([nid]));
89
+ return out;
90
+ }
91
+
92
+ /**
93
+ * @param {{cfg:{entry:string, nodes:Map|object}, params?:string[]}} fn
94
+ * @returns {{versionsAt:(nodeId:string, path:string)=>string[]}}
95
+ */
96
+ export function computeReachingDefinitions(fn) {
97
+ const nodes = new Map(nodeEntries(fn.cfg?.nodes));
98
+ const inStates = new Map();
99
+ const entry = new Map();
100
+ for (const p of fn.params ?? []) entry.set(p, new Set([ENTRY_VERSION]));
101
+ inStates.set(fn.cfg.entry, entry);
102
+ const work = [fn.cfg.entry];
103
+ const outStates = new Map();
104
+ let guard = 0;
105
+ const cap = Math.max(10000, nodes.size * 64);
106
+ while (work.length && guard++ < cap) {
107
+ const nid = work.shift();
108
+ const node = nodes.get(nid);
109
+ if (!node) continue;
110
+ const out = transfer(nid, node, inStates.get(nid) ?? new Map());
111
+ const prev = outStates.get(nid);
112
+ if (prev && equal(prev, out)) continue;
113
+ outStates.set(nid, out);
114
+ for (const s of node.succ ?? []) {
115
+ const pi = inStates.get(s);
116
+ const ni = pi ? join(pi, out) : out;
117
+ if (!pi || !equal(pi, ni)) {
118
+ inStates.set(s, ni);
119
+ work.push(s);
120
+ }
121
+ }
122
+ }
123
+ return {
124
+ versionsAt(nodeId, path) {
125
+ const st = inStates.get(nodeId);
126
+ const hit = st ? lookup(st, path) : null;
127
+ return hit ? [...hit].sort() : [ENTRY_VERSION];
128
+ },
129
+ };
130
+ }