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,560 @@
1
+ import { extractCapabilities } from "../index/capability/extractor.ts";
2
+ import type { CapabilityFact, EventExtra } from "../model/capability.ts";
3
+ import type { CoverageReason } from "../model/coverage.ts";
4
+ import type { Routine, VariableSymbol } from "../model/entities.ts";
5
+ import type { Diagnostic } from "../model/finding.ts";
6
+ import type { EventId, RoutineId } from "../model/ids.ts";
7
+ import type { SemanticModel } from "../model/model.ts";
8
+ import type { DbEffect, RecordRoleSummary, RoutineSummary, Uncertainty } from "../model/summary.ts";
9
+ import { makeLap } from "../perf/profiler.ts";
10
+ import { composeInheritedCones } from "./capability-cone.ts";
11
+ import type { CombinedEdge, CombinedGraph } from "./combined-graph.ts";
12
+ import { walkRoutine } from "./control-flow-walker.ts";
13
+ import { effectKeyOf, joinPresence, mergeVia } from "./effect-lattice.ts";
14
+ import { tarjanScc } from "./scc.ts";
15
+ import { type SummaryContext, buildSummaryContext } from "./summary-context.ts";
16
+ import { baseIntraproceduralSummaryCtx } from "./summary-engine.ts";
17
+ import { compareStrings, uncertaintyKey } from "./uncertainty-util.ts";
18
+
19
+ const MAX_FIXED_POINT_ITERATIONS = 1000;
20
+
21
+ /**
22
+ * Optional SCC instrumentation, gated on `AL_SEM_SCC_STATS=1`. Cheap counters useful
23
+ * for deciding whether further fixed-point optimizations (fingerprint caching, worklist
24
+ * propagation) are worth the complexity. Prints once to stderr at the end of `runSummaries`.
25
+ */
26
+ const SCC_STATS_ENABLED = process.env.AL_SEM_SCC_STATS === "1";
27
+
28
+ export interface SummaryRunOptions {
29
+ /**
30
+ * A fixed leaf: a routine whose `summary` is authoritative and must NOT be recomputed.
31
+ * Default: any routine that already carries a summary. This covers both pipelines —
32
+ * workspace routines and a cold run's current-app routines start with `summary ===
33
+ * undefined`, so they are computed; merged-in artifact routines arrive with `summary`
34
+ * set, so they are fixed leaves.
35
+ */
36
+ isLeaf?: (r: Routine) => boolean;
37
+ }
38
+
39
+ function defaultIsLeaf(r: Routine): boolean {
40
+ return r.summary !== undefined;
41
+ }
42
+
43
+ /** Map a combined-edge kind to the `via` tag callee effects inherit through it. */
44
+ function viaForEdge(
45
+ kind: CombinedEdge["kind"],
46
+ ): "inherited" | "implicit-trigger" | "event-subscriber" | "dynamic" {
47
+ if (kind === "implicit-trigger") return "implicit-trigger";
48
+ if (kind === "event-dispatch") return "event-subscriber";
49
+ if (kind === "dynamic") return "dynamic";
50
+ return "inherited";
51
+ }
52
+
53
+ /**
54
+ * Map an EventSymbol.eventKind to the EventExtra.eventClass discriminant.
55
+ * Phase 1a Family B publisher fix — used by the publish-fact injector in runSummaries.
56
+ */
57
+ function mapEventKindToClass(
58
+ k: "integration" | "business" | "trigger" | "internal" | "unknown",
59
+ ): EventExtra["eventClass"] {
60
+ if (k === "business") return "Business";
61
+ if (k === "internal") return "Internal";
62
+ if (k === "trigger") return "Trigger";
63
+ return "Integration";
64
+ }
65
+
66
+ /** Stable fingerprint for fixed-point change detection. */
67
+ function summaryFingerprint(s: RoutineSummary): string {
68
+ return JSON.stringify([
69
+ s.dbEffects.map((e) => `${e.effectKey}:${e.via}`),
70
+ s.hasUnresolvedCalls,
71
+ s.uncertainties.map(uncertaintyKey),
72
+ // Include may-fact fields so c1b changes are detected during fixed-point iteration.
73
+ // Entry-requirement fields (Phase 4 walker) and dirtyAtExit + currentLoadedFieldsAtExit
74
+ // (Phase 6) are pre-included so future phases cannot silently regress convergence by
75
+ // forgetting to extend the fingerprint.
76
+ s.parameterRoles.map((r) => [
77
+ r.parameterIndex,
78
+ r.loadsFromDbParam,
79
+ r.initialisesParam,
80
+ r.persistsCurrentRecord,
81
+ r.setBasedDbWrites,
82
+ r.validatesParam,
83
+ r.copiesIntoParam,
84
+ r.resetsFiltersOnParam,
85
+ r.mutatesParam,
86
+ r.requiresLoadedAtEntry,
87
+ r.mutatesBeforeLoad,
88
+ Array.isArray(r.requiredLoadedFieldsAtEntry)
89
+ ? r.requiredLoadedFieldsAtEntry.join(",")
90
+ : r.requiredLoadedFieldsAtEntry,
91
+ r.dirtyAtExit,
92
+ typeof r.currentLoadedFieldsAtExit === "string"
93
+ ? r.currentLoadedFieldsAtExit
94
+ : r.currentLoadedFieldsAtExit.join(","),
95
+ ]),
96
+ ]);
97
+ }
98
+
99
+ /**
100
+ * Compose a routine's full summary: start from its base intraprocedural summary, then fold
101
+ * in every outgoing combined edge's callee summary.
102
+ *
103
+ * O(1)-lookup + mutable-accumulator variant — the hot path inside `runSummaries`.
104
+ *
105
+ * Old hot-loop shape was:
106
+ * acc.dbEffects = mergeDbEffects(acc.dbEffects, calleeEffects); // new Map + sort
107
+ * acc.publishesEvents = [...new Set([...acc, ...callee])].sort(); // new Set + array + sort
108
+ * acc.uncertainties = dedupeUncertainties([...acc, ...callee]); // new Map + sort
109
+ * — once PER outgoing edge. On large dependency graphs that's a lot of GC pressure.
110
+ *
111
+ * New shape: seed local Maps/Sets from the base summary once, fold every callee into them
112
+ * in place, then materialize sorted/canonical arrays exactly once at the end.
113
+ *
114
+ * `baseLookup` optionally returns a precomputed base summary. When set, this function
115
+ * reuses the precomputed summary's data (skipping a fresh `baseIntraproceduralSummaryCtx`
116
+ * call). Required for the fixed-point loop on recursive SCCs.
117
+ */
118
+ export function composeRoutineCtx(
119
+ routine: Routine,
120
+ lookup: (id: RoutineId) => RoutineSummary | undefined,
121
+ graph: CombinedGraph,
122
+ ctx: SummaryContext,
123
+ baseLookup?: (id: RoutineId) => RoutineSummary | undefined,
124
+ ): RoutineSummary {
125
+ const base = baseLookup?.(routine.id) ?? baseIntraproceduralSummaryCtx(routine, ctx);
126
+ const calleeOpaque = (id: RoutineId): boolean => ctx.routineById.get(id)?.bodyAvailable === false;
127
+
128
+ // Scalar/lattice accumulators — copied because the base is shared and must not mutate.
129
+ let hasUnresolvedCalls = base.hasUnresolvedCalls;
130
+
131
+ // Set/Map accumulators seeded from the base. dbEffects key on effectKey so duplicate
132
+ // inherited entries merge by `via` precedence (matches the old mergeDbEffects).
133
+ const dbEffectsByKey = new Map<string, DbEffect>();
134
+ for (const e of base.dbEffects) dbEffectsByKey.set(e.effectKey, e);
135
+ const uncertaintiesByKey = new Map<string, Uncertainty>();
136
+ for (const u of base.uncertainties) uncertaintiesByKey.set(uncertaintyKey(u), u);
137
+
138
+ for (const edge of graph.edgesByFrom.get(routine.id) ?? []) {
139
+ const calleeSummary = lookup(edge.to);
140
+ if (calleeSummary === undefined) {
141
+ hasUnresolvedCalls = true;
142
+ continue;
143
+ }
144
+ const via = viaForEdge(edge.kind);
145
+ for (const e of calleeSummary.dbEffects) {
146
+ // Tag the inherited effect with the edge's via; recompute the key (effectKeyOf
147
+ // excludes via, so it's stable). Merge into the local map by precedence.
148
+ const key = effectKeyOf(e);
149
+ const existing = dbEffectsByKey.get(key);
150
+ if (existing) {
151
+ dbEffectsByKey.set(key, { ...existing, via: mergeVia(existing.via, via) });
152
+ } else {
153
+ dbEffectsByKey.set(key, { ...e, effectKey: key, via });
154
+ }
155
+ }
156
+ for (const u of calleeSummary.uncertainties) {
157
+ const k = uncertaintyKey(u);
158
+ if (!uncertaintiesByKey.has(k)) uncertaintiesByKey.set(k, u);
159
+ }
160
+ if (calleeSummary.hasUnresolvedCalls) hasUnresolvedCalls = true;
161
+
162
+ // interface / dynamic edges, and opaque callees, are confidence-lowering — the CALLER
163
+ // holds the callsiteId, so the opaque-callee uncertainty is attached here, not on the
164
+ // callee's own summary.
165
+ if (edge.kind === "interface" || edge.kind === "dynamic" || calleeOpaque(edge.to)) {
166
+ if (edge.callsiteId !== undefined) {
167
+ const u: Uncertainty = { kind: "opaque-callee", callsiteId: edge.callsiteId };
168
+ const k = uncertaintyKey(u);
169
+ if (!uncertaintiesByKey.has(k)) uncertaintiesByKey.set(k, u);
170
+ }
171
+ hasUnresolvedCalls = true;
172
+ }
173
+ }
174
+
175
+ // Uncertainty edges (to-less call sites) on this routine — looked up by `from`
176
+ // instead of scanned globally, which on big graphs was O(R × U) per iteration.
177
+ for (const ue of ctx.uncertaintyEdgesByFrom.get(routine.id) ?? []) {
178
+ const k = uncertaintyKey(ue.uncertainty);
179
+ if (!uncertaintiesByKey.has(k)) uncertaintiesByKey.set(k, ue.uncertainty);
180
+ hasUnresolvedCalls = true;
181
+ }
182
+
183
+ // Materialize deterministic arrays once. dbEffects sort key matches the old
184
+ // mergeDbEffects: (effectKey, operationId).
185
+ const dbEffects = [...dbEffectsByKey.values()].sort((a, b) => {
186
+ if (a.effectKey !== b.effectKey) return compareStrings(a.effectKey, b.effectKey);
187
+ return compareStrings(a.operationId, b.operationId);
188
+ });
189
+ const uncertainties = [...uncertaintiesByKey.values()].sort((a, b) =>
190
+ compareStrings(uncertaintyKey(a), uncertaintyKey(b)),
191
+ );
192
+
193
+ // Cross-call exit-effect composition (spec §(c1b)) — compose only when BOTH the
194
+ // caller-side source and the callee-side parameter are var.
195
+ // Deep-copy the base parameterRoles so we can mutate them independently each iteration.
196
+ // NOTE: entry-requirement fields (requiresLoadedAtEntry, mutatesBeforeLoad,
197
+ // requiredLoadedFieldsAtEntry) are overwritten by the walker below; base values
198
+ // computed in baseIntraproceduralSummaryCtx are intentionally superseded.
199
+ const parameterRoles: RecordRoleSummary[] = base.parameterRoles.map((r) => ({ ...r }));
200
+ for (const cs of routine.features.callSites) {
201
+ for (const binding of cs.argumentBindings) {
202
+ if (binding.bindingResolution !== "resolved") continue;
203
+ if (binding.sourceParameterIndex === undefined) continue;
204
+ if (!binding.callerSourceParameterIsVar) continue;
205
+ if (!binding.calleeParameterIsVar) continue;
206
+ const edge = ctx.resolvedCallEdgeByCallsite.get(cs.id);
207
+ if (edge?.to === undefined) continue;
208
+ const calleeRoutine = ctx.routineById.get(edge.to);
209
+ const calleeRoleSummary = lookup(edge.to);
210
+ const calleeRole = calleeRoleSummary?.parameterRoles.find(
211
+ (r) => r.parameterIndex === binding.parameterIndex,
212
+ );
213
+ const p = parameterRoles.find((r) => r.parameterIndex === binding.sourceParameterIndex);
214
+ if (p === undefined) continue;
215
+ // Opaque guard: any of the three reasons we cannot trust callee facts
216
+ // (no role, routine missing, body unavailable) takes the unknown branch.
217
+ // Phase 6's symbol-only projections may produce a callee role with
218
+ // bodyAvailable=false; this guard keeps such cases on the opaque path.
219
+ const opaque =
220
+ calleeRole === undefined ||
221
+ calleeRoutine === undefined ||
222
+ calleeRoutine.bodyAvailable === false;
223
+ if (opaque) {
224
+ p.persistsCurrentRecord = joinPresence(p.persistsCurrentRecord, "unknown");
225
+ p.setBasedDbWrites = joinPresence(p.setBasedDbWrites, "unknown");
226
+ p.validatesParam = joinPresence(p.validatesParam, "unknown");
227
+ p.copiesIntoParam = joinPresence(p.copiesIntoParam, "unknown");
228
+ p.resetsFiltersOnParam = joinPresence(p.resetsFiltersOnParam, "unknown");
229
+ } else {
230
+ p.persistsCurrentRecord = joinPresence(
231
+ p.persistsCurrentRecord,
232
+ calleeRole.persistsCurrentRecord,
233
+ );
234
+ p.setBasedDbWrites = joinPresence(p.setBasedDbWrites, calleeRole.setBasedDbWrites);
235
+ p.validatesParam = joinPresence(p.validatesParam, calleeRole.validatesParam);
236
+ p.copiesIntoParam = joinPresence(p.copiesIntoParam, calleeRole.copiesIntoParam);
237
+ p.resetsFiltersOnParam = joinPresence(
238
+ p.resetsFiltersOnParam,
239
+ calleeRole.resetsFiltersOnParam,
240
+ );
241
+ }
242
+ p.mutatesParam = joinPresence(
243
+ joinPresence(p.persistsCurrentRecord, p.validatesParam),
244
+ p.copiesIntoParam,
245
+ );
246
+ }
247
+ }
248
+
249
+ // Path-aware entry-requirement composition (spec §(c1a)).
250
+ // Run the walker with the current fixed-point `lookup` so callee summaries
251
+ // are from the current iteration (not the stale routine.summary).
252
+ // Only run on routines with a body — opaque/parse-incomplete cases stay "unknown"
253
+ // as set by baseIntraproceduralSummaryCtx.
254
+ //
255
+ // Memoization note (I7, deferred — see review): the walker is re-run every
256
+ // fixed-point iteration for routines in recursive SCCs. Measurement on DC/Cloud
257
+ // (AL_SEM_SCC_STATS=1: 35 recursive SCCs, maxSize=4, totalIters=65, maxIters=5)
258
+ // shows redundant walks are bounded at ~30 across the whole workspace — well
259
+ // below 1% of total walker cost. Phase 6's full statement-tree walker should
260
+ // re-measure; if cost grows materially, cache walkRoutine output keyed by
261
+ // (routineId × fingerprint of callee summaries' requires/mutates fields).
262
+ if (routine.bodyAvailable && !routine.parseIncomplete) {
263
+ const pathFacts = walkRoutine(routine, ctx, lookup);
264
+ const pathByIndex = new Map(pathFacts.map((p) => [p.parameterIndex, p]));
265
+ for (const role of parameterRoles) {
266
+ const pf = pathByIndex.get(role.parameterIndex);
267
+ if (pf === undefined) continue;
268
+ role.requiresLoadedAtEntry = pf.requiresLoadedAtEntry;
269
+ role.requiredLoadedFieldsAtEntry = pf.requiredLoadedFieldsAtEntry;
270
+ role.mutatesBeforeLoad = pf.mutatesBeforeLoad;
271
+ // Phase 6: walker now emits path-proven dirtyAtExit and
272
+ // currentLoadedFieldsAtExit. These override the base "unknown"
273
+ // placeholders set by baseIntraproceduralSummaryCtx.
274
+ role.dirtyAtExit = pf.dirtyAtExit;
275
+ role.currentLoadedFieldsAtExit = pf.currentLoadedFieldsAtExit;
276
+ }
277
+ }
278
+
279
+ return {
280
+ routineId: routine.id,
281
+ dbEffects,
282
+ inRecursiveCycle: base.inRecursiveCycle,
283
+ hasUnresolvedCalls,
284
+ uncertainties,
285
+ parameterRoles,
286
+ };
287
+ }
288
+
289
+ /**
290
+ * Public, model-based form kept for callers outside the runner (and the older spec text
291
+ * referenced in docs). Builds a one-shot context per call — fine for one-offs, NOT for
292
+ * hot loops. The runner uses `composeRoutineCtx` directly.
293
+ */
294
+ export function composeRoutine(
295
+ routine: Routine,
296
+ lookup: (id: RoutineId) => RoutineSummary | undefined,
297
+ graph: CombinedGraph,
298
+ model: SemanticModel,
299
+ ): RoutineSummary {
300
+ return composeRoutineCtx(routine, lookup, graph, buildSummaryContext(model, graph));
301
+ }
302
+
303
+ /**
304
+ * Compute a RoutineSummary for every NON-leaf routine and mutate `routine.summary` in place.
305
+ * Fixed-leaf routines (see `SummaryRunOptions.isLeaf`) keep their existing `summary` and are
306
+ * pre-seeded into the lookup map so callers compose against them. Walks the SCC condensation
307
+ * bottom-up; recursive SCCs get a finite monotone fixed-point.
308
+ */
309
+ export function runSummaries(
310
+ model: SemanticModel,
311
+ graph: CombinedGraph,
312
+ diagnostics: Diagnostic[],
313
+ options?: SummaryRunOptions,
314
+ ): void {
315
+ const isLeaf = options?.isLeaf ?? defaultIsLeaf;
316
+ const lap = makeLap("summary:");
317
+ // Build O(1) lookup indexes once. This replaces ~50G+ linear scans on Base App-sized
318
+ // dependencies — the cold-run dominant cost before the refactor.
319
+ const ctx = buildSummaryContext(model, graph);
320
+ lap("buildSummaryContext");
321
+
322
+ // Precompute base intraprocedural summaries ONCE per non-leaf routine. For non-recursive
323
+ // SCCs this is the same as before; for recursive SCCs it eliminates recomputing the base
324
+ // every fixed-point iteration (was a significant cost — base touches every record op,
325
+ // every call site, and computes parameterRoles, all of which never change between iters).
326
+ const baseSummaries = new Map<RoutineId, RoutineSummary>();
327
+ for (const r of model.routines) {
328
+ if (isLeaf(r)) continue;
329
+ baseSummaries.set(r.id, baseIntraproceduralSummaryCtx(r, ctx));
330
+ }
331
+ const baseLookup = (id: RoutineId): RoutineSummary | undefined => baseSummaries.get(id);
332
+ lap(`base-precompute (${baseSummaries.size} routines)`);
333
+
334
+ const final = new Map<RoutineId, RoutineSummary>();
335
+ // Pre-seed fixed leaves so composition can look them up; they are never recomputed.
336
+ for (const r of model.routines) {
337
+ if (isLeaf(r) && r.summary !== undefined) final.set(r.id, r.summary);
338
+ }
339
+
340
+ const { sccs } = tarjanScc(graph);
341
+ lap(`tarjanScc (${sccs.length} sccs)`);
342
+ let nonRecursiveSccs = 0;
343
+ let recursiveSccs = 0;
344
+ let maxSccSize = 0;
345
+ let totalSccMembers = 0;
346
+ let totalIterations = 0;
347
+ let maxIterations = 0;
348
+ let fingerprintCalls = 0;
349
+
350
+ for (const scc of sccs) {
351
+ if (SCC_STATS_ENABLED) {
352
+ if (scc.recursive) recursiveSccs++;
353
+ else nonRecursiveSccs++;
354
+ if (scc.members.length > maxSccSize) maxSccSize = scc.members.length;
355
+ totalSccMembers += scc.members.length;
356
+ }
357
+ if (!scc.recursive) {
358
+ const id = scc.members[0];
359
+ const routine = id !== undefined ? ctx.routineById.get(id) : undefined;
360
+ if (id === undefined || routine === undefined) continue;
361
+ if (isLeaf(routine)) continue; // fixed leaf — already in `final`
362
+ final.set(
363
+ id,
364
+ composeRoutineCtx(routine, (x) => final.get(x), graph, ctx, baseLookup),
365
+ );
366
+ continue;
367
+ }
368
+ // Recursive SCC — finite monotone fixed-point with snapshot iteration.
369
+ const inProgress = new Map<RoutineId, RoutineSummary>();
370
+ for (const id of scc.members) {
371
+ const routine = ctx.routineById.get(id);
372
+ if (routine === undefined) continue;
373
+ if (isLeaf(routine)) continue;
374
+ const base = baseSummaries.get(id);
375
+ if (base !== undefined) inProgress.set(id, base);
376
+ }
377
+ let iterations = 0;
378
+ let changed = true;
379
+ while (changed) {
380
+ changed = false;
381
+ iterations++;
382
+ const snapshot = new Map(inProgress);
383
+ for (const id of scc.members) {
384
+ const routine = ctx.routineById.get(id);
385
+ if (routine === undefined || isLeaf(routine)) continue;
386
+ const next = composeRoutineCtx(
387
+ routine,
388
+ (x) => snapshot.get(x) ?? final.get(x),
389
+ graph,
390
+ ctx,
391
+ baseLookup,
392
+ );
393
+ const prev = inProgress.get(id);
394
+ if (prev === undefined || summaryFingerprint(prev) !== summaryFingerprint(next)) {
395
+ changed = true;
396
+ }
397
+ if (SCC_STATS_ENABLED) fingerprintCalls += prev === undefined ? 1 : 2;
398
+ inProgress.set(id, next);
399
+ }
400
+ if (iterations >= MAX_FIXED_POINT_ITERATIONS) {
401
+ diagnostics.push({
402
+ severity: "warning",
403
+ stage: "summarize",
404
+ message: `Summary fixed-point did not converge for SCC [${scc.members.join(", ")}]`,
405
+ });
406
+ break;
407
+ }
408
+ }
409
+ if (SCC_STATS_ENABLED) {
410
+ totalIterations += iterations;
411
+ if (iterations > maxIterations) maxIterations = iterations;
412
+ }
413
+ for (const id of scc.members) {
414
+ const summary = inProgress.get(id);
415
+ if (summary !== undefined) final.set(id, { ...summary, inRecursiveCycle: true });
416
+ }
417
+ }
418
+
419
+ lap("scc-compose");
420
+ // Phase 0b-β: attach direct capability facts and baseline coverage to each non-leaf
421
+ // routine's final summary. Capability extraction is per-routine (intrinsic, not
422
+ // inherited) — it runs once here, after the fixed-point has converged, so every
423
+ // routine gets its own direct facts attached exactly once.
424
+ //
425
+ // capabilityFactsInherited stays [] and coverage.inheritedStatus stays "unknown"
426
+ // until Tasks 21-22 (coverage composer + inherited composition).
427
+ //
428
+ // Engine-never-throws: extractCapabilities catches its own errors; this loop adds a
429
+ // second outer guard so a bug in the wire-in itself cannot crash the engine.
430
+ //
431
+ // Index publisher events by routine ONCE. The per-routine publisher-fact injection below
432
+ // otherwise filtered the entire eventGraph.events array per routine — O(routines × events),
433
+ // which on Base App (84k non-leaf routines × thousands of platform events) is a multi-second
434
+ // quadratic. This map makes it O(routines + events). Insertion order matches eventGraph.events
435
+ // order, so injected-fact order is unchanged.
436
+ const eventsForPublisherIndex = model.eventGraph?.events ?? [];
437
+ const publisherEventsByRoutine = new Map<RoutineId, typeof eventsForPublisherIndex>();
438
+ for (const evt of eventsForPublisherIndex) {
439
+ if (evt.publisherRoutineId === undefined) continue;
440
+ const list = publisherEventsByRoutine.get(evt.publisherRoutineId);
441
+ if (list) list.push(evt);
442
+ else publisherEventsByRoutine.set(evt.publisherRoutineId, [evt]);
443
+ }
444
+
445
+ for (const routine of model.routines) {
446
+ if (isLeaf(routine)) continue;
447
+ const summary = final.get(routine.id);
448
+ if (summary === undefined) continue;
449
+
450
+ // Build a minimal ExtractionContext — the orchestrator rebuilds variables
451
+ // internally, so ctx.variables here is just a safe placeholder.
452
+ const dispatchCtx = {
453
+ routine,
454
+ variables: new Map<string, VariableSymbol>(),
455
+ receiverTypeOf: (_name: string) => "unknown",
456
+ reportDiagnostic: (_d: Diagnostic) => {},
457
+ reportCoverageGap: (_r: CoverageReason, _t?: string) => {},
458
+ };
459
+
460
+ let { facts, status, reasons } = extractCapabilities(routine, dispatchCtx);
461
+
462
+ // Opaque / parse-incomplete routines: skip extraction (no body to extract from).
463
+ // extractCapabilities already handles this internally but we reinforce intent.
464
+ if (!routine.bodyAvailable || routine.parseIncomplete) {
465
+ facts = [];
466
+ status = "unknown";
467
+ reasons = routine.parseIncomplete ? ["parse-incomplete"] : ["opaque-dependency"];
468
+ }
469
+
470
+ // Phase 1a Family B — publisher-fact injection.
471
+ //
472
+ // The events-family extractor (src/index/capability/events.ts) runs at
473
+ // routine-indexing time and has no access to the resolved event graph, so it
474
+ // intentionally emits SUBSCRIBE facts only. Publisher routines ([IntegrationEvent]
475
+ // / [BusinessEvent] / [InternalEvent]) therefore have no direct publish facts, which
476
+ // caused publishesEventsOf to return [] for publisher routines — direct publish
477
+ // facts must be present so callers' inherited cones surface the event.
478
+ //
479
+ // Here, in the summary engine where model.eventGraph is already resolved, we look up
480
+ // every EventSymbol whose publisherRoutineId === routine.id and inject one direct
481
+ // publish CapabilityFact per event. The Task-22 inherited-facts BFS (below) then
482
+ // naturally propagates these facts to callers via direct-call edges, satisfying the
483
+ // publishesEventsOf invariant on publisher routines.
484
+ //
485
+ // Injection runs BEFORE final.set so the facts are present when the BFS reads
486
+ // capabilityFactsDirect for each routine.
487
+ const publisherEvents = publisherEventsByRoutine.get(routine.id) ?? [];
488
+ for (const evt of publisherEvents) {
489
+ const extra: EventExtra = {
490
+ kind: "event",
491
+ eventClass: mapEventKindToClass(evt.eventKind),
492
+ };
493
+ const publishFact: CapabilityFact = {
494
+ subject: routine.id,
495
+ op: "publish",
496
+ resourceKind: "event",
497
+ resourceId: evt.id as EventId,
498
+ confidence: "static",
499
+ provenance: "direct",
500
+ via: "self",
501
+ extra,
502
+ };
503
+ facts = [...facts, publishFact];
504
+ }
505
+
506
+ final.set(routine.id, {
507
+ ...summary,
508
+ capabilityFactsDirect: facts,
509
+ capabilityFactsInherited: [],
510
+ coverage: {
511
+ subject: routine.id,
512
+ directStatus: status,
513
+ inheritedStatus: "unknown",
514
+ reasons,
515
+ unknownTargets: [],
516
+ },
517
+ });
518
+ }
519
+
520
+ lap("task17-capability-extract");
521
+ const cones = composeInheritedCones(model, final, isLeaf, diagnostics);
522
+ for (const routine of model.routines) {
523
+ if (isLeaf(routine)) continue;
524
+ const summary = final.get(routine.id);
525
+ if (summary === undefined) continue;
526
+ const cone = cones.get(routine.id);
527
+ if (cone === undefined) continue; // composition failed → keep task-17 baseline coverage + []
528
+ final.set(routine.id, {
529
+ ...summary,
530
+ capabilityFactsInherited: cone.inherited,
531
+ coverage: cone.coverage,
532
+ });
533
+ }
534
+
535
+ lap("task22-inherited-bfs");
536
+ for (const routine of model.routines) {
537
+ if (isLeaf(routine)) continue; // leaves keep their authoritative summary
538
+ routine.summary = final.get(routine.id);
539
+ }
540
+ lap("writeback");
541
+
542
+ if (SCC_STATS_ENABLED) {
543
+ process.stderr.write(
544
+ `al-sem SCC stats: sccs=${nonRecursiveSccs + recursiveSccs}` +
545
+ ` (recursive=${recursiveSccs} non-recursive=${nonRecursiveSccs})` +
546
+ ` maxSize=${maxSccSize} totalMembers=${totalSccMembers}` +
547
+ ` totalIterations=${totalIterations} maxIterations=${maxIterations}` +
548
+ ` fingerprintCalls=${fingerprintCalls}\n`,
549
+ );
550
+ }
551
+ }
552
+
553
+ /** Phase 2b-compatible entry point: run summaries with the default leaf policy. */
554
+ export function computeSummaries(
555
+ model: SemanticModel,
556
+ graph: CombinedGraph,
557
+ diagnostics: Diagnostic[],
558
+ ): void {
559
+ runSummaries(model, graph, diagnostics);
560
+ }
@@ -0,0 +1,112 @@
1
+ import {
2
+ publishesEventsOf,
3
+ reachableCoverage,
4
+ writesTablesOf,
5
+ } from "../detectors/capability-query.ts";
6
+ import { roleOf } from "../model/entities.ts";
7
+ import type { EventId, OperationId, RoutineId, TableId } from "../model/ids.ts";
8
+ import type { SemanticModel } from "../model/model.ts";
9
+ import type { CombinedGraph } from "./combined-graph.ts";
10
+ import type { ReverseCallGraph } from "./reverse-call-graph.ts";
11
+
12
+ export interface TransactionSpan {
13
+ /** The Commit operation that bounds the span. */
14
+ commitOperationId: OperationId;
15
+ /** The routine containing the bounding Commit. */
16
+ commitRoutineId: RoutineId;
17
+ /** All routines reachable backward from commitRoutineId up to another commit or root. */
18
+ routinesInSpan: RoutineId[];
19
+ /** Union of tables written by any routine in the span (from writesTablesOf — sorted+deduped). */
20
+ writesTables: TableId[];
21
+ /** Union of events published by any routine in the span (from publishesEventsOf — sorted+deduped). */
22
+ publishesEvents: EventId[];
23
+ /** Entry roots — routines in the span with no upstream caller inside the span. */
24
+ spanRoots: RoutineId[];
25
+ /**
26
+ * True iff EVERY routine in `routinesInSpan` has a defined summary AND
27
+ * `reachableCoverage(summary) === "complete"`. False when at least
28
+ * one routine's inherited cone is partial / unknown — which means
29
+ * the aggregated `writesTables` and `publishesEvents` sets may be
30
+ * an under-approximation of what this span actually touches.
31
+ *
32
+ * Replaces the legacy per-routine `summary.writesTables === "unknown"`
33
+ * probe D9 used to do. The signal is coarser (overall coverage, not
34
+ * per-family) because Phase 1a's `reachableCoverage` is per-routine
35
+ * overall. When per-family coverage roll-up lands (spec §3.7
36
+ * FingerprintCoverage.perFamily), this field upgrades cleanly to a
37
+ * `writesCoverage: CoverageStatus`.
38
+ */
39
+ coverageComplete: boolean;
40
+ }
41
+
42
+ const MAX_DEPTH = 50;
43
+
44
+ /**
45
+ * Compute transaction spans. For each primary-app routine that contains a Commit, walk
46
+ * callers backward to find every routine that participates in the transaction. The walk
47
+ * stops at any routine that itself commits (that's a prior span's domain) or at the depth
48
+ * bound. Each Commit operation yields one TransactionSpan.
49
+ */
50
+ export function computeTransactionSpans(
51
+ model: SemanticModel,
52
+ _graph: CombinedGraph,
53
+ reverse: ReverseCallGraph,
54
+ ): TransactionSpan[] {
55
+ const routineById = new Map(model.routines.map((r) => [r.id, r]));
56
+ const spans: TransactionSpan[] = [];
57
+
58
+ // Build: routineId → list of Commit operationIds in that routine (from operationSites
59
+ // with kind === "commit"). We only care about primary-app routines.
60
+ const commitsByRoutine = new Map<RoutineId, OperationId[]>();
61
+ for (const r of model.routines) {
62
+ if (roleOf(r) !== "primary") continue;
63
+ const commitOps = r.features.operationSites
64
+ .filter((os) => os.kind === "commit")
65
+ .map((os) => os.id);
66
+ if (commitOps.length > 0) commitsByRoutine.set(r.id, commitOps);
67
+ }
68
+
69
+ for (const [commitRoutineId, commitOps] of commitsByRoutine) {
70
+ for (const commitOperationId of commitOps) {
71
+ const visited = new Set<RoutineId>();
72
+ const queue: { id: RoutineId; depth: number }[] = [{ id: commitRoutineId, depth: 0 }];
73
+ while (queue.length > 0) {
74
+ const item = queue.shift();
75
+ if (!item) break;
76
+ const { id, depth } = item;
77
+ if (visited.has(id)) continue;
78
+ visited.add(id);
79
+ if (depth >= MAX_DEPTH) continue;
80
+ // Don't walk past another committing routine (prior span bounds the trace).
81
+ if (id !== commitRoutineId && commitsByRoutine.has(id)) continue;
82
+ for (const caller of reverse.get(id) ?? []) {
83
+ if (!visited.has(caller.from)) queue.push({ id: caller.from, depth: depth + 1 });
84
+ }
85
+ }
86
+ const writes = new Set<TableId>();
87
+ const events = new Set<EventId>();
88
+ let coverageComplete = true;
89
+ for (const rid of visited) {
90
+ const r = routineById.get(rid);
91
+ if (r?.summary === undefined) {
92
+ coverageComplete = false;
93
+ continue;
94
+ }
95
+ for (const t of writesTablesOf(r.summary)) writes.add(t);
96
+ for (const e of publishesEventsOf(r.summary)) events.add(e);
97
+ if (reachableCoverage(r.summary) !== "complete") coverageComplete = false;
98
+ }
99
+ const spanRoots = [...visited].filter((rid) => (reverse.get(rid) ?? []).length === 0);
100
+ spans.push({
101
+ commitOperationId,
102
+ commitRoutineId,
103
+ routinesInSpan: [...visited].sort(),
104
+ writesTables: [...writes].sort(),
105
+ publishesEvents: [...events].sort(),
106
+ spanRoots: spanRoots.sort(),
107
+ coverageComplete,
108
+ });
109
+ }
110
+ }
111
+ return spans;
112
+ }