@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
@@ -0,0 +1,529 @@
1
+ //
2
+ // path-predicates.js: branch conditions carried with a witness (DAG-04, R04.1 to R04.4).
3
+ //
4
+ // A reconstructed witness is a chain of provenance edges. Each edge was emitted at one CFG node of one
5
+ // function (`edge.scope`, `edge.siteNodeId`). This module answers, for every edge on a witness:
6
+ // * which branch conditions MUST hold for that node to execute (predicate references, with branch
7
+ // polarity and the value versions of the variables the condition reads), and
8
+ // * whether the conjunction of those conditions along the whole witness can hold at all.
9
+ //
10
+ // Rules this module keeps:
11
+ // * A node is guarded by (branch, polarity) only when that arm's first node DOMINATES it and has the
12
+ // branch as its only predecessor. A node after a join is guarded by neither arm. So alternatives are
13
+ // never conjoined by construction: the two arms of one branch cannot both guard the same node.
14
+ // * One witness is one execution path. If it needs both arms of one branch in one frame, that is a
15
+ // reported contradiction (`mutually-exclusive-branches`), not a silent conjunction. In a loop the
16
+ // two arms may belong to different iterations, so the answer there is `unknown`.
17
+ // * Different witnesses are never merged: each carries its own conjunction (alternatives stay distinct).
18
+ // * A disjunction inside one condition (`a || b`) is kept as distinct alternatives (DNF, bounded).
19
+ // * Every variable is identified by (call frame, name, reaching-definition versions), so a reassigned
20
+ // variable is a different value, and a callee parameter is tied to the caller's argument only through
21
+ // an explicit call binding.
22
+ // * Existing bounded abstract domains first: `numeric-domain.js` intervals narrowed through
23
+ // `symbolic-exec.js` `assume`, plus equality on constants and boolean atoms. No solver is needed.
24
+ //
25
+ // Result vocabulary (R04.4): feasible-model, infeasible-under-model, unknown, unsupported, timeout. Every
26
+ // result carries the semantics version and its limitations. SATISFIABILITY UNDER THIS MODEL DOES NOT IMPLY
27
+ // A WORKING EXPLOIT, and `infeasible-under-model` is relative to the model's stated limits, not a proof
28
+ // about the running program.
29
+ //
30
+ // Pure: no filesystem, no engine imports.
31
+
32
+ import crypto from 'node:crypto';
33
+ import { computeDominators } from '../ir/ssa.js';
34
+ import { accessPathOf } from '../dataflow/access-paths.js';
35
+ import * as N from '../dataflow/numeric-domain.js';
36
+ import { assume, newState } from '../dataflow/symbolic-exec.js';
37
+ import { computeReachingDefinitions, ENTRY_VERSION } from './value-versions.js';
38
+ import { exprRoots, walkExpr } from './source-seeding.js';
39
+
40
+ export const PATH_PREDICATE_VERSION = 'path-predicates/1';
41
+ export const FEASIBILITY_RESULTS = Object.freeze(['feasible-model', 'infeasible-under-model', 'unknown', 'unsupported', 'timeout']);
42
+ export const SATISFIABILITY_NOTE = 'Satisfiability under this model does not imply a working exploit.';
43
+
44
+ export const PREDICATE_LIMITS = Object.freeze({ maxAlternativesPerClause: 16, maxCombinations: 256, maxClauses: 256 });
45
+
46
+ const BASE_LIMITATIONS = Object.freeze([
47
+ 'Bounded abstract domains only: numeric intervals, equality against constants and boolean atoms.',
48
+ 'A condition outside those domains is recorded as an unmodeled predicate and never decides feasibility.',
49
+ 'Mutation of a variable through a callee, an alias or a closure is not modeled.',
50
+ 'infeasible-under-model is relative to this model, not a proof about the running program.',
51
+ ]);
52
+
53
+ const REFLECTIVE_CALLEES = new Set([
54
+ 'eval', 'Function', 'Reflect.apply', 'Reflect.get', 'Reflect.set', 'Reflect.construct', 'getattr', 'setattr', '__import__',
55
+ 'importlib.import_module', 'Class.forName', 'invoke', 'instance_eval', 'class_eval', 'public_send', 'constantize', 'vm.runInContext', 'vm.runInNewContext',
56
+ ]);
57
+
58
+ const NUM_OPS = new Set(['<', '<=', '>', '>=', '==', '===', '!=', '!==']);
59
+ const NEG_OP = { '<': '>=', '<=': '>', '>': '<=', '>=': '<', '==': '!=', '===': '!==', '!=': '==', '!==': '===' };
60
+
61
+ const sha12 = (s) => crypto.createHash('sha256').update(s).digest('hex').slice(0, 12);
62
+ const arr = (v) => (Array.isArray(v) ? v : []);
63
+ const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
64
+
65
+ // ---- CFG helpers -------------------------------------------------------------------------------------
66
+
67
+ const _cfgCache = new WeakMap();
68
+
69
+ function cfgInfo(fn) {
70
+ const cfg = fn?.cfg;
71
+ if (!cfg || !cfg.nodes) return null;
72
+ let info = _cfgCache.get(cfg);
73
+ if (info) return info;
74
+ const nodesObj = cfg.nodes instanceof Map ? Object.fromEntries(cfg.nodes) : cfg.nodes;
75
+ const normalized = { entry: cfg.entry, nodes: nodesObj };
76
+ const preds = new Map(Object.keys(nodesObj).map((k) => [k, []]));
77
+ for (const [id, n] of Object.entries(nodesObj)) for (const s of arr(n.succ)) if (preds.has(s)) preds.get(s).push(id);
78
+ info = { nodes: nodesObj, entry: cfg.entry, preds, dom: computeDominators(normalized), rd: null, reach: new Map() };
79
+ _cfgCache.set(cfg, info);
80
+ return info;
81
+ }
82
+
83
+ function reachingDefs(fn, info) {
84
+ if (!info.rd) info.rd = computeReachingDefinitions({ cfg: { entry: info.entry, nodes: info.nodes }, params: fn.params ?? [] });
85
+ return info.rd;
86
+ }
87
+
88
+ /** Can `to` be reached from `from` by following successors (used to detect "this branch is inside a loop"). */
89
+ function reachableFrom(info, from, to) {
90
+ const key = `${from}>${to}`;
91
+ if (info.reach.has(key)) return info.reach.get(key);
92
+ const seen = new Set(); const stack = [...arr(info.nodes[from]?.succ)]; let found = false;
93
+ while (stack.length && !found) {
94
+ const n = stack.pop();
95
+ if (n === to) { found = true; break; }
96
+ if (seen.has(n)) continue;
97
+ seen.add(n);
98
+ for (const s of arr(info.nodes[n]?.succ)) stack.push(s);
99
+ }
100
+ info.reach.set(key, found);
101
+ return found;
102
+ }
103
+
104
+ /**
105
+ * Branch arms guarding a CFG node: [{ branchNodeId, polarity: true|false }]. Sound for "must hold": an arm
106
+ * guards only when it dominates the node and has the branch as its only predecessor.
107
+ */
108
+ export function guardsFor(fn, nodeId) {
109
+ const info = cfgInfo(fn);
110
+ if (!info || !info.nodes[nodeId]) return [];
111
+ const dom = info.dom.get(nodeId);
112
+ if (!dom) return [];
113
+ const out = [];
114
+ for (const [bid, b] of Object.entries(info.nodes)) {
115
+ if (b.kind !== 'if' || bid === nodeId) continue;
116
+ const succ = arr(b.succ);
117
+ if (succ.length !== 2 || succ[0] === succ[1]) continue;
118
+ for (const [idx, polarity] of [[0, true], [1, false]]) {
119
+ const arm = succ[idx];
120
+ if (!dom.has(arm)) continue;
121
+ const armPreds = info.preds.get(arm) ?? [];
122
+ if (armPreds.length === 1 && armPreds[0] === bid) out.push({ branchNodeId: bid, polarity });
123
+ }
124
+ }
125
+ return out.sort((a, b) => (a.branchNodeId < b.branchNodeId ? -1 : a.branchNodeId > b.branchNodeId ? 1 : a.polarity ? -1 : 1));
126
+ }
127
+
128
+ // ---- conditions ----------------------------------------------------------------------------------------
129
+
130
+ function renderExpr(e) {
131
+ if (!e || typeof e !== 'object') return '?';
132
+ switch (e.kind) {
133
+ case 'ident': return e.name;
134
+ case 'literal': return typeof e.value === 'string' ? JSON.stringify(e.value) : String(e.value);
135
+ case 'member': { const o = renderExpr(e.object); return `${o}.${e.prop}`; }
136
+ case 'binary': case 'logical': return `(${renderExpr(e.left)} ${e.op} ${renderExpr(e.right)})`;
137
+ case 'unary': case 'unary-not': return `!${renderExpr(e.operand ?? e.argument)}`;
138
+ default: return e.kind ?? '?';
139
+ }
140
+ }
141
+
142
+ /** The variable a leaf expression reads, or null. `scopeCtx.versionsOf(path)` gives its reaching definitions. */
143
+ function varOf(e, scopeCtx) {
144
+ if (!e) return null;
145
+ if (e.kind === 'ident' || e.kind === 'member') {
146
+ const p = e.kind === 'ident' ? e.name : accessPathOf(e);
147
+ if (typeof p !== 'string' || !p) return null;
148
+ return { frame: scopeCtx.frame, name: p, ver: scopeCtx.versionsOf(p).join(',') };
149
+ }
150
+ return null;
151
+ }
152
+
153
+ export const varKey = (v) => `${v.frame}|${v.name}@${v.ver}`;
154
+
155
+ const TRUE_ALTS = [[]];
156
+ const FALSE_ALTS = [];
157
+
158
+ /**
159
+ * Condition to disjunctive normal form: an array of alternatives, each a conjunction (array) of literals.
160
+ * `[[]]` is true, `[]` is false. Bounded: an expansion beyond the cap collapses to one unmodeled literal.
161
+ */
162
+ export function condToDnf(expr, polarity, scopeCtx) {
163
+ const tooBig = (alts) => alts.length > PREDICATE_LIMITS.maxAlternativesPerClause;
164
+ const opaque = () => [[{ t: 'opaque', text: renderExpr(expr).slice(0, 120), polarity }]];
165
+ if (!expr) return opaque();
166
+ if (expr.kind === 'logical' && (expr.op === '&&' || expr.op === '||')) {
167
+ const conj = (expr.op === '&&') === polarity; // && true, or || false: both operands must hold (in their polarity)
168
+ const l = condToDnf(expr.left, polarity, scopeCtx);
169
+ const r = condToDnf(expr.right, polarity, scopeCtx);
170
+ if (conj) {
171
+ const out = [];
172
+ for (const a of l) for (const b of r) out.push([...a, ...b]);
173
+ return tooBig(out) ? opaque() : out;
174
+ }
175
+ const out = [...l, ...r];
176
+ return tooBig(out) ? opaque() : out;
177
+ }
178
+ if (expr.kind === 'unary' || expr.kind === 'unary-not') {
179
+ if (expr.kind === 'unary-not' || expr.op === '!') return condToDnf(expr.operand ?? expr.argument, !polarity, scopeCtx);
180
+ return opaque();
181
+ }
182
+ if (expr.kind === 'literal') {
183
+ const truthy = Boolean(expr.value);
184
+ return truthy === polarity ? TRUE_ALTS.map((a) => a.slice()) : FALSE_ALTS.slice();
185
+ }
186
+ if ((expr.kind === 'binary' || expr.kind === 'bin') && NUM_OPS.has(expr.op)) {
187
+ const op = polarity ? expr.op : NEG_OP[expr.op];
188
+ const lv = varOf(expr.left, scopeCtx); const rv = varOf(expr.right, scopeCtx);
189
+ const lc = expr.left?.kind === 'literal' ? expr.left.value : undefined;
190
+ const rc = expr.right?.kind === 'literal' ? expr.right.value : undefined;
191
+ const isNum = (x) => typeof x === 'number' && Number.isFinite(x);
192
+ if ((lv || isNum(lc)) && (rv || isNum(rc)) && (lv || rv)) {
193
+ return [[{ t: 'num', op, left: lv ? { v: lv } : { c: lc }, right: rv ? { v: rv } : { c: rc }, text: renderExpr(expr).slice(0, 120) }]];
194
+ }
195
+ const strEq = ['==', '===', '!=', '!=='].includes(expr.op);
196
+ if (strEq && ((lv && typeof rc === 'string') || (rv && typeof lc === 'string'))) {
197
+ const v = lv ?? rv; const value = typeof rc === 'string' ? rc : lc;
198
+ const equal = expr.op === '==' || expr.op === '===';
199
+ return [[{ t: 'eq', v, value, equal: polarity ? equal : !equal, text: renderExpr(expr).slice(0, 120) }]];
200
+ }
201
+ return opaque();
202
+ }
203
+ const v = varOf(expr, scopeCtx);
204
+ if (v) return [[{ t: 'bool', v, truth: polarity, text: renderExpr(expr).slice(0, 120) }]];
205
+ return opaque();
206
+ }
207
+
208
+ // ---- collection ----------------------------------------------------------------------------------------
209
+
210
+ function calleeName(callee) {
211
+ if (typeof callee === 'string') return callee;
212
+ if (!callee) return null;
213
+ if (callee.kind === 'ident') return callee.name;
214
+ if (callee.kind === 'member') { const o = calleeName(callee.object); return o ? `${o}.${callee.prop}` : callee.prop; }
215
+ return null;
216
+ }
217
+
218
+ function callsAt(node) {
219
+ const calls = [];
220
+ if (!node) return calls;
221
+ for (const root of exprRoots(node)) walkExpr(root, (e) => { if (e.kind === 'call') calls.push(e); });
222
+ if (node.kind === 'call' && node.callee) calls.unshift({ kind: 'call', callee: node.callee, args: node.args ?? [] });
223
+ return calls;
224
+ }
225
+
226
+ /**
227
+ * Collect the conditions a witness requires.
228
+ *
229
+ * @param {object[]} hops `path.hops` of a reconstructed path (source first)
230
+ * @param {{fnOf:(qid:string)=>object|null}} o
231
+ * @returns {object} { semanticsVersion, frames, clauses, edgeGuards, bindings, unsupported, limitations }
232
+ */
233
+ export function collectWitnessConditions(hops, { fnOf } = {}) {
234
+ const frames = []; // { id, scope, context }
235
+ const stack = []; // indices into frames (the active call stack)
236
+ const clauses = new Map();
237
+ const edgeGuards = {};
238
+ const bindings = [];
239
+ const unsupported = [];
240
+ const limitations = [...BASE_LIMITATIONS];
241
+ const lookup = typeof fnOf === 'function' ? fnOf : () => null;
242
+ let pendingCall = null; // call-arg-bind seen on the previous hop
243
+
244
+ const hopList = arr(hops);
245
+ for (let i = 0; i < hopList.length; i++) {
246
+ const h = hopList[i];
247
+ const scope = h.scope;
248
+ // ---- frame tracking: a scope change is a call (push) or a return to an open frame (pop) ----
249
+ const cur = stack.length ? frames[stack[stack.length - 1]] : null;
250
+ if (!cur || cur.scope !== scope) {
251
+ const parentIdx = stack.length >= 2 ? stack[stack.length - 2] : -1;
252
+ if (parentIdx >= 0 && frames[parentIdx].scope === scope) stack.pop();
253
+ else { frames.push({ id: `f${frames.length}`, scope, context: h.context ?? null }); stack.push(frames.length - 1); }
254
+ }
255
+ const frame = frames[stack[stack.length - 1]];
256
+ const fn = lookup(scope);
257
+ const info = fn ? cfgInfo(fn) : null;
258
+ if (!info) {
259
+ unsupported.push({ reason: 'no-cfg', scope, edgeId: h.edgeId ?? null });
260
+ continue;
261
+ }
262
+ const node = info.nodes[h.siteNodeId];
263
+
264
+ // ---- pending call binding: the previous hop bound arguments and this hop is in the callee ----
265
+ if (pendingCall && pendingCall.callerFrameId !== frame.id && pendingCall.calleeScope === scope) {
266
+ const callee = fn;
267
+ const callerFrame = frames.find((f) => f.id === pendingCall.callerFrameId);
268
+ for (const [idx, p] of arr(callee.params).entries()) {
269
+ const a = pendingCall.args[idx];
270
+ if (!a) continue;
271
+ if (a.kind === 'literal' && (typeof a.value === 'number' || typeof a.value === 'string')) {
272
+ bindings.push({ calleeFrame: frame.id, param: p, const: a.value });
273
+ } else {
274
+ const ap = a.kind === 'ident' ? a.name : a.kind === 'member' ? accessPathOf(a) : null;
275
+ if (ap && callerFrame) {
276
+ bindings.push({ calleeFrame: frame.id, param: p, callerFrame: callerFrame.id, arg: ap, argVer: pendingCall.argVersions(ap) });
277
+ }
278
+ }
279
+ }
280
+ pendingCall = null;
281
+ }
282
+ if ((h.outSubKind === 'call-arg-bind' || h.inSubKind === 'call-arg-bind') && node) {
283
+ const calls = callsAt(node);
284
+ const rd = reachingDefs(fn, info);
285
+ pendingCall = {
286
+ callerFrameId: frame.id,
287
+ calleeScope: null,
288
+ calls,
289
+ args: [],
290
+ argVersions: (p) => rd.versionsAt(h.siteNodeId, p).join(','),
291
+ };
292
+ // The callee scope is the next hop whose scope differs; resolve it lazily by looking ahead.
293
+ const next = hopList.slice(i + 1).find((x) => x.scope !== scope);
294
+ if (next) {
295
+ pendingCall.calleeScope = next.scope;
296
+ const calleeFn = lookup(next.scope);
297
+ const nm = calleeFn?.name;
298
+ const match = calls.find((c) => {
299
+ const n = calleeName(c.callee);
300
+ return n && nm && (n === nm || n.endsWith(`.${nm}`));
301
+ }) ?? (calls.length === 1 ? calls[0] : null);
302
+ pendingCall.args = match ? arr(match.args) : [];
303
+ } else pendingCall = null;
304
+ }
305
+
306
+ // ---- reflective dispatch anywhere in this frame's function could rewrite any local: not assessable ----
307
+ if (!frame.reflectiveChecked) {
308
+ frame.reflectiveChecked = true;
309
+ for (const [nid, n] of Object.entries(info.nodes)) {
310
+ for (const c of callsAt(n)) {
311
+ const nm = calleeName(c.callee);
312
+ if (nm && REFLECTIVE_CALLEES.has(nm)) unsupported.push({ reason: 'reflective-dispatch', callee: nm, scope, nodeId: nid, edgeId: h.edgeId ?? null });
313
+ }
314
+ }
315
+ }
316
+
317
+ // ---- guards for this site ----
318
+ const rd = reachingDefs(fn, info);
319
+ const refsHere = [];
320
+ for (const g of guardsFor(fn, h.siteNodeId)) {
321
+ const bnode = info.nodes[g.branchNodeId];
322
+ const key = `${frame.id}|${g.branchNodeId}|${g.polarity}`;
323
+ let clause = clauses.get(key);
324
+ if (!clause) {
325
+ const scopeCtx = { frame: frame.id, versionsOf: (p) => rd.versionsAt(g.branchNodeId, p) };
326
+ const alts = condToDnf(bnode.cond ?? bnode.condition, g.polarity, scopeCtx);
327
+ clause = {
328
+ ref: `pred:${sha12(`${frame.id}|${scope}|${g.branchNodeId}|${g.polarity}`)}`,
329
+ frame: frame.id, scope, branchNodeId: g.branchNodeId, polarity: g.polarity,
330
+ text: renderExpr(bnode.cond ?? bnode.condition).slice(0, 160),
331
+ line: bnode.line ?? null,
332
+ alts, inLoop: reachableFrom(info, g.branchNodeId, g.branchNodeId),
333
+ atEdgeIds: [],
334
+ versions: [...new Set(alts.flat().flatMap((l) => (l.t === 'num' ? [l.left.v, l.right.v] : l.v ? [l.v] : [])).filter(Boolean).map((v) => `${v.name}@${v.ver}`))].sort(),
335
+ consts: {},
336
+ };
337
+ // A definition inside the loop that also feeds this branch produces a NEW value every iteration, so the
338
+ // same definition id does not mean the same value across iterations. Make such variables clause-local.
339
+ if (clause.inLoop) {
340
+ for (const l of clause.alts.flat()) {
341
+ for (const v of (l.t === 'num' ? [l.left.v, l.right.v] : l.v ? [l.v] : [])) {
342
+ if (!v) continue;
343
+ const vers = rd.versionsAt(g.branchNodeId, v.name);
344
+ if (vers.some((x) => x !== ENTRY_VERSION && info.nodes[x] && reachableFrom(info, x, g.branchNodeId) && reachableFrom(info, g.branchNodeId, x))) v.ver = `${v.ver}#iter:${clause.ref}`;
345
+ }
346
+ }
347
+ clause.versions = [...new Set(clause.alts.flat().flatMap((l) => (l.t === 'num' ? [l.left.v, l.right.v] : l.v ? [l.v] : [])).filter(Boolean).map((v) => `${v.name}@${v.ver}`))].sort();
348
+ }
349
+ // Constants established by a single reaching assignment of a literal.
350
+ for (const l of clause.alts.flat()) {
351
+ for (const v of (l.t === 'num' ? [l.left.v, l.right.v] : l.v ? [l.v] : [])) {
352
+ if (!v) continue;
353
+ const vers = rd.versionsAt(g.branchNodeId, v.name);
354
+ if (vers.length === 1 && vers[0] !== ENTRY_VERSION) {
355
+ const def = info.nodes[vers[0]];
356
+ if (def?.kind === 'assign' && def.source?.kind === 'literal' && (typeof def.source.value === 'number' || typeof def.source.value === 'string')) clause.consts[varKey(v)] = def.source.value;
357
+ }
358
+ }
359
+ }
360
+ clauses.set(key, clause);
361
+ }
362
+ if (h.edgeId && !clause.atEdgeIds.includes(h.edgeId)) clause.atEdgeIds.push(h.edgeId);
363
+ refsHere.push({ ref: clause.ref, polarity: g.polarity, branchNodeId: g.branchNodeId, versions: clause.versions });
364
+ }
365
+ if (h.edgeId) edgeGuards[h.edgeId] = refsHere;
366
+ }
367
+ if (clauses.size > PREDICATE_LIMITS.maxClauses) limitations.push(`more than ${PREDICATE_LIMITS.maxClauses} guarding branches; evaluation is bounded`);
368
+ return {
369
+ semanticsVersion: PATH_PREDICATE_VERSION,
370
+ frames, clauses: [...clauses.values()], edgeGuards, bindings, unsupported, limitations,
371
+ };
372
+ }
373
+
374
+ // ---- evaluation ----------------------------------------------------------------------------------------
375
+
376
+ class Aliases {
377
+ constructor() { this.p = new Map(); }
378
+ find(k) { let r = k; while (this.p.has(r) && this.p.get(r) !== r) r = this.p.get(r); return r; }
379
+ union(a, b) { const ra = this.find(a); const rb = this.find(b); if (ra !== rb) this.p.set(ra, rb); }
380
+ }
381
+
382
+ function aliasesOf(collected) {
383
+ const al = new Aliases();
384
+ const consts = new Map();
385
+ for (const b of arr(collected.bindings)) {
386
+ const pk = `${b.calleeFrame}|${b.param}@${ENTRY_VERSION}`;
387
+ if (b.const !== undefined) consts.set(pk, b.const);
388
+ else al.union(pk, `${b.callerFrame}|${b.arg}@${b.argVer}`);
389
+ }
390
+ for (const c of arr(collected.clauses)) for (const [k, v] of Object.entries(c.consts ?? {})) consts.set(k, v);
391
+ return { al, consts };
392
+ }
393
+
394
+ function checkCombo(literals, collected) {
395
+ const { al, consts } = aliasesOf(collected);
396
+ const canon = (v) => al.find(varKey(v));
397
+ const constOf = (v) => consts.get(canon(v)) ?? consts.get(varKey(v));
398
+ const state = newState();
399
+ for (const [k, v] of consts) if (typeof v === 'number') state.vars.set(al.find(k), N.constant(v));
400
+ const bools = new Map(); const eqs = new Map();
401
+ let unmodeled = 0; let bottom = false; const core = new Set();
402
+ for (const lit of literals) {
403
+ if (bottom) break;
404
+ if (lit.t === 'opaque') { unmodeled += 1; continue; }
405
+ if (lit.t === 'bool') {
406
+ const k = canon(lit.v);
407
+ if (bools.has(k) && bools.get(k).truth !== lit.truth) { bottom = true; core.add(lit.ref); core.add(bools.get(k).ref); } else bools.set(k, { truth: lit.truth, ref: lit.ref });
408
+ continue;
409
+ }
410
+ if (lit.t === 'eq') {
411
+ const k = canon(lit.v); const known = constOf(lit.v);
412
+ if (known !== undefined) {
413
+ if ((known === lit.value) !== lit.equal) { bottom = true; core.add(lit.ref); }
414
+ continue;
415
+ }
416
+ const e = eqs.get(k) ?? { eq: new Map(), ne: new Map() };
417
+ if (lit.equal) {
418
+ if ([...e.eq.keys()].some((x) => x !== lit.value) || e.ne.has(lit.value)) { bottom = true; core.add(lit.ref); for (const r of [...e.eq.values(), ...e.ne.values()]) core.add(r); }
419
+ e.eq.set(lit.value, lit.ref);
420
+ } else {
421
+ if (e.eq.has(lit.value)) { bottom = true; core.add(lit.ref); core.add(e.eq.get(lit.value)); }
422
+ e.ne.set(lit.value, lit.ref);
423
+ }
424
+ eqs.set(k, e);
425
+ continue;
426
+ }
427
+ if (lit.t === 'num') {
428
+ const side = (s) => (s.v ? { kind: 'ident', name: canon(s.v) } : { kind: 'literal', value: s.c });
429
+ const next = assume(state, { kind: 'bin', op: lit.op, left: side(lit.left), right: side(lit.right) });
430
+ if (next.bottom) { bottom = true; core.add(lit.ref); for (const l of literals) if (l.ref && l.t === 'num') core.add(l.ref); break; }
431
+ state.vars = next.vars;
432
+ }
433
+ }
434
+ return { bottom, unmodeled, core: [...core] };
435
+ }
436
+
437
+ /**
438
+ * Decide the conjunction of a witness's conditions under the bounded abstract model.
439
+ *
440
+ * @param {object} collected `collectWitnessConditions` result
441
+ * @param {object} [o] { maxCombinations, deadlineMs, now }
442
+ */
443
+ export function evaluateWitnessConditions(collected, o = {}) {
444
+ const now = o.now ?? Date.now;
445
+ const started = now();
446
+ const base = { semanticsVersion: PATH_PREDICATE_VERSION, satisfiabilityNote: SATISFIABILITY_NOTE, limitations: [...arr(collected.limitations)] };
447
+ const clauses = arr(collected.clauses);
448
+ const unmodeledRefs = [];
449
+
450
+ if (arr(collected.unsupported).length) {
451
+ const reasons = [...new Set(collected.unsupported.map((u) => u.reason))].sort();
452
+ return { ...base, status: 'unsupported', reasons, unsupported: collected.unsupported, contradiction: null, unmodeledPredicates: [], clauseRefs: clauses.map((c) => c.ref) };
453
+ }
454
+ if (!clauses.length) return { ...base, status: 'feasible-model', reasons: ['no-guarding-branches'], contradiction: null, unmodeledPredicates: [], clauseRefs: [] };
455
+
456
+ // One witness, one execution path: both arms of one branch in one frame cannot both hold.
457
+ const byBranch = new Map();
458
+ for (const c of clauses) {
459
+ const k = `${c.frame}|${c.branchNodeId}`;
460
+ if (!byBranch.has(k)) byBranch.set(k, []);
461
+ byBranch.get(k).push(c);
462
+ }
463
+ const looped = [];
464
+ for (const list of byBranch.values()) {
465
+ if (list.length === 2 && list[0].polarity !== list[1].polarity) {
466
+ if (list.some((c) => c.inLoop)) looped.push(...list.map((c) => c.ref));
467
+ else {
468
+ return { ...base, status: 'infeasible-under-model', reasons: ['mutually-exclusive-branches'], contradiction: { kind: 'mutually-exclusive-branches', refs: list.map((c) => c.ref).sort() }, unmodeledPredicates: [], clauseRefs: clauses.map((c) => c.ref) };
469
+ }
470
+ }
471
+ }
472
+
473
+ // Expand alternatives (distinct, bounded) into combinations; a witness is feasible when ANY combination holds.
474
+ let combos = [[]];
475
+ const maxCombos = o.maxCombinations ?? PREDICATE_LIMITS.maxCombinations;
476
+ for (const c of clauses) {
477
+ const tagged = c.alts.map((alt) => alt.map((l) => ({ ...l, ref: c.ref })));
478
+ const next = [];
479
+ for (const base0 of combos) for (const alt of tagged) { next.push([...base0, ...alt]); if (next.length > maxCombos) break; }
480
+ combos = next;
481
+ if (combos.length > maxCombos) {
482
+ return { ...base, status: 'unknown', reasons: ['alternative-explosion'], contradiction: null, unmodeledPredicates: [], clauseRefs: clauses.map((cl) => cl.ref), limitHit: 'maxCombinations' };
483
+ }
484
+ }
485
+ if (!combos.length) {
486
+ return { ...base, status: 'infeasible-under-model', reasons: ['constant-false-condition'], contradiction: { kind: 'constant-false-condition', refs: clauses.filter((c) => c.alts.length === 0).map((c) => c.ref).sort() }, unmodeledPredicates: [], clauseRefs: clauses.map((c) => c.ref) };
487
+ }
488
+ let best = null; const cores = [];
489
+ for (const combo of combos) {
490
+ if (o.deadlineMs !== undefined && now() - started > o.deadlineMs) {
491
+ return { ...base, status: 'timeout', reasons: ['deadline'], contradiction: null, unmodeledPredicates: [], clauseRefs: clauses.map((c) => c.ref) };
492
+ }
493
+ const r = checkCombo(combo, collected);
494
+ if (r.bottom) { cores.push(r.core); continue; }
495
+ if (!best || r.unmodeled < best.unmodeled) best = { ...r, combo };
496
+ }
497
+ if (!best) {
498
+ return { ...base, status: 'infeasible-under-model', reasons: ['contradictory-conditions'], contradiction: { kind: 'contradictory-conditions', refs: [...new Set(cores.flat())].sort() }, unmodeledPredicates: [], clauseRefs: clauses.map((c) => c.ref) };
499
+ }
500
+ for (const l of best.combo) if (l.t === 'opaque') unmodeledRefs.push(l.ref);
501
+ if (best.unmodeled > 0 || looped.length) {
502
+ const reasons = [];
503
+ if (best.unmodeled > 0) reasons.push('unmodeled-predicates');
504
+ if (looped.length) reasons.push('branch-arms-in-loop');
505
+ return { ...base, status: 'unknown', reasons, contradiction: null, unmodeledPredicates: [...new Set(unmodeledRefs)].sort(), clauseRefs: clauses.map((c) => c.ref) };
506
+ }
507
+ return { ...base, status: 'feasible-model', reasons: ['consistent-under-model'], contradiction: null, unmodeledPredicates: [], clauseRefs: clauses.map((c) => c.ref) };
508
+ }
509
+
510
+ /** A solver-neutral view of the collected conditions (see smt-orchestrator.js). Pure data. */
511
+ export function toSolverProblem(collected) {
512
+ const { al, consts } = aliasesOf(collected);
513
+ const canon = (v) => al.find(varKey(v));
514
+ const vars = new Set(); const assertions = [];
515
+ const nameOf = (v) => canon(v);
516
+ for (const c of arr(collected.clauses)) {
517
+ const alts = c.alts.map((alt) => alt.map((l) => {
518
+ if (l.t === 'num') {
519
+ for (const s of [l.left, l.right]) if (s.v) vars.add(nameOf(s.v));
520
+ return { t: 'num', op: l.op, left: l.left.v ? { var: nameOf(l.left.v) } : { const: l.left.c }, right: l.right.v ? { var: nameOf(l.right.v) } : { const: l.right.c } };
521
+ }
522
+ if (l.t === 'eq') { vars.add(nameOf(l.v)); return { t: 'eq', var: nameOf(l.v), value: l.value, equal: l.equal }; }
523
+ if (l.t === 'bool') { vars.add(nameOf(l.v)); return { t: 'bool', var: nameOf(l.v), truth: l.truth }; }
524
+ return { t: 'opaque', text: l.text };
525
+ }));
526
+ assertions.push({ ref: c.ref, alternatives: alts });
527
+ }
528
+ return { semanticsVersion: PATH_PREDICATE_VERSION, logic: 'QF_LIA+EUF', vars: [...vars].sort(), constants: Object.fromEntries([...consts].map(([k, v]) => [al.find(k), v])), assertions };
529
+ }