@clear-capabilities/agentic-security-scanner 0.145.0 → 0.147.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 +139 -0
- package/bin/agentic-security.js +3508 -69
- package/dist/1122.index.js +702 -0
- package/dist/{301.index.js → 1301.index.js} +2 -2
- package/dist/1379.index.js +591 -0
- package/dist/{444.index.js → 1444.index.js} +2 -2
- package/dist/{660.index.js → 1660.index.js} +2 -2
- package/dist/{700.index.js → 1700.index.js} +2 -2
- package/dist/{905.index.js → 1905.index.js} +3 -3
- package/dist/{920.index.js → 1920.index.js} +3 -3
- package/dist/{238.index.js → 2238.index.js} +3 -3
- package/dist/{271.index.js → 2271.index.js} +2 -2
- package/dist/{985.index.js → 2376.index.js} +1260 -340
- package/dist/2432.index.js +793 -0
- package/dist/2659.index.js +93 -0
- package/dist/{826.index.js → 2826.index.js} +2 -2
- package/dist/{830.index.js → 2830.index.js} +2 -2
- package/dist/2923.index.js +298 -0
- package/dist/{1.index.js → 3001.index.js} +5 -5
- package/dist/{117.index.js → 3117.index.js} +3 -3
- package/dist/3180.index.js +307 -0
- package/dist/3276.index.js +117 -0
- package/dist/{415.index.js → 3415.index.js} +2 -2
- package/dist/{499.index.js → 3499.index.js} +2 -2
- package/dist/3518.index.js +450 -0
- package/dist/{526.index.js → 3526.index.js} +5 -5
- package/dist/{736.index.js → 3736.index.js} +4 -4
- package/dist/{839.index.js → 3839.index.js} +4 -4
- package/dist/{113.index.js → 4113.index.js} +5 -5
- package/dist/{265.index.js → 4265.index.js} +2 -2
- package/dist/{384.index.js → 4384.index.js} +3 -3
- package/dist/4547.index.js +268 -0
- package/dist/4863.index.js +422 -0
- package/dist/{970.index.js → 4970.index.js} +2 -2
- package/dist/5051.index.js +770 -0
- package/dist/{144.index.js → 5144.index.js} +5 -5
- package/dist/{333.index.js → 5333.index.js} +3 -3
- package/dist/5343.index.js +185 -0
- package/dist/5350.index.js +866 -0
- package/dist/5561.index.js +436 -0
- package/dist/{637.index.js → 5637.index.js} +3 -3
- package/dist/{449.index.js → 5830.index.js} +2 -2
- package/dist/6626.index.js +532 -0
- package/dist/6662.index.js +297 -0
- package/dist/{675.index.js → 6675.index.js} +5 -5
- package/dist/{730.index.js → 6730.index.js} +6 -6
- package/dist/6829.index.js +225 -0
- package/dist/6944.index.js +130 -0
- package/dist/{178.index.js → 7178.index.js} +3 -3
- package/dist/{227.index.js → 7227.index.js} +2 -2
- package/dist/7310.index.js +520 -0
- package/dist/{552.index.js → 7552.index.js} +4 -4
- package/dist/7709.index.js +78 -0
- package/dist/8218.index.js +160 -0
- package/dist/{476.index.js → 8476.index.js} +4 -4
- package/dist/{513.index.js → 8513.index.js} +5 -5
- package/dist/{520.index.js → 8520.index.js} +2 -2
- package/dist/{718.index.js → 8718.index.js} +2 -2
- package/dist/{752.index.js → 8752.index.js} +2 -2
- package/dist/8846.index.js +100 -0
- package/dist/{435.index.js → 9091.index.js} +560 -189
- package/dist/{207.index.js → 9207.index.js} +2 -2
- package/dist/{220.index.js → 9220.index.js} +2 -2
- package/dist/9390.index.js +163 -0
- package/dist/{503.index.js → 9503.index.js} +2 -2
- package/dist/{801.index.js → 9801.index.js} +2 -2
- package/dist/{824.index.js → 9824.index.js} +2 -2
- package/dist/agentic-security.mjs +16 -16
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/compliance-frameworks/hipaa-security-rule.json +3 -2
- package/package.json +12 -5
- package/src/dataflow/CLAUDE.md +1 -1
- package/src/dataflow/catalog.js +42 -0
- package/src/dataflow/orm-write-catalog.js +175 -0
- package/src/engine.js +92 -1
- package/src/ir/CLAUDE.md +1 -0
- package/src/ir/chrome-probe.mjs +150 -0
- package/src/ir/parser-js.js +94 -7
- package/src/lineage/CLAUDE.md +1203 -0
- package/src/lineage/DESIGN_DESTINATION_RESOLVER.md +156 -0
- package/src/lineage/DESIGN_GRAPH_BUILDER.md +938 -0
- package/src/lineage/DESIGN_HANDLING_ANALYZER.md +355 -0
- package/src/lineage/DESIGN_INTRAPROCEDURAL.md +628 -0
- package/src/lineage/DESIGN_PATH_PROVENANCE.md +3451 -0
- package/src/lineage/DESIGN_QUEUE_DETAIL.md +120 -0
- package/src/lineage/DESIGN_REGISTRIES.md +880 -0
- package/src/lineage/DESIGN_STORE_DETAIL.md +143 -0
- package/src/lineage/DESIGN_TRANSIT_PROTECTION.md +245 -0
- package/src/lineage/classification.js +56 -0
- package/src/lineage/coverage.js +658 -0
- package/src/lineage/cross-repo-link.js +107 -0
- package/src/lineage/dataflow-graph.schema.json +184 -0
- package/src/lineage/decision-story.js +206 -0
- package/src/lineage/drift-policy.js +279 -0
- package/src/lineage/driver.js +135 -0
- package/src/lineage/engine.js +992 -0
- package/src/lineage/export-briefing.js +628 -0
- package/src/lineage/export-csv.js +62 -0
- package/src/lineage/export-json.js +238 -0
- package/src/lineage/export-privacy.js +258 -0
- package/src/lineage/federation-loader.js +111 -0
- package/src/lineage/field-identity.js +78 -0
- package/src/lineage/fixtures/build-flagship-fixture.mjs +272 -0
- package/src/lineage/fixtures/flagship-graph.json +1453 -0
- package/src/lineage/flow-grade.js +221 -0
- package/src/lineage/governance-edit.js +169 -0
- package/src/lineage/graph-builder.js +1114 -0
- package/src/lineage/graph-diff.js +431 -0
- package/src/lineage/graph-snapshot.js +180 -0
- package/src/lineage/handling-analyzer.js +168 -0
- package/src/lineage/ids.js +349 -0
- package/src/lineage/impact-assessment.js +76 -0
- package/src/lineage/impact-engine.js +268 -0
- package/src/lineage/index.js +281 -0
- package/src/lineage/language-coverage-tiers.js +58 -0
- package/src/lineage/obligation-mapping.js +126 -0
- package/src/lineage/obligation-predicates.js +235 -0
- package/src/lineage/observation-adapters.js +282 -0
- package/src/lineage/observation-correlation.js +622 -0
- package/src/lineage/observation-store.js +497 -0
- package/src/lineage/path-query.js +410 -0
- package/src/lineage/path-store.js +400 -0
- package/src/lineage/protection.js +53 -0
- package/src/lineage/recipient-profile.js +192 -0
- package/src/lineage/recipient-registry.js +394 -0
- package/src/lineage/redact-graph.js +224 -0
- package/src/lineage/remediation.js +417 -0
- package/src/lineage/resolve-destination.js +91 -0
- package/src/lineage/runtime-observation.js +464 -0
- package/src/lineage/scenario-diff.js +84 -0
- package/src/lineage/scenario-engine.js +251 -0
- package/src/lineage/scenario.js +101 -0
- package/src/lineage/schema.js +167 -0
- package/src/lineage/sink-registry.js +427 -0
- package/src/lineage/source-registry.js +357 -0
- package/src/lineage/source-seeding.js +212 -0
- package/src/lineage/summaries.js +590 -0
- package/src/lineage/transform-catalog.js +397 -0
- package/src/lineage/transit-protection.js +150 -0
- package/src/lineage/validate.js +285 -0
- package/src/mcp/CLAUDE.md +7 -1
- package/src/mcp/dataflow-tools.js +160 -0
- package/src/mcp/server.js +1 -1
- package/src/mcp/tools.js +2 -1
- package/src/pipeline/scan-health.js +19 -1
- package/src/posture/CLAUDE.md +19 -0
- package/src/posture/artifact-registry.js +52 -0
- package/src/posture/auditor-walkthrough.js +76 -0
- package/src/posture/compliance-frameworks/hipaa-security-rule.json +3 -2
- package/src/posture/obligation-evidence-pack.js +202 -0
- package/src/posture/remediation-ledger.js +337 -0
- package/src/server/CLAUDE.md +47 -0
- package/src/server/graph-loader.js +141 -0
- package/src/server/http-server.js +325 -0
- package/src/server/routes.js +129 -0
- package/src/server/security.js +111 -0
- package/src/server/static-assets.js +139 -0
- package/dist/11.index.js +0 -353
- package/dist/259.index.js +0 -975
- package/dist/317.index.js +0 -300
- package/dist/609.index.js +0 -741
- package/dist/838.index.js +0 -152
|
@@ -0,0 +1,590 @@
|
|
|
1
|
+
import { hashState, emptyState, addIdentity } from './field-identity.js';
|
|
2
|
+
import { resolveExprIdentities, residualFlat, analyzeFunctionFieldIdentity } from './engine.js';
|
|
3
|
+
import { accessPathOf } from '../dataflow/access-paths.js';
|
|
4
|
+
import { functionRecord } from '../ir/callgraph.js';
|
|
5
|
+
|
|
6
|
+
export function emptyFieldSummary() {
|
|
7
|
+
return { returnFlat: new Set(), returnByPath: new Map(), mutatedParams: new Map(), widenings: [] };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// Increment B6: the per-function distinct-context cap's operator-facing
|
|
11
|
+
// knob. `AGENTIC_SECURITY_LINEAGE_MAX_CONTEXTS` is DELIBERATELY a separate
|
|
12
|
+
// env var from dataflow/summaries.js's own `AGENTIC_SECURITY_KCFA_MAX_CONTEXTS`
|
|
13
|
+
// — per the isolation principle every prior increment in this sub-project
|
|
14
|
+
// has verified holds, the two engines' tuning knobs must stay decoupled,
|
|
15
|
+
// so an operator tuning one engine's cap can never silently affect the
|
|
16
|
+
// other's. Mirrors dataflow's own exact validation FORMULA
|
|
17
|
+
// (`Number.isFinite(...) && ... >= 0`, falling back to 16 on anything
|
|
18
|
+
// invalid or absent) — same reasoning, independently re-derived for this
|
|
19
|
+
// package rather than shared config.
|
|
20
|
+
//
|
|
21
|
+
// The formula match does NOT extend to what a cap of exactly `0` means,
|
|
22
|
+
// though — a final whole-branch review found and corrected an earlier,
|
|
23
|
+
// wrong claim of full equivalence here (see the regression test's own
|
|
24
|
+
// comment in summaries.test.js for the full trace). dataflow's own
|
|
25
|
+
// compute() exempts the empty-entry context from its cap entirely ("Empty
|
|
26
|
+
// entry is always allowed"), so a cap of 0 there is genuinely monovariant
|
|
27
|
+
// — the empty-entry pass still runs, over-cap contexts reuse it. THIS
|
|
28
|
+
// package's compute() has no such exemption (unchanged since B1): the
|
|
29
|
+
// empty-entry context counts against the cap like any other, so
|
|
30
|
+
// AGENTIC_SECURITY_LINEAGE_MAX_CONTEXTS=0 degrades EVERY context,
|
|
31
|
+
// including the empty one, to an empty summary with nothing real to fall
|
|
32
|
+
// back to — resolution goes fully off, not merely monovariant. Verified
|
|
33
|
+
// empirically through a real call-graph/parser scenario. Closing this gap
|
|
34
|
+
// (giving the empty-entry context the same exemption dataflow's own has)
|
|
35
|
+
// is a candidate for a later increment; nothing in B1-B6 commits to it.
|
|
36
|
+
//
|
|
37
|
+
// Evaluated as a FUNCTION (not a module-level constant) specifically so it
|
|
38
|
+
// is read fresh on every `new FieldIdentitySummaryCache()` call with no
|
|
39
|
+
// explicit constructor argument — JS default-parameter expressions are
|
|
40
|
+
// evaluated at CALL time, not at module-load time, which is what makes
|
|
41
|
+
// this testable via `process.env` mutation without needing to re-import
|
|
42
|
+
// the module between test cases.
|
|
43
|
+
function _defaultMaxContexts() {
|
|
44
|
+
const envCap = Number(process.env.AGENTIC_SECURITY_LINEAGE_MAX_CONTEXTS);
|
|
45
|
+
return Number.isFinite(envCap) && envCap >= 0 ? envCap : 16;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export class FieldIdentitySummaryCache {
|
|
49
|
+
constructor(maxContextsPerFn = _defaultMaxContexts()) {
|
|
50
|
+
this._cache = new Map();
|
|
51
|
+
this._stack = new Set();
|
|
52
|
+
this._contextsByQid = new Map();
|
|
53
|
+
this._maxContextsPerFn = maxContextsPerFn;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
_key(qid, entryState) {
|
|
57
|
+
return `${qid}::${hashState(entryState)}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
has(qid, entryState) {
|
|
61
|
+
return this._cache.has(this._key(qid, entryState));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
get(qid, entryState) {
|
|
65
|
+
return this._cache.get(this._key(qid, entryState));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
set(qid, entryState, summary) {
|
|
69
|
+
this._cache.set(this._key(qid, entryState), summary);
|
|
70
|
+
const hash = hashState(entryState);
|
|
71
|
+
const seen = this._contextsByQid.get(qid) ?? new Set();
|
|
72
|
+
seen.add(hash);
|
|
73
|
+
this._contextsByQid.set(qid, seen);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
compute(qid, entryState, analyzeFn) {
|
|
77
|
+
// Ordering matches dataflow/summaries.js's own compute() exactly:
|
|
78
|
+
// cache-hit checked FIRST, before the `_stack`-based recursion guard.
|
|
79
|
+
// This is load-bearing, not incidental — B5's whole point is that a
|
|
80
|
+
// nested self/mutual-reference call, on a LATER refinement round, must
|
|
81
|
+
// see the PRIOR round's real (already-cached) summary instead of the
|
|
82
|
+
// bottom stub, so the summary can genuinely grow round over round (e.g.
|
|
83
|
+
// a self-recursive `function chain() { return {base: X, nested:
|
|
84
|
+
// chain()}}` picks up one more `nested:` layer per round). An earlier
|
|
85
|
+
// version of this method checked `_stack.has(qid)` BEFORE the cache-hit
|
|
86
|
+
// check specifically to keep the pre-existing B1 recursion test passing
|
|
87
|
+
// unmodified — but that ordering makes every nested self-call within
|
|
88
|
+
// the SAME compute() invocation hit the bottom-stub branch on every
|
|
89
|
+
// round (since `qid` never leaves `_stack` until the whole call
|
|
90
|
+
// returns), so round 2's self-call is byte-identical to round 1's, and
|
|
91
|
+
// `fieldSummaryEq` converges immediately without ever changing anything
|
|
92
|
+
// — refinement runs but is a structural no-op for genuine
|
|
93
|
+
// self-recursion, defeating the actual purpose of this increment.
|
|
94
|
+
// Verified empirically: with `_stack` checked first, a self-referencing
|
|
95
|
+
// `chain()`-shaped scenario stays stuck at `{'data:base'}` forever;
|
|
96
|
+
// with the cache-hit checked first (this ordering), it genuinely grows
|
|
97
|
+
// (`{'data:base', 'nested:data:base', 'nested:nested:data:base', ...}`)
|
|
98
|
+
// across rounds, bounded by FP_MAX. The pre-existing B1 test's own
|
|
99
|
+
// safety property (a nested self-call is NEVER unboundedly recursive,
|
|
100
|
+
// and — checked once, on its FIRST occurrence — gets a genuine bottom
|
|
101
|
+
// stub) still holds under this ordering; only the test's assertion
|
|
102
|
+
// needed updating to check the FIRST occurrence rather than every
|
|
103
|
+
// occurrence, since round 2+'s nested self-call now legitimately
|
|
104
|
+
// resolves to the real (still `_stack`-guarded from ever calling
|
|
105
|
+
// `innerFn` a second time — see below) prior-round summary. This is
|
|
106
|
+
// this increment's own coordinator-reviewed correction — see the
|
|
107
|
+
// Task 1 report's "Fix round" section for the full trace.
|
|
108
|
+
if (this.has(qid, entryState)) {
|
|
109
|
+
const cached = this.get(qid, entryState);
|
|
110
|
+
if (!cached._recursive) return cached;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const hash = hashState(entryState);
|
|
114
|
+
const seen = this._contextsByQid.get(qid) ?? new Set();
|
|
115
|
+
if (!seen.has(hash) && seen.size >= this._maxContextsPerFn) {
|
|
116
|
+
// Past this function's distinct-context cap: degrade to the
|
|
117
|
+
// empty-entry summary (if one exists) rather than computing an
|
|
118
|
+
// unbounded number of contexts. Mirrors
|
|
119
|
+
// dataflow/summaries.js's own graceful degradation past its own cap.
|
|
120
|
+
//
|
|
121
|
+
// Path provenance (Sub-project C, increment 3, §13.6/§13.7 item 11):
|
|
122
|
+
// mark the degradation with a PERMANENT, externally-visible
|
|
123
|
+
// `degradedReason` — unlike `_recursive` (a transient
|
|
124
|
+
// recursion-in-progress marker stripped before external use), a
|
|
125
|
+
// degraded summary stays degraded for the life of this cache entry,
|
|
126
|
+
// so this field is never stripped. Marked on a SHALLOW COPY, never
|
|
127
|
+
// on `base` in place: `base` is the exact object already cached for
|
|
128
|
+
// this qid's empty-entry context (Finding 2) — mutating it here
|
|
129
|
+
// would retroactively mark that PRECISE summary as degraded for
|
|
130
|
+
// every later reader of the empty-entry context too.
|
|
131
|
+
const base = this._cache.get(this._key(qid, emptyState())) ?? emptyFieldSummary();
|
|
132
|
+
const fallback = { ...base, degradedReason: 'context-cap' };
|
|
133
|
+
this.set(qid, entryState, fallback);
|
|
134
|
+
return fallback;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (this._stack.has(qid)) {
|
|
138
|
+
// Recursion guard: return a bottom stub immediately to the NESTED
|
|
139
|
+
// caller, never recurse further — this part is unchanged from B1 and
|
|
140
|
+
// stays the safety mechanism (never infinite-loop on a recursive call
|
|
141
|
+
// graph). What B5 adds is downstream of this: this ALSO flags
|
|
142
|
+
// `_hitRecursion` on the cache instance (shared across the whole
|
|
143
|
+
// nested call chain, mirroring dataflow/summaries.js's own exact
|
|
144
|
+
// design) so that whichever OUTER compute() call is currently
|
|
145
|
+
// mid-analyzeFn() knows, once its own analyzeFn() returns, to
|
|
146
|
+
// refine its result via the bounded fixed-point loop below. This
|
|
147
|
+
// branch is only ever reached when the cache-hit check above found
|
|
148
|
+
// NOTHING cached yet for this exact qid+entryState — i.e. the FIRST
|
|
149
|
+
// time this qid is encountered while still on `_stack` (every LATER
|
|
150
|
+
// encounter, within the same or a later refinement round, hits the
|
|
151
|
+
// cache-hit branch above instead, once the first round has cached a
|
|
152
|
+
// real summary).
|
|
153
|
+
this._hitRecursion = true;
|
|
154
|
+
return { ...emptyFieldSummary(), _recursive: true };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
this._stack.add(qid);
|
|
158
|
+
// Mirrors dataflow/summaries.js's own exact placement: reset AFTER
|
|
159
|
+
// pushing onto `_stack`, immediately before the analyzeFn() call this
|
|
160
|
+
// flag is scoped to. Nothing reads `_hitRecursion` between the push and
|
|
161
|
+
// this reset, so the two orderings are behaviorally identical here —
|
|
162
|
+
// reset-after-push is what the reference precedent actually does.
|
|
163
|
+
//
|
|
164
|
+
// Known, disclosed imprecision (a final whole-branch review found this
|
|
165
|
+
// via a real 2-function, non-recursive-then-recursive scenario through
|
|
166
|
+
// the real driver — see CLAUDE.md's B5 section): this flag is
|
|
167
|
+
// LAST-WRITER-WINS across the whole nested call chain, not scoped to
|
|
168
|
+
// "did MY OWN analyzeFn() hit recursion." If `analyzeFn`'s body first
|
|
169
|
+
// makes a self/mutual-recursive call (setting the flag true) and THEN
|
|
170
|
+
// makes an unrelated, cache-missing call to some other function, that
|
|
171
|
+
// second call's own `this._hitRecursion = false` (right here, for ITS
|
|
172
|
+
// OWN frame) does not touch the OUTER flag — but if that inner call
|
|
173
|
+
// itself hits a recursion or cache-miss chain, the flag can end up
|
|
174
|
+
// reset by the time control returns to this frame's own post-analyze
|
|
175
|
+
// check, silently skipping refinement this frame otherwise deserved.
|
|
176
|
+
// Sound either way (skipping refinement only under-approximates,
|
|
177
|
+
// never fabricates), but means refinement reliably fires only when
|
|
178
|
+
// the recursive self-call is the LAST uncached compute() an
|
|
179
|
+
// analyzeFn() body makes — not merely "somewhere in the body."
|
|
180
|
+
// Mirrors dataflow/summaries.js's own precedent exactly, so not a new
|
|
181
|
+
// risk this package introduces; inherit this knowledge if B6 touches
|
|
182
|
+
// this same flag.
|
|
183
|
+
this._hitRecursion = false;
|
|
184
|
+
try {
|
|
185
|
+
// A final whole-branch review found this method previously had no
|
|
186
|
+
// try/finally around analyzeFn — if it threw, qid stayed on _stack
|
|
187
|
+
// forever, and every LATER compute() call for that qid would fall
|
|
188
|
+
// into the recursion guard above and silently return a bottom stub,
|
|
189
|
+
// permanently. Not reachable at this increment's own hand-built-test
|
|
190
|
+
// scope, but live the moment a real driver (B4) runs
|
|
191
|
+
// analyzeFunctionFieldIdentity over real parsed IR — that function
|
|
192
|
+
// throws a plain TypeError on a malformed `fn`, and a sensible
|
|
193
|
+
// driver catching-and-continuing would otherwise silently spread
|
|
194
|
+
// "one function failed" into "this function under-reports for the
|
|
195
|
+
// rest of the run," the exact silent-under-approximation class the
|
|
196
|
+
// intraprocedural engine spent six rounds closing. Mirrors
|
|
197
|
+
// dataflow/summaries.js's own try/finally around the identical
|
|
198
|
+
// stack-push/pop pattern.
|
|
199
|
+
let summary = analyzeFn(entryState);
|
|
200
|
+
this.set(qid, entryState, summary);
|
|
201
|
+
// Increment B5: bounded fixed-point refinement. If a NESTED call for
|
|
202
|
+
// THIS SAME qid (still on `_stack` for the duration of this
|
|
203
|
+
// analyzeFn() call) hit the recursion guard above, the summary just
|
|
204
|
+
// computed treated that self/mutual reference as carrying zero
|
|
205
|
+
// identity (the bottom stub) — an honest but possibly permanent
|
|
206
|
+
// under-approximation if nothing ever revisits it. Re-invoking
|
|
207
|
+
// analyzeFn() now, AFTER this qid is already cached with a real
|
|
208
|
+
// (non-bottom-stub) summary, lets a nested self-call resolve against
|
|
209
|
+
// THAT cached value instead of the guard on the next round, so each
|
|
210
|
+
// round can only get more complete, never regress. Bounded by
|
|
211
|
+
// FP_MAX (mirrors dataflow's own `FP_MAX = 3`) as a hard safety cap,
|
|
212
|
+
// not a precision target — a function that never converges within
|
|
213
|
+
// the cap is left at whatever the last round produced, an honest,
|
|
214
|
+
// sound under-approximation. `fieldSummaryEq` (membership-based, see
|
|
215
|
+
// below) decides convergence: a round judged equal to the previous
|
|
216
|
+
// one is NOT cached (the cache still holds the prior — equal — round,
|
|
217
|
+
// which is correct), and the loop stops. Ordering (cache the round
|
|
218
|
+
// BEFORE the loop starts; compare BEFORE caching each subsequent
|
|
219
|
+
// round) mirrors dataflow/summaries.js's own compute() exactly.
|
|
220
|
+
if (this._hitRecursion) {
|
|
221
|
+
const FP_MAX = 3;
|
|
222
|
+
for (let fp = 0; fp < FP_MAX; fp++) {
|
|
223
|
+
const prev = summary;
|
|
224
|
+
summary = analyzeFn(entryState);
|
|
225
|
+
if (fieldSummaryEq(prev, summary)) break;
|
|
226
|
+
this.set(qid, entryState, summary);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
// Defensive strip, mirroring dataflow's own equivalent: analyzeFn's
|
|
230
|
+
// real return shape here never actually carries `_recursive` under
|
|
231
|
+
// normal operation, but this guards against a future change to
|
|
232
|
+
// analyzeFn's callers accidentally leaking it through a cached,
|
|
233
|
+
// externally-visible summary.
|
|
234
|
+
if (summary._recursive) delete summary._recursive;
|
|
235
|
+
return summary;
|
|
236
|
+
} finally {
|
|
237
|
+
this._stack.delete(qid);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
size() {
|
|
242
|
+
return this._cache.size;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
clear() {
|
|
246
|
+
this._cache.clear();
|
|
247
|
+
this._stack.clear();
|
|
248
|
+
this._contextsByQid.clear();
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Compares two FieldSummary objects for VALUE equality — by membership,
|
|
253
|
+
// never by size alone. dataflow/summaries.js's own equivalent (_summaryEq)
|
|
254
|
+
// carries a documented, previously-shipped bug ("Stage 3 correctness
|
|
255
|
+
// audit," see that file's comment directly above _summaryEq): an earlier
|
|
256
|
+
// version compared mutatedParams by SIZE only, so two summaries with the
|
|
257
|
+
// same cardinality but different actual members were wrongly judged
|
|
258
|
+
// equal — the fixed-point loop then broke early WITHOUT caching the
|
|
259
|
+
// fresher, more-correct summary, silently serving a stale one to any
|
|
260
|
+
// LATER cache read. This function is written correctly from the start,
|
|
261
|
+
// citing that precedent as the reason, not discovered the same way twice.
|
|
262
|
+
//
|
|
263
|
+
// Deliberately does NOT compare `widenings` — mirrors dataflow's own
|
|
264
|
+
// `_summaryEq`, which also excludes its diagnostic-list equivalent
|
|
265
|
+
// (`findings`) from the equality check. Two summaries that agree on their
|
|
266
|
+
// actual FACTS (returnFlat, returnByPath, mutatedParams) but happen to
|
|
267
|
+
// carry a differently-ordered or differently-worded widening-reason list
|
|
268
|
+
// should still be treated as converged — the facts are what a caller
|
|
269
|
+
// actually consumes; the widening list is diagnostic.
|
|
270
|
+
//
|
|
271
|
+
// `degradedReason` (Sub-project C, increment 3, §13.6/§13.7 item 13) is
|
|
272
|
+
// excluded from this comparison for the SAME reason as `widenings` — it is
|
|
273
|
+
// diagnostic (why a summary is honestly incomplete), never a fact the
|
|
274
|
+
// analysis result itself depends on. This is stated here deliberately, not
|
|
275
|
+
// left as an accidental omission: `fieldSummaryEq`'s field-by-field
|
|
276
|
+
// comparison below never touched `degradedReason` to begin with (it isn't
|
|
277
|
+
// one of `returnFlat`/`mutatedParams`), so nothing had to change to keep
|
|
278
|
+
// this true — the comment exists so a future reader doesn't "fix" the
|
|
279
|
+
// omission by adding it.
|
|
280
|
+
//
|
|
281
|
+
// `returnByPath` is deliberately NOT compared either — it is currently
|
|
282
|
+
// always `new Map()` for every summary this cache ever stores, per B1's
|
|
283
|
+
// own disclosed, still-open limitation; comparing two always-empty Maps
|
|
284
|
+
// would be a no-op check, not a meaningful omission. If a future
|
|
285
|
+
// increment populates `returnByPath`, this function will need extending.
|
|
286
|
+
export function fieldSummaryEq(a, b) {
|
|
287
|
+
if (!a || !b) return a === b;
|
|
288
|
+
if (a.returnFlat.size !== b.returnFlat.size) return false;
|
|
289
|
+
for (const id of a.returnFlat) if (!b.returnFlat.has(id)) return false;
|
|
290
|
+
if (a.mutatedParams.size !== b.mutatedParams.size) return false;
|
|
291
|
+
for (const [path, ids] of a.mutatedParams) {
|
|
292
|
+
const bIds = b.mutatedParams.get(path);
|
|
293
|
+
if (!bIds || bIds.size !== ids.size) return false;
|
|
294
|
+
for (const id of ids) if (!bIds.has(id)) return false;
|
|
295
|
+
}
|
|
296
|
+
return true;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Maps a call site's argument expressions onto a fresh entry state for the
|
|
300
|
+
// callee, keyed by the callee's own parameter names — the interprocedural
|
|
301
|
+
// analog of `engine.js`'s `assign` transfer function: each argument is
|
|
302
|
+
// resolved against the CALLER's current state via `resolveExprIdentities`,
|
|
303
|
+
// and its residual (root-level) identities plus its byPath (field-level)
|
|
304
|
+
// structure are both written into the callee's entry state at the
|
|
305
|
+
// corresponding parameter name, using the exact same residual+byPath split
|
|
306
|
+
// `assign` already uses — this is a direct, deliberate reuse of Sub-project
|
|
307
|
+
// A's already-hardened write pattern, not a new mechanism.
|
|
308
|
+
export function entryStateFromCall(paramNames, callArgs, callerState, ctx) {
|
|
309
|
+
// Path provenance (Sub-project C, increment 3, §13.2a). `ctx` is an
|
|
310
|
+
// OPTIONAL 4th parameter. THE SINGLE MOST IMPORTANT THING HERE: derive a
|
|
311
|
+
// RECORDER-ONLY ctx — never forward `ctx` itself to resolveExprIdentities.
|
|
312
|
+
// Forwarding the full ctx hands resolveExprIdentities a live
|
|
313
|
+
// `resolveCallSummary`, so a call argument that is itself a resolvable
|
|
314
|
+
// call (e.g. `sink(scrub(user))`) starts resolving interprocedurally
|
|
315
|
+
// where the shipped engine takes the unresolved fallback — changing the
|
|
316
|
+
// ANALYSIS RESULT with no recorder attached anywhere, in the unsound
|
|
317
|
+
// direction under a tight B6 context cap (the extra nested resolve
|
|
318
|
+
// consumes the callee's only context slot, so a later, unrelated call
|
|
319
|
+
// degrades to an empty summary and loses an identity the shipped engine
|
|
320
|
+
// keeps). This is exactly the hazard found and closed in
|
|
321
|
+
// DESIGN_PATH_PROVENANCE.md §13.2a's fix round; see
|
|
322
|
+
// engine-provenance-interprocedural.test.js's golden-baseline regression
|
|
323
|
+
// tests for the guard that pins this closed.
|
|
324
|
+
//
|
|
325
|
+
// Deriving this ONCE, here, inside entryStateFromCall itself (not at a
|
|
326
|
+
// call site) is load-bearing, not stylistic — it means no future second
|
|
327
|
+
// caller of entryStateFromCall can reintroduce the hazard by passing the
|
|
328
|
+
// full ctx through a different path.
|
|
329
|
+
const argCtx = ctx?.recordHop ? { recordHop: ctx.recordHop } : undefined;
|
|
330
|
+
let entryState = emptyState();
|
|
331
|
+
const n = Math.min(paramNames.length, callArgs.length);
|
|
332
|
+
for (let i = 0; i < n; i++) {
|
|
333
|
+
const paramName = paramNames[i];
|
|
334
|
+
const resolved = resolveExprIdentities(callerState, callArgs[i], argCtx);
|
|
335
|
+
const residual = residualFlat(resolved.flat, resolved.byPath);
|
|
336
|
+
for (const id of residual) entryState = addIdentity(entryState, paramName, id);
|
|
337
|
+
for (const [subPath, ids] of resolved.byPath) {
|
|
338
|
+
for (const id of ids) entryState = addIdentity(entryState, `${paramName}.${subPath}`, id);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
return entryState;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Maps a callee's summary back onto the CALLER's own state at the call
|
|
345
|
+
// site — the interprocedural analog of reading a function's return value
|
|
346
|
+
// and observing its side effects. Unlike dataflow/summaries.js's
|
|
347
|
+
// applyAtCallSite (which only propagates a mutation back for a bare
|
|
348
|
+
// `ident` argument, silently dropping a `member`-expression argument like
|
|
349
|
+
// `f(obj.field)`), this version also resolves a member-expression argument
|
|
350
|
+
// via `accessPathOf` — a deliberate, scoped improvement: field mutations
|
|
351
|
+
// plausibly target `obj.field`-shaped arguments often enough that
|
|
352
|
+
// dropping them silently would be a real, avoidable under-approximation.
|
|
353
|
+
export function applyAtCallSite(summary, paramNames, callArgs) {
|
|
354
|
+
const mutations = [];
|
|
355
|
+
for (const [paramPath, ids] of summary.mutatedParams) {
|
|
356
|
+
const [rootParamName, ...rest] = paramPath.split('.');
|
|
357
|
+
const idx = paramNames.indexOf(rootParamName);
|
|
358
|
+
if (idx === -1) continue;
|
|
359
|
+
const arg = callArgs[idx];
|
|
360
|
+
const argPath = accessPathOf(arg);
|
|
361
|
+
if (!argPath) continue;
|
|
362
|
+
const fullPath = rest.length > 0 ? `${argPath}.${rest.join('.')}` : argPath;
|
|
363
|
+
mutations.push({ path: fullPath, dataElementIds: [...ids] });
|
|
364
|
+
}
|
|
365
|
+
return { returnFlat: summary.returnFlat, returnByPath: summary.returnByPath, mutations };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Builds a `resolveCallSummary` closure — the shape `resolveExprIdentities`'s
|
|
369
|
+
// `call` case now consults (see engine.js) — wired to a real
|
|
370
|
+
// FieldIdentitySummaryCache. `lookupCallee` is itself injected and
|
|
371
|
+
// deliberately opaque to this function: this increment's own tests pass a
|
|
372
|
+
// simple hand-built name-to-function map; increment B3's real call-graph
|
|
373
|
+
// integration will pass a resolver backed by `scanner/src/ir/callgraph.js`
|
|
374
|
+
// instead, without this function (or `resolveExprIdentities`) needing to
|
|
375
|
+
// change at all.
|
|
376
|
+
export function createCallSummaryResolver(cache, lookupCallee) {
|
|
377
|
+
return function resolveCallSummary(calleeExpr, callArgs, callerState, ctx) {
|
|
378
|
+
const resolved = lookupCallee(calleeExpr);
|
|
379
|
+
if (!resolved) return null;
|
|
380
|
+
const { qid, fn } = resolved;
|
|
381
|
+
// Path provenance (Sub-project C, increment 3, §13.7 item 7):
|
|
382
|
+
// `entryStateFromCall` does the recorder-only stripping itself (see its
|
|
383
|
+
// own header comment) — this call site forwards the caller's `ctx`
|
|
384
|
+
// unmodified; the hazard cannot reappear here because the strip
|
|
385
|
+
// happens one level down, not at each call site.
|
|
386
|
+
const entryState = entryStateFromCall(fn.params, callArgs, callerState, ctx);
|
|
387
|
+
// §13.2's first half: the callee's own entry context, computed once so
|
|
388
|
+
// both the bind hop below and the return-direction wrapper at the
|
|
389
|
+
// bottom of this function can reference the exact same value.
|
|
390
|
+
const calleeContext = hashState(entryState);
|
|
391
|
+
|
|
392
|
+
// Path provenance (§13.2b): the argument -> parameter binding out-half,
|
|
393
|
+
// emitted once per (path, id) entry of the freshly built entryState —
|
|
394
|
+
// entryState IS the complete record of every (toPath, id) the binding
|
|
395
|
+
// wrote, so nothing has to be re-resolved to enumerate them. fromPath
|
|
396
|
+
// stays null: the argument expression's own in-halves (emitted inside
|
|
397
|
+
// entryStateFromCall, above) already carry the real contributing keys
|
|
398
|
+
// at the join key (callerScope, callerNodeId, id, callerContext); a
|
|
399
|
+
// non-null fromPath here would double-emit the same information in a
|
|
400
|
+
// differently-shaped record. peerScope/peerContext are mandatory, not
|
|
401
|
+
// decorative: toPath lives in the CALLEE's namespace, so without them
|
|
402
|
+
// C4 would collide this binding's target with any caller-local
|
|
403
|
+
// variable of the same name (Decision 5's bug class).
|
|
404
|
+
if (ctx?.recordHop) {
|
|
405
|
+
for (const [path, ids] of entryState) {
|
|
406
|
+
for (const id of ids) {
|
|
407
|
+
ctx.recordHop({
|
|
408
|
+
kind: 'write-out', subKind: 'call-arg-bind',
|
|
409
|
+
fromPath: null, toPath: path, dataElementId: id,
|
|
410
|
+
syntacticPath: null, widenReason: null, lossReason: null,
|
|
411
|
+
peerScope: qid, peerContext: calleeContext,
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const summary = cache.compute(qid, entryState, (es) => {
|
|
418
|
+
// Pass THIS SAME resolver down as the callee's own ctx — without
|
|
419
|
+
// this, a chain of resolved calls (outer resolves to middle, middle
|
|
420
|
+
// itself calls inner) would silently stop resolving after one hop:
|
|
421
|
+
// middle's own analysis would run with no ctx, so its call to inner
|
|
422
|
+
// would take the unresolved fallback, and outer would receive a
|
|
423
|
+
// coarsely-widened summary reported as `widened: false` (since
|
|
424
|
+
// resolveExprIdentities's call case only reads summary.returnFlat/
|
|
425
|
+
// returnByPath, never summary.widenings) — a confident-looking
|
|
426
|
+
// answer that's silently wrong one level down. A final whole-branch
|
|
427
|
+
// review found and proved this exact gap via a real three-function
|
|
428
|
+
// chain. Passing the resolver down makes resolution recurse through
|
|
429
|
+
// as many resolved hops as `lookupCallee` can cover, with the
|
|
430
|
+
// existing recursion guard (field-identity summary cache's `_stack`
|
|
431
|
+
// bottom-stub) already sufficient to keep a self- or mutually-
|
|
432
|
+
// recursive chain safe (verified: both terminate immediately,
|
|
433
|
+
// returning an empty, honestly-unrefined result — precision there
|
|
434
|
+
// is increment B5's job, not this fix's).
|
|
435
|
+
//
|
|
436
|
+
// Path provenance (§13.7 item 9, hole 3): keep the caller's recorder
|
|
437
|
+
// alive on the callee's own ctx instead of discarding it (the
|
|
438
|
+
// pre-C3 `{ resolveCallSummary }`-only object was hole 3). Do NOT
|
|
439
|
+
// re-stamp `context` here — the callee's own analyzeFunctionFieldIdentity
|
|
440
|
+
// call (engine.js's stepCtx wrapper) computes and stamps its own
|
|
441
|
+
// `context` from ITS OWN entry state (`es`, not the caller's), and
|
|
442
|
+
// its stamps win over anything this object would set, by spread
|
|
443
|
+
// order (`{ ...ctx, ..., ...h }` — the innermost `h` from the
|
|
444
|
+
// deepest call always wins). Passing a `context` field here would be
|
|
445
|
+
// silently overwritten and is dead code.
|
|
446
|
+
const calleeCtx = ctx?.recordHop
|
|
447
|
+
? { resolveCallSummary, recordHop: ctx.recordHop }
|
|
448
|
+
: { resolveCallSummary };
|
|
449
|
+
const result = analyzeFunctionFieldIdentity(fn, es, calleeCtx);
|
|
450
|
+
return summaryFromAnalysisResult(result);
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
// Path provenance (§13.6/§13.7 item 12): a summary the cache honestly
|
|
454
|
+
// degraded (B6 context-cap) has an empty `returnFlat`, so
|
|
455
|
+
// engine.js's `case 'call'` `for (const id of flat)` loop can never
|
|
456
|
+
// fire — there is no hop at all to carry a marker (Finding 1: the
|
|
457
|
+
// degradation is otherwise completely silent). Emitted HERE, at the
|
|
458
|
+
// resolver, one loss hop per id that entered the callee (the entry
|
|
459
|
+
// state's own ids — the identities whose downstream fate is now
|
|
460
|
+
// unrepresented), `fromPath`/`toPath` both null so it reads as an
|
|
461
|
+
// ANNOTATION on the argument's own real in-half at the same join key
|
|
462
|
+
// under §2.2's rule (or, when there is no path-shaped argument, as
|
|
463
|
+
// edge-forming in its own right) — never dropped either way.
|
|
464
|
+
if (summary?.degradedReason && ctx?.recordHop) {
|
|
465
|
+
for (const [, ids] of entryState) {
|
|
466
|
+
for (const id of ids) {
|
|
467
|
+
ctx.recordHop({
|
|
468
|
+
kind: 'production', subKind: 'call-resolved',
|
|
469
|
+
fromPath: null, toPath: null, dataElementId: id,
|
|
470
|
+
syntacticPath: null, widenReason: null,
|
|
471
|
+
lossReason: 'context-cap-degraded',
|
|
472
|
+
peerScope: qid, peerContext: calleeContext,
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// §13.2(c), the return direction: a FRESH wrapper every call — the
|
|
479
|
+
// cached summary is never mutated, so fieldSummaryEq and the B5
|
|
480
|
+
// refinement loop are untouched. engine.js reads only
|
|
481
|
+
// returnFlat/returnByPath, so this augmentation is inert to it; it
|
|
482
|
+
// exists purely so `case 'call'`'s own production/call-resolved hop can
|
|
483
|
+
// carry peerScope/peerContext naming the callee it resolved to.
|
|
484
|
+
return summary ? { ...summary, resolvedQid: qid, resolvedContext: calleeContext } : summary;
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Converts analyzeFunctionFieldIdentity's raw per-function result
|
|
489
|
+
// (`{exitState, returnFacts, mutatedParams, widenings}`) into the
|
|
490
|
+
// FieldSummary shape (`{returnFlat, returnByPath, mutatedParams,
|
|
491
|
+
// widenings}`) that FieldIdentitySummaryCache stores and every consumer of
|
|
492
|
+
// a resolved summary reads. Extracted (increment B4) from what was
|
|
493
|
+
// previously inline-only logic inside createCallSummaryResolver's own
|
|
494
|
+
// cache.compute callback, so increment B4's project-wide driver can seed
|
|
495
|
+
// the cache with the exact SAME conversion for a function's own top-level
|
|
496
|
+
// analysis, rather than reimplementing it a second time and risking the
|
|
497
|
+
// two copies drifting apart.
|
|
498
|
+
//
|
|
499
|
+
// Unions identities across EVERY return site, not just the first — a
|
|
500
|
+
// function with multiple return statements (e.g. an early-return branch)
|
|
501
|
+
// must have all of them reflected, not just whichever happened to be
|
|
502
|
+
// recorded first. This was a genuine correctness improvement over
|
|
503
|
+
// increment B1's own round-trip test's `returnFacts[0]` shortcut (that
|
|
504
|
+
// test only ever exercised a single-return-site function, so the shortcut
|
|
505
|
+
// was harmless there).
|
|
506
|
+
export function summaryFromAnalysisResult(result) {
|
|
507
|
+
const returnFlat = new Set();
|
|
508
|
+
for (const rf of result.returnFacts) {
|
|
509
|
+
for (const id of rf.identities) returnFlat.add(id);
|
|
510
|
+
}
|
|
511
|
+
return {
|
|
512
|
+
returnFlat,
|
|
513
|
+
returnByPath: new Map(), // still flat-only — see B1's disclosed limitation in CLAUDE.md; not closed by this increment either.
|
|
514
|
+
// If this ever stops being an unconditional empty Map, fieldSummaryEq
|
|
515
|
+
// (above) MUST be extended to compare it too — that function currently
|
|
516
|
+
// omits it, reasoned as safe only because every value here is always
|
|
517
|
+
// empty. Skipping this would silently reintroduce the exact
|
|
518
|
+
// "Stage 3 correctness audit" bug class dataflow/summaries.js
|
|
519
|
+
// documents: two summaries wrongly judged equal, the fresher one
|
|
520
|
+
// never cached, a later reader silently served the stale value.
|
|
521
|
+
mutatedParams: result.mutatedParams,
|
|
522
|
+
widenings: result.widenings,
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// Resolves a call expression's callee to a bare, resolvable name — the
|
|
527
|
+
// lineage-engine analog of dataflow/engine.js's own `_resolvableCalleeName`
|
|
528
|
+
// BASE CASE (before that file's later, CHA-gated member-expression
|
|
529
|
+
// extension) for the `calleeExpr.kind === 'ident'` branch specifically.
|
|
530
|
+
// Deliberately narrow: only a bare identifier callee (`helper(x)`) resolves
|
|
531
|
+
// to a name at all. A member-expression callee (`obj.helper(x)`) returns
|
|
532
|
+
// null here, on purpose — resolving THAT safely needs class-hierarchy
|
|
533
|
+
// analysis (which method does the object concretely carry), a separate,
|
|
534
|
+
// much larger mechanism dataflow built specifically for its own R11
|
|
535
|
+
// requirement (`_resolveMemberCalleeViaCHA`, gated on a `_cha` object this
|
|
536
|
+
// package has no equivalent of and is not in scope to build here).
|
|
537
|
+
// Guessing from the property name alone would fabricate a call edge that
|
|
538
|
+
// may not exist — worse than leaving the call unresolved, matching this
|
|
539
|
+
// whole codebase's own stated doctrine (see callgraph.js's comments on
|
|
540
|
+
// `resolveKnownCallee` vs. the guessing `resolve()`).
|
|
541
|
+
//
|
|
542
|
+
// UNLIKE dataflow's version, this one does NOT also accept a plain string
|
|
543
|
+
// callee (`typeof calleeExpr === 'string'`). Per the IR shape contract
|
|
544
|
+
// (scanner/src/ir/CLAUDE.md), `callee` is `string|expr` — the seven
|
|
545
|
+
// hand-rolled parsers (Python/Ruby/PHP/Go/Java/C#/Kotlin) emit a flat,
|
|
546
|
+
// dot-joined STRING callee, never a structured `{kind:'ident'}` node. This
|
|
547
|
+
// means `createCallGraphLookup` below resolves NOTHING for those
|
|
548
|
+
// languages' IR today — every call in a non-JS/TS file silently takes the
|
|
549
|
+
// unresolved-call fallback (flat + widened:true), the same fail-safe
|
|
550
|
+
// direction as an unresolvable JS/TS call, just unconditionally so. This
|
|
551
|
+
// is not an oversight: naively accepting a dotted string (e.g. Java's
|
|
552
|
+
// class-qualified `"App.getUser"`) and handing it to `resolveKnownCallee`
|
|
553
|
+
// would resolve it via that function's project-wide bare-name index —
|
|
554
|
+
// which is member-call resolution in disguise, exactly what this
|
|
555
|
+
// increment's CHA-free scope forbids. Whether/how to extend real
|
|
556
|
+
// interprocedural resolution to the hand-rolled-parser languages is
|
|
557
|
+
// undecided and out of scope for Sub-project B's current B1-B6 breakdown.
|
|
558
|
+
function _resolvableCalleeName(calleeExpr) {
|
|
559
|
+
if (!calleeExpr) return null;
|
|
560
|
+
if (calleeExpr.kind === 'ident') return calleeExpr.name || null;
|
|
561
|
+
return null;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// Builds a real `lookupCallee` closure — the shape `createCallSummaryResolver`
|
|
565
|
+
// expects as its second argument — backed by a real call graph from
|
|
566
|
+
// `scanner/src/ir/callgraph.js#buildCallGraph`. `callerFile` is fixed at
|
|
567
|
+
// construction time: one `lookupCallee` closure is built per analyzed
|
|
568
|
+
// function/file (mirroring how `dataflow/engine.js`'s own
|
|
569
|
+
// `_resolveCalleeForSummary` derives `_callerFile` fresh per call context),
|
|
570
|
+
// so `createCallSummaryResolver`'s existing single-argument `lookupCallee`
|
|
571
|
+
// shape (no caller-file parameter) does not need to change.
|
|
572
|
+
//
|
|
573
|
+
// Uses `resolveKnownCallee` — never `resolve()` — matching `callgraph.js`'s
|
|
574
|
+
// own documented distinction: `resolveKnownCallee` is "safe-by-default,"
|
|
575
|
+
// refusing the bare-name-tail guess `resolve()` is willing to make. This
|
|
576
|
+
// package's own doctrine (see FR-301, never silently merge/drop distinct
|
|
577
|
+
// identities) treats a fabricated call edge as strictly worse than a missed
|
|
578
|
+
// one, same as dataflow's own precedent.
|
|
579
|
+
export function createCallGraphLookup(callGraph, callerFile) {
|
|
580
|
+
return function lookupCallee(calleeExpr) {
|
|
581
|
+
if (!callGraph || typeof callGraph.resolveKnownCallee !== 'function') return null;
|
|
582
|
+
const name = _resolvableCalleeName(calleeExpr);
|
|
583
|
+
if (!name) return null;
|
|
584
|
+
const resolved = callGraph.resolveKnownCallee(name, callerFile);
|
|
585
|
+
if (!resolved) return null;
|
|
586
|
+
const fn = functionRecord(callGraph, resolved);
|
|
587
|
+
if (!fn) return null;
|
|
588
|
+
return { qid: resolved, fn };
|
|
589
|
+
};
|
|
590
|
+
}
|