al-sem 0.0.1

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 (231) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +361 -0
  3. package/package.json +64 -0
  4. package/scripts/d40-diff.ts +44 -0
  5. package/scripts/fetch-native-parser.ts +179 -0
  6. package/scripts/precision-sample.ts +99 -0
  7. package/scripts/precision-study.ts +42 -0
  8. package/scripts/precision-tabulate.ts +52 -0
  9. package/src/cli/baseline.ts +31 -0
  10. package/src/cli/diff.ts +199 -0
  11. package/src/cli/events-chains.ts +56 -0
  12. package/src/cli/events-fanout.ts +87 -0
  13. package/src/cli/exit-code.ts +30 -0
  14. package/src/cli/fingerprint-indexes.ts +130 -0
  15. package/src/cli/fingerprint-query.ts +543 -0
  16. package/src/cli/fingerprint-witness.ts +493 -0
  17. package/src/cli/fingerprint.ts +292 -0
  18. package/src/cli/format-compact-json.ts +45 -0
  19. package/src/cli/format-events.ts +77 -0
  20. package/src/cli/format-fingerprint.ts +295 -0
  21. package/src/cli/format-html.ts +503 -0
  22. package/src/cli/format-json.ts +13 -0
  23. package/src/cli/format-policy.ts +95 -0
  24. package/src/cli/format-sarif.ts +186 -0
  25. package/src/cli/format-terminal.ts +153 -0
  26. package/src/cli/index.ts +566 -0
  27. package/src/cli/policy.ts +204 -0
  28. package/src/config/roots-config.ts +302 -0
  29. package/src/deps/cache-versions.ts +74 -0
  30. package/src/deps/canonical-json.ts +27 -0
  31. package/src/deps/dependency-artifact.ts +144 -0
  32. package/src/deps/dependency-cache.ts +262 -0
  33. package/src/deps/dependency-dag.ts +128 -0
  34. package/src/deps/dependency-package-discovery.ts +85 -0
  35. package/src/deps/dependency-pipeline.ts +483 -0
  36. package/src/deps/dependency-projection.ts +211 -0
  37. package/src/deps/dependency-resolver.ts +154 -0
  38. package/src/deps/workspace-dependencies.ts +114 -0
  39. package/src/detectors/capability-query.ts +145 -0
  40. package/src/detectors/confidence.ts +52 -0
  41. package/src/detectors/d1-db-op-in-loop.ts +457 -0
  42. package/src/detectors/d10-self-modifying-loop.ts +114 -0
  43. package/src/detectors/d11-modify-without-get.ts +129 -0
  44. package/src/detectors/d12-dead-integration-event.ts +81 -0
  45. package/src/detectors/d13-cross-app-internal-call.ts +105 -0
  46. package/src/detectors/d14-dead-routine.ts +151 -0
  47. package/src/detectors/d16-obsolete-routine-call.ts +94 -0
  48. package/src/detectors/d17-min-version-drift.ts +157 -0
  49. package/src/detectors/d18-constant-filter-in-loop.ts +151 -0
  50. package/src/detectors/d19-unused-parameter.ts +116 -0
  51. package/src/detectors/d2-event-fanout-in-loop.ts +240 -0
  52. package/src/detectors/d20-unreachable-after-exit.ts +92 -0
  53. package/src/detectors/d21-read-without-load.ts +128 -0
  54. package/src/detectors/d22-flowfield-without-calcfields.ts +168 -0
  55. package/src/detectors/d29-subscriber-modify-on-event-record.ts +163 -0
  56. package/src/detectors/d3-load-state.ts +72 -0
  57. package/src/detectors/d3-missing-setloadfields.ts +234 -0
  58. package/src/detectors/d32-constant-boolean-parameter.ts +185 -0
  59. package/src/detectors/d33-unfiltered-bulk-write.ts +173 -0
  60. package/src/detectors/d34-commit-in-loop.ts +206 -0
  61. package/src/detectors/d35-commit-in-event-subscriber.ts +138 -0
  62. package/src/detectors/d36-late-setloadfields.ts +162 -0
  63. package/src/detectors/d37-validate-without-persist.ts +271 -0
  64. package/src/detectors/d38-subscriber-to-obsolete-event.ts +140 -0
  65. package/src/detectors/d39-record-left-dirty-across-chain.ts +165 -0
  66. package/src/detectors/d4-repeated-lookup-in-loop.ts +128 -0
  67. package/src/detectors/d40-transitive-load-missing.ts +217 -0
  68. package/src/detectors/d41-transitive-filter-loss.ts +200 -0
  69. package/src/detectors/d42-cross-call-wrong-setloadfields.ts +243 -0
  70. package/src/detectors/d43-event-ishandled-skip.ts +257 -0
  71. package/src/detectors/d44-event-multi-subscriber-overlap.ts +223 -0
  72. package/src/detectors/d45-event-transitive-table-exposure.ts +159 -0
  73. package/src/detectors/d5-set-based-opportunity.ts +162 -0
  74. package/src/detectors/d7-recursive-event-expansion.ts +151 -0
  75. package/src/detectors/d8-commit-in-transaction.ts +132 -0
  76. package/src/detectors/d9-transaction-span-summary.ts +107 -0
  77. package/src/detectors/detector-context.ts +121 -0
  78. package/src/detectors/finding-grouping.ts +61 -0
  79. package/src/detectors/path-merge.ts +174 -0
  80. package/src/detectors/registry.ts +176 -0
  81. package/src/detectors/table-display.ts +42 -0
  82. package/src/diff/diff-abi.ts +195 -0
  83. package/src/diff/diff-capabilities.ts +179 -0
  84. package/src/diff/diff-engine.ts +146 -0
  85. package/src/diff/diff-events.ts +323 -0
  86. package/src/diff/diff-identity.ts +73 -0
  87. package/src/diff/diff-indexes.ts +199 -0
  88. package/src/diff/diff-permissions.ts +260 -0
  89. package/src/diff/diff-policy.ts +101 -0
  90. package/src/diff/diff-preflight.ts +66 -0
  91. package/src/diff/diff-renames.ts +104 -0
  92. package/src/diff/diff-schema.ts +232 -0
  93. package/src/diff/format-diff.ts +148 -0
  94. package/src/engine/attribute-parser.ts +50 -0
  95. package/src/engine/capability-cone.ts +531 -0
  96. package/src/engine/combined-graph.ts +357 -0
  97. package/src/engine/control-flow-walker.ts +1317 -0
  98. package/src/engine/dispatch-sites.ts +199 -0
  99. package/src/engine/effect-lattice.ts +81 -0
  100. package/src/engine/entry-points.ts +57 -0
  101. package/src/engine/event-flow.ts +524 -0
  102. package/src/engine/event-relay.ts +92 -0
  103. package/src/engine/op-classification.ts +92 -0
  104. package/src/engine/path-walker.ts +189 -0
  105. package/src/engine/reverse-call-graph.ts +23 -0
  106. package/src/engine/root-classifier-overlay.ts +194 -0
  107. package/src/engine/root-classifier.ts +135 -0
  108. package/src/engine/scc.ts +110 -0
  109. package/src/engine/source-anchor.ts +25 -0
  110. package/src/engine/summary-context.ts +104 -0
  111. package/src/engine/summary-engine.ts +296 -0
  112. package/src/engine/summary-runner.ts +560 -0
  113. package/src/engine/transaction-spans.ts +112 -0
  114. package/src/engine/uncertainty-util.ts +54 -0
  115. package/src/hash.ts +31 -0
  116. package/src/index/attribute-from-node.ts +141 -0
  117. package/src/index/callee-from-node.ts +181 -0
  118. package/src/index/capability/background.ts +90 -0
  119. package/src/index/capability/commit.ts +44 -0
  120. package/src/index/capability/dispatch.ts +164 -0
  121. package/src/index/capability/events.ts +65 -0
  122. package/src/index/capability/extractor.ts +124 -0
  123. package/src/index/capability/file-blob.ts +137 -0
  124. package/src/index/capability/http.ts +159 -0
  125. package/src/index/capability/hyperlink.ts +60 -0
  126. package/src/index/capability/isolated-storage.ts +179 -0
  127. package/src/index/capability/table.ts +113 -0
  128. package/src/index/capability/telemetry.ts +84 -0
  129. package/src/index/capability/ui.ts +55 -0
  130. package/src/index/capability/value-source.ts +202 -0
  131. package/src/index/expression-from-node.ts +117 -0
  132. package/src/index/indexer.ts +102 -0
  133. package/src/index/intraprocedural-body.ts +1467 -0
  134. package/src/index/intraprocedural-ops.ts +253 -0
  135. package/src/index/intraprocedural-refs.ts +188 -0
  136. package/src/index/object-indexer.ts +279 -0
  137. package/src/index/routine-indexer.ts +282 -0
  138. package/src/index/routine-signature.ts +46 -0
  139. package/src/index/variable-indexer.ts +134 -0
  140. package/src/index/variable-initializer-extractor.ts +155 -0
  141. package/src/index/variable-type-normalizer.ts +83 -0
  142. package/src/index.ts +267 -0
  143. package/src/mcp/server.ts +72 -0
  144. package/src/mcp/session.ts +49 -0
  145. package/src/mcp/tools/explain-path.ts +75 -0
  146. package/src/mcp/tools/get-analysis-health.ts +62 -0
  147. package/src/mcp/tools/get-finding.ts +47 -0
  148. package/src/mcp/tools/get-routine-summary.ts +126 -0
  149. package/src/mcp/tools/list-findings.ts +85 -0
  150. package/src/mcp/tools/list-hotspots.ts +78 -0
  151. package/src/mcp/tools/list-rollups.ts +103 -0
  152. package/src/mcp/tools/validators.ts +25 -0
  153. package/src/model/attributes.ts +120 -0
  154. package/src/model/callee.ts +45 -0
  155. package/src/model/capability.ts +187 -0
  156. package/src/model/coverage.ts +85 -0
  157. package/src/model/entities.ts +628 -0
  158. package/src/model/expression.ts +98 -0
  159. package/src/model/finding.ts +110 -0
  160. package/src/model/graph-edge.ts +93 -0
  161. package/src/model/graph.ts +62 -0
  162. package/src/model/identity.ts +81 -0
  163. package/src/model/ids.ts +90 -0
  164. package/src/model/index.ts +13 -0
  165. package/src/model/model.ts +51 -0
  166. package/src/model/permission.ts +76 -0
  167. package/src/model/root-classification.ts +116 -0
  168. package/src/model/stable-identity.ts +102 -0
  169. package/src/model/summary.ts +96 -0
  170. package/src/parser/ast.ts +82 -0
  171. package/src/parser/native/ffi.ts +145 -0
  172. package/src/parser/native/parse-index-pool.ts +148 -0
  173. package/src/parser/native/parse-index-worker.ts +94 -0
  174. package/src/parser/native/wrapper.ts +353 -0
  175. package/src/parser/parser-init.ts +43 -0
  176. package/src/perf/profiler.ts +66 -0
  177. package/src/policy/policy-default.yaml +83 -0
  178. package/src/policy/policy-engine.ts +339 -0
  179. package/src/policy/policy-loader.ts +257 -0
  180. package/src/policy/policy-schema.json +379 -0
  181. package/src/policy/policy-types.ts +81 -0
  182. package/src/policy/predicate-compiler.ts +151 -0
  183. package/src/policy/predicate-evaluator.ts +267 -0
  184. package/src/policy/predicate-fields.ts +439 -0
  185. package/src/projection/actionable-anchor.ts +48 -0
  186. package/src/projection/finding-filters.ts +44 -0
  187. package/src/projection/finding-fingerprint.ts +54 -0
  188. package/src/projection/finding-groups.ts +41 -0
  189. package/src/projection/finding-summary.ts +110 -0
  190. package/src/projection/rollup-findings.ts +105 -0
  191. package/src/providers/discover.ts +88 -0
  192. package/src/providers/external.ts +46 -0
  193. package/src/providers/types.ts +36 -0
  194. package/src/providers/workspace.ts +117 -0
  195. package/src/resolve/call-resolver.ts +117 -0
  196. package/src/resolve/coverage.ts +61 -0
  197. package/src/resolve/event-graph.ts +166 -0
  198. package/src/resolve/implicit-edges.ts +53 -0
  199. package/src/resolve/record-types.ts +36 -0
  200. package/src/resolve/resolver.ts +23 -0
  201. package/src/resolve/semantic-graph.ts +29 -0
  202. package/src/resolve/symbol-table.ts +69 -0
  203. package/src/snapshot/app-snapshot.ts +74 -0
  204. package/src/snapshot/compose.ts +100 -0
  205. package/src/snapshot/derive/callsite-evidence.ts +76 -0
  206. package/src/snapshot/derive/capability-facts.ts +70 -0
  207. package/src/snapshot/derive/contracts.ts +131 -0
  208. package/src/snapshot/derive/coverage.ts +35 -0
  209. package/src/snapshot/derive/event-declarations.ts +140 -0
  210. package/src/snapshot/derive/identity-table.ts +58 -0
  211. package/src/snapshot/derive/inputs.ts +91 -0
  212. package/src/snapshot/derive/operation-evidence.ts +70 -0
  213. package/src/snapshot/derive/permissions.ts +186 -0
  214. package/src/snapshot/derive/root-classifications.ts +56 -0
  215. package/src/snapshot/derive/schema.ts +130 -0
  216. package/src/snapshot/derive/typed-edges.ts +60 -0
  217. package/src/snapshot/derive/workspace-fingerprint.ts +19 -0
  218. package/src/snapshot/deserialize.ts +40 -0
  219. package/src/snapshot/serialize-cbor-gz.ts +12 -0
  220. package/src/snapshot/serialize-cbor.ts +19 -0
  221. package/src/snapshot/serialize-json.ts +22 -0
  222. package/src/snapshot/shard.ts +134 -0
  223. package/src/snapshot/types.ts +181 -0
  224. package/src/symbols/app-manifest.ts +96 -0
  225. package/src/symbols/app-package-zip.ts +50 -0
  226. package/src/symbols/embedded-source-reader.ts +41 -0
  227. package/src/symbols/package-hash.ts +81 -0
  228. package/src/symbols/symbol-reader.ts +101 -0
  229. package/src/symbols/symbol-reference-parser.ts +378 -0
  230. package/src/symbols/symbol-reference-reader.ts +27 -0
  231. package/tsconfig.json +18 -0
