@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.
- package/CHANGELOG.md +29 -0
- package/bin/agentic-security.js +88 -0
- package/dist/{2a8456e0800aeee871b6.json → 0b301d45b231f144a1b4.json} +27 -9
- package/dist/1128.index.js +908 -0
- package/dist/1336.index.js +261 -112
- package/dist/1665.index.js +828 -0
- package/dist/2923.index.js +17 -9
- package/dist/9382.index.js +23 -2
- package/dist/9579.index.js +11 -4
- package/dist/agentic-security.mjs +15 -15
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/frontend/src/shell.js +12 -2
- package/package.json +27 -9
- package/src/context-intelligence/controls.js +15 -0
- package/src/context-intelligence/reachability.js +8 -2
- package/src/dataflow/CLAUDE.md +43 -1
- package/src/dataflow/backward.js +42 -7
- package/src/dataflow/cross-service-taint.js +83 -1
- package/src/dataflow/demand-query.js +929 -0
- package/src/dataflow/dep-fingerprint.js +718 -0
- package/src/dataflow/engine.js +48 -6
- 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/summaries.js +72 -6
- package/src/dataflow/tabulation.js +80 -24
- package/src/discovery/hunter.js +6 -2
- package/src/discovery/index.js +5 -2
- package/src/engine.js +63 -3
- 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/language/haskell-disclosure.js +13 -1
- package/src/lineage/CLAUDE.md +71 -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/observation-adapters.js +17 -9
- 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 +138 -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 +19 -0
- package/src/posture/CLAUDE.md +13 -0
- package/src/posture/evidence-bundle.js +1 -1
- package/src/posture/witness-bundle.js +573 -0
- package/src/posture/witness-runtime.js +276 -0
- 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
|
-
|
|
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 ──
|
package/src/lineage/summaries.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { hashState, emptyState, addIdentity } from './field-identity.js';
|
|
2
|
-
import {
|
|
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
|
-
? {
|
|
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
|
+
}
|