@clear-capabilities/agentic-security-scanner 0.144.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 +390 -0
- package/bin/agentic-security.js +3813 -83
- 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} +13 -4
- 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/2271.index.js +165 -0
- 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} +14 -6
- 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} +14 -6
- 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} +67 -3
- 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} +29 -7
- package/dist/{449.index.js → 5830.index.js} +78 -14
- 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} +26 -8
- 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} +580 -187
- 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 +23 -10
- package/src/compare.js +6 -1
- package/src/dataflow/CLAUDE.md +2 -2
- package/src/dataflow/catalog.js +42 -0
- package/src/dataflow/orm-write-catalog.js +175 -0
- package/src/engine.js +580 -30
- package/src/fix/apply-fix-service.js +1 -0
- package/src/history-scan.js +22 -5
- package/src/ir/CLAUDE.md +2 -1
- 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/lsp/server.js +49 -2
- 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 +22 -1
- package/src/pipeline/assurance-mode.js +64 -1
- package/src/pipeline/finding-schema.js +8 -1
- package/src/pipeline/scan-health.js +19 -1
- package/src/posture/CLAUDE.md +140 -0
- package/src/posture/accuracy-scorecard.js +60 -0
- package/src/posture/artifact-registry.js +76 -0
- package/src/posture/auditor-walkthrough.js +192 -13
- package/src/posture/compliance-frameworks/hipaa-security-rule.json +3 -2
- package/src/posture/compliance-policy.js +12 -2
- package/src/posture/cross-repo-memory.js +7 -2
- package/src/posture/fix-history.js +25 -2
- package/src/posture/fix-verify.js +9 -1
- package/src/posture/fleet.js +0 -0
- package/src/posture/git-history.js +13 -5
- package/src/posture/material-change.js +21 -2
- package/src/posture/mttr.js +75 -12
- package/src/posture/obligation-evidence-pack.js +202 -0
- package/src/posture/pre-incident-archaeology.js +39 -7
- package/src/posture/privacy-framework.js +14 -0
- package/src/posture/provenance/ai-authorship.js +68 -0
- package/src/posture/provenance/branch-entry.js +80 -0
- package/src/posture/provenance/cache.js +143 -0
- package/src/posture/provenance/confidence.js +36 -0
- package/src/posture/provenance/coordinator.js +786 -0
- package/src/posture/provenance/dag-walk.js +249 -0
- package/src/posture/provenance/evidence-attribution.js +59 -0
- package/src/posture/provenance/git-evidence.js +310 -0
- package/src/posture/provenance/lifecycle.js +208 -0
- package/src/posture/provenance/missing-control-resolver.js +137 -0
- package/src/posture/provenance/origin-resolver.js +342 -0
- package/src/posture/provenance/predicate-replay.js +133 -0
- package/src/posture/provenance/providers/config.js +39 -0
- package/src/posture/provenance/providers/github.js +62 -0
- package/src/posture/provenance/providers/gitlab.js +58 -0
- package/src/posture/provenance/repo-lineage.js +74 -0
- package/src/posture/provenance/sca-origin.js +139 -0
- package/src/posture/provenance/schema.js +255 -0
- package/src/posture/provenance/transitive-sca.js +147 -0
- package/src/posture/provenance/validate.js +30 -0
- package/src/posture/provenance-evidence-bundle.js +144 -0
- package/src/posture/remediation-ledger.js +337 -0
- package/src/posture/sbom-diff.js +15 -2
- package/src/posture/secret-history.js +10 -2
- package/src/posture/state-dir.js +38 -14
- package/src/posture/vuln-archaeology.js +8 -2
- package/src/pr-delta.js +25 -4
- package/src/report/index.js +197 -3
- package/src/runScan.js +34 -5
- package/src/sast/rate-limit.js +33 -3
- 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/src/util/git-hardening.js +128 -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,410 @@
|
|
|
1
|
+
//
|
|
2
|
+
// path-query.js — bounded backward-reconstruction query (Sub-project C,
|
|
3
|
+
// increment 5).
|
|
4
|
+
//
|
|
5
|
+
// Binding spec: DESIGN_PATH_PROVENANCE.md §15 (§15.1-§15.11; design + PoC
|
|
6
|
+
// landed as Task 1, this is Task 2, the mechanical implementation). This
|
|
7
|
+
// module is a PURE CONSUMER of a built `PathStore` (Sub-project C,
|
|
8
|
+
// increment 4) — it never sees a hop record, C4 already turned those into a
|
|
9
|
+
// DAG. It must NEVER import `engine.js`, `summaries.js`, or `driver.js`
|
|
10
|
+
// (§15.1, the same boundary §14.1 established for `path-store.js`), and it
|
|
11
|
+
// consumes `PathStore` ONLY through its public read API — never a
|
|
12
|
+
// `_`-prefixed field. Its only import is `ids.js`, for `pathId`.
|
|
13
|
+
//
|
|
14
|
+
// The backward walk is an ITERATIVE DFS over an explicit stack — never
|
|
15
|
+
// recursion (§15.3): the DAG can genuinely be cyclic (§9.3, proven by
|
|
16
|
+
// `path-store.test.js`'s own mutual-recursion fixture), and this module's
|
|
17
|
+
// whole job is graph TRAVERSAL, unlike `path-store.js`'s own single linear
|
|
18
|
+
// pass. Cycle safety comes from an explicit PER-PATH visited set (never a
|
|
19
|
+
// global one — the SAME node can legitimately appear on two different
|
|
20
|
+
// candidate paths), not from any budget; the budgets exist to bound WORK
|
|
21
|
+
// and OUTPUT size, not to be the thing that makes the walk terminate.
|
|
22
|
+
//
|
|
23
|
+
|
|
24
|
+
import { pathId } from './ids.js';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* All `opts`-overridable. Two-plus orders of magnitude above what every
|
|
28
|
+
* fixture measured in the design task's PoC needs (§15.11's largest row
|
|
29
|
+
* uses 35 expansions) — deliberately UNCALIBRATED (§15.3): nothing bigger
|
|
30
|
+
* than a hand-built fixture is measurable until a driver run emits real
|
|
31
|
+
* hops (Sub-projects D/E). A starting point to re-measure then, not a
|
|
32
|
+
* tuned result.
|
|
33
|
+
*/
|
|
34
|
+
export const DEFAULTS = {
|
|
35
|
+
maxPaths: 32,
|
|
36
|
+
maxPathsPerTerminal: 8,
|
|
37
|
+
maxCandidatePaths: 256,
|
|
38
|
+
maxExpansions: 10000,
|
|
39
|
+
maxDepth: 64,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// §15.4's terminal-reason vocabulary. Only 'origin' means "this really is
|
|
43
|
+
// where the recorded flow starts" (`complete: true`). 'expansion-budget'
|
|
44
|
+
// and 'candidate-cap' are deliberately NOT terminal reasons — see the loop
|
|
45
|
+
// below; those two branches are abandoned, never emitted as a marked
|
|
46
|
+
// partial path.
|
|
47
|
+
const TERMINAL_ORIGIN = 'origin';
|
|
48
|
+
const TERMINAL_INCOMPLETE = 'incomplete-record';
|
|
49
|
+
const TERMINAL_CYCLE = 'cycle';
|
|
50
|
+
const TERMINAL_DEPTH = 'depth-limit';
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* §15.9's stand-in for Sub-project D's source/sink registry, which does not
|
|
54
|
+
* exist yet. NOT a registry: it returns every structurally terminal node
|
|
55
|
+
* kind (`return`/`escape`/`loss`), with no notion of whether any of them is
|
|
56
|
+
* a security-relevant sink — that judgment belongs to Sub-project D's
|
|
57
|
+
* eventual registry. Named `sinkCandidates`, not `sinks`, for the same
|
|
58
|
+
* reason `reconstructPaths`' own parameter is `startNodeId` rather than
|
|
59
|
+
* `sinkNodeId`: it is a structural filter with no security opinion.
|
|
60
|
+
*/
|
|
61
|
+
export function sinkCandidates(store) {
|
|
62
|
+
return store.nodes().filter((n) => n.kind === 'return' || n.kind === 'escape' || n.kind === 'loss');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Denormalizes one edge into a path hop — §15.2's grading material carried
|
|
67
|
+
* INLINE, not just the edge id. A path that cannot be graded without also
|
|
68
|
+
* carrying the store is a poor hand-off to C6, Sub-project E's graph
|
|
69
|
+
* builder, and Milestone 3's API.
|
|
70
|
+
*/
|
|
71
|
+
function hopOf(edge) {
|
|
72
|
+
return {
|
|
73
|
+
edgeId: edge.id,
|
|
74
|
+
fromNodeId: edge.fromNodeId,
|
|
75
|
+
toNodeId: edge.toNodeId,
|
|
76
|
+
line: edge.line,
|
|
77
|
+
scope: edge.scope,
|
|
78
|
+
context: edge.context,
|
|
79
|
+
siteNodeId: edge.siteNodeId,
|
|
80
|
+
dataElementId: edge.dataElementId,
|
|
81
|
+
inKind: edge.inKind,
|
|
82
|
+
inSubKind: edge.inSubKind,
|
|
83
|
+
outKind: edge.outKind,
|
|
84
|
+
outSubKind: edge.outSubKind,
|
|
85
|
+
crossScope: edge.crossScope,
|
|
86
|
+
widenReasons: edge.widenReasons,
|
|
87
|
+
lossReasons: edge.lossReasons,
|
|
88
|
+
ambiguousCorrelation: edge.ambiguousCorrelation,
|
|
89
|
+
annotations: edge.annotations,
|
|
90
|
+
originated: edge.originated,
|
|
91
|
+
truncated: edge.truncated,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Source-first materialization of one enumerated DFS branch (§15.2). The
|
|
97
|
+
* walk itself runs sink-first (backward from `startNodeId`); a human reads
|
|
98
|
+
* a flow source -> sink, so the arrays are reversed here, once, at the
|
|
99
|
+
* point a candidate is emitted.
|
|
100
|
+
*/
|
|
101
|
+
function materialize(store, nodesRev, edgesRev, terminalReason) {
|
|
102
|
+
const nodeIds = [...nodesRev].reverse();
|
|
103
|
+
const edges = [...edgesRev].reverse();
|
|
104
|
+
const hops = edges.map((e) => hopOf(e));
|
|
105
|
+
const edgeIds = hops.map((h) => h.edgeId);
|
|
106
|
+
const nodes = nodeIds.map((id) => store.getNode(id));
|
|
107
|
+
const crossScopeCount = hops.filter((h) => h.crossScope).length;
|
|
108
|
+
// §16.7 Finding 1 / §16.8 item 7: read `hop.annotations[]` too, not just
|
|
109
|
+
// the two edge-forming top-level arrays — a genuine widen/loss reason can
|
|
110
|
+
// live ONLY in `annotations[]` (§16.5), and a consumer reading only the
|
|
111
|
+
// top-level arrays under-reports it. Do NOT push this into
|
|
112
|
+
// `path-store.js`'s `edge.widenReasons`/`edge.lossReasons`: those two
|
|
113
|
+
// arrays are part of `provenanceEdgeId`'s discriminator (§14.5), so
|
|
114
|
+
// widening them would move every `pedge:`/`ppath:` id.
|
|
115
|
+
const _annWiden = (h) => h.widenReasons.length > 0 || (h.annotations ?? []).some((a) => a.widenReason != null);
|
|
116
|
+
const _annLoss = (h) => h.lossReasons.length > 0 || (h.annotations ?? []).some((a) => a.lossReason != null);
|
|
117
|
+
const widenedHopCount = hops.filter(_annWiden).length;
|
|
118
|
+
const lossHopCount = hops.filter(_annLoss).length;
|
|
119
|
+
const ambiguousHopCount = hops.filter((h) => h.ambiguousCorrelation).length;
|
|
120
|
+
const analysisTruncated = nodes.some((n) => n && n.truncated) || hops.some((h) => h.truncated);
|
|
121
|
+
return {
|
|
122
|
+
id: pathId({ startNodeId: nodeIds[nodeIds.length - 1], edgeIds }),
|
|
123
|
+
nodeIds,
|
|
124
|
+
edgeIds,
|
|
125
|
+
hops,
|
|
126
|
+
hopCount: hops.length,
|
|
127
|
+
// §15.2: a connected walk is field-precise BY CONSTRUCTION — every edge
|
|
128
|
+
// `path-store.js` builds joins two nodes whose descriptors both take
|
|
129
|
+
// `dataElementId` from the same hop (§14.3) — so this needs no
|
|
130
|
+
// filtering, only reading off the first hop.
|
|
131
|
+
dataElementId: hops[0]?.dataElementId ?? null,
|
|
132
|
+
sourceNodeId: nodeIds[0],
|
|
133
|
+
sinkNodeId: nodeIds[nodeIds.length - 1],
|
|
134
|
+
terminal: { nodeId: nodeIds[0], reason: terminalReason, kind: nodes[0]?.kind ?? null },
|
|
135
|
+
complete: terminalReason === TERMINAL_ORIGIN,
|
|
136
|
+
crossScopeCount,
|
|
137
|
+
widenedHopCount,
|
|
138
|
+
lossHopCount,
|
|
139
|
+
ambiguousHopCount,
|
|
140
|
+
analysisTruncated,
|
|
141
|
+
// §15.7's diversity signature. `transformation` and `protection` are
|
|
142
|
+
// DELIBERATELY absent — Sub-project D / Milestone 2 own them; using
|
|
143
|
+
// today's widen/loss reasons as a stand-in for either would be
|
|
144
|
+
// inventing vocabulary that isn't backed by data.
|
|
145
|
+
shape: [
|
|
146
|
+
terminalReason === TERMINAL_ORIGIN ? 'complete' : 'partial',
|
|
147
|
+
crossScopeCount > 0 ? 'boundary' : 'local',
|
|
148
|
+
widenedHopCount > 0 ? 'widened' : 'explicit',
|
|
149
|
+
lossHopCount > 0 ? 'lossy' : 'intact',
|
|
150
|
+
ambiguousHopCount > 0 ? 'ambiguous' : 'correlated',
|
|
151
|
+
].join('/'),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* §15.7's total order over reconstructed paths. Deterministic: the final
|
|
157
|
+
* key is the content-hash id, so no tie is ever left to insertion order.
|
|
158
|
+
* Exported, not merely internal (§15.10 item 11b, fix round 1's own
|
|
159
|
+
* correction to the original design's export list) — the shipped tests
|
|
160
|
+
* call it directly to build the naive-global-cap contrast fixture.
|
|
161
|
+
*/
|
|
162
|
+
export function comparePaths(a, b) {
|
|
163
|
+
if (a.complete !== b.complete) return a.complete ? -1 : 1;
|
|
164
|
+
if (a.ambiguousHopCount !== b.ambiguousHopCount) return a.ambiguousHopCount - b.ambiguousHopCount;
|
|
165
|
+
if (a.lossHopCount !== b.lossHopCount) return a.lossHopCount - b.lossHopCount;
|
|
166
|
+
if (a.widenedHopCount !== b.widenedHopCount) return a.widenedHopCount - b.widenedHopCount;
|
|
167
|
+
if (a.crossScopeCount !== b.crossScopeCount) return b.crossScopeCount - a.crossScopeCount;
|
|
168
|
+
if (a.hopCount !== b.hopCount) return a.hopCount - b.hopCount;
|
|
169
|
+
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* §15.3's bounded backward walk from `startNodeId`, deliberately not named
|
|
174
|
+
* `sinkNodeId` (there is no sink registry — see `sinkCandidates` above;
|
|
175
|
+
* once Sub-project D lands, the caller supplies a registered sink here and
|
|
176
|
+
* nothing about the signature changes). Iterative — an explicit stack,
|
|
177
|
+
* never recursion — cycle-safe by a PER-PATH visited set, and bounded by
|
|
178
|
+
* three independent in-walk budgets (`maxExpansions`, `maxDepth`,
|
|
179
|
+
* `maxCandidatePaths`) plus the two post-hoc caps below (`maxPaths`,
|
|
180
|
+
* `maxPathsPerTerminal`) — five knobs total, matching `DEFAULTS`' own key
|
|
181
|
+
* count (Task 2 review finding 5: an earlier comment here and in this
|
|
182
|
+
* module's CLAUDE.md row both said "four," inherited from §15.1/§15.3's
|
|
183
|
+
* own inconsistent framing — corrected here).
|
|
184
|
+
*
|
|
185
|
+
* Returns a `ReconstructionResult`: §15.4's five pairwise-distinguishable
|
|
186
|
+
* answers, so a truncated result can NEVER be mistaken for "no path exists"
|
|
187
|
+
* (§18.4's single most load-bearing constraint).
|
|
188
|
+
*/
|
|
189
|
+
export function reconstructPaths(store, startNodeId, opts = {}) {
|
|
190
|
+
const budget = { ...DEFAULTS, ...opts };
|
|
191
|
+
const startNode = store.getNode(startNodeId);
|
|
192
|
+
const truncationReasons = new Set();
|
|
193
|
+
|
|
194
|
+
const result = {
|
|
195
|
+
startNodeId,
|
|
196
|
+
startNodeKind: startNode ? startNode.kind : null,
|
|
197
|
+
unknownStartNode: !startNode,
|
|
198
|
+
paths: [],
|
|
199
|
+
truncated: false,
|
|
200
|
+
truncationReasons: [],
|
|
201
|
+
noPathReason: null,
|
|
202
|
+
enumeratedPathCount: 0,
|
|
203
|
+
returnedPathCount: 0,
|
|
204
|
+
droppedPathCount: 0,
|
|
205
|
+
completePathCount: 0,
|
|
206
|
+
cyclesClipped: 0,
|
|
207
|
+
terminals: [],
|
|
208
|
+
analysisTruncated: false,
|
|
209
|
+
budget: { ...budget, expansionsUsed: 0 },
|
|
210
|
+
};
|
|
211
|
+
// An id that names no node is NOT "no path exists" — it is "you asked
|
|
212
|
+
// about something that is not in this store". §15.4 keeps the two apart,
|
|
213
|
+
// because collapsing them is §18.4's failure mode wearing a different hat.
|
|
214
|
+
if (!startNode) return result;
|
|
215
|
+
|
|
216
|
+
const orphaned = new Set(store.diagnostics().orphanedPeerSources.map((o) => o.nodeId));
|
|
217
|
+
|
|
218
|
+
const candidates = [];
|
|
219
|
+
let expansions = 0;
|
|
220
|
+
const stack = [{
|
|
221
|
+
nodeId: startNodeId,
|
|
222
|
+
nodesRev: [startNodeId],
|
|
223
|
+
edgesRev: [],
|
|
224
|
+
onPath: new Set([startNodeId]),
|
|
225
|
+
}];
|
|
226
|
+
|
|
227
|
+
while (stack.length > 0) {
|
|
228
|
+
if (candidates.length >= budget.maxCandidatePaths) { truncationReasons.add('candidate-cap'); break; }
|
|
229
|
+
if (expansions >= budget.maxExpansions) { truncationReasons.add('expansion-budget'); break; }
|
|
230
|
+
const frame = stack.pop();
|
|
231
|
+
const hops = frame.edgesRev.length;
|
|
232
|
+
// `edgesTo` is the traversal primitive; sorted for determinism, since
|
|
233
|
+
// it is backed by a Set and carries no inherent order.
|
|
234
|
+
const incoming = [...store.edgesTo(frame.nodeId)].sort((x, y) => (x.id < y.id ? -1 : x.id > y.id ? 1 : 0));
|
|
235
|
+
|
|
236
|
+
if (incoming.length === 0) {
|
|
237
|
+
if (hops > 0) {
|
|
238
|
+
candidates.push(materialize(store, frame.nodesRev, frame.edgesRev,
|
|
239
|
+
orphaned.has(frame.nodeId) ? TERMINAL_INCOMPLETE : TERMINAL_ORIGIN));
|
|
240
|
+
}
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
// §15.3's exact ordering constraint: the zero-in-edges check above MUST
|
|
244
|
+
// run BEFORE this depth check. A node with no predecessors is a genuine
|
|
245
|
+
// origin no matter how deep the walk is, so a path that reaches one is
|
|
246
|
+
// `complete: true` even at `maxDepth: 1`. Only a branch the LIMIT
|
|
247
|
+
// stopped is marked partial.
|
|
248
|
+
//
|
|
249
|
+
// Task 2 review finding 2: `hops > 0` guards against `maxDepth: 0`
|
|
250
|
+
// emitting a degenerate ZERO-hop "path" at the start frame itself
|
|
251
|
+
// (edgeIds: [], dataElementId: null) — §15.6's own stated invariant is
|
|
252
|
+
// that a path always has at least one hop.
|
|
253
|
+
//
|
|
254
|
+
// Final whole-branch review finding 1: the guard's actual EFFECT,
|
|
255
|
+
// stated precisely (an earlier version of this comment claimed "the
|
|
256
|
+
// walk can never leave the start node" under `maxDepth: 0`, which is
|
|
257
|
+
// FALSE — measured: `maxDepth: 0` and `maxDepth: 1` are byte-identical,
|
|
258
|
+
// because the depth check never fires at `hops === 0`, so the walk
|
|
259
|
+
// still expands exactly one hop before the check can bind). `maxDepth`
|
|
260
|
+
// therefore has an effective floor of 1, not 0, despite what `opts`
|
|
261
|
+
// literally says — every RETURNED path still honestly has >= 1 hop
|
|
262
|
+
// (which is the property this guard actually exists to protect), and
|
|
263
|
+
// truncation is still reported honestly (`truncated: true`,
|
|
264
|
+
// `'depth-limit'`), but a caller passing `maxDepth: 0` expecting a
|
|
265
|
+
// literal zero-hop budget will not get one.
|
|
266
|
+
if (hops >= budget.maxDepth && hops > 0) {
|
|
267
|
+
truncationReasons.add('depth-limit');
|
|
268
|
+
candidates.push(materialize(store, frame.nodesRev, frame.edgesRev, TERMINAL_DEPTH));
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
let extended = 0;
|
|
273
|
+
let clipped = 0;
|
|
274
|
+
for (const e of incoming) {
|
|
275
|
+
expansions += 1;
|
|
276
|
+
if (frame.onPath.has(e.fromNodeId)) { clipped += 1; continue; }
|
|
277
|
+
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
|
+
});
|
|
284
|
+
}
|
|
285
|
+
result.cyclesClipped += clipped;
|
|
286
|
+
if (extended === 0 && hops > 0) {
|
|
287
|
+
// Every continuation would revisit a node already on this path. The
|
|
288
|
+
// branch ends HERE, and it ends because of a cycle — never silently
|
|
289
|
+
// as if this node were the flow's origin.
|
|
290
|
+
candidates.push(materialize(store, frame.nodesRev, frame.edgesRev, TERMINAL_CYCLE));
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
result.budget.expansionsUsed = expansions;
|
|
295
|
+
result.enumeratedPathCount = candidates.length;
|
|
296
|
+
// Task 2 review finding 1: this is scoped to EVERY complete path found
|
|
297
|
+
// during enumeration, not just the ones the caps below end up returning
|
|
298
|
+
// in `result.paths` — the same enumeration scope `enumeratedPathCount`
|
|
299
|
+
// and `droppedPathCount` already carry, so it can legitimately exceed
|
|
300
|
+
// `result.paths.length` under a tight cap. A consumer wanting "how many
|
|
301
|
+
// complete paths are actually IN this response" should filter
|
|
302
|
+
// `result.paths` itself (`.filter(p => p.complete).length`), not read
|
|
303
|
+
// this field for that purpose.
|
|
304
|
+
result.completePathCount = candidates.filter((p) => p.complete).length;
|
|
305
|
+
|
|
306
|
+
// §15.5's cap, applied PER TERMINAL first. The terminal node is the
|
|
307
|
+
// closest thing to a "source" this increment has; a purely global cap
|
|
308
|
+
// lets one prolific terminal crowd another out entirely, which would
|
|
309
|
+
// report a real source as having zero paths.
|
|
310
|
+
const byTerminal = new Map();
|
|
311
|
+
for (const p of candidates) {
|
|
312
|
+
if (!byTerminal.has(p.terminal.nodeId)) byTerminal.set(p.terminal.nodeId, []);
|
|
313
|
+
byTerminal.get(p.terminal.nodeId).push(p);
|
|
314
|
+
}
|
|
315
|
+
const kept = [];
|
|
316
|
+
const terminals = [];
|
|
317
|
+
for (const [nodeId, group] of [...byTerminal.entries()].sort((a, b) => (a[0] < b[0] ? -1 : 1))) {
|
|
318
|
+
const ordered = [...group].sort(comparePaths);
|
|
319
|
+
const take = ordered.slice(0, budget.maxPathsPerTerminal);
|
|
320
|
+
if (ordered.length > take.length) truncationReasons.add('per-terminal-cap');
|
|
321
|
+
kept.push(...take);
|
|
322
|
+
terminals.push({
|
|
323
|
+
nodeId,
|
|
324
|
+
// §15.5's own corrected rule: a sorted UNION over the terminal's own
|
|
325
|
+
// paths, never a positional pick — a terminal can genuinely carry
|
|
326
|
+
// MIXED reasons (e.g. one sink reached by both a `cycle` clip and a
|
|
327
|
+
// `depth-limit` stop), and picking `group[0]`'s reason would make the
|
|
328
|
+
// answer depend on DFS enumeration order.
|
|
329
|
+
terminalReasons: [...new Set(group.map((p) => p.terminal.reason))].sort(),
|
|
330
|
+
enumeratedPathCount: group.length,
|
|
331
|
+
keptPathCount: take.length,
|
|
332
|
+
// Filled in AFTER the global cap runs, below. Computing `truncated`
|
|
333
|
+
// here — from the per-terminal cap alone — would report `false` for a
|
|
334
|
+
// terminal the GLOBAL cap later starves to zero returned paths. This
|
|
335
|
+
// is §15.5's fix round 1, finding 1 (BLOCKING): the single most
|
|
336
|
+
// load-bearing correctness property in this module.
|
|
337
|
+
returnedPathCount: 0,
|
|
338
|
+
droppedPathCount: 0,
|
|
339
|
+
truncated: false,
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// §15.7: the global cap is applied DIVERSITY-FIRST, round-robin across
|
|
344
|
+
// (terminal, shape) buckets, so §18.4's "prioritize paths that differ in
|
|
345
|
+
// boundary/transformation/protection state" is honoured as written — it
|
|
346
|
+
// asks for a DIVERSE retained set, not a top-N by any single scalar.
|
|
347
|
+
let returned = kept;
|
|
348
|
+
if (kept.length > budget.maxPaths) {
|
|
349
|
+
truncationReasons.add('path-cap');
|
|
350
|
+
const buckets = new Map();
|
|
351
|
+
for (const p of [...kept].sort(comparePaths)) {
|
|
352
|
+
const key = `${p.terminal.nodeId}|${p.shape}`;
|
|
353
|
+
if (!buckets.has(key)) buckets.set(key, []);
|
|
354
|
+
buckets.get(key).push(p);
|
|
355
|
+
}
|
|
356
|
+
const order = [...buckets.keys()].sort();
|
|
357
|
+
returned = [];
|
|
358
|
+
let progress = true;
|
|
359
|
+
while (returned.length < budget.maxPaths && progress) {
|
|
360
|
+
progress = false;
|
|
361
|
+
for (const k of order) {
|
|
362
|
+
if (returned.length >= budget.maxPaths) break;
|
|
363
|
+
const b = buckets.get(k);
|
|
364
|
+
if (b.length > 0) { returned.push(b.shift()); progress = true; }
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
returned = [...returned].sort(comparePaths);
|
|
370
|
+
result.paths = returned;
|
|
371
|
+
result.returnedPathCount = returned.length;
|
|
372
|
+
result.droppedPathCount = candidates.length - returned.length;
|
|
373
|
+
result.analysisTruncated = returned.some((p) => p.analysisTruncated);
|
|
374
|
+
// §15.5's corrected formula, computed AFTER the global round-robin above,
|
|
375
|
+
// never from the per-terminal cap alone: `enumerated > returned` is the
|
|
376
|
+
// only definition that covers both caps. `droppedPathCount` makes the
|
|
377
|
+
// count explicit per pair the way `result.droppedPathCount` does per
|
|
378
|
+
// call, and the two always sum consistently — a consumer can never be
|
|
379
|
+
// told two different stories.
|
|
380
|
+
for (const t of terminals) {
|
|
381
|
+
t.returnedPathCount = returned.filter((p) => p.terminal.nodeId === t.nodeId).length;
|
|
382
|
+
t.droppedPathCount = t.enumeratedPathCount - t.returnedPathCount;
|
|
383
|
+
t.truncated = t.droppedPathCount > 0;
|
|
384
|
+
}
|
|
385
|
+
result.terminals = terminals;
|
|
386
|
+
|
|
387
|
+
result.truncated = truncationReasons.size > 0 || result.droppedPathCount > 0;
|
|
388
|
+
result.truncationReasons = [...truncationReasons].sort();
|
|
389
|
+
|
|
390
|
+
if (result.paths.length === 0 && !result.truncated) {
|
|
391
|
+
// The one place a genuinely empty answer is produced — and it says
|
|
392
|
+
// WHICH kind of empty it is. This ordering IS §18.4's own load-bearing
|
|
393
|
+
// constraint expressed as a code path: `noPathReason` is computed ONLY
|
|
394
|
+
// when `truncated === false`, so a truncated result can never acquire a
|
|
395
|
+
// `noPathReason` — never a stylistic choice.
|
|
396
|
+
result.noPathReason = orphaned.has(startNodeId) ? TERMINAL_INCOMPLETE : 'no-incoming-edges';
|
|
397
|
+
}
|
|
398
|
+
return result;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* AC-10's persistent partial-coverage banner predicate — §15.4's five-term
|
|
403
|
+
* disjunction, exported so no caller re-derives it (and no caller forgets a
|
|
404
|
+
* term).
|
|
405
|
+
*/
|
|
406
|
+
export function isIncompleteAnswer(result) {
|
|
407
|
+
return result.truncated || result.unknownStartNode || result.analysisTruncated
|
|
408
|
+
|| result.noPathReason === TERMINAL_INCOMPLETE
|
|
409
|
+
|| result.paths.some((p) => !p.complete);
|
|
410
|
+
}
|