@@ -0,0 +1,493 @@
1
+ import type { CapabilityFact, CapabilityVia } from "../model/capability.ts";
2
+ import type { CoverageRecord } from "../model/coverage.ts";
3
+ import type { GraphEdge } from "../model/graph-edge.ts";
4
+ import type { CallsiteId, OperationId } from "../model/ids.ts";
5
+ import type { StableObjectId, StableRoutineId } from "../model/stable-identity.ts";
6
+ import type { CallsiteEvidence, OperationEvidence } from "../snapshot/types.ts";
7
+
8
+ export interface WitnessRequest {
9
+ rootId: StableRoutineId;
10
+ fact: CapabilityFact;
11
+ /**
12
+ * Maximum witness paths to return.
13
+ * - `false` — suppress all witness reconstruction; returns
14
+ * `{ paths: [], truncated: false, incomplete: false }`. Caller
15
+ * knows it disabled witnesses; the outcome does NOT signal
16
+ * "no evidence" — it signals "evidence not collected".
17
+ * - `number` — clamped to `[0, HARD_PATH_CAP=256]`.
18
+ * - `"all"` — equivalent to `HARD_PATH_CAP`.
19
+ */
20
+ limit: false | number | "all";
21
+ indexes: WitnessIndexes;
22
+ maxDepth?: number;
23
+ maxExpandedStates?: number;
24
+ }
25
+
26
+ export interface WitnessIndexes {
27
+ outgoingEdges: Map<StableRoutineId, readonly GraphEdge[]>;
28
+ directFactsByRoutine: Map<StableRoutineId, readonly CapabilityFact[]>;
29
+ callsiteById: Map<CallsiteId, CallsiteEvidence>;
30
+ operationById: Map<OperationId, OperationEvidence>;
31
+ routineDisplayById: Map<StableRoutineId, string>;
32
+ stableIdToDisplay: Map<string, string>;
33
+ eventDisplayById?: Map<string, string>;
34
+ coverageByRoutine?: Map<StableRoutineId, CoverageRecord>;
35
+ }
36
+
37
+ export interface WitnessOutcome {
38
+ paths: readonly WitnessPath[];
39
+ truncated: boolean;
40
+ incomplete: boolean;
41
+ diagnostics: readonly WitnessDiagnostic[];
42
+ }
43
+
44
+ export interface WitnessPath {
45
+ hops: readonly WitnessHop[];
46
+ }
47
+
48
+ export type WitnessDiagnostic =
49
+ | { kind: "path-limit-reached"; cap: number }
50
+ | { kind: "depth-exceeded"; maxDepth: number }
51
+ | { kind: "state-limit-exceeded"; maxExpandedStates: number }
52
+ | { kind: "first-hop-not-found"; callsiteId?: CallsiteId; via: CapabilityVia }
53
+ | { kind: "terminal-not-found" }
54
+ | { kind: "missing-callsite-evidence"; callsiteId: CallsiteId }
55
+ | { kind: "missing-operation-evidence"; operationId: OperationId }
56
+ | { kind: "missing-witness-anchor"; subject: StableRoutineId }
57
+ | { kind: "unresolved-dispatch"; callsiteId?: CallsiteId }
58
+ | { kind: "opaque-or-unresolved-boundary"; routineId?: StableRoutineId; target?: string };
59
+
60
+ export type WitnessHop =
61
+ | {
62
+ kind: "call";
63
+ routineId: StableRoutineId;
64
+ routineDisplay: string;
65
+ calleeDisplay: string;
66
+ callsiteId: CallsiteId;
67
+ sourceFile?: string;
68
+ line?: number;
69
+ column?: number;
70
+ }
71
+ | {
72
+ kind: "object-run";
73
+ routineId: StableRoutineId;
74
+ routineDisplay: string;
75
+ targetObjectId?: StableObjectId;
76
+ targetDisplay?: string;
77
+ resolved: boolean;
78
+ callsiteId?: CallsiteId;
79
+ sourceFile?: string;
80
+ line?: number;
81
+ column?: number;
82
+ }
83
+ | {
84
+ kind: "event-dispatch";
85
+ routineId: StableRoutineId;
86
+ routineDisplay: string;
87
+ eventId: string;
88
+ eventDisplay: string;
89
+ }
90
+ | {
91
+ kind: "implicit-trigger";
92
+ routineId: StableRoutineId;
93
+ routineDisplay: string;
94
+ triggerKind: string;
95
+ operationId?: OperationId;
96
+ sourceFile?: string;
97
+ line?: number;
98
+ column?: number;
99
+ }
100
+ | {
101
+ kind: "dependency-export";
102
+ routineId: StableRoutineId;
103
+ routineDisplay: string;
104
+ targetAppGuid: string;
105
+ callsiteId: CallsiteId;
106
+ calleeDisplay?: string;
107
+ sourceFile?: string;
108
+ line?: number;
109
+ column?: number;
110
+ }
111
+ | {
112
+ kind: "terminal";
113
+ evidenceKind: "operation" | "callsite" | "synthetic";
114
+ operationId?: OperationId;
115
+ callsiteId?: CallsiteId;
116
+ displayText: string;
117
+ sourceFile?: string;
118
+ line?: number;
119
+ column?: number;
120
+ };
121
+
122
+ const HARD_PATH_CAP = 256;
123
+
124
+ function effectiveLimit(limit: WitnessRequest["limit"]): number {
125
+ if (limit === false) return 0;
126
+ if (limit === "all") return HARD_PATH_CAP;
127
+ return Math.max(0, Math.min(limit, HARD_PATH_CAP));
128
+ }
129
+
130
+ function buildDirectTerminal(
131
+ evidenceKind: "operation" | "callsite",
132
+ witnessId: OperationId | CallsiteId,
133
+ evidence:
134
+ | {
135
+ displayText?: string;
136
+ calleeDisplay?: string;
137
+ sourceFile?: string;
138
+ startLine?: number;
139
+ startColumn?: number;
140
+ }
141
+ | undefined,
142
+ ): WitnessHop & { kind: "terminal" } {
143
+ const displayText =
144
+ evidenceKind === "operation"
145
+ ? (evidence?.displayText ?? String(witnessId))
146
+ : (evidence?.calleeDisplay ?? String(witnessId));
147
+ return {
148
+ kind: "terminal",
149
+ evidenceKind,
150
+ operationId: evidenceKind === "operation" ? (witnessId as OperationId) : undefined,
151
+ callsiteId: evidenceKind === "callsite" ? (witnessId as CallsiteId) : undefined,
152
+ displayText,
153
+ sourceFile: evidence?.sourceFile,
154
+ line: evidence?.startLine,
155
+ column: evidence?.startColumn,
156
+ };
157
+ }
158
+
159
+ export function reconstructWitnessPaths(req: WitnessRequest): WitnessOutcome {
160
+ const cap = effectiveLimit(req.limit);
161
+ if (cap === 0) return { paths: [], truncated: false, incomplete: false, diagnostics: [] };
162
+
163
+ const diagnostics: WitnessDiagnostic[] = [];
164
+ const { fact, indexes } = req;
165
+
166
+ if (fact.provenance === "direct") {
167
+ // Case A: witnessOperationId present → terminal "operation" evidence.
168
+ if (fact.witnessOperationId !== undefined) {
169
+ const ev = indexes.operationById.get(fact.witnessOperationId);
170
+ const hop = buildDirectTerminal("operation", fact.witnessOperationId, ev);
171
+ if (ev === undefined) {
172
+ diagnostics.push({
173
+ kind: "missing-operation-evidence",
174
+ operationId: fact.witnessOperationId,
175
+ });
176
+ }
177
+ const path: WitnessPath = { hops: [hop] };
178
+ return { paths: [path], truncated: false, incomplete: ev === undefined, diagnostics };
179
+ }
180
+ // Case B: only witnessCallsiteId → terminal "callsite" evidence.
181
+ if (fact.witnessCallsiteId !== undefined) {
182
+ const ev = indexes.callsiteById.get(fact.witnessCallsiteId);
183
+ const hop = buildDirectTerminal("callsite", fact.witnessCallsiteId, ev);
184
+ if (ev === undefined) {
185
+ diagnostics.push({ kind: "missing-callsite-evidence", callsiteId: fact.witnessCallsiteId });
186
+ }
187
+ const path: WitnessPath = { hops: [hop] };
188
+ return { paths: [path], truncated: false, incomplete: ev === undefined, diagnostics };
189
+ }
190
+ // Direct with no witness anchor → synthetic.
191
+ diagnostics.push({ kind: "missing-witness-anchor", subject: fact.subject as StableRoutineId });
192
+ return {
193
+ paths: [
194
+ {
195
+ hops: [
196
+ {
197
+ kind: "terminal",
198
+ evidenceKind: "synthetic",
199
+ displayText: `${fact.op} ${fact.resourceKind}`,
200
+ },
201
+ ],
202
+ },
203
+ ],
204
+ truncated: false,
205
+ incomplete: true,
206
+ diagnostics,
207
+ };
208
+ }
209
+
210
+ // --- Case C: inherited fact ---
211
+ const maxDepth = req.maxDepth ?? 64;
212
+ const maxStates = req.maxExpandedStates ?? 25_000;
213
+ let stateCount = 0;
214
+ let truncated = false;
215
+ let incomplete = false;
216
+ let depthExceeded = false;
217
+
218
+ const paths: WitnessPath[] = [];
219
+
220
+ // First-hop constraint
221
+ if (fact.witnessCallsiteId === undefined) {
222
+ diagnostics.push({ kind: "first-hop-not-found", via: fact.via });
223
+ return { paths: [], truncated: false, incomplete: true, diagnostics };
224
+ }
225
+ const outFromRoot = indexes.outgoingEdges.get(req.rootId) ?? [];
226
+ const firstEdges = outFromRoot.filter(
227
+ (e) =>
228
+ "callsiteId" in e && (e as { callsiteId?: CallsiteId }).callsiteId === fact.witnessCallsiteId,
229
+ );
230
+ if (firstEdges.length === 0) {
231
+ diagnostics.push({
232
+ kind: "first-hop-not-found",
233
+ callsiteId: fact.witnessCallsiteId,
234
+ via: fact.via,
235
+ });
236
+ return { paths: [], truncated: false, incomplete: true, diagnostics };
237
+ }
238
+
239
+ type State = { routine: StableRoutineId; hops: WitnessHop[]; visited: Set<StableRoutineId> };
240
+ const queue: State[] = [];
241
+ for (const edge of firstEdges) {
242
+ const hop = edgeToHop(edge, indexes);
243
+ if (hop === undefined) continue;
244
+ const to = (edge as { to?: StableRoutineId }).to;
245
+ if (to === undefined) continue;
246
+ const visited = new Set<StableRoutineId>([req.rootId, to]);
247
+ queue.push({ routine: to, hops: [hop], visited });
248
+ }
249
+
250
+ queue.sort((a, b) => (a.routine < b.routine ? -1 : 1));
251
+
252
+ while (queue.length > 0 && paths.length < cap) {
253
+ const state = queue.shift();
254
+ if (state === undefined) break;
255
+ stateCount++;
256
+ if (stateCount > maxStates) {
257
+ diagnostics.push({ kind: "state-limit-exceeded", maxExpandedStates: maxStates });
258
+ incomplete = true;
259
+ break;
260
+ }
261
+ if (state.hops.length > maxDepth) {
262
+ depthExceeded = true;
263
+ continue;
264
+ }
265
+ // Terminal check
266
+ const directs = indexes.directFactsByRoutine.get(state.routine) ?? [];
267
+ const equivalent = directs.find((d) => factEquivalent(d, fact));
268
+ if (equivalent !== undefined) {
269
+ const terminalHop = terminalHopFromFact(equivalent, indexes);
270
+ paths.push({ hops: [...state.hops, terminalHop] });
271
+ continue;
272
+ }
273
+ // Opaque-or-unresolved-boundary detection at the current routine: no
274
+ // outgoing edges, no direct facts, and coverage marks it unknown. The
275
+ // path terminates here — the last hop (already in state.hops) is the
276
+ // boundary; no terminal hop is appended.
277
+ const routineOut = indexes.outgoingEdges.get(state.routine);
278
+ const routineCov = indexes.coverageByRoutine?.get(state.routine);
279
+ if (
280
+ (routineOut?.length ?? 0) === 0 &&
281
+ directs.length === 0 &&
282
+ routineCov?.directStatus === "unknown"
283
+ ) {
284
+ diagnostics.push({ kind: "opaque-or-unresolved-boundary", routineId: state.routine });
285
+ // Emit the path up to (and including) the last hop already accumulated;
286
+ // no terminal is appended because we have no evidence at this boundary.
287
+ if (state.hops.length > 0) {
288
+ paths.push({ hops: [...state.hops] });
289
+ }
290
+ continue;
291
+ }
292
+ // Expand
293
+ const out = indexes.outgoingEdges.get(state.routine) ?? [];
294
+ const sorted = [...out].sort(edgeCompare);
295
+ for (const edge of sorted) {
296
+ const to = (edge as { to?: StableRoutineId }).to;
297
+ if (to === undefined) continue;
298
+ if (state.visited.has(to)) continue;
299
+ const hop = edgeToHop(edge, indexes);
300
+ if (hop === undefined) continue;
301
+ // Per-path visited (cloned per expansion). Allows the same routine to
302
+ // participate in different alternative paths; bounded by maxDepth=64 and
303
+ // maxStates=25_000.
304
+ const newVisited = new Set(state.visited);
305
+ newVisited.add(to);
306
+ queue.push({ routine: to, hops: [...state.hops, hop], visited: newVisited });
307
+ }
308
+ }
309
+
310
+ if (paths.length >= cap && queue.length > 0) {
311
+ truncated = true;
312
+ diagnostics.push({ kind: "path-limit-reached", cap });
313
+ }
314
+ if (depthExceeded) {
315
+ diagnostics.push({ kind: "depth-exceeded", maxDepth });
316
+ incomplete = true;
317
+ }
318
+ if (paths.length === 0 && !incomplete) {
319
+ diagnostics.push({ kind: "terminal-not-found" });
320
+ incomplete = true;
321
+ }
322
+
323
+ // Stability over perf: JSON.stringify gives byte-stable tiebreak across
324
+ // path enumeration order. Cost is O(cap^2 * serialize); pre-compute per
325
+ // path if cap grows beyond a few hundred.
326
+ paths.sort((a, b) => {
327
+ if (a.hops.length !== b.hops.length) return a.hops.length - b.hops.length;
328
+ return JSON.stringify(a.hops) < JSON.stringify(b.hops) ? -1 : 1;
329
+ });
330
+
331
+ return { paths, truncated, incomplete, diagnostics };
332
+ }
333
+
334
+ /**
335
+ * Best-effort equivalence check for "is this direct fact the terminal evidence
336
+ * for an inherited fact?". The deriver pipeline preserves (op, resourceKind,
337
+ * resourceId, resourceArgSource, extra) when propagating direct facts upward
338
+ * into inherited facts, so a matching direct fact at a BFS terminal should
339
+ * agree on these dimensions.
340
+ *
341
+ * The resourceId guard is asymmetric on purpose: if one side has a resourceId
342
+ * and the other doesn't, the comparison is skipped (treated as equivalent).
343
+ * This handles the case where the inherited fact carries a specialized
344
+ * resourceId (e.g., resolved from a literal SetRange argument) while the
345
+ * direct fact at the terminal stays generic (resourceId undefined for a
346
+ * dynamic dispatch site whose record-variable target couldn't be resolved).
347
+ *
348
+ * If this leniency produces false-positive terminal matches in practice,
349
+ * tighten to require bilateral agreement.
350
+ */
351
+ function factEquivalent(a: CapabilityFact, b: CapabilityFact): boolean {
352
+ if (a.op !== b.op) return false;
353
+ if (a.resourceKind !== b.resourceKind) return false;
354
+ if (a.resourceId !== undefined && b.resourceId !== undefined) {
355
+ if (String(a.resourceId) !== String(b.resourceId)) return false;
356
+ }
357
+ if (a.resourceArgSource !== undefined && b.resourceArgSource !== undefined) {
358
+ if (JSON.stringify(a.resourceArgSource) !== JSON.stringify(b.resourceArgSource)) return false;
359
+ }
360
+ const extraA = a.extra as { kind?: string; objectType?: string } | undefined;
361
+ const extraB = b.extra as { kind?: string; objectType?: string } | undefined;
362
+ if (extraA?.kind === "dispatch" || extraB?.kind === "dispatch") {
363
+ if (extraA?.objectType !== extraB?.objectType) return false;
364
+ }
365
+ return true;
366
+ }
367
+
368
+ function terminalHopFromFact(fact: CapabilityFact, indexes: WitnessIndexes): WitnessHop {
369
+ if (fact.witnessOperationId !== undefined) {
370
+ const ev = indexes.operationById.get(fact.witnessOperationId);
371
+ return {
372
+ kind: "terminal",
373
+ evidenceKind: "operation",
374
+ operationId: fact.witnessOperationId,
375
+ displayText: ev?.displayText ?? String(fact.witnessOperationId),
376
+ sourceFile: ev?.sourceFile,
377
+ line: ev?.startLine,
378
+ column: ev?.startColumn,
379
+ };
380
+ }
381
+ if (fact.witnessCallsiteId !== undefined) {
382
+ const ev = indexes.callsiteById.get(fact.witnessCallsiteId);
383
+ return {
384
+ kind: "terminal",
385
+ evidenceKind: "callsite",
386
+ callsiteId: fact.witnessCallsiteId,
387
+ displayText: ev?.calleeDisplay ?? String(fact.witnessCallsiteId),
388
+ sourceFile: ev?.sourceFile,
389
+ line: ev?.startLine,
390
+ column: ev?.startColumn,
391
+ };
392
+ }
393
+ return {
394
+ kind: "terminal",
395
+ evidenceKind: "synthetic",
396
+ displayText: `${fact.op} ${fact.resourceKind}`,
397
+ };
398
+ }
399
+
400
+ function edgeToHop(edge: GraphEdge, indexes: WitnessIndexes): WitnessHop | undefined {
401
+ switch (edge.kind) {
402
+ case "direct-call": {
403
+ const to = edge.to as unknown as StableRoutineId;
404
+ const display = indexes.routineDisplayById.get(to) ?? String(to);
405
+ const cs = indexes.callsiteById.get(edge.callsiteId);
406
+ return {
407
+ kind: "call",
408
+ routineId: to,
409
+ routineDisplay: display,
410
+ calleeDisplay: cs?.calleeDisplay ?? "",
411
+ callsiteId: edge.callsiteId,
412
+ sourceFile: cs?.sourceFile,
413
+ line: cs?.startLine,
414
+ column: cs?.startColumn,
415
+ };
416
+ }
417
+ case "object-run-resolved": {
418
+ const to = edge.to as unknown as StableRoutineId;
419
+ const display = indexes.routineDisplayById.get(to) ?? String(to);
420
+ const cs = indexes.callsiteById.get(edge.callsiteId);
421
+ const targetObject = String(edge.targetObject) as StableObjectId;
422
+ return {
423
+ kind: "object-run",
424
+ routineId: to,
425
+ routineDisplay: display,
426
+ targetObjectId: targetObject,
427
+ targetDisplay: indexes.stableIdToDisplay.get(String(edge.targetObject)),
428
+ resolved: true,
429
+ callsiteId: edge.callsiteId,
430
+ sourceFile: cs?.sourceFile,
431
+ line: cs?.startLine,
432
+ column: cs?.startColumn,
433
+ };
434
+ }
435
+ case "object-run-unresolved":
436
+ // No `to` on unresolved dispatch — BFS cannot walk through.
437
+ // Boundary detection emits an `unresolved-dispatch` diagnostic (see BFS expand loop).
438
+ return undefined;
439
+ case "event-dispatch": {
440
+ const to = edge.to as unknown as StableRoutineId;
441
+ const display = indexes.routineDisplayById.get(to) ?? String(to);
442
+ const eid = String(edge.eventId);
443
+ return {
444
+ kind: "event-dispatch",
445
+ routineId: to,
446
+ routineDisplay: display,
447
+ eventId: eid,
448
+ eventDisplay: indexes.eventDisplayById?.get(eid) ?? eid,
449
+ };
450
+ }
451
+ case "implicit-trigger": {
452
+ const to = edge.to as unknown as StableRoutineId;
453
+ const display = indexes.routineDisplayById.get(to) ?? String(to);
454
+ return {
455
+ kind: "implicit-trigger",
456
+ routineId: to,
457
+ routineDisplay: display,
458
+ triggerKind: edge.triggerKind,
459
+ operationId: edge.operationId,
460
+ };
461
+ }
462
+ case "dependency-export": {
463
+ const to = edge.to as unknown as StableRoutineId;
464
+ const display = indexes.routineDisplayById.get(to) ?? String(to);
465
+ const cs = indexes.callsiteById.get(edge.callsiteId);
466
+ return {
467
+ kind: "dependency-export",
468
+ routineId: to,
469
+ routineDisplay: display,
470
+ targetAppGuid: edge.targetAppGuid,
471
+ callsiteId: edge.callsiteId,
472
+ calleeDisplay: cs?.calleeDisplay,
473
+ sourceFile: cs?.sourceFile,
474
+ line: cs?.startLine,
475
+ column: cs?.startColumn,
476
+ };
477
+ }
478
+ default:
479
+ return undefined;
480
+ }
481
+ }
482
+
483
+ function edgeCompare(a: GraphEdge, b: GraphEdge): number {
484
+ if (a.kind !== b.kind) return a.kind < b.kind ? -1 : 1;
485
+ const csOf = (e: GraphEdge) => String((e as { callsiteId?: unknown }).callsiteId ?? "");
486
+ const csA = csOf(a);
487
+ const csB = csOf(b);
488
+ if (csA !== csB) return csA < csB ? -1 : 1;
489
+ const toOf = (e: GraphEdge) => String((e as { to?: unknown }).to ?? "");
490
+ const toA = toOf(a);
491
+ const toB = toOf(b);
492
+ return toA < toB ? -1 : 1;
493
+ }