@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.
Files changed (209) hide show
  1. package/CHANGELOG.md +390 -0
  2. package/bin/agentic-security.js +3813 -83
  3. package/dist/1122.index.js +702 -0
  4. package/dist/{301.index.js → 1301.index.js} +2 -2
  5. package/dist/1379.index.js +591 -0
  6. package/dist/{444.index.js → 1444.index.js} +13 -4
  7. package/dist/{660.index.js → 1660.index.js} +2 -2
  8. package/dist/{700.index.js → 1700.index.js} +2 -2
  9. package/dist/{905.index.js → 1905.index.js} +3 -3
  10. package/dist/{920.index.js → 1920.index.js} +3 -3
  11. package/dist/{238.index.js → 2238.index.js} +3 -3
  12. package/dist/2271.index.js +165 -0
  13. package/dist/{985.index.js → 2376.index.js} +1260 -340
  14. package/dist/2432.index.js +793 -0
  15. package/dist/2659.index.js +93 -0
  16. package/dist/{826.index.js → 2826.index.js} +2 -2
  17. package/dist/{830.index.js → 2830.index.js} +2 -2
  18. package/dist/2923.index.js +298 -0
  19. package/dist/{1.index.js → 3001.index.js} +5 -5
  20. package/dist/{117.index.js → 3117.index.js} +3 -3
  21. package/dist/3180.index.js +307 -0
  22. package/dist/3276.index.js +117 -0
  23. package/dist/{415.index.js → 3415.index.js} +2 -2
  24. package/dist/{499.index.js → 3499.index.js} +2 -2
  25. package/dist/3518.index.js +450 -0
  26. package/dist/{526.index.js → 3526.index.js} +14 -6
  27. package/dist/{736.index.js → 3736.index.js} +4 -4
  28. package/dist/{839.index.js → 3839.index.js} +4 -4
  29. package/dist/{113.index.js → 4113.index.js} +14 -6
  30. package/dist/{265.index.js → 4265.index.js} +2 -2
  31. package/dist/{384.index.js → 4384.index.js} +3 -3
  32. package/dist/4547.index.js +268 -0
  33. package/dist/4863.index.js +422 -0
  34. package/dist/{970.index.js → 4970.index.js} +67 -3
  35. package/dist/5051.index.js +770 -0
  36. package/dist/{144.index.js → 5144.index.js} +5 -5
  37. package/dist/{333.index.js → 5333.index.js} +3 -3
  38. package/dist/5343.index.js +185 -0
  39. package/dist/5350.index.js +866 -0
  40. package/dist/5561.index.js +436 -0
  41. package/dist/{637.index.js → 5637.index.js} +29 -7
  42. package/dist/{449.index.js → 5830.index.js} +78 -14
  43. package/dist/6626.index.js +532 -0
  44. package/dist/6662.index.js +297 -0
  45. package/dist/{675.index.js → 6675.index.js} +5 -5
  46. package/dist/{730.index.js → 6730.index.js} +6 -6
  47. package/dist/6829.index.js +225 -0
  48. package/dist/6944.index.js +130 -0
  49. package/dist/{178.index.js → 7178.index.js} +26 -8
  50. package/dist/{227.index.js → 7227.index.js} +2 -2
  51. package/dist/7310.index.js +520 -0
  52. package/dist/{552.index.js → 7552.index.js} +4 -4
  53. package/dist/7709.index.js +78 -0
  54. package/dist/8218.index.js +160 -0
  55. package/dist/{476.index.js → 8476.index.js} +4 -4
  56. package/dist/{513.index.js → 8513.index.js} +5 -5
  57. package/dist/{520.index.js → 8520.index.js} +2 -2
  58. package/dist/{718.index.js → 8718.index.js} +2 -2
  59. package/dist/{752.index.js → 8752.index.js} +2 -2
  60. package/dist/8846.index.js +100 -0
  61. package/dist/{435.index.js → 9091.index.js} +580 -187
  62. package/dist/{207.index.js → 9207.index.js} +2 -2
  63. package/dist/{220.index.js → 9220.index.js} +2 -2
  64. package/dist/9390.index.js +163 -0
  65. package/dist/{503.index.js → 9503.index.js} +2 -2
  66. package/dist/{801.index.js → 9801.index.js} +2 -2
  67. package/dist/{824.index.js → 9824.index.js} +2 -2
  68. package/dist/agentic-security.mjs +16 -16
  69. package/dist/agentic-security.mjs.sha256 +1 -1
  70. package/dist/compliance-frameworks/hipaa-security-rule.json +3 -2
  71. package/package.json +23 -10
  72. package/src/compare.js +6 -1
  73. package/src/dataflow/CLAUDE.md +2 -2
  74. package/src/dataflow/catalog.js +42 -0
  75. package/src/dataflow/orm-write-catalog.js +175 -0
  76. package/src/engine.js +580 -30
  77. package/src/fix/apply-fix-service.js +1 -0
  78. package/src/history-scan.js +22 -5
  79. package/src/ir/CLAUDE.md +2 -1
  80. package/src/ir/chrome-probe.mjs +150 -0
  81. package/src/ir/parser-js.js +94 -7
  82. package/src/lineage/CLAUDE.md +1203 -0
  83. package/src/lineage/DESIGN_DESTINATION_RESOLVER.md +156 -0
  84. package/src/lineage/DESIGN_GRAPH_BUILDER.md +938 -0
  85. package/src/lineage/DESIGN_HANDLING_ANALYZER.md +355 -0
  86. package/src/lineage/DESIGN_INTRAPROCEDURAL.md +628 -0
  87. package/src/lineage/DESIGN_PATH_PROVENANCE.md +3451 -0
  88. package/src/lineage/DESIGN_QUEUE_DETAIL.md +120 -0
  89. package/src/lineage/DESIGN_REGISTRIES.md +880 -0
  90. package/src/lineage/DESIGN_STORE_DETAIL.md +143 -0
  91. package/src/lineage/DESIGN_TRANSIT_PROTECTION.md +245 -0
  92. package/src/lineage/classification.js +56 -0
  93. package/src/lineage/coverage.js +658 -0
  94. package/src/lineage/cross-repo-link.js +107 -0
  95. package/src/lineage/dataflow-graph.schema.json +184 -0
  96. package/src/lineage/decision-story.js +206 -0
  97. package/src/lineage/drift-policy.js +279 -0
  98. package/src/lineage/driver.js +135 -0
  99. package/src/lineage/engine.js +992 -0
  100. package/src/lineage/export-briefing.js +628 -0
  101. package/src/lineage/export-csv.js +62 -0
  102. package/src/lineage/export-json.js +238 -0
  103. package/src/lineage/export-privacy.js +258 -0
  104. package/src/lineage/federation-loader.js +111 -0
  105. package/src/lineage/field-identity.js +78 -0
  106. package/src/lineage/fixtures/build-flagship-fixture.mjs +272 -0
  107. package/src/lineage/fixtures/flagship-graph.json +1453 -0
  108. package/src/lineage/flow-grade.js +221 -0
  109. package/src/lineage/governance-edit.js +169 -0
  110. package/src/lineage/graph-builder.js +1114 -0
  111. package/src/lineage/graph-diff.js +431 -0
  112. package/src/lineage/graph-snapshot.js +180 -0
  113. package/src/lineage/handling-analyzer.js +168 -0
  114. package/src/lineage/ids.js +349 -0
  115. package/src/lineage/impact-assessment.js +76 -0
  116. package/src/lineage/impact-engine.js +268 -0
  117. package/src/lineage/index.js +281 -0
  118. package/src/lineage/language-coverage-tiers.js +58 -0
  119. package/src/lineage/obligation-mapping.js +126 -0
  120. package/src/lineage/obligation-predicates.js +235 -0
  121. package/src/lineage/observation-adapters.js +282 -0
  122. package/src/lineage/observation-correlation.js +622 -0
  123. package/src/lineage/observation-store.js +497 -0
  124. package/src/lineage/path-query.js +410 -0
  125. package/src/lineage/path-store.js +400 -0
  126. package/src/lineage/protection.js +53 -0
  127. package/src/lineage/recipient-profile.js +192 -0
  128. package/src/lineage/recipient-registry.js +394 -0
  129. package/src/lineage/redact-graph.js +224 -0
  130. package/src/lineage/remediation.js +417 -0
  131. package/src/lineage/resolve-destination.js +91 -0
  132. package/src/lineage/runtime-observation.js +464 -0
  133. package/src/lineage/scenario-diff.js +84 -0
  134. package/src/lineage/scenario-engine.js +251 -0
  135. package/src/lineage/scenario.js +101 -0
  136. package/src/lineage/schema.js +167 -0
  137. package/src/lineage/sink-registry.js +427 -0
  138. package/src/lineage/source-registry.js +357 -0
  139. package/src/lineage/source-seeding.js +212 -0
  140. package/src/lineage/summaries.js +590 -0
  141. package/src/lineage/transform-catalog.js +397 -0
  142. package/src/lineage/transit-protection.js +150 -0
  143. package/src/lineage/validate.js +285 -0
  144. package/src/lsp/server.js +49 -2
  145. package/src/mcp/CLAUDE.md +7 -1
  146. package/src/mcp/dataflow-tools.js +160 -0
  147. package/src/mcp/server.js +1 -1
  148. package/src/mcp/tools.js +22 -1
  149. package/src/pipeline/assurance-mode.js +64 -1
  150. package/src/pipeline/finding-schema.js +8 -1
  151. package/src/pipeline/scan-health.js +19 -1
  152. package/src/posture/CLAUDE.md +140 -0
  153. package/src/posture/accuracy-scorecard.js +60 -0
  154. package/src/posture/artifact-registry.js +76 -0
  155. package/src/posture/auditor-walkthrough.js +192 -13
  156. package/src/posture/compliance-frameworks/hipaa-security-rule.json +3 -2
  157. package/src/posture/compliance-policy.js +12 -2
  158. package/src/posture/cross-repo-memory.js +7 -2
  159. package/src/posture/fix-history.js +25 -2
  160. package/src/posture/fix-verify.js +9 -1
  161. package/src/posture/fleet.js +0 -0
  162. package/src/posture/git-history.js +13 -5
  163. package/src/posture/material-change.js +21 -2
  164. package/src/posture/mttr.js +75 -12
  165. package/src/posture/obligation-evidence-pack.js +202 -0
  166. package/src/posture/pre-incident-archaeology.js +39 -7
  167. package/src/posture/privacy-framework.js +14 -0
  168. package/src/posture/provenance/ai-authorship.js +68 -0
  169. package/src/posture/provenance/branch-entry.js +80 -0
  170. package/src/posture/provenance/cache.js +143 -0
  171. package/src/posture/provenance/confidence.js +36 -0
  172. package/src/posture/provenance/coordinator.js +786 -0
  173. package/src/posture/provenance/dag-walk.js +249 -0
  174. package/src/posture/provenance/evidence-attribution.js +59 -0
  175. package/src/posture/provenance/git-evidence.js +310 -0
  176. package/src/posture/provenance/lifecycle.js +208 -0
  177. package/src/posture/provenance/missing-control-resolver.js +137 -0
  178. package/src/posture/provenance/origin-resolver.js +342 -0
  179. package/src/posture/provenance/predicate-replay.js +133 -0
  180. package/src/posture/provenance/providers/config.js +39 -0
  181. package/src/posture/provenance/providers/github.js +62 -0
  182. package/src/posture/provenance/providers/gitlab.js +58 -0
  183. package/src/posture/provenance/repo-lineage.js +74 -0
  184. package/src/posture/provenance/sca-origin.js +139 -0
  185. package/src/posture/provenance/schema.js +255 -0
  186. package/src/posture/provenance/transitive-sca.js +147 -0
  187. package/src/posture/provenance/validate.js +30 -0
  188. package/src/posture/provenance-evidence-bundle.js +144 -0
  189. package/src/posture/remediation-ledger.js +337 -0
  190. package/src/posture/sbom-diff.js +15 -2
  191. package/src/posture/secret-history.js +10 -2
  192. package/src/posture/state-dir.js +38 -14
  193. package/src/posture/vuln-archaeology.js +8 -2
  194. package/src/pr-delta.js +25 -4
  195. package/src/report/index.js +197 -3
  196. package/src/runScan.js +34 -5
  197. package/src/sast/rate-limit.js +33 -3
  198. package/src/server/CLAUDE.md +47 -0
  199. package/src/server/graph-loader.js +141 -0
  200. package/src/server/http-server.js +325 -0
  201. package/src/server/routes.js +129 -0
  202. package/src/server/security.js +111 -0
  203. package/src/server/static-assets.js +139 -0
  204. package/src/util/git-hardening.js +128 -0
  205. package/dist/11.index.js +0 -353
  206. package/dist/259.index.js +0 -975
  207. package/dist/317.index.js +0 -300
  208. package/dist/609.index.js +0 -741
  209. 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
+ }