@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
|
@@ -23,6 +23,11 @@
|
|
|
23
23
|
|
|
24
24
|
import { pathId } from './ids.js';
|
|
25
25
|
|
|
26
|
+
// Bound on the call-context stack a version 2 walk tracks. Kept here, not
|
|
27
|
+
// imported from correlation.js, because this module's only import is ids.js
|
|
28
|
+
// (§15.1); correlation.test.js pins the two values equal.
|
|
29
|
+
export const DEFAULT_MAX_CALL_DEPTH = 8;
|
|
30
|
+
|
|
26
31
|
/**
|
|
27
32
|
* All `opts`-overridable. Two-plus orders of magnitude above what every
|
|
28
33
|
* fixture measured in the design task's PoC needs (§15.11's largest row
|
|
@@ -35,10 +40,38 @@ export const DEFAULTS = {
|
|
|
35
40
|
maxPaths: 32,
|
|
36
41
|
maxPathsPerTerminal: 8,
|
|
37
42
|
maxCandidatePaths: 256,
|
|
43
|
+
// R07.5: total hops copied into candidate paths. Each candidate costs its
|
|
44
|
+
// length, so this bounds output memory independently of the candidate count.
|
|
45
|
+
maxMaterializedHops: 1000000,
|
|
38
46
|
maxExpansions: 10000,
|
|
39
47
|
maxDepth: 64,
|
|
40
48
|
};
|
|
41
49
|
|
|
50
|
+
// DAG-07 R07.4: documented upper bounds. A budget must be a finite,
|
|
51
|
+
// nonnegative integer no larger than its bound; anything else (NaN,
|
|
52
|
+
// Infinity, negative, fractional, a string) makes the query `invalid`
|
|
53
|
+
// rather than silently running unbounded or being coerced.
|
|
54
|
+
export const BUDGET_UPPER_BOUNDS = {
|
|
55
|
+
maxPaths: 1e7,
|
|
56
|
+
maxPathsPerTerminal: 1e7,
|
|
57
|
+
maxCandidatePaths: 1e9,
|
|
58
|
+
maxMaterializedHops: 1e9,
|
|
59
|
+
maxExpansions: 1e9,
|
|
60
|
+
maxDepth: 1e6,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export function validateBudget(opts) {
|
|
64
|
+
const errors = [];
|
|
65
|
+
for (const [k, v] of Object.entries(opts ?? {})) {
|
|
66
|
+
if (!(k in BUDGET_UPPER_BOUNDS)) continue;
|
|
67
|
+
if (v === undefined) continue;
|
|
68
|
+
if (!Number.isInteger(v) || v < 0 || v > BUDGET_UPPER_BOUNDS[k]) {
|
|
69
|
+
errors.push(`${k} must be an integer in [0, ${BUDGET_UPPER_BOUNDS[k]}], got ${typeof v === 'number' ? String(v) : typeof v}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return errors;
|
|
73
|
+
}
|
|
74
|
+
|
|
42
75
|
// §15.4's terminal-reason vocabulary. Only 'origin' means "this really is
|
|
43
76
|
// where the recorded flow starts" (`complete: true`). 'expansion-budget'
|
|
44
77
|
// and 'candidate-cap' are deliberately NOT terminal reasons — see the loop
|
|
@@ -48,6 +81,9 @@ const TERMINAL_ORIGIN = 'origin';
|
|
|
48
81
|
const TERMINAL_INCOMPLETE = 'incomplete-record';
|
|
49
82
|
const TERMINAL_CYCLE = 'cycle';
|
|
50
83
|
const TERMINAL_DEPTH = 'depth-limit';
|
|
84
|
+
// Version 2 only: every incoming edge of the node would have returned to a
|
|
85
|
+
// call site other than the one this walk entered through.
|
|
86
|
+
const TERMINAL_UNBALANCED = 'unbalanced-call';
|
|
51
87
|
|
|
52
88
|
/**
|
|
53
89
|
* §15.9's stand-in for Sub-project D's source/sink registry, which does not
|
|
@@ -89,6 +125,13 @@ function hopOf(edge) {
|
|
|
89
125
|
annotations: edge.annotations,
|
|
90
126
|
originated: edge.originated,
|
|
91
127
|
truncated: edge.truncated,
|
|
128
|
+
// Version 2 edges only; a version 1 hop has exactly the fields it had.
|
|
129
|
+
...(edge.correlationVersion ? {
|
|
130
|
+
correlationVersion: edge.correlationVersion,
|
|
131
|
+
correlation: edge.correlation,
|
|
132
|
+
callBoundary: edge.callBoundary,
|
|
133
|
+
invocationId: edge.invocationId,
|
|
134
|
+
} : null),
|
|
92
135
|
};
|
|
93
136
|
}
|
|
94
137
|
|
|
@@ -98,7 +141,7 @@ function hopOf(edge) {
|
|
|
98
141
|
* a flow source -> sink, so the arrays are reversed here, once, at the
|
|
99
142
|
* point a candidate is emitted.
|
|
100
143
|
*/
|
|
101
|
-
function materialize(store, nodesRev, edgesRev, terminalReason) {
|
|
144
|
+
function materialize(store, nodesRev, edgesRev, terminalReason, callContext = null) {
|
|
102
145
|
const nodeIds = [...nodesRev].reverse();
|
|
103
146
|
const edges = [...edgesRev].reverse();
|
|
104
147
|
const hops = edges.map((e) => hopOf(e));
|
|
@@ -138,6 +181,9 @@ function materialize(store, nodesRev, edgesRev, terminalReason) {
|
|
|
138
181
|
lossHopCount,
|
|
139
182
|
ambiguousHopCount,
|
|
140
183
|
analysisTruncated,
|
|
184
|
+
// Correlation version 2 only (absent, not null, in version 1): whether the
|
|
185
|
+
// walk had to abstract the call context to stay within its bound.
|
|
186
|
+
...(callContext ? { callContext } : null),
|
|
141
187
|
// §15.7's diversity signature. `transformation` and `protection` are
|
|
142
188
|
// DELIBERATELY absent — Sub-project D / Milestone 2 own them; using
|
|
143
189
|
// today's widen/loss reasons as a stand-in for either would be
|
|
@@ -187,6 +233,7 @@ export function comparePaths(a, b) {
|
|
|
187
233
|
* (§18.4's single most load-bearing constraint).
|
|
188
234
|
*/
|
|
189
235
|
export function reconstructPaths(store, startNodeId, opts = {}) {
|
|
236
|
+
const budgetErrors = validateBudget(opts);
|
|
190
237
|
const budget = { ...DEFAULTS, ...opts };
|
|
191
238
|
const startNode = store.getNode(startNodeId);
|
|
192
239
|
const truncationReasons = new Set();
|
|
@@ -206,36 +253,125 @@ export function reconstructPaths(store, startNodeId, opts = {}) {
|
|
|
206
253
|
cyclesClipped: 0,
|
|
207
254
|
terminals: [],
|
|
208
255
|
analysisTruncated: false,
|
|
256
|
+
status: 'complete',
|
|
257
|
+
negativeClaimAllowed: false,
|
|
209
258
|
budget: { ...budget, expansionsUsed: 0 },
|
|
210
259
|
};
|
|
260
|
+
if (budgetErrors.length) {
|
|
261
|
+
// Invalid input is neither "no path" nor a partial answer.
|
|
262
|
+
result.status = 'invalid';
|
|
263
|
+
result.budgetErrors = budgetErrors;
|
|
264
|
+
result.budget = { ...DEFAULTS, expansionsUsed: 0 };
|
|
265
|
+
return result;
|
|
266
|
+
}
|
|
211
267
|
// An id that names no node is NOT "no path exists" — it is "you asked
|
|
212
268
|
// about something that is not in this store". §15.4 keeps the two apart,
|
|
213
269
|
// because collapsing them is §18.4's failure mode wearing a different hat.
|
|
214
|
-
if (!startNode)
|
|
270
|
+
if (!startNode) {
|
|
271
|
+
result.status = 'partial';
|
|
272
|
+
// A store that refused input may simply not contain the node asked for.
|
|
273
|
+
if (typeof store.limitStatus === 'function' && store.limitStatus().hit) {
|
|
274
|
+
result.truncated = true;
|
|
275
|
+
result.truncationReasons = ['store-limit'];
|
|
276
|
+
}
|
|
277
|
+
return result;
|
|
278
|
+
}
|
|
215
279
|
|
|
216
280
|
const orphaned = new Set(store.diagnostics().orphanedPeerSources.map((o) => o.nodeId));
|
|
217
281
|
|
|
282
|
+
// Correlation version 2 (DAG-03, R03.5): a precise witness is a REALIZABLE
|
|
283
|
+
// path, so a return edge may only be followed back to the call it returned
|
|
284
|
+
// to. `path.calls` is the stack of invocations the backward walk has
|
|
285
|
+
// entered (a return edge pushes its invocation, the matching call edge
|
|
286
|
+
// pops it). Past `maxCallDepth` the stack is abstracted: further entries
|
|
287
|
+
// are only counted (`overflow`), a call edge met while any are pending is
|
|
288
|
+
// accepted without a match, and the path says so in `callContext`.
|
|
289
|
+
const v2 = typeof store.correlationVersion === 'function' && store.correlationVersion() === 2;
|
|
290
|
+
const maxCallDepth = Number.isInteger(opts.maxCallDepth) && opts.maxCallDepth >= 1 ? opts.maxCallDepth : DEFAULT_MAX_CALL_DEPTH;
|
|
291
|
+
const balancing = { enabled: v2, maxCallDepth, unbalancedPruned: 0, prunedEdgeIds: [], contextWidenedPaths: 0 };
|
|
292
|
+
if (v2) {
|
|
293
|
+
result.correlationVersion = 2;
|
|
294
|
+
result.budget.maxCallDepth = maxCallDepth;
|
|
295
|
+
}
|
|
296
|
+
|
|
218
297
|
const candidates = [];
|
|
219
298
|
let expansions = 0;
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
299
|
+
let materializedHops = 0;
|
|
300
|
+
const materializeCounted = (n, e, reason) => { materializedHops += e.length; return materialize(store, n, e, reason, ctxOf()); };
|
|
301
|
+
// DAG-07 R07.6: ONE shared path (arrays + visited set) that is unwound on
|
|
302
|
+
// backtrack, instead of copying both for every frame (which made a chain
|
|
303
|
+
// of depth d cost O(d^2)). A frame is only {nodeId, edge, hops}; visit
|
|
304
|
+
// order and expansion accounting are identical to the copying version.
|
|
305
|
+
const path = {
|
|
306
|
+
nodesRev: [startNodeId], edgesRev: [], onPath: new Set([startNodeId]),
|
|
307
|
+
calls: [], overflow: 0, effects: [], widened: 0,
|
|
308
|
+
};
|
|
309
|
+
const ctxOf = () => (v2 ? { balanced: path.widened === 0, widened: path.widened > 0, maxCallDepth } : null);
|
|
310
|
+
const stack = [{ nodeId: startNodeId, edge: null, hops: 0 }];
|
|
311
|
+
// R07.9: a continuation (continuation.js) hands back the frontier a
|
|
312
|
+
// previous call stopped at. Shape is checked, never trusted blindly; a
|
|
313
|
+
// malformed one makes the query invalid rather than silently restarting.
|
|
314
|
+
if (opts.resumeState !== undefined) {
|
|
315
|
+
const rs = opts.resumeState;
|
|
316
|
+
const ok = rs && rs.startNodeId === startNodeId && Array.isArray(rs.nodesRev) && Array.isArray(rs.edgesRev)
|
|
317
|
+
&& Array.isArray(rs.stack) && rs.nodesRev.length === rs.edgesRev.length + 1;
|
|
318
|
+
if (!ok) {
|
|
319
|
+
result.status = 'invalid';
|
|
320
|
+
result.budgetErrors = ['resumeState is malformed or belongs to a different start node'];
|
|
321
|
+
return result;
|
|
322
|
+
}
|
|
323
|
+
path.nodesRev = rs.nodesRev.slice();
|
|
324
|
+
path.edgesRev = rs.edgesRev.slice();
|
|
325
|
+
path.onPath = new Set(rs.nodesRev);
|
|
326
|
+
stack.length = 0;
|
|
327
|
+
for (const f of rs.stack) stack.push({ ...f });
|
|
328
|
+
result.resumed = true;
|
|
329
|
+
}
|
|
226
330
|
|
|
227
331
|
while (stack.length > 0) {
|
|
228
332
|
if (candidates.length >= budget.maxCandidatePaths) { truncationReasons.add('candidate-cap'); break; }
|
|
333
|
+
if (materializedHops >= budget.maxMaterializedHops) { truncationReasons.add('materialization-budget'); break; }
|
|
229
334
|
if (expansions >= budget.maxExpansions) { truncationReasons.add('expansion-budget'); break; }
|
|
230
335
|
const frame = stack.pop();
|
|
231
|
-
const hops = frame.
|
|
336
|
+
const hops = frame.hops;
|
|
337
|
+
// Unwind to this frame's parent, then step onto the frame.
|
|
338
|
+
const parentLen = hops === 0 ? 0 : hops - 1;
|
|
339
|
+
while (path.edgesRev.length > parentLen) {
|
|
340
|
+
path.edgesRev.pop();
|
|
341
|
+
path.onPath.delete(path.nodesRev.pop());
|
|
342
|
+
if (v2) {
|
|
343
|
+
const eff = path.effects.pop();
|
|
344
|
+
if (eff?.push) path.calls.pop();
|
|
345
|
+
if (eff?.pop) path.calls.push(eff.pop);
|
|
346
|
+
if (eff?.overflowInc) path.overflow -= 1;
|
|
347
|
+
if (eff?.overflowDec) path.overflow += 1;
|
|
348
|
+
if (eff?.widened) path.widened -= 1;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
if (frame.edge) {
|
|
352
|
+
if (v2) {
|
|
353
|
+
const eff = {};
|
|
354
|
+
const b = frame.edge.callBoundary;
|
|
355
|
+
if (b === 'return') {
|
|
356
|
+
if (path.calls.length < maxCallDepth) { path.calls.push(frame.edge.invocationId); eff.push = true; }
|
|
357
|
+
else { path.overflow += 1; eff.overflowInc = true; eff.widened = true; path.widened += 1; }
|
|
358
|
+
} else if (b === 'call') {
|
|
359
|
+
if (path.overflow > 0) { path.overflow -= 1; eff.overflowDec = true; eff.widened = true; path.widened += 1; }
|
|
360
|
+
else if (path.calls.length > 0) eff.pop = path.calls.pop();
|
|
361
|
+
}
|
|
362
|
+
path.effects.push(eff);
|
|
363
|
+
}
|
|
364
|
+
path.edgesRev.push(frame.edge);
|
|
365
|
+
path.nodesRev.push(frame.nodeId);
|
|
366
|
+
path.onPath.add(frame.nodeId);
|
|
367
|
+
}
|
|
232
368
|
// `edgesTo` is the traversal primitive; sorted for determinism, since
|
|
233
369
|
// it is backed by a Set and carries no inherent order.
|
|
234
370
|
const incoming = [...store.edgesTo(frame.nodeId)].sort((x, y) => (x.id < y.id ? -1 : x.id > y.id ? 1 : 0));
|
|
235
371
|
|
|
236
372
|
if (incoming.length === 0) {
|
|
237
373
|
if (hops > 0) {
|
|
238
|
-
candidates.push(
|
|
374
|
+
candidates.push(materializeCounted(path.nodesRev, path.edgesRev,
|
|
239
375
|
orphaned.has(frame.nodeId) ? TERMINAL_INCOMPLETE : TERMINAL_ORIGIN));
|
|
240
376
|
}
|
|
241
377
|
continue;
|
|
@@ -265,33 +401,56 @@ export function reconstructPaths(store, startNodeId, opts = {}) {
|
|
|
265
401
|
// literal zero-hop budget will not get one.
|
|
266
402
|
if (hops >= budget.maxDepth && hops > 0) {
|
|
267
403
|
truncationReasons.add('depth-limit');
|
|
268
|
-
candidates.push(
|
|
404
|
+
candidates.push(materializeCounted(path.nodesRev, path.edgesRev, TERMINAL_DEPTH));
|
|
269
405
|
continue;
|
|
270
406
|
}
|
|
271
407
|
|
|
272
408
|
let extended = 0;
|
|
273
409
|
let clipped = 0;
|
|
410
|
+
let unbalanced = 0;
|
|
274
411
|
for (const e of incoming) {
|
|
275
412
|
expansions += 1;
|
|
276
|
-
if (
|
|
413
|
+
if (v2 && e.callBoundary === 'call' && path.overflow === 0 && path.calls.length > 0
|
|
414
|
+
&& path.calls[path.calls.length - 1] !== e.invocationId) {
|
|
415
|
+
// The walk entered the callee through another call site, so this
|
|
416
|
+
// edge belongs to a call/return pair no execution can produce.
|
|
417
|
+
unbalanced += 1;
|
|
418
|
+
balancing.unbalancedPruned += 1;
|
|
419
|
+
if (balancing.prunedEdgeIds.length < 10) balancing.prunedEdgeIds.push(e.id);
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
if (path.onPath.has(e.fromNodeId)) { clipped += 1; continue; }
|
|
277
423
|
extended += 1;
|
|
278
|
-
stack.push({
|
|
279
|
-
nodeId: e.fromNodeId,
|
|
280
|
-
nodesRev: [...frame.nodesRev, e.fromNodeId],
|
|
281
|
-
edgesRev: [...frame.edgesRev, e],
|
|
282
|
-
onPath: new Set([...frame.onPath, e.fromNodeId]),
|
|
283
|
-
});
|
|
424
|
+
stack.push({ nodeId: e.fromNodeId, edge: e, hops: hops + 1 });
|
|
284
425
|
}
|
|
285
426
|
result.cyclesClipped += clipped;
|
|
427
|
+
if (extended === 0 && clipped === 0 && unbalanced > 0 && hops > 0) {
|
|
428
|
+
// Everything that led here is a mismatched call/return pair. Say so,
|
|
429
|
+
// as a partial path, instead of letting an empty answer read as
|
|
430
|
+
// "nothing flows".
|
|
431
|
+
candidates.push(materialize(store, path.nodesRev, path.edgesRev, TERMINAL_UNBALANCED, ctxOf()));
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
286
434
|
if (extended === 0 && hops > 0) {
|
|
287
435
|
// Every continuation would revisit a node already on this path. The
|
|
288
436
|
// branch ends HERE, and it ends because of a cycle — never silently
|
|
289
437
|
// as if this node were the flow's origin.
|
|
290
|
-
candidates.push(
|
|
438
|
+
candidates.push(materializeCounted(path.nodesRev, path.edgesRev, TERMINAL_CYCLE));
|
|
291
439
|
}
|
|
292
440
|
}
|
|
293
441
|
|
|
294
442
|
result.budget.expansionsUsed = expansions;
|
|
443
|
+
// R07.1: work still queued when a budget stopped the walk (0 when exhausted).
|
|
444
|
+
result.unexploredFrontierCount = stack.length;
|
|
445
|
+
if (stack.length > 0) {
|
|
446
|
+
result.resumeState = { startNodeId, nodesRev: path.nodesRev.slice(), edgesRev: path.edgesRev.slice(), stack: stack.map((f) => ({ ...f })) };
|
|
447
|
+
}
|
|
448
|
+
result.budget.materializedHops = materializedHops;
|
|
449
|
+
if (v2) {
|
|
450
|
+
balancing.contextWidenedPaths = candidates.filter((p) => p.callContext?.widened).length;
|
|
451
|
+
balancing.prunedEdgeIds.sort();
|
|
452
|
+
result.balancing = balancing;
|
|
453
|
+
}
|
|
295
454
|
result.enumeratedPathCount = candidates.length;
|
|
296
455
|
// Task 2 review finding 1: this is scoped to EVERY complete path found
|
|
297
456
|
// during enumeration, not just the ones the caps below end up returning
|
|
@@ -384,6 +543,9 @@ export function reconstructPaths(store, startNodeId, opts = {}) {
|
|
|
384
543
|
}
|
|
385
544
|
result.terminals = terminals;
|
|
386
545
|
|
|
546
|
+
// R07.5: a store that refused hops/edges or skipped a group is missing
|
|
547
|
+
// pairings this query cannot see, so it can never prove "no path".
|
|
548
|
+
if (typeof store.limitStatus === 'function' && store.limitStatus().hit) truncationReasons.add('store-limit');
|
|
387
549
|
result.truncated = truncationReasons.size > 0 || result.droppedPathCount > 0;
|
|
388
550
|
result.truncationReasons = [...truncationReasons].sort();
|
|
389
551
|
|
|
@@ -395,6 +557,13 @@ export function reconstructPaths(store, startNodeId, opts = {}) {
|
|
|
395
557
|
// `noPathReason` — never a stylistic choice.
|
|
396
558
|
result.noPathReason = orphaned.has(startNodeId) ? TERMINAL_INCOMPLETE : 'no-incoming-edges';
|
|
397
559
|
}
|
|
560
|
+
result.status = isIncompleteAnswer(result) ? 'partial' : 'complete';
|
|
561
|
+
// "No modeled path" is allowed only for an exhausted query; it is a
|
|
562
|
+
// statement about the modeled graph, never about the software.
|
|
563
|
+
// A resumed page only describes the work done since the continuation, so it
|
|
564
|
+
// can never license a negative claim about the whole query.
|
|
565
|
+
result.negativeClaimAllowed = result.status === 'complete' && result.paths.length === 0 && !result.resumed
|
|
566
|
+
&& result.noPathReason === 'no-incoming-edges';
|
|
398
567
|
return result;
|
|
399
568
|
}
|
|
400
569
|
|
|
@@ -404,7 +573,7 @@ export function reconstructPaths(store, startNodeId, opts = {}) {
|
|
|
404
573
|
* term).
|
|
405
574
|
*/
|
|
406
575
|
export function isIncompleteAnswer(result) {
|
|
407
|
-
return result.truncated || result.unknownStartNode || result.analysisTruncated
|
|
576
|
+
return result.status === 'invalid' || result.truncated || result.unknownStartNode || result.analysisTruncated
|
|
408
577
|
|| result.noPathReason === TERMINAL_INCOMPLETE
|
|
409
578
|
|| result.paths.some((p) => !p.complete);
|
|
410
579
|
}
|
|
@@ -21,7 +21,11 @@
|
|
|
21
21
|
// C5's job and C5's alone.
|
|
22
22
|
//
|
|
23
23
|
|
|
24
|
+
import { createHash } from 'node:crypto';
|
|
24
25
|
import { provenanceNodeId, provenanceEdgeId } from './ids.js';
|
|
26
|
+
import {
|
|
27
|
+
CORRELATION_VERSION_2, V2_HOP_FIELDS, resolveCorrelationVersion, pairCompatibility, buildCrosswalk,
|
|
28
|
+
} from './correlation.js';
|
|
25
29
|
|
|
26
30
|
// §3 + §13.0's exact 14-field hop shape, in a FIXED order. Never derive a
|
|
27
31
|
// dedupe/discriminator key via `Object.keys(h)` — that is order-dependent,
|
|
@@ -40,8 +44,11 @@ export const HOP_FIELDS = [
|
|
|
40
44
|
|
|
41
45
|
const IN_KINDS = new Set(['production', 'selection']);
|
|
42
46
|
|
|
43
|
-
function hopDedupeKey(h) {
|
|
44
|
-
|
|
47
|
+
function hopDedupeKey(h, v2 = false) {
|
|
48
|
+
const base = HOP_FIELDS.map((f) => JSON.stringify(h[f] ?? null)).join('|');
|
|
49
|
+
// Version 2 hops carry additive evidence fields; two hops that differ only
|
|
50
|
+
// in that evidence are different hops. Version 1 never reads them.
|
|
51
|
+
return v2 ? `${base}|${V2_HOP_FIELDS.map((f) => JSON.stringify(h[f] ?? null)).join('|')}` : base;
|
|
45
52
|
}
|
|
46
53
|
|
|
47
54
|
function joinKeyOf(h) {
|
|
@@ -149,8 +156,31 @@ export function classifyOut(h) {
|
|
|
149
156
|
return { role: 'unclassified', crossScope: false, node: null };
|
|
150
157
|
}
|
|
151
158
|
|
|
159
|
+
// DAG-07 R07.5: construction bounds. Hitting one never throws and never
|
|
160
|
+
// silently drops: it is recorded in diagnostics().limits and makes every
|
|
161
|
+
// reconstruction over this store report `store-limit` truncation.
|
|
162
|
+
export const STORE_LIMIT_DEFAULTS = {
|
|
163
|
+
maxHops: 2000000,
|
|
164
|
+
maxPairingsPerGroup: 10000,
|
|
165
|
+
maxEdges: 5000000,
|
|
166
|
+
};
|
|
167
|
+
|
|
152
168
|
export class PathStore {
|
|
153
|
-
|
|
169
|
+
/**
|
|
170
|
+
* `options.correlationVersion` (or AGENTIC_SECURITY_LINEAGE_CORRELATION=2)
|
|
171
|
+
* selects provenance correlation version 2 (DAG-03): value-versioned path
|
|
172
|
+
* nodes, evidence-gated pairing of the two hop halves and call-boundary
|
|
173
|
+
* edges that carry their invocation. The default is version 1, whose nodes,
|
|
174
|
+
* edges, ids and diagnostics are byte-identical to the pre-DAG-03 store.
|
|
175
|
+
*/
|
|
176
|
+
constructor(limits = {}, options = {}) {
|
|
177
|
+
this._v2 = resolveCorrelationVersion(options?.correlationVersion) === CORRELATION_VERSION_2;
|
|
178
|
+
this._limits = { ...STORE_LIMIT_DEFAULTS };
|
|
179
|
+
for (const k of Object.keys(STORE_LIMIT_DEFAULTS)) {
|
|
180
|
+
const v = limits?.[k];
|
|
181
|
+
if (Number.isInteger(v) && v >= 0) this._limits[k] = v;
|
|
182
|
+
}
|
|
183
|
+
this._limitHits = { hopsRefused: 0, groupsSkipped: [], edgesRefused: 0, summarizedGroups: 0 };
|
|
154
184
|
this._groups = new Map();
|
|
155
185
|
this._seen = new Set();
|
|
156
186
|
this._truncations = new Map();
|
|
@@ -178,8 +208,12 @@ export class PathStore {
|
|
|
178
208
|
if (missing.length > 0) {
|
|
179
209
|
this._malformed.push({ hop, missing });
|
|
180
210
|
}
|
|
181
|
-
const k = hopDedupeKey(hop);
|
|
211
|
+
const k = hopDedupeKey(hop, this._v2);
|
|
182
212
|
if (this._seen.has(k)) return false;
|
|
213
|
+
if (this._stats.hopsAccepted >= this._limits.maxHops) {
|
|
214
|
+
this._limitHits.hopsRefused += 1;
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
183
217
|
this._seen.add(k);
|
|
184
218
|
this._stats.hopsAccepted += 1;
|
|
185
219
|
|
|
@@ -196,14 +230,24 @@ export class PathStore {
|
|
|
196
230
|
if (IN_KINDS.has(hop.kind)) {
|
|
197
231
|
const c = classifyIn(hop);
|
|
198
232
|
if (c.role === 'source') {
|
|
199
|
-
|
|
233
|
+
if (this._v2 && !c.crossScope && c.node.kind === 'path') {
|
|
234
|
+
// One in-half node per reaching definition: a merge keeps every
|
|
235
|
+
// alternative, a killed definition is simply absent.
|
|
236
|
+
const vs = Array.isArray(hop.fromVersions) && hop.fromVersions.length > 0 ? hop.fromVersions : [null];
|
|
237
|
+
for (const v of vs) g.in.push({ hop, ...c, node: { ...c.node, version: v } });
|
|
238
|
+
} else {
|
|
239
|
+
g.in.push({ hop, ...c });
|
|
240
|
+
}
|
|
200
241
|
if (c.crossScope) this._peerSourced.push({ hop, node: c.node });
|
|
201
242
|
} else {
|
|
202
243
|
g.annotations.push(hop);
|
|
203
244
|
}
|
|
204
245
|
} else if (hop.kind === 'write-out') {
|
|
205
246
|
const c = classifyOut(hop);
|
|
206
|
-
if (c.role === 'target')
|
|
247
|
+
if (c.role === 'target') {
|
|
248
|
+
const node = this._v2 && c.node.kind === 'path' ? { ...c.node, version: hop.toVersion ?? null } : c.node;
|
|
249
|
+
g.out.push({ hop, ...c, node });
|
|
250
|
+
}
|
|
207
251
|
else this._unclassified.push(hop);
|
|
208
252
|
} else {
|
|
209
253
|
this._unclassified.push(hop);
|
|
@@ -233,13 +277,15 @@ export class PathStore {
|
|
|
233
277
|
|
|
234
278
|
_build() {
|
|
235
279
|
if (this._built) return this._built;
|
|
280
|
+
this._limitHits = { hopsRefused: this._limitHits.hopsRefused, groupsSkipped: [], edgesRefused: 0, summarizedGroups: 0 };
|
|
236
281
|
const nodes = new Map();
|
|
237
282
|
const edges = new Map();
|
|
238
283
|
const outIndex = new Map();
|
|
239
284
|
const inIndex = new Map();
|
|
240
285
|
|
|
286
|
+
const nid = (desc) => this.nodeIdFor(desc);
|
|
241
287
|
const intern = (desc) => {
|
|
242
|
-
const id =
|
|
288
|
+
const id = nid(desc);
|
|
243
289
|
let n = nodes.get(id);
|
|
244
290
|
if (!n) {
|
|
245
291
|
n = {
|
|
@@ -251,8 +297,29 @@ export class PathStore {
|
|
|
251
297
|
return n;
|
|
252
298
|
};
|
|
253
299
|
|
|
300
|
+
// Version 2: a definition that never wrote this data element at this path
|
|
301
|
+
// cannot be where it came from, so a source node (path, version, id) with
|
|
302
|
+
// no producing out-half is a phantom alternative and is dropped. The entry
|
|
303
|
+
// version has no producer by nature (a seed, or a caller binding), so it
|
|
304
|
+
// is kept. Counted in diagnostics, never silent.
|
|
305
|
+
const produced = new Set();
|
|
306
|
+
if (this._v2) {
|
|
307
|
+
for (const g of this._groups.values()) {
|
|
308
|
+
for (const o of g.out) if (o.node.kind === 'path') produced.add(nid(o.node));
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
let versionPruned = 0;
|
|
254
312
|
for (const g of this._groups.values()) {
|
|
255
313
|
let sources = g.in;
|
|
314
|
+
if (this._v2) {
|
|
315
|
+
sources = g.in.filter((s) => {
|
|
316
|
+
const v = s.node.version;
|
|
317
|
+
if (s.node.kind !== 'path' || v == null || v === 'entry') return true;
|
|
318
|
+
if (produced.has(nid(s.node))) return true;
|
|
319
|
+
versionPruned += 1;
|
|
320
|
+
return false;
|
|
321
|
+
});
|
|
322
|
+
}
|
|
256
323
|
let originated = false;
|
|
257
324
|
// Populated only when `originated` below — the union of every
|
|
258
325
|
// annotation's widen/loss reason at this key, so an edge originating
|
|
@@ -266,7 +333,7 @@ export class PathStore {
|
|
|
266
333
|
// exact shape a Sub-project D source registry will produce first).
|
|
267
334
|
let originWidenReasons = [];
|
|
268
335
|
let originLossReasons = [];
|
|
269
|
-
if (
|
|
336
|
+
if (g.in.length === 0 && g.annotations.length > 0) {
|
|
270
337
|
// §2.2's annotation rule, in the ONE direction it is still right:
|
|
271
338
|
// with no non-null in-half anywhere at this key, the annotation is
|
|
272
339
|
// itself the origin of the value ("no prior aliasing source").
|
|
@@ -294,16 +361,65 @@ export class PathStore {
|
|
|
294
361
|
// in-half and the return's in-half share one join key). Counting
|
|
295
362
|
// only the pairings the store would ACTUALLY form (after the peer x
|
|
296
363
|
// peer exclusion) recovers the distinction at zero cost.
|
|
297
|
-
const pairable = (s, o) =>
|
|
298
|
-
|
|
299
|
-
|
|
364
|
+
const pairable = (s, o) => {
|
|
365
|
+
if (s.crossScope && o.crossScope) return false;
|
|
366
|
+
return !this._v2 || originated || pairCompatibility(s.hop, o.hop).compatible;
|
|
367
|
+
};
|
|
368
|
+
const sourcesFor = (o) => new Set(sources.filter((s) => pairable(s, o)).map((s) => nid(s.node)));
|
|
369
|
+
const targetsFor = (s) => new Set(g.out.filter((o) => pairable(s, o)).map((o) => nid(o.node)));
|
|
300
370
|
|
|
301
371
|
const annotations = g.annotations.map((a) => ({
|
|
302
372
|
kind: a.kind, subKind: a.subKind,
|
|
303
373
|
widenReason: a.widenReason ?? null, lossReason: a.lossReason ?? null,
|
|
304
374
|
peerScope: a.peerScope ?? null, peerContext: a.peerContext ?? null,
|
|
305
375
|
}));
|
|
376
|
+
// Version 2: the graph content must not depend on hop delivery order,
|
|
377
|
+
// and an annotation list is built in delivery order. Sorted by content.
|
|
378
|
+
if (this._v2) annotations.sort((a, b) => { const x = JSON.stringify(a); const y = JSON.stringify(b); return x < y ? -1 : x > y ? 1 : 0; });
|
|
306
379
|
|
|
380
|
+
// R07.5: an explicit cross product above the cap is not materialized;
|
|
381
|
+
// the group is reported instead of being silently pruned.
|
|
382
|
+
if (sources.length * g.out.length > this._limits.maxPairingsPerGroup) {
|
|
383
|
+
this._limitHits.groupsSkipped.push({
|
|
384
|
+
scope: g.scope, nodeId: g.nodeId, dataElementId: g.dataElementId,
|
|
385
|
+
pairings: sources.length * g.out.length,
|
|
386
|
+
});
|
|
387
|
+
// R07.5: the explicit cross product (S x O edges) is replaced by a
|
|
388
|
+
// typed ambiguous-summary relation: every source reaches one summary
|
|
389
|
+
// node and that node reaches every target (S + O edges). Which
|
|
390
|
+
// source feeds which target is deliberately not claimed, so every
|
|
391
|
+
// such edge is marked ambiguous; reachability is kept, nothing is
|
|
392
|
+
// silently dropped, and the group stays reported above.
|
|
393
|
+
const hub = intern({ kind: 'summary', scope: g.scope, context: g.context, path: null, siteNodeId: g.nodeId, dataElementId: g.dataElementId });
|
|
394
|
+
this._limitHits.summarizedGroups += 1;
|
|
395
|
+
const addSummaryEdge = (fromN, toN, inHop, outHop, crossScope) => {
|
|
396
|
+
const widenReasons = [...new Set([inHop.widenReason, outHop.widenReason].filter((r) => r != null))].sort();
|
|
397
|
+
const lossReasons = [...new Set([inHop.lossReason, outHop.lossReason].filter((r) => r != null))].sort();
|
|
398
|
+
const desc = {
|
|
399
|
+
fromNodeId: fromN.id, toNodeId: toN.id, dataElementId: g.dataElementId,
|
|
400
|
+
scope: g.scope, context: g.context, siteNodeId: g.nodeId,
|
|
401
|
+
inKind: inHop.kind, inSubKind: inHop.subKind,
|
|
402
|
+
outKind: outHop.kind, outSubKind: outHop.subKind,
|
|
403
|
+
widenReasons, lossReasons,
|
|
404
|
+
};
|
|
405
|
+
const eid = provenanceEdgeId(desc);
|
|
406
|
+
if (edges.has(eid)) return;
|
|
407
|
+
if (edges.size >= this._limits.maxEdges) { this._limitHits.edgesRefused += 1; return; }
|
|
408
|
+
edges.set(eid, {
|
|
409
|
+
id: eid, ...desc, line: g.line, crossScope, originated,
|
|
410
|
+
ambiguousCorrelation: true, ambiguousSummary: true, annotations,
|
|
411
|
+
truncated: this._truncations.has(scopeKeyOf(g.scope, g.context)),
|
|
412
|
+
});
|
|
413
|
+
if (!outIndex.has(fromN.id)) outIndex.set(fromN.id, new Set());
|
|
414
|
+
if (!inIndex.has(toN.id)) inIndex.set(toN.id, new Set());
|
|
415
|
+
outIndex.get(fromN.id).add(eid);
|
|
416
|
+
inIndex.get(toN.id).add(eid);
|
|
417
|
+
};
|
|
418
|
+
const summaryHop = { kind: 'summary', subKind: 'ambiguous-summary', widenReason: null, lossReason: null };
|
|
419
|
+
for (const s of sources) addSummaryEdge(intern(s.node), hub, s.hop, summaryHop, s.crossScope);
|
|
420
|
+
for (const o of g.out) addSummaryEdge(hub, intern(o.node), summaryHop, o.hop, o.crossScope);
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
307
423
|
for (const s of sources) {
|
|
308
424
|
for (const o of g.out) {
|
|
309
425
|
// §14.3: an edge with BOTH endpoints in the peer's namespace is
|
|
@@ -327,13 +443,34 @@ export class PathStore {
|
|
|
327
443
|
outKind: o.hop.kind, outSubKind: o.hop.subKind,
|
|
328
444
|
widenReasons, lossReasons,
|
|
329
445
|
};
|
|
330
|
-
|
|
331
|
-
|
|
446
|
+
let v2Edge = null;
|
|
447
|
+
let idParts = [];
|
|
448
|
+
if (this._v2) {
|
|
449
|
+
const pc = originated ? { proven: false } : pairCompatibility(s.hop, o.hop);
|
|
450
|
+
const invocationId = s.hop.invocationId ?? o.hop.invocationId ?? null;
|
|
451
|
+
const callBoundary = o.hop.callRole === 'bind' ? 'call' : s.hop.callRole === 'return' ? 'return' : null;
|
|
452
|
+
v2Edge = {
|
|
453
|
+
correlationVersion: CORRELATION_VERSION_2,
|
|
454
|
+
correlation: pc.proven ? 'proven' : 'legacy-unknown',
|
|
455
|
+
invocationId, callBoundary,
|
|
456
|
+
};
|
|
457
|
+
idParts = ['cv2', invocationId, callBoundary];
|
|
458
|
+
}
|
|
459
|
+
const id = provenanceEdgeId(desc, idParts);
|
|
460
|
+
if (edges.has(id) && v2Edge && v2Edge.correlation !== 'proven') {
|
|
461
|
+
// Two pairings that dedupe to one edge: the edge is only as proven
|
|
462
|
+
// as its weakest pairing, whatever order they arrived in.
|
|
463
|
+
edges.get(id).correlation = 'legacy-unknown';
|
|
464
|
+
}
|
|
465
|
+
if (!edges.has(id) && edges.size >= this._limits.maxEdges) {
|
|
466
|
+
this._limitHits.edgesRefused += 1;
|
|
467
|
+
} else if (!edges.has(id)) {
|
|
332
468
|
edges.set(id, {
|
|
333
469
|
id, ...desc, line: g.line,
|
|
334
470
|
crossScope: s.crossScope || o.crossScope,
|
|
335
471
|
originated,
|
|
336
472
|
ambiguousCorrelation: sourcesFor(o).size >= 2 && targetsFor(s).size >= 2,
|
|
473
|
+
...v2Edge,
|
|
337
474
|
annotations,
|
|
338
475
|
truncated: this._truncations.has(scopeKeyOf(g.scope, g.context)),
|
|
339
476
|
});
|
|
@@ -356,14 +493,14 @@ export class PathStore {
|
|
|
356
493
|
// fabricated into an origin, never dropped.
|
|
357
494
|
const orphanedPeerSources = [];
|
|
358
495
|
for (const { hop, node } of this._peerSourced) {
|
|
359
|
-
const id =
|
|
496
|
+
const id = nid(node);
|
|
360
497
|
const inEdges = inIndex.get(id);
|
|
361
498
|
if (!inEdges || inEdges.size === 0) {
|
|
362
499
|
orphanedPeerSources.push({ hop, nodeId: id });
|
|
363
500
|
}
|
|
364
501
|
}
|
|
365
502
|
|
|
366
|
-
this._built = { nodes, edges, outIndex, inIndex, orphanedPeerSources };
|
|
503
|
+
this._built = { nodes, edges, outIndex, inIndex, orphanedPeerSources, versionPruned };
|
|
367
504
|
return this._built;
|
|
368
505
|
}
|
|
369
506
|
|
|
@@ -375,11 +512,46 @@ export class PathStore {
|
|
|
375
512
|
edges() { return [...this._build().edges.values()]; }
|
|
376
513
|
getNode(id) { return this._build().nodes.get(id) ?? null; }
|
|
377
514
|
getEdge(id) { return this._build().edges.get(id) ?? null; }
|
|
378
|
-
nodeIdFor(desc) {
|
|
515
|
+
nodeIdFor(desc) {
|
|
516
|
+
return this._v2 ? provenanceNodeId(desc, ['cv2', desc.version ?? null]) : provenanceNodeId(desc);
|
|
517
|
+
}
|
|
518
|
+
/** 1 or 2: the correlation version this store was built with. */
|
|
519
|
+
correlationVersion() { return this._v2 ? 2 : 1; }
|
|
520
|
+
/** Version 1 to version 2 id crosswalk (version 2 stores only; `null` for version 1). */
|
|
521
|
+
crosswalk() { return this._v2 ? buildCrosswalk(this) : null; }
|
|
379
522
|
edgesFrom(nodeId) { return [...(this._build().outIndex.get(nodeId) ?? [])].map((e) => this._build().edges.get(e)); }
|
|
380
523
|
edgesTo(nodeId) { return [...(this._build().inIndex.get(nodeId) ?? [])].map((e) => this._build().edges.get(e)); }
|
|
381
524
|
hasEdge(fromId, toId) { return this.edgesFrom(fromId).some((e) => e.toNodeId === toId); }
|
|
382
|
-
|
|
525
|
+
/**
|
|
526
|
+
* R07.9: content digest of the built graph (sorted node and edge ids plus
|
|
527
|
+
* the construction-limit outcome). Two stores with equal digests answer
|
|
528
|
+
* every query identically; a continuation is bound to it.
|
|
529
|
+
*/
|
|
530
|
+
digest() {
|
|
531
|
+
const b = this._build();
|
|
532
|
+
const h = createHash('sha256');
|
|
533
|
+
h.update(JSON.stringify([[...b.nodes.keys()].sort(), [...b.edges.keys()].sort(), this.limitStatus().hit]));
|
|
534
|
+
return h.digest('hex');
|
|
535
|
+
}
|
|
536
|
+
/** R07.5: has any construction bound been hit? Forces the build so the join-time counters are current. */
|
|
537
|
+
limitStatus() {
|
|
538
|
+
this._build();
|
|
539
|
+
const h = this._limitHits;
|
|
540
|
+
return {
|
|
541
|
+
hit: h.hopsRefused > 0 || h.groupsSkipped.length > 0 || h.edgesRefused > 0,
|
|
542
|
+
hopsRefused: h.hopsRefused,
|
|
543
|
+
groupsSkipped: h.groupsSkipped.slice(),
|
|
544
|
+
edgesRefused: h.edgesRefused,
|
|
545
|
+
ambiguousSummaries: h.summarizedGroups,
|
|
546
|
+
limits: { ...this._limits },
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
stats() {
|
|
550
|
+
return {
|
|
551
|
+
...this._stats, groups: this._groups.size, nodes: this._build().nodes.size, edges: this._build().edges.size,
|
|
552
|
+
...(this._v2 ? { correlationVersion: 2 } : null),
|
|
553
|
+
};
|
|
554
|
+
}
|
|
383
555
|
|
|
384
556
|
/**
|
|
385
557
|
* Four buckets, all "recorded, never thrown, never silently dropped"
|
|
@@ -395,6 +567,8 @@ export class PathStore {
|
|
|
395
567
|
unclassified: this._unclassified,
|
|
396
568
|
truncations: [...this._truncations.entries()],
|
|
397
569
|
orphanedPeerSources: this._build().orphanedPeerSources,
|
|
570
|
+
limits: this.limitStatus(),
|
|
571
|
+
...(this._v2 ? { correlationVersion: 2, versionPruned: this._build().versionPruned } : null),
|
|
398
572
|
};
|
|
399
573
|
}
|
|
400
574
|
}
|