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,524 @@
1
+ import type { CapabilityFact } from "../model/capability.ts";
2
+ import { type Scope, roleOf } from "../model/entities.ts";
3
+ import type { EventEdge } from "../model/graph.ts";
4
+ import type { EventId, RoutineId } from "../model/ids.ts";
5
+ import type { SemanticModel } from "../model/model.ts";
6
+ import { compareStrings } from "./uncertainty-util.ts";
7
+
8
+ /**
9
+ * Per-event lookup of subscribers whose subscriberAppId differs from the
10
+ * publisher's appGuid. Sorted RoutineId list. Empty when all subscribers
11
+ * are in-app. Returns an empty array (not undefined) for events not
12
+ * tracked or with no cross-app subscribers.
13
+ *
14
+ * Used by D43/D44/D45 to populate Finding.crossExtensionSubscribers
15
+ * evidence (Phase 3.3 §4.2 — evidence-only, no detector filtering).
16
+ */
17
+ export function buildCrossExtensionSubscribers(
18
+ model: SemanticModel,
19
+ ): ReadonlyMap<EventId, readonly RoutineId[]> {
20
+ // Map object id → appGuid for fast lookup.
21
+ const objectAppById = new Map<string, string>();
22
+ for (const obj of model.objects) {
23
+ if (obj.appGuid !== undefined) objectAppById.set(obj.id, obj.appGuid);
24
+ }
25
+
26
+ // Event publisher app: from EventSymbol.publisherObjectId → objectAppById.
27
+ const eventPubApp = new Map<EventId, string>();
28
+ for (const ev of model.eventGraph.events) {
29
+ const app = objectAppById.get(ev.publisherObjectId);
30
+ if (app !== undefined) eventPubApp.set(ev.id as EventId, app);
31
+ }
32
+
33
+ // Bucket cross-extension subs per event.
34
+ const buckets = new Map<EventId, Set<RoutineId>>();
35
+ for (const edge of model.eventGraph.edges) {
36
+ const pubApp = eventPubApp.get(edge.eventId as EventId);
37
+ if (pubApp === undefined) continue;
38
+ if (edge.subscriberAppId === pubApp) continue;
39
+ const set = buckets.get(edge.eventId as EventId) ?? new Set<RoutineId>();
40
+ set.add(edge.subscriberRoutineId);
41
+ buckets.set(edge.eventId as EventId, set);
42
+ }
43
+
44
+ const out = new Map<EventId, readonly RoutineId[]>();
45
+ for (const [eventId, set] of buckets) {
46
+ out.set(eventId, [...set].sort(compareStrings) as RoutineId[]);
47
+ }
48
+ return out;
49
+ }
50
+
51
+ export type EventKind = "integration" | "business" | "internal";
52
+
53
+ export interface FanoutCoverage {
54
+ dispatchEdges: "complete" | "partial" | "unknown";
55
+ subscriberDiscovery: "complete" | "partial" | "unknown";
56
+ capabilityComposition: "complete" | "partial" | "unknown";
57
+ }
58
+
59
+ export interface FanoutEntry {
60
+ publisher: RoutineId;
61
+ eventId: EventId;
62
+ eventName: string;
63
+ eventKind: EventKind;
64
+ directSubscriberCount: number;
65
+ coverage: FanoutCoverage;
66
+ }
67
+
68
+ export function eventKindOf(k: string): EventKind {
69
+ if (k === "business" || k === "internal") return k;
70
+ return "integration"; // trigger / unknown / integration → integration default
71
+ }
72
+
73
+ export interface EventFlowIndexes {
74
+ /** EventId → publisher RoutineId (when present). */
75
+ readonly publisherByEvent: ReadonlyMap<EventId, RoutineId>;
76
+ /** Publisher RoutineId → events it publishes. */
77
+ readonly eventsByPublisher: ReadonlyMap<RoutineId, readonly EventId[]>;
78
+ /** EventId → sorted unique subscriber RoutineIds. */
79
+ readonly subscribersByEvent: ReadonlyMap<EventId, readonly RoutineId[]>;
80
+ /** Subscriber RoutineId → sorted unique publisher RoutineIds. */
81
+ readonly publishersBySubscriber: ReadonlyMap<RoutineId, readonly RoutineId[]>;
82
+ /** EventId → event name. Built once; used by walkEventChain for node labels. */
83
+ readonly eventNameByEvent: ReadonlyMap<EventId, string>;
84
+ /** RoutineIds whose analysisRole is primary (absent ⇒ primary). For scope filtering. */
85
+ readonly primaryRoutines: ReadonlySet<RoutineId>;
86
+ }
87
+
88
+ export function buildEventFlowIndexes(model: SemanticModel): EventFlowIndexes {
89
+ const publisherByEvent = new Map<EventId, RoutineId>();
90
+ const eventsByPublisherSet = new Map<RoutineId, Set<EventId>>();
91
+ const eventNameByEvent = new Map<EventId, string>();
92
+ const primaryRoutines = new Set<RoutineId>();
93
+ for (const r of model.routines) {
94
+ if (roleOf(r) !== "dependency") primaryRoutines.add(r.id);
95
+ }
96
+ for (const ev of model.eventGraph.events) {
97
+ eventNameByEvent.set(ev.id, ev.eventName);
98
+ if (ev.publisherRoutineId !== undefined) {
99
+ publisherByEvent.set(ev.id, ev.publisherRoutineId);
100
+ const bag = eventsByPublisherSet.get(ev.publisherRoutineId) ?? new Set<EventId>();
101
+ bag.add(ev.id);
102
+ eventsByPublisherSet.set(ev.publisherRoutineId, bag);
103
+ }
104
+ }
105
+
106
+ const subscribersByEventSet = new Map<EventId, Set<RoutineId>>();
107
+ const publishersBySubscriberSet = new Map<RoutineId, Set<RoutineId>>();
108
+ for (const e of model.eventGraph.edges) {
109
+ if (e.resolution !== "resolved") continue;
110
+ const subs = subscribersByEventSet.get(e.eventId) ?? new Set<RoutineId>();
111
+ subs.add(e.subscriberRoutineId);
112
+ subscribersByEventSet.set(e.eventId, subs);
113
+ const pub = publisherByEvent.get(e.eventId);
114
+ if (pub !== undefined) {
115
+ const pubs = publishersBySubscriberSet.get(e.subscriberRoutineId) ?? new Set<RoutineId>();
116
+ pubs.add(pub);
117
+ publishersBySubscriberSet.set(e.subscriberRoutineId, pubs);
118
+ }
119
+ }
120
+
121
+ const sortedFreeze = <K, V extends string>(m: Map<K, Set<V>>): Map<K, readonly V[]> => {
122
+ const out = new Map<K, readonly V[]>();
123
+ for (const [k, set] of m) out.set(k, [...set].sort(compareStrings) as readonly V[]);
124
+ return out;
125
+ };
126
+
127
+ return {
128
+ publisherByEvent,
129
+ eventsByPublisher: sortedFreeze(eventsByPublisherSet),
130
+ subscribersByEvent: sortedFreeze(subscribersByEventSet),
131
+ publishersBySubscriber: sortedFreeze(publishersBySubscriberSet),
132
+ eventNameByEvent,
133
+ primaryRoutines,
134
+ };
135
+ }
136
+
137
+ export function getSubscribersOfPublisher(
138
+ publisher: RoutineId,
139
+ ix: EventFlowIndexes,
140
+ ): readonly RoutineId[] {
141
+ const events = ix.eventsByPublisher.get(publisher) ?? [];
142
+ const out = new Set<RoutineId>();
143
+ for (const ev of events) {
144
+ for (const sub of ix.subscribersByEvent.get(ev) ?? []) out.add(sub);
145
+ }
146
+ return [...out].sort(compareStrings) as readonly RoutineId[];
147
+ }
148
+
149
+ export function getPublishersForSubscriber(
150
+ subscriber: RoutineId,
151
+ ix: EventFlowIndexes,
152
+ ): readonly RoutineId[] {
153
+ return ix.publishersBySubscriber.get(subscriber) ?? [];
154
+ }
155
+
156
+ export function getSubscribersOfEvent(
157
+ eventId: EventId,
158
+ ix: EventFlowIndexes,
159
+ ): readonly RoutineId[] {
160
+ return ix.subscribersByEvent.get(eventId) ?? [];
161
+ }
162
+
163
+ export function getPublisherOfEvent(eventId: EventId, ix: EventFlowIndexes): RoutineId | undefined {
164
+ return ix.publisherByEvent.get(eventId);
165
+ }
166
+
167
+ export function computeFanout(model: SemanticModel, ix: EventFlowIndexes): readonly FanoutEntry[] {
168
+ const out: FanoutEntry[] = [];
169
+
170
+ // Group all edges (resolved and unresolved) by eventId for dispatchEdges coverage.
171
+ const edgesByEvent = new Map<EventId, EventEdge[]>();
172
+ for (const e of model.eventGraph.edges) {
173
+ const bag = edgesByEvent.get(e.eventId) ?? [];
174
+ bag.push(e);
175
+ edgesByEvent.set(e.eventId, bag);
176
+ }
177
+
178
+ const summaryByRoutine = new Map(model.routines.map((r) => [r.id, r.summary] as const));
179
+
180
+ for (const ev of model.eventGraph.events) {
181
+ if (ev.publisherRoutineId === undefined) continue;
182
+
183
+ const resolvedSubs = ix.subscribersByEvent.get(ev.id) ?? [];
184
+ const allEdges = edgesByEvent.get(ev.id) ?? [];
185
+ let unresolvedEdges = 0;
186
+ for (const e of allEdges) if (e.resolution !== "resolved") unresolvedEdges++;
187
+
188
+ const dispatchEdges: FanoutCoverage["dispatchEdges"] =
189
+ allEdges.length === 0 ? "complete" : unresolvedEdges === 0 ? "complete" : "partial";
190
+
191
+ // subscriberDiscovery: mirrors dispatchEdges for now (refined when per-event
192
+ // coverage substrate lands).
193
+ const subscriberDiscovery: FanoutCoverage["subscriberDiscovery"] = dispatchEdges;
194
+
195
+ // capabilityComposition: derive from the worst subscriber summary coverage.
196
+ let capCov: FanoutCoverage["capabilityComposition"] = "complete";
197
+ if (resolvedSubs.length === 0) {
198
+ capCov = "unknown";
199
+ } else {
200
+ let sawPartial = false;
201
+ let sawMissing = false;
202
+ for (const sub of resolvedSubs) {
203
+ const sum = summaryByRoutine.get(sub);
204
+ const status = sum?.coverage?.inheritedStatus;
205
+ if (status === undefined) sawMissing = true;
206
+ else if (status === "partial" || status === "unknown") sawPartial = true;
207
+ }
208
+ capCov = sawMissing ? "unknown" : sawPartial ? "partial" : "complete";
209
+ }
210
+
211
+ out.push({
212
+ publisher: ev.publisherRoutineId,
213
+ eventId: ev.id,
214
+ eventName: ev.eventName,
215
+ eventKind: eventKindOf(ev.eventKind),
216
+ directSubscriberCount: resolvedSubs.length,
217
+ coverage: { dispatchEdges, subscriberDiscovery, capabilityComposition: capCov },
218
+ });
219
+ }
220
+
221
+ out.sort(
222
+ (a, b) =>
223
+ compareStrings(a.publisher, b.publisher) ||
224
+ compareStrings(a.eventName, b.eventName) ||
225
+ compareStrings(a.eventId, b.eventId),
226
+ );
227
+
228
+ return out;
229
+ }
230
+
231
+ // ---------------------------------------------------------------------------
232
+ // walkEventChain
233
+ // ---------------------------------------------------------------------------
234
+
235
+ /** Reserved kinds. Currently emitted: "root", "event-dispatch", "subscriber".
236
+ * "publisher" is reserved for a future enhancement (a subscriber that also
237
+ * publishes its own event in the chain). */
238
+ export type ChainNodeKind = "root" | "publisher" | "event-dispatch" | "subscriber";
239
+
240
+ export interface ChainNode {
241
+ kind: ChainNodeKind;
242
+ routineId?: RoutineId;
243
+ eventId?: EventId;
244
+ eventName?: string;
245
+ children: readonly ChainNode[];
246
+ cycleDetected?: boolean;
247
+ depthTruncated?: boolean;
248
+ }
249
+
250
+ export interface ChainWalkOptions {
251
+ maxDepth?: number;
252
+ maxNodes?: number;
253
+ }
254
+
255
+ const DEFAULT_MAX_DEPTH = 16;
256
+ const DEFAULT_MAX_NODES = 1024;
257
+
258
+ /**
259
+ * Walk the event-chain tree from a root publisher RoutineId.
260
+ *
261
+ * ONLY follows event-graph edges (publisher → subscriber via
262
+ * `ix.eventsByPublisher` + `ix.subscribersByEvent`). Does NOT follow
263
+ * call-graph relays where a subscriber routine's body calls another
264
+ * event-publisher — for that, use `collectRelaySubscribers` from
265
+ * `./event-relay.ts`. Event-name labels on dispatch nodes come from
266
+ * `ix.eventNameByEvent` (built once), so this is a pure function of `ix`.
267
+ *
268
+ * Truncation precedence (locked):
269
+ * 1. cycle wins when the next edge revisits a node already on the active path
270
+ * 2. depth wins when expansion would exceed maxDepth before evaluating children
271
+ * 3. nodes wins globally once adding another node would exceed maxNodes
272
+ * Collision (cycle reached AT depth limit): cycle marker fires first.
273
+ */
274
+ export function walkEventChain(
275
+ root: RoutineId,
276
+ ix: EventFlowIndexes,
277
+ opts: ChainWalkOptions = {},
278
+ ): ChainNode {
279
+ const maxDepth = opts.maxDepth ?? DEFAULT_MAX_DEPTH;
280
+ const maxNodes = opts.maxNodes ?? DEFAULT_MAX_NODES;
281
+ let nodeBudget = maxNodes;
282
+ const onPath = new Set<RoutineId>();
283
+
284
+ // `eventDepth` is the depth at which the event-dispatch nodes emitted by this
285
+ // call will land in the output tree. Conceptually the root routine is at
286
+ // depth -1; the initial call passes 0 so the root's immediate events land at
287
+ // depth 0. Subscribers of those events are at eventDepth+1; their published
288
+ // events (grand-events) are at eventDepth+2.
289
+ function expand(routine: RoutineId, eventDepth: number): readonly ChainNode[] {
290
+ if (eventDepth >= maxDepth) return [];
291
+ if (nodeBudget <= 0) return [];
292
+ onPath.add(routine);
293
+ const out: ChainNode[] = [];
294
+ const events = ix.eventsByPublisher.get(routine) ?? [];
295
+ for (const ev of events) {
296
+ if (nodeBudget <= 0) break;
297
+ nodeBudget--;
298
+
299
+ const subs = ix.subscribersByEvent.get(ev) ?? [];
300
+ const subChildren: ChainNode[] = [];
301
+ let depthTrunc = false;
302
+
303
+ if (eventDepth + 1 >= maxDepth) {
304
+ // Subscribers would be at depth >= maxDepth — truncate entirely.
305
+ depthTrunc = true;
306
+ } else {
307
+ for (const sub of subs) {
308
+ if (nodeBudget <= 0) break;
309
+ nodeBudget--;
310
+ // Cycle check wins over depth check (precedence rule 1 > 2).
311
+ if (onPath.has(sub)) {
312
+ subChildren.push({
313
+ kind: "subscriber",
314
+ routineId: sub,
315
+ children: [],
316
+ cycleDetected: true,
317
+ });
318
+ continue;
319
+ }
320
+ // Subscriber's own children (events it publishes) would be at
321
+ // eventDepth+2. If eventDepth+2 >= maxDepth they'd be truncated,
322
+ // but the subscriber node itself is still emitted — the truncation
323
+ // marker goes on its event-dispatch children, which expand()
324
+ // handles recursively.
325
+ const grand = expand(sub, eventDepth + 2);
326
+ subChildren.push({ kind: "subscriber", routineId: sub, children: grand });
327
+ }
328
+ }
329
+
330
+ out.push({
331
+ kind: "event-dispatch",
332
+ eventId: ev,
333
+ eventName: ix.eventNameByEvent.get(ev),
334
+ children: subChildren,
335
+ ...(depthTrunc ? { depthTruncated: true } : {}),
336
+ });
337
+ }
338
+ onPath.delete(routine);
339
+ return out;
340
+ }
341
+
342
+ nodeBudget--; // root counts as one node
343
+ // Root routine is conceptually at depth -1; pass 0 so its immediate events
344
+ // land at depth 0.
345
+ const children = expand(root, 0);
346
+ return { kind: "root", routineId: root, children };
347
+ }
348
+
349
+ // ---------------------------------------------------------------------------
350
+ // publisherBranchFacts
351
+ // ---------------------------------------------------------------------------
352
+
353
+ export type BranchSliceConfidence = "exact" | "pattern" | "unknown";
354
+
355
+ export interface PublisherBranchFacts {
356
+ confidence: BranchSliceConfidence;
357
+ /** Facts that fire when IsHandled is FALSE (the default branch). */
358
+ defaultBranchFacts: readonly CapabilityFact[];
359
+ /** Facts that fire when IsHandled is TRUE (the guarded branch — subscriber set it). */
360
+ guardedBranchFacts: readonly CapabilityFact[];
361
+ /** Facts in neither branch (unconditional). */
362
+ unguardedFacts: readonly CapabilityFact[];
363
+ /** Identifier name of the boolean guard variable (lowercased). */
364
+ guardParamName?: string;
365
+ }
366
+
367
+ export const IS_HANDLED_RE = /^is.?handled$/i;
368
+
369
+ /**
370
+ * BranchSliceConfidence ladder:
371
+ * - `exact` — no branching at all; every direct fact is in the default branch.
372
+ * - `pattern` — branching present AND IsHandled is assigned somewhere in the body.
373
+ * - `unknown` — branching present but no IsHandled assignment detected.
374
+ *
375
+ * Returns undefined when the routine has no IsHandled-shaped parameter.
376
+ *
377
+ * The default-branch slicing is conservative: we return ALL direct facts as
378
+ * `defaultBranchFacts` and leave `guardedBranchFacts`/`unguardedFacts` empty.
379
+ * Refinement (true AST slicing) is deferred — D43 emits findings only when
380
+ * confidence is `exact` or `pattern`, so this conservative slice still yields
381
+ * correct hits without false positives.
382
+ */
383
+ export function publisherBranchFacts(
384
+ publisher: RoutineId,
385
+ model: SemanticModel,
386
+ ): PublisherBranchFacts | undefined {
387
+ const routine = model.routines.find((r) => r.id === publisher);
388
+ if (routine === undefined) return undefined;
389
+ const guardParam = routine.parameters.find((p) => IS_HANDLED_RE.test(p.name));
390
+ if (guardParam === undefined) return undefined;
391
+ const directFacts: readonly CapabilityFact[] = routine.summary?.capabilityFactsDirect ?? [];
392
+ const hasBranching = routine.features.hasBranching === true;
393
+ const assignsIsHandled = (routine.features.varAssignments ?? []).some((a) =>
394
+ IS_HANDLED_RE.test(a.lhsName),
395
+ );
396
+ let confidence: BranchSliceConfidence;
397
+ if (!hasBranching) {
398
+ confidence = "exact";
399
+ } else if (assignsIsHandled) {
400
+ confidence = "pattern";
401
+ } else {
402
+ confidence = "unknown";
403
+ }
404
+ return {
405
+ confidence,
406
+ defaultBranchFacts: directFacts,
407
+ guardedBranchFacts: [],
408
+ unguardedFacts: [],
409
+ guardParamName: guardParam.name.toLowerCase(),
410
+ };
411
+ }
412
+
413
+ // ---------------------------------------------------------------------------
414
+ // Report composition
415
+ // ---------------------------------------------------------------------------
416
+
417
+ export interface FanoutReport {
418
+ entries: readonly FanoutEntry[];
419
+ summary: {
420
+ totalPublishers: number;
421
+ totalEvents: number;
422
+ zeroSubscriberEvents: number;
423
+ hotEvents: number;
424
+ coveragePartialEvents: number;
425
+ };
426
+ }
427
+
428
+ export function computeFanoutReport(
429
+ model: SemanticModel,
430
+ ix: EventFlowIndexes,
431
+ opts?: { scope?: Scope },
432
+ ): FanoutReport {
433
+ const scope = opts?.scope ?? "all";
434
+ const all = computeFanout(model, ix);
435
+ // FanoutEntry omits the subscriber id list (only the count), so re-query the index
436
+ // to test whether any subscriber is primary.
437
+ const entries =
438
+ scope === "all"
439
+ ? all
440
+ : all.filter(
441
+ (e) =>
442
+ ix.primaryRoutines.has(e.publisher) ||
443
+ (ix.subscribersByEvent.get(e.eventId) ?? []).some((s) => ix.primaryRoutines.has(s)),
444
+ );
445
+ const publishers = new Set(entries.map((e) => e.publisher));
446
+ let zero = 0;
447
+ let hot = 0;
448
+ let partial = 0;
449
+ for (const e of entries) {
450
+ if (e.directSubscriberCount === 0) zero++;
451
+ if (e.directSubscriberCount > 5) hot++;
452
+ if (e.coverage.dispatchEdges === "partial" || e.coverage.capabilityComposition === "partial") {
453
+ partial++;
454
+ }
455
+ }
456
+ return {
457
+ entries,
458
+ summary: {
459
+ totalPublishers: publishers.size,
460
+ totalEvents: entries.length,
461
+ zeroSubscriberEvents: zero,
462
+ hotEvents: hot,
463
+ coveragePartialEvents: partial,
464
+ },
465
+ };
466
+ }
467
+
468
+ export interface ChainReport {
469
+ chains: readonly ChainNode[];
470
+ summary: {
471
+ totalRoots: number;
472
+ rootsWithEvents: number;
473
+ maxChainDepth: number;
474
+ cyclesDetected: number;
475
+ depthTruncatedNodes: number;
476
+ };
477
+ }
478
+
479
+ function chainStats(
480
+ node: ChainNode,
481
+ depth: number,
482
+ acc: { max: number; cycles: number; depthTruncated: number },
483
+ ): void {
484
+ acc.max = Math.max(acc.max, depth);
485
+ if (node.cycleDetected) acc.cycles++;
486
+ if (node.depthTruncated) acc.depthTruncated++;
487
+ for (const c of node.children) chainStats(c, depth + 1, acc);
488
+ }
489
+
490
+ function treeTouchesPrimary(node: ChainNode, primary: ReadonlySet<RoutineId>): boolean {
491
+ if (node.routineId !== undefined && primary.has(node.routineId)) return true;
492
+ for (const c of node.children) if (treeTouchesPrimary(c, primary)) return true;
493
+ return false;
494
+ }
495
+
496
+ export function computeChainReport(
497
+ ix: EventFlowIndexes,
498
+ opts?: ChainWalkOptions & { scope?: Scope },
499
+ ): ChainReport {
500
+ const scope = opts?.scope ?? "all";
501
+ const roots = [...ix.eventsByPublisher.keys()].sort(compareStrings);
502
+ const chains: ChainNode[] = [];
503
+ const acc = { max: 0, cycles: 0, depthTruncated: 0 };
504
+ for (const root of roots) {
505
+ const tree = walkEventChain(root, ix, opts);
506
+ // scope=primary keeps a tree only when a primary routine participates (root or any
507
+ // descendant subscriber). Stats accumulate over the KEPT trees so the summary matches.
508
+ if (scope === "primary" && !treeTouchesPrimary(tree, ix.primaryRoutines)) continue;
509
+ chains.push(tree);
510
+ chainStats(tree, 0, acc);
511
+ }
512
+ // Every key in eventsByPublisher has >=1 event, so rootsWithEvents always equals
513
+ // totalRoots; under scope=primary both reflect the kept (participating) chains.
514
+ return {
515
+ chains,
516
+ summary: {
517
+ totalRoots: chains.length,
518
+ rootsWithEvents: chains.length,
519
+ maxChainDepth: acc.max,
520
+ cyclesDetected: acc.cycles,
521
+ depthTruncatedNodes: acc.depthTruncated,
522
+ },
523
+ };
524
+ }
@@ -0,0 +1,92 @@
1
+ import type { RoutineId } from "../model/ids.ts";
2
+ import type { SemanticModel } from "../model/model.ts";
3
+ import type { CombinedGraph } from "./combined-graph.ts";
4
+ import type { EventFlowIndexes } from "./event-flow.ts";
5
+ import { compareStrings } from "./uncertainty-util.ts";
6
+
7
+ /**
8
+ * Options for {@link collectRelaySubscribers}.
9
+ */
10
+ export interface RelayWalkOptions {
11
+ /** Maximum chain depth (default 4). Depth 1 = direct subscriber of the root. */
12
+ maxDepth?: number;
13
+ /** Maximum total subscribers discovered before the walk terminates (default 256). */
14
+ maxNodes?: number;
15
+ }
16
+
17
+ /**
18
+ * Walk the transitive subscriber chain starting from `publisher`, bridging
19
+ * event-graph dispatches with call-graph relay edges. Returns a Map of
20
+ * subscriber RoutineId → minimum chain depth (depth 1 = direct subscriber
21
+ * of the root, depth 2 = subscriber of an event published by a relay
22
+ * subscriber, etc.).
23
+ *
24
+ * Unlike `walkEventChain` (which only follows event-graph edges), this
25
+ * helper bridges the gap where a subscriber routine's BODY calls another
26
+ * event-publisher routine — that's a call-graph edge, not an event-graph
27
+ * edge. In canonical AL, "relay subscriber" patterns where one event's
28
+ * handler dispatches a downstream event live in real production code,
29
+ * so D45-style detectors need this traversal.
30
+ *
31
+ * Traversal alternates two graph types:
32
+ * - Event graph: publisher → (events) → direct subscribers (depth +1)
33
+ * - Call graph: subscriber → (callees that are event-publishers) →
34
+ * their subscribers (depth +2 per event-hop crossing)
35
+ *
36
+ * Caps:
37
+ * - `maxDepth` bounds chain length (default 4).
38
+ * - `maxNodes` bounds total subscribers discovered (default 256).
39
+ *
40
+ * Deterministic: subscribers iterated in `compareStrings` order at each hop.
41
+ */
42
+ export function collectRelaySubscribers(
43
+ publisher: RoutineId,
44
+ _model: SemanticModel,
45
+ ix: EventFlowIndexes,
46
+ graph: CombinedGraph,
47
+ opts?: RelayWalkOptions,
48
+ ): Map<RoutineId, number> {
49
+ const maxDepth = opts?.maxDepth ?? 4;
50
+ const maxNodes = opts?.maxNodes ?? 256;
51
+
52
+ const result = new Map<RoutineId, number>();
53
+ // Queue: [routineId that publishes events, depth at which its direct subscribers land]
54
+ const queue: Array<{ pub: RoutineId; depth: number }> = [{ pub: publisher, depth: 1 }];
55
+ // Track visited publishers to avoid cycles.
56
+ const visitedPubs = new Set<RoutineId>([publisher]);
57
+
58
+ while (queue.length > 0) {
59
+ const item = queue.shift();
60
+ if (item === undefined) break;
61
+ const { pub, depth } = item;
62
+ if (depth > maxDepth) continue;
63
+
64
+ const events = ix.eventsByPublisher.get(pub) ?? [];
65
+ for (const ev of events) {
66
+ const subs = [...(ix.subscribersByEvent.get(ev) ?? [])].sort(compareStrings);
67
+ for (const sub of subs) {
68
+ if (result.size >= maxNodes) break;
69
+ // Record this subscriber at min depth.
70
+ const existing = result.get(sub);
71
+ if (existing === undefined || depth < existing) {
72
+ result.set(sub, depth);
73
+ }
74
+ if (depth + 1 > maxDepth) continue;
75
+ // Follow call-graph edges from this subscriber to event-publisher callees.
76
+ // This bridges the gap where a subscriber calls an IntegrationEvent procedure
77
+ // in its body, making it a "relay" to the next hop.
78
+ const callEdges = graph.edgesByFrom.get(sub) ?? [];
79
+ for (const edge of callEdges) {
80
+ const callee = edge.to;
81
+ if (!ix.eventsByPublisher.has(callee)) continue;
82
+ if (visitedPubs.has(callee)) continue;
83
+ visitedPubs.add(callee);
84
+ // The callee is an event-publisher; its direct subscribers land at depth+1.
85
+ queue.push({ pub: callee, depth: depth + 1 });
86
+ }
87
+ }
88
+ }
89
+ }
90
+
91
+ return result;
92
+ }