@clear-capabilities/agentic-security-scanner 0.195.24 → 0.195.26
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 +32 -0
- package/bin/agentic-security.js +9 -0
- package/dist/5752.index.js +16 -3
- package/dist/{daefda0819bb18309b75.json → 60f58f89664e53fc2f96.json} +3 -3
- package/dist/8218.index.js +19 -2
- package/dist/9382.index.js +315 -174
- package/dist/9560.index.js +19 -2
- package/dist/agentic-security.mjs +2 -2
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +3 -3
- package/src/context-intelligence/CLAUDE.md +4 -0
- package/src/context-intelligence/entrypoints.js +141 -9
- package/src/context-intelligence/imports.js +1 -1
- package/src/context-intelligence/review.js +8 -3
- package/src/dataflow/CLAUDE.md +1 -1
- package/src/engine.js +13 -0
- package/src/lineage/CLAUDE.md +17 -0
- package/src/lineage/boundary-join.js +384 -0
- package/src/lineage/coverage.js +4 -1
- package/src/lineage/engine.js +20 -0
- package/src/lineage/graph-builder.js +172 -34
- package/src/lineage/index.js +39 -0
- package/src/lineage/source-seeding.js +11 -1
- package/src/posture/dag-commands.js +15 -2
- package/src/posture/provenance/git-evidence.js +21 -0
- package/src/server/graph-loader.js +19 -2
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
//
|
|
2
|
+
// boundary-join.js: cross-service handoffs in the lineage graph (GA plan,
|
|
3
|
+
// GA-03.3: "integrate existing cross-service boundary joins into the
|
|
4
|
+
// documented opt-in scan path; validate producer/consumer schemas, argument
|
|
5
|
+
// mapping and field protection; unknown dynamic edges must remain unknown").
|
|
6
|
+
//
|
|
7
|
+
// A HANDOFF is a value leaving one function through a queue publish or a
|
|
8
|
+
// storage write and re-entering another function through a queue receive or
|
|
9
|
+
// a storage read. The field-identity engine sees the two halves as an escape
|
|
10
|
+
// and an unrelated call result; nothing in the hop stream connects them, so
|
|
11
|
+
// before this module the multi-service fixture's queue consumer and its
|
|
12
|
+
// reporting handlers reported no flow at all (3 of 8 labelled positives).
|
|
13
|
+
//
|
|
14
|
+
// What a join IS, and what it is not:
|
|
15
|
+
// * A join is a MODELED connection: the producer and the consumer name the
|
|
16
|
+
// SAME identity by a literal the parser can read (the same string literal
|
|
17
|
+
// or the same `process.env` name for a queue, the same collection literal
|
|
18
|
+
// for a store), or an operator declared the edge in a boundary contract
|
|
19
|
+
// (boundary-contract.js, DAG-08). Nothing here observes a message on the
|
|
20
|
+
// wire or a row in a table: every seed carries `modeledInput: true,
|
|
21
|
+
// observed: false`, exactly the vocabulary DAG-08 fixed.
|
|
22
|
+
// * Two sites that share only a FIELD NAME are never joined. Two sites whose
|
|
23
|
+
// identity is computed (`QueueUrl: makeUrl(env)`, `collection(name)`) are
|
|
24
|
+
// reported as `unjoined` with the reason `dynamic-identity`; an identity
|
|
25
|
+
// with no partner in the scanned tree is `no-producer-in-scope`.
|
|
26
|
+
// * The fields that cross are the fields that REACHED the producer site in
|
|
27
|
+
// the analysis: the record expression's object-literal keys are mapped to
|
|
28
|
+
// the data-element ids whose contributing state keys the key's value
|
|
29
|
+
// expression references (`{note: body.note, total: body.qty * PRICE}`
|
|
30
|
+
// hands `note` to the key `note` and `qty` to the key `total`). A record
|
|
31
|
+
// that is not an object literal hands every reaching id to the record as
|
|
32
|
+
// a whole, disclosed as `basis: 'whole-record'`.
|
|
33
|
+
// * The consumer is seeded on the parsed record's FIELDS, after the call
|
|
34
|
+
// that received it (`source-seeding.js`'s `after-node` placement), under
|
|
35
|
+
// the consumer-side record path the receive shape implies (an SQS
|
|
36
|
+
// `ReceiveMessageCommand` result is `Messages[].Body`; a Mongo `find()`
|
|
37
|
+
// result is the record itself). A declared contract seeds the handler
|
|
38
|
+
// parameter the contract binds, in the function's entry state.
|
|
39
|
+
//
|
|
40
|
+
// The join needs the producer's analysis before the consumer can be seeded,
|
|
41
|
+
// and a chain (HTTP to queue to store to HTTP response) needs the store
|
|
42
|
+
// writer analysed WITH its queue seeds before the store reader can be
|
|
43
|
+
// joined. graph-builder.js therefore re-runs the field analysis in bounded
|
|
44
|
+
// rounds (`MAX_BOUNDARY_ROUNDS`) while a round mints new seeds, and reports
|
|
45
|
+
// `truncated` when the last round still would.
|
|
46
|
+
//
|
|
47
|
+
// Pure: no file access, no engine import. graph-builder.js owns the rounds;
|
|
48
|
+
// index.js loads the contracts file; engine.js reads the opt-in variable.
|
|
49
|
+
|
|
50
|
+
import { createHash } from 'node:crypto';
|
|
51
|
+
import { accessPathOf } from '../dataflow/access-paths.js';
|
|
52
|
+
import { exprRoots, walkExpr } from './source-seeding.js';
|
|
53
|
+
import { bindBoundaryContracts } from './boundary-contract.js';
|
|
54
|
+
import { classifyDataElementName } from './classification.js';
|
|
55
|
+
|
|
56
|
+
export const MAX_BOUNDARY_ROUNDS = 4;
|
|
57
|
+
export const BOUNDARY_JOIN_KINDS = Object.freeze(['queue', 'storage']);
|
|
58
|
+
|
|
59
|
+
// AWS SDK v3 command classes: `client.send(new <Command>(input))`. The input
|
|
60
|
+
// object carries the identity (`QueueUrl`/`TopicArn`) and the record.
|
|
61
|
+
export const QUEUE_SEND_COMMANDS = Object.freeze({
|
|
62
|
+
SendMessageCommand: { provider: 'sqs', record: 'MessageBody', entry: 'privacy-js-queue-sendMessage' },
|
|
63
|
+
SendMessageBatchCommand: { provider: 'sqs', record: 'Entries', entry: 'privacy-js-queue-sendMessage' },
|
|
64
|
+
PublishCommand: { provider: 'sns', record: 'Message', entry: 'privacy-js-queue-publish' },
|
|
65
|
+
});
|
|
66
|
+
export const QUEUE_RECEIVE_COMMANDS = Object.freeze({
|
|
67
|
+
ReceiveMessageCommand: { provider: 'sqs', recordPath: 'Messages.Body', encoding: 'json' },
|
|
68
|
+
});
|
|
69
|
+
const QUEUE_IDENTITY_KEYS = Object.freeze(['QueueUrl', 'TopicArn', 'topic', 'queueName']);
|
|
70
|
+
// Record-bearing keys for the v2 shapes `sqs.sendMessage({...})` / `sns.publish({...})`.
|
|
71
|
+
const QUEUE_RECORD_KEYS = Object.freeze(['MessageBody', 'Message']);
|
|
72
|
+
|
|
73
|
+
// MongoDB driver shapes on a `collection('<literal>')` handle.
|
|
74
|
+
const STORAGE_WRITE_METHODS = Object.freeze({ insertOne: 0, replaceOne: 1, insertMany: 0 });
|
|
75
|
+
const STORAGE_READ_METHODS = Object.freeze({ find: 'many', findOne: 'one', findMany: 'many' });
|
|
76
|
+
const STORAGE_READ_CHAIN_TAIL = Object.freeze(['toArray']);
|
|
77
|
+
|
|
78
|
+
const isObj = (v) => v !== null && typeof v === 'object';
|
|
79
|
+
const propOf = (objExpr, key) => (objExpr && objExpr.kind === 'object' ? (objExpr.props || []).find((p) => !p.spread && p.key === key) : null);
|
|
80
|
+
|
|
81
|
+
/** The file's module-scope function (`<file>::top::<module>@N`), when the call graph has one. */
|
|
82
|
+
function moduleFunctionOf(callGraph, file) {
|
|
83
|
+
for (const fn of callGraph.functions.values()) {
|
|
84
|
+
if (fn.file === file && /::top::<module>@/.test(String(fn.qid))) return fn;
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The single module-scope assignment to `name` in `file`, or null (none, or more than one). */
|
|
90
|
+
function moduleConstant(callGraph, file, name) {
|
|
91
|
+
const fn = moduleFunctionOf(callGraph, file);
|
|
92
|
+
if (!fn) return null;
|
|
93
|
+
let found = null; let count = 0;
|
|
94
|
+
for (const node of Object.values(fn.cfg?.nodes ?? {})) {
|
|
95
|
+
if (node.kind === 'assign' && node.target === name && node.source) { found = node.source; count += 1; }
|
|
96
|
+
}
|
|
97
|
+
return count === 1 ? found : null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Resolve an identity-bearing expression to a literal the parser can read:
|
|
102
|
+
* `{kind: 'literal', value}` for a string/number literal, `{kind: 'env',
|
|
103
|
+
* value: NAME}` for `process.env.NAME`, following a module-scope constant up
|
|
104
|
+
* to three hops. Anything else is null: a computed identity is unknown.
|
|
105
|
+
*/
|
|
106
|
+
export function resolveIdentityExpr(expr, callGraph, file, depth = 0) {
|
|
107
|
+
if (!isObj(expr) || depth > 3) return null;
|
|
108
|
+
if (expr.kind === 'literal' && (typeof expr.value === 'string' || typeof expr.value === 'number')) return { kind: 'literal', value: String(expr.value) };
|
|
109
|
+
if (expr.kind === 'member' && typeof expr.prop === 'string' && expr.object?.kind === 'member'
|
|
110
|
+
&& expr.object.prop === 'env' && expr.object.object?.kind === 'ident' && expr.object.object.name === 'process') {
|
|
111
|
+
return { kind: 'env', value: expr.prop };
|
|
112
|
+
}
|
|
113
|
+
if (expr.kind === 'ident' && callGraph) {
|
|
114
|
+
const c = moduleConstant(callGraph, file, expr.name);
|
|
115
|
+
return c ? resolveIdentityExpr(c, callGraph, file, depth + 1) : null;
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const identityKey = (kind, r) => (r ? `${kind}:${r.kind}:${r.value}` : null);
|
|
121
|
+
|
|
122
|
+
/** `client.send(new SendMessageCommand({...}))` and friends: the command descriptor, or null. */
|
|
123
|
+
export function queueCommandOf(calleeExpr, args) {
|
|
124
|
+
if (!isObj(calleeExpr) || calleeExpr.kind !== 'member' || calleeExpr.prop !== 'send') return null;
|
|
125
|
+
const a0 = Array.isArray(args) ? args[0] : null;
|
|
126
|
+
if (!isObj(a0) || a0.kind !== 'call' || !a0.isNew || a0.callee?.kind !== 'ident') return null;
|
|
127
|
+
const name = a0.callee.name;
|
|
128
|
+
if (QUEUE_SEND_COMMANDS[name]) return { role: 'send', command: name, ...QUEUE_SEND_COMMANDS[name], input: a0.args?.[0] ?? null };
|
|
129
|
+
if (QUEUE_RECEIVE_COMMANDS[name]) return { role: 'receive', command: name, ...QUEUE_RECEIVE_COMMANDS[name], input: a0.args?.[0] ?? null };
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function queueIdentityOf(inputExpr, callGraph, file) {
|
|
134
|
+
if (!isObj(inputExpr) || inputExpr.kind !== 'object') return { identity: null, reason: 'no-literal-input' };
|
|
135
|
+
for (const key of QUEUE_IDENTITY_KEYS) {
|
|
136
|
+
const p = propOf(inputExpr, key);
|
|
137
|
+
if (!p) continue;
|
|
138
|
+
const r = resolveIdentityExpr(p.value, callGraph, file);
|
|
139
|
+
return r ? { identity: identityKey('queue', r), detail: { key, ...r } } : { identity: null, reason: 'dynamic-identity', detail: { key } };
|
|
140
|
+
}
|
|
141
|
+
return { identity: null, reason: 'no-identity-key' };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** `JSON.stringify(x)` → x; anything else unchanged. */
|
|
145
|
+
function unwrapSerialization(e) {
|
|
146
|
+
if (isObj(e) && e.kind === 'call' && e.callee?.kind === 'member' && e.callee.prop === 'stringify'
|
|
147
|
+
&& e.callee.object?.kind === 'ident' && e.callee.object.name === 'JSON' && e.args?.length) return e.args[0];
|
|
148
|
+
return e;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** `<handle>.collection('<literal>')` → `{table, handleExpr}`, or null. */
|
|
152
|
+
function collectionCallOf(e) {
|
|
153
|
+
if (!isObj(e) || e.kind !== 'call' || e.callee?.kind !== 'member' || e.callee.prop !== 'collection') return null;
|
|
154
|
+
const a0 = e.args?.[0];
|
|
155
|
+
if (!isObj(a0)) return { table: null, handleExpr: e.callee.object, reason: 'dynamic-identity' };
|
|
156
|
+
if (a0.kind !== 'literal' || typeof a0.value !== 'string') return { table: null, handleExpr: e.callee.object, reason: 'dynamic-identity' };
|
|
157
|
+
return { table: a0.value, handleExpr: e.callee.object };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The database name behind a collection handle when it is `x.db('<literal>')` reachable through module constants. */
|
|
161
|
+
function databaseOf(handleExpr, callGraph, file) {
|
|
162
|
+
let cur = handleExpr; let depth = 0;
|
|
163
|
+
while (isObj(cur) && depth++ < 4) {
|
|
164
|
+
if (cur.kind === 'ident') { cur = moduleConstant(callGraph, file, cur.name); continue; }
|
|
165
|
+
if (cur.kind === 'call' && cur.callee?.kind === 'member' && cur.callee.prop === 'db') {
|
|
166
|
+
const a0 = cur.args?.[0];
|
|
167
|
+
return isObj(a0) && a0.kind === 'literal' && typeof a0.value === 'string' ? a0.value : null;
|
|
168
|
+
}
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function storageIdentity(coll, callGraph, file) {
|
|
175
|
+
if (!coll || !coll.table) return { identity: null, reason: coll ? coll.reason : 'no-collection', detail: null };
|
|
176
|
+
const database = databaseOf(coll.handleExpr, callGraph, file);
|
|
177
|
+
return { identity: `storage:collection:${coll.table}`, detail: { table: coll.table, database } };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** A storage WRITE site: `<handle>.collection('<lit>').insertOne(record)` and friends. */
|
|
181
|
+
function storageWriteOf(calleeExpr, args) {
|
|
182
|
+
if (!isObj(calleeExpr) || calleeExpr.kind !== 'member') return null;
|
|
183
|
+
const argIndex = STORAGE_WRITE_METHODS[calleeExpr.prop];
|
|
184
|
+
if (argIndex === undefined) return null;
|
|
185
|
+
const coll = collectionCallOf(calleeExpr.object);
|
|
186
|
+
if (!coll) return null;
|
|
187
|
+
return { method: calleeExpr.prop, coll, recordExpr: Array.isArray(args) ? args[argIndex] ?? null : null };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** A storage READ expression: `<handle>.collection('<lit>').find(q).toArray()` / `.findOne(q)`. */
|
|
191
|
+
export function storageReadOf(expr) {
|
|
192
|
+
if (!isObj(expr) || expr.kind !== 'call' || expr.callee?.kind !== 'member') return null;
|
|
193
|
+
let e = expr;
|
|
194
|
+
if (STORAGE_READ_CHAIN_TAIL.includes(e.callee.prop)) e = e.callee.object;
|
|
195
|
+
if (!isObj(e) || e.kind !== 'call' || e.callee?.kind !== 'member') return null;
|
|
196
|
+
const cardinality = STORAGE_READ_METHODS[e.callee.prop];
|
|
197
|
+
if (!cardinality) return null;
|
|
198
|
+
const coll = collectionCallOf(e.callee.object);
|
|
199
|
+
if (!coll) return null;
|
|
200
|
+
return { method: e.callee.prop, cardinality, coll };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Every producer (a sink site that publishes to a queue or writes a store)
|
|
205
|
+
* and every consumer (an assignment whose source receives from a queue or
|
|
206
|
+
* reads a store) in the call graph. `sites` is graph-builder.js's
|
|
207
|
+
* `enumerateSinkSites(callGraph).sites`. Endpoints whose identity cannot be
|
|
208
|
+
* read are listed in `unjoined` with the reason, never silently dropped.
|
|
209
|
+
*/
|
|
210
|
+
export function collectBoundaryEndpoints(callGraph, sites) {
|
|
211
|
+
const producers = []; const consumers = []; const unjoined = [];
|
|
212
|
+
for (const site of sites || []) {
|
|
213
|
+
const cat = site.decision?.category;
|
|
214
|
+
if (cat === 'queue') {
|
|
215
|
+
const cmd = queueCommandOf(site.calleeExpr, site.args);
|
|
216
|
+
let input = cmd ? cmd.input : (site.args?.[0] ?? null);
|
|
217
|
+
let recordExpr = null;
|
|
218
|
+
if (cmd && cmd.role === 'send') recordExpr = propOf(input, cmd.record)?.value ?? null;
|
|
219
|
+
else for (const k of QUEUE_RECORD_KEYS) { const p = propOf(input, k); if (p) { recordExpr = p.value; break; } }
|
|
220
|
+
const id = queueIdentityOf(input, callGraph, site.file);
|
|
221
|
+
// A producer is kept whether or not its identity resolved: a declared
|
|
222
|
+
// contract names its producer by site, not by identity. Only a
|
|
223
|
+
// discovered join needs the identity, and only an identified
|
|
224
|
+
// producer takes part in one.
|
|
225
|
+
producers.push({ kind: 'queue', site, identity: id.identity, detail: id.detail ?? null, recordExpr: unwrapSerialization(recordExpr), provider: cmd?.provider ?? null });
|
|
226
|
+
if (!id.identity) unjoined.push({ side: 'producer', kind: 'queue', file: site.file, line: site.line, reason: id.reason });
|
|
227
|
+
} else if (cat === 'database') {
|
|
228
|
+
const w = storageWriteOf(site.calleeExpr, site.args);
|
|
229
|
+
if (!w) continue;
|
|
230
|
+
const id = storageIdentity(w.coll, callGraph, site.file);
|
|
231
|
+
producers.push({ kind: 'storage', site, identity: id.identity, detail: id.detail, recordExpr: w.recordExpr, method: w.method });
|
|
232
|
+
if (!id.identity) unjoined.push({ side: 'producer', kind: 'storage', file: site.file, line: site.line, reason: id.reason });
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
for (const fn of callGraph.functions.values()) {
|
|
236
|
+
for (const [nid, node] of Object.entries(fn.cfg?.nodes ?? {})) {
|
|
237
|
+
if (node.kind !== 'assign' || typeof node.target !== 'string' || node.target.includes('.') || !isObj(node.source) || node.source.kind !== 'call') continue;
|
|
238
|
+
const base = { file: fn.file, qid: fn.qid, nodeId: nid, line: node.line ?? null, target: node.target };
|
|
239
|
+
const cmd = queueCommandOf(node.source.callee, node.source.args);
|
|
240
|
+
if (cmd && cmd.role === 'receive') {
|
|
241
|
+
const id = queueIdentityOf(cmd.input, callGraph, fn.file);
|
|
242
|
+
const ep = { ...base, kind: 'queue', identity: id.identity, detail: id.detail ?? null, recordPath: cmd.recordPath, encoding: cmd.encoding, provider: cmd.provider };
|
|
243
|
+
if (id.identity) consumers.push(ep); else unjoined.push({ side: 'consumer', kind: 'queue', file: fn.file, line: base.line, reason: id.reason });
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
const r = storageReadOf(node.source);
|
|
247
|
+
if (r) {
|
|
248
|
+
const id = storageIdentity(r.coll, callGraph, fn.file);
|
|
249
|
+
const ep = { ...base, kind: 'storage', identity: id.identity, detail: id.detail, recordPath: '', cardinality: r.cardinality, method: r.method };
|
|
250
|
+
if (id.identity) consumers.push(ep); else unjoined.push({ side: 'consumer', kind: 'storage', file: fn.file, line: base.line, reason: id.reason });
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return { producers, consumers, unjoined };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const pathCovers = (a, b) => a === b || a.startsWith(`${b}.`) || b.startsWith(`${a}.`);
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Which data-element ids crossed at a producer site, per record key.
|
|
261
|
+
* `siteHops` are the hops stamped with the site's scope and node id: the
|
|
262
|
+
* `write-out/call-arg` hops say which ids reached the call at all, the
|
|
263
|
+
* `production`/`selection` hops say which state key each id came from. A
|
|
264
|
+
* key's ids are the reaching ids whose contributing key its value expression
|
|
265
|
+
* references.
|
|
266
|
+
*/
|
|
267
|
+
export function recordShape(recordExpr, siteHops) {
|
|
268
|
+
const reached = new Set();
|
|
269
|
+
const byKey = new Map();
|
|
270
|
+
const contributions = [];
|
|
271
|
+
for (const h of siteHops || []) {
|
|
272
|
+
if (h.kind === 'write-out' && h.subKind === 'call-arg') reached.add(h.dataElementId);
|
|
273
|
+
// An identifier read is a `production/ident` hop, a field read a
|
|
274
|
+
// `selection/member` hop; both carry the contributing state key.
|
|
275
|
+
if ((h.kind === 'production' || h.kind === 'selection') && typeof h.fromPath === 'string') contributions.push({ path: h.fromPath, id: h.dataElementId });
|
|
276
|
+
}
|
|
277
|
+
if (!reached.size) return { basis: 'none', byKey, whole: new Set() };
|
|
278
|
+
const props = isObj(recordExpr) && recordExpr.kind === 'object' ? recordExpr.props || [] : null;
|
|
279
|
+
if (!props || props.some((p) => p.spread) || !props.length) return { basis: 'whole-record', byKey, whole: new Set(reached) };
|
|
280
|
+
for (const p of props) {
|
|
281
|
+
if (typeof p.key !== 'string' || p.key === '*') continue;
|
|
282
|
+
const refs = new Set();
|
|
283
|
+
// Outermost access paths only: `body.note` references `body.note`, not
|
|
284
|
+
// `body`, or every sibling field the record carries would be joined too.
|
|
285
|
+
walkExpr(p.value, (e, parent) => {
|
|
286
|
+
if (e.kind !== 'ident' && e.kind !== 'member') return;
|
|
287
|
+
if (parent && parent.kind === 'member' && parent.object === e) return;
|
|
288
|
+
const ap = accessPathOf(e);
|
|
289
|
+
if (ap && !ap.includes('*')) refs.add(ap);
|
|
290
|
+
});
|
|
291
|
+
const ids = new Set();
|
|
292
|
+
for (const c of contributions) if (reached.has(c.id) && [...refs].some((r) => pathCovers(r, c.path))) ids.add(c.id);
|
|
293
|
+
if (ids.size) byKey.set(p.key, ids);
|
|
294
|
+
}
|
|
295
|
+
return { basis: 'object-literal', byKey, whole: new Set() };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const joinIdOf = (parts) => `join:${createHash('sha256').update(parts.join('\u0000')).digest('hex').slice(0, 12)}`;
|
|
299
|
+
|
|
300
|
+
function mintSeed({ consumer, placement, seedPath, id, join }) {
|
|
301
|
+
const canonicalName = seedPath.slice(seedPath.lastIndexOf('.') + 1);
|
|
302
|
+
return {
|
|
303
|
+
file: consumer.file, qid: consumer.qid, nodeId: consumer.nodeId, line: consumer.line,
|
|
304
|
+
entryId: `boundary:${join.kind}`, seedPath, canonicalName, seedKind: placement,
|
|
305
|
+
category: join.kind === 'queue' ? 'queue-message' : 'database-read', coverageStatus: 'modeled', externality: 'unknown',
|
|
306
|
+
reason: `${join.kind} handoff ${join.provenance === 'manual' ? 'declared by contract ' + join.contractId : 'discovered from the literal identity ' + join.identity}; modeled, not observed`,
|
|
307
|
+
dataElementId: id, dataClasses: classifyDataElementName(canonicalName).classes,
|
|
308
|
+
boundary: {
|
|
309
|
+
joinId: join.joinId, kind: join.kind, identity: join.identity, provenance: join.provenance, contractId: join.contractId ?? null,
|
|
310
|
+
basis: join.basis, producer: join.producer, modeledInput: true, observed: false, exploited: false,
|
|
311
|
+
},
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Plan the consumer seeds for every join the current hop stream supports.
|
|
317
|
+
* `minted` is the cross-round set of seed keys already handed out, so a
|
|
318
|
+
* round only reports what is new. Returns `{seeds, joins, unjoined, gaps}`.
|
|
319
|
+
*/
|
|
320
|
+
export function planBoundaryJoins({ endpoints, hops, contracts = [], callGraph, minted = new Set() }) {
|
|
321
|
+
const hopsBySite = new Map();
|
|
322
|
+
for (const h of hops || []) {
|
|
323
|
+
const k = `${h.scope}|${h.nodeId}`;
|
|
324
|
+
if (!hopsBySite.has(k)) hopsBySite.set(k, []);
|
|
325
|
+
hopsBySite.get(k).push(h);
|
|
326
|
+
}
|
|
327
|
+
const seeds = []; const joins = []; const unjoined = [...(endpoints.unjoined || [])]; const gaps = [];
|
|
328
|
+
const siteKey = (s) => `${s.qid}|${s.nodeId}`;
|
|
329
|
+
const shapeOf = (p) => recordShape(p.recordExpr, hopsBySite.get(siteKey(p.site)) ?? []);
|
|
330
|
+
const emit = (consumer, producer, placement, basePath, fieldMappings, join) => {
|
|
331
|
+
const shape = shapeOf(producer);
|
|
332
|
+
const fields = [];
|
|
333
|
+
const add = (path, id, from, to) => {
|
|
334
|
+
const key = `${consumer.qid}|${consumer.nodeId}|${path}|${id}`;
|
|
335
|
+
if (minted.has(key)) return;
|
|
336
|
+
minted.add(key);
|
|
337
|
+
seeds.push(mintSeed({ consumer, placement, seedPath: path, id, join: { ...join, basis: shape.basis } }));
|
|
338
|
+
fields.push({ from, to, dataElementId: id });
|
|
339
|
+
};
|
|
340
|
+
if (fieldMappings) {
|
|
341
|
+
for (const m of fieldMappings) {
|
|
342
|
+
const ids = shape.byKey.get(m.from) ?? (shape.basis === 'whole-record' ? shape.whole : new Set());
|
|
343
|
+
for (const id of ids) add([basePath, m.to].filter(Boolean).join('.'), id, m.from, m.to);
|
|
344
|
+
}
|
|
345
|
+
} else {
|
|
346
|
+
for (const [key, ids] of shape.byKey) for (const id of ids) add([basePath, key].filter(Boolean).join('.'), id, key, key);
|
|
347
|
+
for (const id of shape.whole) add(basePath, id, null, null);
|
|
348
|
+
}
|
|
349
|
+
joins.push({ ...join, basis: shape.basis, fields, consumer: { file: consumer.file, line: consumer.line, qid: consumer.qid, nodeId: consumer.nodeId }, reached: shape.whole.size + [...shape.byKey.values()].reduce((n, s) => n + s.size, 0) });
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
// Discovered joins: the same literal identity on both sides.
|
|
353
|
+
for (const c of endpoints.consumers) {
|
|
354
|
+
const partners = endpoints.producers.filter((p) => p.kind === c.kind && p.identity && p.identity === c.identity
|
|
355
|
+
&& !(p.kind === 'storage' && p.detail?.database && c.detail?.database && p.detail.database !== c.detail.database));
|
|
356
|
+
if (!partners.length) { unjoined.push({ side: 'consumer', kind: c.kind, file: c.file, line: c.line, identity: c.identity, reason: 'no-producer-in-scope' }); continue; }
|
|
357
|
+
for (const p of partners) {
|
|
358
|
+
const producer = { file: p.site.file, line: p.site.line, qid: p.site.qid, nodeId: p.site.nodeId };
|
|
359
|
+
const join = { joinId: joinIdOf([c.kind, c.identity, siteKey(p.site), `${c.qid}|${c.nodeId}`]), kind: c.kind, identity: c.identity, provenance: 'code', contractId: null, producer };
|
|
360
|
+
emit(c, p, 'after-node', [c.target, c.recordPath].filter(Boolean).join('.'), null, join);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
// Declared joins: a DAG-08 contract bound to a consumer parameter; the
|
|
364
|
+
// producer is the contract's producer site, matched by file and function.
|
|
365
|
+
const bound = contracts.length ? bindBoundaryContracts(contracts, callGraph) : { bindings: [], gaps: [], rejected: [] };
|
|
366
|
+
gaps.push(...bound.gaps.map((g) => ({ ...g, source: 'contract' })), ...bound.rejected.map((r) => ({ contractId: r.contractId, kind: 'rejected-contract', errors: r.errors, source: 'contract' })));
|
|
367
|
+
for (const b of bound.bindings) {
|
|
368
|
+
const contract = contracts.find((c) => c.id === b.contractId);
|
|
369
|
+
const psite = contract?.producer?.site;
|
|
370
|
+
if (!psite) { gaps.push({ contractId: b.contractId, kind: 'unbound-boundary', side: 'producer', reason: 'producer-site-required', detail: 'a lineage join needs the producer site to read which fields reached it', source: 'contract' }); continue; }
|
|
371
|
+
const norm = (f) => String(f || '').replace(/^\.\//, '').replace(/\\/g, '/');
|
|
372
|
+
const partners = endpoints.producers.filter((p) => p.kind === b.kind && norm(p.site.file) === norm(psite.file)
|
|
373
|
+
&& (() => { const fn = callGraph.functions.get(p.site.qid) ?? [...callGraph.functions.values()].find((f) => f.qid === p.site.qid); return fn && (fn.name === psite.function || String(fn.name).endsWith(`.${psite.function}`)); })());
|
|
374
|
+
if (!partners.length) { gaps.push({ contractId: b.contractId, kind: 'unbound-boundary', side: 'producer', reason: 'producer-site-not-a-' + b.kind + '-site', detail: `${psite.function} in ${psite.file}`, source: 'contract' }); continue; }
|
|
375
|
+
const consumer = { file: b.file, qid: b.qid, nodeId: null, line: b.line, target: b.param };
|
|
376
|
+
const recordPath = contract.consumer.site.recordPath || '';
|
|
377
|
+
for (const p of partners) {
|
|
378
|
+
const producer = { file: p.site.file, line: p.site.line, qid: p.site.qid, nodeId: p.site.nodeId };
|
|
379
|
+
const join = { joinId: joinIdOf([b.kind, b.contractId, siteKey(p.site), b.qid]), kind: b.kind, identity: b.channel, provenance: 'manual', contractId: b.contractId, producer };
|
|
380
|
+
emit(consumer, p, 'entry', [b.param, recordPath].filter(Boolean).join('.'), b.mappedFields, join);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
return { seeds, joins, unjoined, gaps };
|
|
384
|
+
}
|
package/src/lineage/coverage.js
CHANGED
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
|
|
57
57
|
import { reclassifySink } from './sink-registry.js';
|
|
58
58
|
import { DEFAULTS as PATH_QUERY_DEFAULTS } from './path-query.js';
|
|
59
|
-
import { buildDataFlowGraph } from './graph-builder.js';
|
|
59
|
+
import { buildDataFlowGraph, boundaryCoverage } from './graph-builder.js';
|
|
60
60
|
// Milestone 2, Sub-project A, increment 1: `resolveDestination` composes
|
|
61
61
|
// with `resolveSiteDecision` below the same way it's imported here — a real
|
|
62
62
|
// ES module cycle (resolve-destination.js imports THREE things back from
|
|
@@ -438,6 +438,9 @@ export function buildCoverageLedger(built, opts = {}) {
|
|
|
438
438
|
|
|
439
439
|
degradedTerminals: built.degraded.length,
|
|
440
440
|
unresolvedDestinations,
|
|
441
|
+
// GA-03.3: present only when the build opted into handoff joins, so a
|
|
442
|
+
// ledger from a build that did not is unchanged.
|
|
443
|
+
...(built.boundaries ? { boundaries: boundaryCoverage(built.boundaries) } : {}),
|
|
441
444
|
|
|
442
445
|
paths: { enumerated: built.stats.pathsEnumerated, projected: built.stats.pathsProjected, truncatedQueries: built.stats.truncatedQueries },
|
|
443
446
|
budgets: { ...PATH_QUERY_DEFAULTS, ...(opts.budget ?? {}) },
|
package/src/lineage/engine.js
CHANGED
|
@@ -74,6 +74,13 @@ function isV2(ctx) {
|
|
|
74
74
|
// the derivation path of the level that emitted it (the innermost stamp
|
|
75
75
|
// wins, because an inner wrapper's fields are already on the hop when the
|
|
76
76
|
// outer wrapper sees it).
|
|
77
|
+
/** `JSON.parse` / `JSON.stringify` (GA-03.3): the one call pair the resolver treats as structure-preserving. */
|
|
78
|
+
function isJsonRoundTrip(callee) {
|
|
79
|
+
if (typeof callee === 'string') return callee === 'JSON.parse' || callee === 'JSON.stringify';
|
|
80
|
+
return !!callee && callee.kind === 'member' && (callee.prop === 'parse' || callee.prop === 'stringify')
|
|
81
|
+
&& callee.object?.kind === 'ident' && callee.object.name === 'JSON';
|
|
82
|
+
}
|
|
83
|
+
|
|
77
84
|
function sub(ctx, seg, stamp) {
|
|
78
85
|
if (!isV2(ctx)) return ctx;
|
|
79
86
|
const derivation = deriveChild(ctx.derivation, seg);
|
|
@@ -610,6 +617,19 @@ export function resolveExprIdentities(state, expr, ctx) {
|
|
|
610
617
|
return { flat, byPath, widened: false };
|
|
611
618
|
}
|
|
612
619
|
}
|
|
620
|
+
// GA-03.3 (0.195.25): a JSON round trip is structure-PRESERVING.
|
|
621
|
+
// `JSON.stringify(v)` carries exactly v's fields (serialized) and
|
|
622
|
+
// `JSON.parse(s)` yields exactly the fields s was serialized from, so
|
|
623
|
+
// both forward the first argument's `{flat, byPath, widened}`
|
|
624
|
+
// unchanged, the way `assign-expr` forwards its source: the field a
|
|
625
|
+
// queue consumer reads off `JSON.parse(m.Body)` keeps the identity the
|
|
626
|
+
// producer put under that key instead of collapsing to the whole
|
|
627
|
+
// record's flat union. The argument's own recursion emits its hops;
|
|
628
|
+
// nothing is recorded for the call itself (there is no new value).
|
|
629
|
+
// The replacer/reviver/space arguments never carry the value.
|
|
630
|
+
if (isJsonRoundTrip(expr.callee) && (expr.args ?? []).length >= 1) {
|
|
631
|
+
return resolveExprIdentities(state, expr.args[0], sub(ctx, 'a0'));
|
|
632
|
+
}
|
|
613
633
|
const flat = new Set();
|
|
614
634
|
// Hotfix (found during Sub-project E1's design spike; see
|
|
615
635
|
// DESIGN_GRAPH_BUILDER.md §11): an unresolved method call's RECEIVER
|