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,1317 @@
1
+ import type {
2
+ CallArgumentBinding,
3
+ CallSite,
4
+ ControlFlowNode,
5
+ FieldAccess,
6
+ RecordOperation,
7
+ Routine,
8
+ } from "../model/entities.ts";
9
+ import type { CallsiteId, FieldId, RoutineId } from "../model/ids.ts";
10
+ import type { EffectPresence, RoutineSummary } from "../model/summary.ts";
11
+ import { joinPresence } from "./effect-lattice.ts";
12
+ import { recordFlowRoleOf } from "./op-classification.ts";
13
+ import type { SummaryContext } from "./summary-context.ts";
14
+
15
+ /**
16
+ * Per-parameter path-aware state computed by the walker.
17
+ *
18
+ * Phase 6 rewrite: the walker now does a recursive AL-statement-tree traversal
19
+ * (via `routine.features.statementTree`) maintaining branch-aware per-parameter
20
+ * state. Output facts:
21
+ * - Entry requirements: `requiresLoadedAtEntry`, `requiredLoadedFieldsAtEntry`,
22
+ * `mutatesBeforeLoad` (same shape as Phase 4 — strictly more precise now).
23
+ * - Exit effects: `dirtyAtExit`, `currentLoadedFieldsAtExit` (path-proven).
24
+ * - Per-callsite snapshot: `currentLoadedFieldsAtCallsite` (for D42 in P8).
25
+ */
26
+ export interface PathAwareFacts {
27
+ parameterIndex: number;
28
+
29
+ // Entry requirements (Phase 4 — preserved with higher precision in Phase 6)
30
+ requiresLoadedAtEntry: EffectPresence;
31
+ requiredLoadedFieldsAtEntry: FieldId[] | "unknown";
32
+ mutatesBeforeLoad: EffectPresence;
33
+
34
+ // Per-callsite snapshots — currentLoadedFields visible to the callee at
35
+ // each forwarding callsite. Indexed by callsite id. Populated in Phase 6.
36
+ currentLoadedFieldsAtCallsite: Map<string, FieldId[] | "full" | "unknown">;
37
+
38
+ // Exit-effect facts (Phase 6).
39
+ dirtyAtExit: EffectPresence;
40
+ currentLoadedFieldsAtExit: FieldId[] | "full" | "unknown";
41
+ }
42
+
43
+ // ============================================================================
44
+ // Internal state machine
45
+ // ============================================================================
46
+
47
+ type Loaded = "yes" | "no" | "unknown";
48
+ type Dirty = "pristine" | "dirty" | "persisted" | "unknown";
49
+ type LoadedFields = FieldId[] | "full" | "unknown";
50
+ type PendingNarrow = FieldId[] | "none" | "unknown";
51
+
52
+ /**
53
+ * Per-parameter mutable state threaded along control flow. Lists are kept sorted
54
+ * and unique so equality comparison + deterministic output are cheap.
55
+ *
56
+ * Conventions:
57
+ * - `loaded`: `"yes"` after a load/init/copyInto op or call that loads on the
58
+ * callee var-param side; `"unknown"` after a branch join where branches disagree.
59
+ * - `dirty`: lattice element from §(b) of the spec. `pristine | dirty | persisted | unknown`.
60
+ * - `pendingNarrow`: the pending SetLoadFields/AddLoadFields set that the NEXT
61
+ * Get/Find/Next would consume into `currentLoadedFields`. `"none"` means no
62
+ * pending narrow (next load loads everything).
63
+ * - `currentLoadedFields`: what's currently loaded in the record. `"full"` at init
64
+ * (matches AL semantics: a fresh record loads all normal fields on first Get).
65
+ * - `requiresLoadedAtEntry` / `mutatesBeforeLoad`: ⊔-accumulated contributions
66
+ * across the walk, never lowered.
67
+ * - `requiredFields`: raw field-name strings (case-preserved from the indexer).
68
+ * Cast to FieldId at output time to match the v1 walker's shape.
69
+ * `"unknown"` absorbs.
70
+ */
71
+ interface PerParamState {
72
+ loaded: Loaded;
73
+ dirty: Dirty;
74
+ pendingNarrow: PendingNarrow;
75
+ currentLoadedFields: LoadedFields;
76
+ requiresLoadedAtEntry: EffectPresence;
77
+ mutatesBeforeLoad: EffectPresence;
78
+ requiredFields: Set<string> | "unknown";
79
+ }
80
+
81
+ function initialState(): PerParamState {
82
+ return {
83
+ loaded: "no",
84
+ dirty: "pristine",
85
+ pendingNarrow: "none",
86
+ currentLoadedFields: "full",
87
+ requiresLoadedAtEntry: "no",
88
+ mutatesBeforeLoad: "no",
89
+ requiredFields: new Set(),
90
+ };
91
+ }
92
+
93
+ // ----- lattice joins --------------------------------------------------------
94
+
95
+ function joinLoaded(a: Loaded, b: Loaded): Loaded {
96
+ if (a === b) return a;
97
+ return "unknown";
98
+ }
99
+
100
+ function joinDirty(a: Dirty, b: Dirty): Dirty {
101
+ if (a === b) return a;
102
+ // "dirty" dominates anything else (sound: at least one path is dirty).
103
+ if (a === "dirty" || b === "dirty") return "dirty";
104
+ // otherwise unknown (mixed pristine/persisted/unknown).
105
+ return "unknown";
106
+ }
107
+
108
+ function joinPending(a: PendingNarrow, b: PendingNarrow): PendingNarrow {
109
+ if (a === "unknown" || b === "unknown") return "unknown";
110
+ if (a === "none" && b === "none") return "none";
111
+ if (a === "none" || b === "none") return "unknown";
112
+ // Both are lists — they must agree or join to unknown.
113
+ if (a.length !== b.length) return "unknown";
114
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return "unknown";
115
+ return a;
116
+ }
117
+
118
+ function joinLoadedFields(a: LoadedFields, b: LoadedFields): LoadedFields {
119
+ if (a === "unknown" || b === "unknown") return "unknown";
120
+ if (a === "full" && b === "full") return "full";
121
+ if (a === "full" || b === "full") return "unknown";
122
+ // Both are lists — equal lists merge; differing -> unknown (sound, less precise).
123
+ if (a.length !== b.length) return "unknown";
124
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return "unknown";
125
+ return a;
126
+ }
127
+
128
+ function joinRequiredFields(
129
+ a: Set<string> | "unknown",
130
+ b: Set<string> | "unknown",
131
+ ): Set<string> | "unknown" {
132
+ if (a === "unknown" || b === "unknown") return "unknown";
133
+ const out = new Set<string>(a);
134
+ for (const f of b) out.add(f);
135
+ return out;
136
+ }
137
+
138
+ function joinStates(a: PerParamState, b: PerParamState): PerParamState {
139
+ return {
140
+ loaded: joinLoaded(a.loaded, b.loaded),
141
+ dirty: joinDirty(a.dirty, b.dirty),
142
+ pendingNarrow: joinPending(a.pendingNarrow, b.pendingNarrow),
143
+ currentLoadedFields: joinLoadedFields(a.currentLoadedFields, b.currentLoadedFields),
144
+ requiresLoadedAtEntry: joinPresence(a.requiresLoadedAtEntry, b.requiresLoadedAtEntry),
145
+ mutatesBeforeLoad: joinPresence(a.mutatesBeforeLoad, b.mutatesBeforeLoad),
146
+ requiredFields: joinRequiredFields(a.requiredFields, b.requiredFields),
147
+ };
148
+ }
149
+
150
+ function statesEqual(a: PerParamState, b: PerParamState): boolean {
151
+ if (a.loaded !== b.loaded) return false;
152
+ if (a.dirty !== b.dirty) return false;
153
+ if (a.requiresLoadedAtEntry !== b.requiresLoadedAtEntry) return false;
154
+ if (a.mutatesBeforeLoad !== b.mutatesBeforeLoad) return false;
155
+ if (!pendingEqual(a.pendingNarrow, b.pendingNarrow)) return false;
156
+ if (!loadedFieldsEqual(a.currentLoadedFields, b.currentLoadedFields)) return false;
157
+ if (!requiredFieldsEqual(a.requiredFields, b.requiredFields)) return false;
158
+ return true;
159
+ }
160
+
161
+ function pendingEqual(a: PendingNarrow, b: PendingNarrow): boolean {
162
+ if (a === b) return true;
163
+ if (typeof a === "string" || typeof b === "string") return false;
164
+ if (a.length !== b.length) return false;
165
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
166
+ return true;
167
+ }
168
+
169
+ function loadedFieldsEqual(a: LoadedFields, b: LoadedFields): boolean {
170
+ if (a === b) return true;
171
+ if (typeof a === "string" || typeof b === "string") return false;
172
+ if (a.length !== b.length) return false;
173
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
174
+ return true;
175
+ }
176
+
177
+ function requiredFieldsEqual(a: Set<string> | "unknown", b: Set<string> | "unknown"): boolean {
178
+ if (a === "unknown" && b === "unknown") return true;
179
+ if (a === "unknown" || b === "unknown") return false;
180
+ if (a.size !== b.size) return false;
181
+ for (const f of a) if (!b.has(f)) return false;
182
+ return true;
183
+ }
184
+
185
+ /**
186
+ * Saturate all "decidable" fields to unknown — used by:
187
+ * - bounded loop overshoot (state did not converge),
188
+ * - opaque callee on a var-param (cannot reason about effect),
189
+ * - "other" / unrecognised statement nodes (conservative).
190
+ *
191
+ * Accumulated entry-requirement contributions (`requiresLoadedAtEntry`,
192
+ * `mutatesBeforeLoad`, `requiredFields`) are NOT lowered — they're monotone
193
+ * lattice elements that only grow.
194
+ */
195
+ function saturateUnknown(s: PerParamState): PerParamState {
196
+ return {
197
+ loaded: "unknown",
198
+ dirty: "unknown",
199
+ pendingNarrow: "unknown",
200
+ currentLoadedFields: "unknown",
201
+ requiresLoadedAtEntry: s.requiresLoadedAtEntry,
202
+ mutatesBeforeLoad: s.mutatesBeforeLoad,
203
+ requiredFields: "unknown",
204
+ };
205
+ }
206
+
207
+ // ============================================================================
208
+ // Walker entry point
209
+ // ============================================================================
210
+
211
+ /**
212
+ * Walk the routine's body with per-parameter state tracking.
213
+ *
214
+ * Phase 6 implementation: recursive AL-statement-tree walker using
215
+ * `routine.features.statementTree` (populated in P6.T1). Maintains
216
+ * branch-aware state per record-parameter, joining state-sets at
217
+ * `if`/`case`/loop joins. Bounded loop fixed-point (max 3) with
218
+ * `"unknown"`-saturation on overshoot.
219
+ *
220
+ * Falls back to a single straight-line pass over `recordOperations` +
221
+ * `fieldAccesses` + `callSites` when `statementTree` is absent — keeps the
222
+ * walker usable on routines whose body wasn't indexed with the CFN builder
223
+ * (e.g. older fixtures, parse-incomplete bodies that still expose flat features).
224
+ *
225
+ * When `lookup` is provided, the walker uses it for callee summaries during the
226
+ * fixed-point (current iteration). When absent, falls back to `routine.summary`
227
+ * on the callee.
228
+ */
229
+ export function walkRoutine(
230
+ routine: Routine,
231
+ ctx: SummaryContext,
232
+ lookup?: (id: RoutineId) => RoutineSummary | undefined,
233
+ ): PathAwareFacts[] {
234
+ const facts: PathAwareFacts[] = [];
235
+
236
+ for (const param of routine.parameters) {
237
+ if (!param.isRecord) continue;
238
+
239
+ const recVar = routine.features.recordVariables.find(
240
+ (rv) => rv.isParameter && rv.parameterIndex === param.index,
241
+ );
242
+ const recVarNameLc = recVar?.name.toLowerCase() ?? param.name.toLowerCase();
243
+ const recVarId = recVar?.id;
244
+ const paramCtx: ParamCtx = { index: param.index, nameLc: recVarNameLc, recVarId };
245
+
246
+ // Per-walk snapshots collected at every forwarding callsite for this param.
247
+ const callsiteSnapshots = new Map<CallsiteId, LoadedFields>();
248
+ // Exit states collected at every `exit`/`error` reached by the walker.
249
+ const exitStates: PerParamState[] = [];
250
+
251
+ const tree = routine.features.statementTree;
252
+ const initial = initialState();
253
+
254
+ let finalState: PerParamState;
255
+ if (tree !== undefined) {
256
+ const opIndex = indexOps(routine);
257
+ const callIndex = indexCalls(routine);
258
+ const faIndex = indexFieldAccesses(routine);
259
+ finalState = walkCFG(
260
+ tree,
261
+ initial,
262
+ paramCtx,
263
+ routine,
264
+ ctx,
265
+ lookup,
266
+ exitStates,
267
+ callsiteSnapshots,
268
+ opIndex,
269
+ callIndex,
270
+ faIndex,
271
+ );
272
+ } else {
273
+ // No statement tree — straight-line fallback using flat features.
274
+ finalState = walkFlat(routine, paramCtx, ctx, lookup, initial, callsiteSnapshots);
275
+ }
276
+
277
+ // Aggregate exit + fallthrough states for the exit-effect facts.
278
+ const allExitStates: PerParamState[] = [...exitStates, finalState];
279
+ const dirtyAtExit = computeDirtyAtExit(allExitStates);
280
+ const currentLoadedFieldsAtExit = computeCurrentLoadedAtExit(allExitStates);
281
+
282
+ // Entry-requirement facts: take the JOIN across all exit + fallthrough states
283
+ // — every path's contribution counts. (These fields are accumulator-style so
284
+ // joinPresence yields the same as the JOIN of contributions on every reached
285
+ // exit state.)
286
+ let requires: EffectPresence = "no";
287
+ let mutates: EffectPresence = "no";
288
+ let required: Set<string> | "unknown" = new Set();
289
+ for (const s of allExitStates) {
290
+ requires = joinPresence(requires, s.requiresLoadedAtEntry);
291
+ mutates = joinPresence(mutates, s.mutatesBeforeLoad);
292
+ required = joinRequiredFields(required, s.requiredFields);
293
+ }
294
+
295
+ const requiredFieldsFinal: FieldId[] | "unknown" =
296
+ required === "unknown" ? "unknown" : ([...required].sort() as FieldId[]);
297
+
298
+ facts.push({
299
+ parameterIndex: param.index,
300
+ requiresLoadedAtEntry: requires,
301
+ requiredLoadedFieldsAtEntry: requiredFieldsFinal,
302
+ mutatesBeforeLoad: mutates,
303
+ currentLoadedFieldsAtCallsite: callsiteSnapshots,
304
+ dirtyAtExit,
305
+ currentLoadedFieldsAtExit,
306
+ });
307
+ }
308
+ return facts;
309
+ }
310
+
311
+ // ============================================================================
312
+ // Recursive walker
313
+ // ============================================================================
314
+
315
+ interface ParamCtx {
316
+ index: number;
317
+ nameLc: string;
318
+ recVarId: string | undefined;
319
+ }
320
+
321
+ type OpIndex = Map<string, RecordOperation>;
322
+ type CallIndex = Map<string, CallSite>;
323
+ type FaIndex = Map<string, FieldAccess[]>; // keyed by `${line}:${column}`
324
+
325
+ function indexOps(routine: Routine): OpIndex {
326
+ const m: OpIndex = new Map();
327
+ for (const op of routine.features.recordOperations) m.set(op.id, op);
328
+ return m;
329
+ }
330
+
331
+ function indexCalls(routine: Routine): CallIndex {
332
+ const m: CallIndex = new Map();
333
+ for (const cs of routine.features.callSites) m.set(cs.id, cs);
334
+ return m;
335
+ }
336
+
337
+ /**
338
+ * Field accesses are not represented as their own CFN leaves (the indexer
339
+ * doesn't anchor them in the statement tree). To attribute field reads to
340
+ * the correct CFN position, we index every FA by `(line, column)` of its
341
+ * source anchor. Each block-walker call (`case "block"` in `walkCFG`) then
342
+ * picks the FAs whose source position falls inside the block but NOT inside
343
+ * any RECURSIVE child (block / if / case / case-branch / loops / try /
344
+ * "other"-with-children) — those recursive children will attribute them
345
+ * themselves. FAs inside a non-recursive leaf (op / call / exit / error /
346
+ * leaf "other") are correctly attributed by the enclosing block.
347
+ *
348
+ * This yields branch-aware attribution: e.g. an FA inside the `then` branch
349
+ * of an `if` is walked by the `if`'s then-branch block, so its contribution
350
+ * is joined with the else-branch contribution at the `if` join point.
351
+ */
352
+ function indexFieldAccesses(routine: Routine): FaIndex {
353
+ const m: FaIndex = new Map();
354
+ for (const fa of routine.features.fieldAccesses) {
355
+ const key = `${fa.sourceAnchor.range.startLine}:${fa.sourceAnchor.range.startColumn}`;
356
+ const list = m.get(key);
357
+ if (list !== undefined) list.push(fa);
358
+ else m.set(key, [fa]);
359
+ }
360
+ return m;
361
+ }
362
+
363
+ function walkCFG(
364
+ node: ControlFlowNode,
365
+ pre: PerParamState,
366
+ param: ParamCtx,
367
+ routine: Routine,
368
+ ctx: SummaryContext,
369
+ lookup: ((id: RoutineId) => RoutineSummary | undefined) | undefined,
370
+ exitStates: PerParamState[],
371
+ snapshots: Map<CallsiteId, LoadedFields>,
372
+ opIndex: OpIndex,
373
+ callIndex: CallIndex,
374
+ faIndex: FaIndex,
375
+ ): PerParamState {
376
+ switch (node.kind) {
377
+ case "block": {
378
+ let state = pre;
379
+ // Interleave field-access events with child CFN nodes by source position.
380
+ // A field read like `Cust.Name` fires at its own line, NOT at the block
381
+ // entry. This matters when an op (e.g. `Cust.Get(...)`) precedes the FA
382
+ // in the same block: the Get walks first → loaded=yes → the FA no
383
+ // longer contributes to requiresLoadedAtEntry.
384
+ const children = node.children ?? [];
385
+ const fas = collectFieldAccessesInBlock(node, children, param, faIndex);
386
+ const events: Array<
387
+ | { kind: "child"; node: ControlFlowNode; line: number; col: number }
388
+ | { kind: "fa"; fa: FieldAccess; line: number; col: number }
389
+ > = [];
390
+ for (const c of children) {
391
+ events.push({
392
+ kind: "child",
393
+ node: c,
394
+ line: c.sourceAnchor.range.startLine,
395
+ col: c.sourceAnchor.range.startColumn,
396
+ });
397
+ }
398
+ for (const fa of fas) {
399
+ events.push({
400
+ kind: "fa",
401
+ fa,
402
+ line: fa.sourceAnchor.range.startLine,
403
+ col: fa.sourceAnchor.range.startColumn,
404
+ });
405
+ }
406
+ events.sort((a, b) => (a.line !== b.line ? a.line - b.line : a.col - b.col));
407
+ for (const e of events) {
408
+ if (e.kind === "child") {
409
+ state = walkCFG(
410
+ e.node,
411
+ state,
412
+ param,
413
+ routine,
414
+ ctx,
415
+ lookup,
416
+ exitStates,
417
+ snapshots,
418
+ opIndex,
419
+ callIndex,
420
+ faIndex,
421
+ );
422
+ } else {
423
+ state = applyFieldRead(state, e.fa);
424
+ }
425
+ }
426
+ return state;
427
+ }
428
+ case "if": {
429
+ // P7.5: condition-position ops/calls evaluate BEFORE branch selection.
430
+ const preBranch = applyConditionLeaves(
431
+ pre,
432
+ node.conditionLeaves,
433
+ param,
434
+ routine,
435
+ ctx,
436
+ lookup,
437
+ exitStates,
438
+ snapshots,
439
+ opIndex,
440
+ callIndex,
441
+ faIndex,
442
+ );
443
+ const thenBranch = node.children?.[0];
444
+ const elseBranch = node.elseChildren?.[0];
445
+ const thenState =
446
+ thenBranch !== undefined
447
+ ? walkCFG(
448
+ thenBranch,
449
+ preBranch,
450
+ param,
451
+ routine,
452
+ ctx,
453
+ lookup,
454
+ exitStates,
455
+ snapshots,
456
+ opIndex,
457
+ callIndex,
458
+ faIndex,
459
+ )
460
+ : preBranch;
461
+ const elseState =
462
+ elseBranch !== undefined
463
+ ? walkCFG(
464
+ elseBranch,
465
+ preBranch,
466
+ param,
467
+ routine,
468
+ ctx,
469
+ lookup,
470
+ exitStates,
471
+ snapshots,
472
+ opIndex,
473
+ callIndex,
474
+ faIndex,
475
+ )
476
+ : preBranch; // missing else = the no-match path = preBranch.
477
+ return joinStates(thenState, elseState);
478
+ }
479
+ case "case": {
480
+ // Walk each case-branch from `pre`; join all post-states.
481
+ // case-else-branch is one of the children (the CFN builder uses the
482
+ // same `case-branch` kind for both, since the lattice doesn't care).
483
+ // If the case has no `else`, the no-match path = pre, which we include
484
+ // in the join (spec §(b): "missing default = pre-state").
485
+ //
486
+ // P7.5: case-value expression evaluates ONCE before branch selection.
487
+ const preBranch = applyConditionLeaves(
488
+ pre,
489
+ node.conditionLeaves,
490
+ param,
491
+ routine,
492
+ ctx,
493
+ lookup,
494
+ exitStates,
495
+ snapshots,
496
+ opIndex,
497
+ callIndex,
498
+ faIndex,
499
+ );
500
+ const branches = node.children ?? [];
501
+ let hasElse = false;
502
+ let acc: PerParamState | undefined;
503
+ for (const c of branches) {
504
+ if (c.sourceAnchor.syntaxKind === "case_else_branch") hasElse = true;
505
+ const post = walkCFG(
506
+ c,
507
+ preBranch,
508
+ param,
509
+ routine,
510
+ ctx,
511
+ lookup,
512
+ exitStates,
513
+ snapshots,
514
+ opIndex,
515
+ callIndex,
516
+ faIndex,
517
+ );
518
+ acc = acc === undefined ? post : joinStates(acc, post);
519
+ }
520
+ if (acc === undefined) return preBranch; // empty case
521
+ if (!hasElse) acc = joinStates(acc, preBranch);
522
+ return acc;
523
+ }
524
+ case "case-branch": {
525
+ // case-branch wraps a single body block as its first child.
526
+ let state = pre;
527
+ for (const c of node.children ?? []) {
528
+ state = walkCFG(
529
+ c,
530
+ state,
531
+ param,
532
+ routine,
533
+ ctx,
534
+ lookup,
535
+ exitStates,
536
+ snapshots,
537
+ opIndex,
538
+ callIndex,
539
+ faIndex,
540
+ );
541
+ }
542
+ return state;
543
+ }
544
+ case "while":
545
+ case "for":
546
+ case "foreach": {
547
+ // Bounded fixed-point: walk body up to 3 times; if state isn't yet
548
+ // converged, saturate decidable lattice fields to "unknown" but keep
549
+ // accumulated entry-requirement contributions.
550
+ //
551
+ // Body iteration semantics: at the start of each iteration we are at
552
+ // the join of (pre, previous body post). The body itself may use
553
+ // `exit`/`error` — those snapshots are captured into `exitStates`.
554
+ //
555
+ // P7.5: condition / range / iterable expression-position ops fire BEFORE
556
+ // each body iteration (pre-condition test). The fixed-point naturally
557
+ // folds them into each iteration: we apply conditionLeaves first, then
558
+ // the body. The post-loop "loop did not run" path also requires the
559
+ // condition to have been evaluated at least once.
560
+ const bodyNode = node.children?.[0];
561
+ // Apply the (zero-or-many) condition leaves once for the "loop ran zero
562
+ // times" path: even if the body never runs, the condition was tested
563
+ // (whether true or false). This ensures the loaded-after-cond effect
564
+ // reaches the post-loop state.
565
+ const preCond = applyConditionLeaves(
566
+ pre,
567
+ node.conditionLeaves,
568
+ param,
569
+ routine,
570
+ ctx,
571
+ lookup,
572
+ exitStates,
573
+ snapshots,
574
+ opIndex,
575
+ callIndex,
576
+ faIndex,
577
+ );
578
+ if (bodyNode === undefined) return preCond;
579
+ let bodyPre = preCond;
580
+ for (let i = 0; i < 3; i++) {
581
+ const bodyPost = walkCFG(
582
+ bodyNode,
583
+ bodyPre,
584
+ param,
585
+ routine,
586
+ ctx,
587
+ lookup,
588
+ exitStates,
589
+ snapshots,
590
+ opIndex,
591
+ callIndex,
592
+ faIndex,
593
+ );
594
+ // Re-apply condition leaves at start of next iteration (pre-condition
595
+ // loops test before each iteration's body).
596
+ const nextIterPre = applyConditionLeaves(
597
+ bodyPost,
598
+ node.conditionLeaves,
599
+ param,
600
+ routine,
601
+ ctx,
602
+ lookup,
603
+ exitStates,
604
+ snapshots,
605
+ opIndex,
606
+ callIndex,
607
+ faIndex,
608
+ );
609
+ const joined = joinStates(bodyPre, nextIterPre);
610
+ if (statesEqual(joined, bodyPre)) {
611
+ return joined;
612
+ }
613
+ bodyPre = joined;
614
+ }
615
+ return saturateUnknown(bodyPre);
616
+ }
617
+ case "repeat": {
618
+ // repeat ... until: body executes at least once, condition tested AFTER body.
619
+ // P7.5: the until-expression's ops fire at the END of each iteration.
620
+ const bodyNode = node.children?.[0];
621
+ if (bodyNode === undefined) {
622
+ // No body — just apply the until-condition (degenerate; grammar shouldn't
623
+ // produce this, but stay safe).
624
+ return applyConditionLeaves(
625
+ pre,
626
+ node.conditionLeaves,
627
+ param,
628
+ routine,
629
+ ctx,
630
+ lookup,
631
+ exitStates,
632
+ snapshots,
633
+ opIndex,
634
+ callIndex,
635
+ faIndex,
636
+ );
637
+ }
638
+ let bodyPre = pre;
639
+ for (let i = 0; i < 3; i++) {
640
+ const bodyPost = walkCFG(
641
+ bodyNode,
642
+ bodyPre,
643
+ param,
644
+ routine,
645
+ ctx,
646
+ lookup,
647
+ exitStates,
648
+ snapshots,
649
+ opIndex,
650
+ callIndex,
651
+ faIndex,
652
+ );
653
+ // Apply until-condition leaves AFTER body (post-condition semantics).
654
+ const afterCond = applyConditionLeaves(
655
+ bodyPost,
656
+ node.conditionLeaves,
657
+ param,
658
+ routine,
659
+ ctx,
660
+ lookup,
661
+ exitStates,
662
+ snapshots,
663
+ opIndex,
664
+ callIndex,
665
+ faIndex,
666
+ );
667
+ const joined = joinStates(bodyPre, afterCond);
668
+ if (statesEqual(joined, bodyPre)) {
669
+ return joined;
670
+ }
671
+ bodyPre = joined;
672
+ }
673
+ return saturateUnknown(bodyPre);
674
+ }
675
+ case "exit": {
676
+ exitStates.push(pre);
677
+ return pre;
678
+ }
679
+ case "error": {
680
+ // P7.5: any argument-position ops on a bare Error(...) call still
681
+ // execute before the Error exits (argument evaluation precedes call).
682
+ const post = applyConditionLeaves(
683
+ pre,
684
+ node.conditionLeaves,
685
+ param,
686
+ routine,
687
+ ctx,
688
+ lookup,
689
+ exitStates,
690
+ snapshots,
691
+ opIndex,
692
+ callIndex,
693
+ faIndex,
694
+ );
695
+ exitStates.push(post);
696
+ return post;
697
+ }
698
+ case "op": {
699
+ // P7.5: nested ops in arguments evaluate BEFORE this op (`foo(bar.X())`).
700
+ const preOp = applyConditionLeaves(
701
+ pre,
702
+ node.conditionLeaves,
703
+ param,
704
+ routine,
705
+ ctx,
706
+ lookup,
707
+ exitStates,
708
+ snapshots,
709
+ opIndex,
710
+ callIndex,
711
+ faIndex,
712
+ );
713
+ const op = opIndex.get(node.operationId ?? "");
714
+ if (op === undefined) return preOp;
715
+ return applyOp(preOp, op, param);
716
+ }
717
+ case "call": {
718
+ // P7.5: argument-position ops evaluate BEFORE the call applies.
719
+ const preCall = applyConditionLeaves(
720
+ pre,
721
+ node.conditionLeaves,
722
+ param,
723
+ routine,
724
+ ctx,
725
+ lookup,
726
+ exitStates,
727
+ snapshots,
728
+ opIndex,
729
+ callIndex,
730
+ faIndex,
731
+ );
732
+ const cs = callIndex.get(node.callsiteId ?? "");
733
+ if (cs === undefined) return preCall;
734
+ return applyCall(preCall, cs, param, ctx, lookup, snapshots);
735
+ }
736
+ case "try": {
737
+ // AL grammar currently does not expose a try_statement, so children
738
+ // is empty. Treat as opaque-with-possible-exit: snapshot a sat-unknown
739
+ // exit state and return saturated.
740
+ const sat = saturateUnknown(pre);
741
+ exitStates.push(sat);
742
+ return sat;
743
+ }
744
+ default: {
745
+ // "other" (and any future unrecognised kind) wraps assignment / message /
746
+ // with / asserterror / etc. Recursively
747
+ // walk any children (e.g. `with_statement` body) so embedded ops/calls
748
+ // inside the wrapped statement still affect state.
749
+ // P7.5: also apply any argument-position leaves harvested by the indexer
750
+ // (e.g. a call_expression statement whose function is unresolved but whose
751
+ // arguments contain a record-op).
752
+ let state = applyConditionLeaves(
753
+ pre,
754
+ node.conditionLeaves,
755
+ param,
756
+ routine,
757
+ ctx,
758
+ lookup,
759
+ exitStates,
760
+ snapshots,
761
+ opIndex,
762
+ callIndex,
763
+ faIndex,
764
+ );
765
+ for (const c of node.children ?? []) {
766
+ state = walkCFG(
767
+ c,
768
+ state,
769
+ param,
770
+ routine,
771
+ ctx,
772
+ lookup,
773
+ exitStates,
774
+ snapshots,
775
+ opIndex,
776
+ callIndex,
777
+ faIndex,
778
+ );
779
+ }
780
+ return state;
781
+ }
782
+ }
783
+ }
784
+
785
+ /**
786
+ * P7.5: process a node's harvested expression-position leaves (`conditionLeaves`)
787
+ * in source order. Each leaf is an `op` / `call` / `error` ControlFlowNode produced
788
+ * by the indexer's `harvestExpressionLeaves`. We re-enter `walkCFG` for each leaf so
789
+ * nested argument leaves (e.g. `Helper(Cust.FindSet())` → call leaf with op
790
+ * conditionLeaves) propagate correctly.
791
+ *
792
+ * The CALLER controls timing: pre-body (if/case/while/for/foreach), post-body
793
+ * (repeat), or pre-effect (op/call/error/other-with-args). This helper is purely
794
+ * a sequencer — it doesn't decide.
795
+ */
796
+ function applyConditionLeaves(
797
+ pre: PerParamState,
798
+ leaves: ControlFlowNode[] | undefined,
799
+ param: ParamCtx,
800
+ routine: Routine,
801
+ ctx: SummaryContext,
802
+ lookup: ((id: RoutineId) => RoutineSummary | undefined) | undefined,
803
+ exitStates: PerParamState[],
804
+ snapshots: Map<CallsiteId, LoadedFields>,
805
+ opIndex: OpIndex,
806
+ callIndex: CallIndex,
807
+ faIndex: FaIndex,
808
+ ): PerParamState {
809
+ if (leaves === undefined || leaves.length === 0) return pre;
810
+ let state = pre;
811
+ for (const leaf of leaves) {
812
+ state = walkCFG(
813
+ leaf,
814
+ state,
815
+ param,
816
+ routine,
817
+ ctx,
818
+ lookup,
819
+ exitStates,
820
+ snapshots,
821
+ opIndex,
822
+ callIndex,
823
+ faIndex,
824
+ );
825
+ }
826
+ return state;
827
+ }
828
+
829
+ // ============================================================================
830
+ // Op + call application
831
+ // ============================================================================
832
+
833
+ function opAffectsParam(op: RecordOperation, param: ParamCtx): boolean {
834
+ if (param.recVarId !== undefined && op.recordVariableId === param.recVarId) return true;
835
+ return op.recordVariableName.toLowerCase() === param.nameLc;
836
+ }
837
+
838
+ function applyOp(state: PerParamState, op: RecordOperation, param: ParamCtx): PerParamState {
839
+ if (!opAffectsParam(op, param)) return state;
840
+ const role = recordFlowRoleOf(op.op);
841
+ const out: PerParamState = {
842
+ ...state,
843
+ requiredFields: state.requiredFields === "unknown" ? "unknown" : new Set(state.requiredFields),
844
+ };
845
+ switch (role) {
846
+ case "loadsFromDb": {
847
+ out.loaded = "yes";
848
+ if (out.pendingNarrow === "unknown") {
849
+ out.currentLoadedFields = "unknown";
850
+ } else if (out.pendingNarrow === "none") {
851
+ out.currentLoadedFields = "full";
852
+ } else {
853
+ out.currentLoadedFields = [...out.pendingNarrow].sort() as FieldId[];
854
+ }
855
+ out.pendingNarrow = "none";
856
+ out.dirty = "pristine";
857
+ break;
858
+ }
859
+ case "initialises": {
860
+ out.loaded = "yes";
861
+ out.currentLoadedFields = "full";
862
+ out.pendingNarrow = "none";
863
+ out.dirty = "pristine";
864
+ break;
865
+ }
866
+ case "copiesInto": {
867
+ out.loaded = "yes";
868
+ // currentLoadedFields after Copy/TransferFields is conservatively unknown
869
+ // (depends on the source record), but we keep prior state — copy doesn't
870
+ // reduce knowledge about what's loaded.
871
+ out.dirty = "pristine";
872
+ break;
873
+ }
874
+ case "persistsCurrent": {
875
+ if (out.loaded !== "yes") {
876
+ out.requiresLoadedAtEntry = "yes";
877
+ out.mutatesBeforeLoad = "yes";
878
+ }
879
+ out.dirty = "persisted";
880
+ break;
881
+ }
882
+ case "validates": {
883
+ if (out.loaded !== "yes") {
884
+ out.requiresLoadedAtEntry = "yes";
885
+ out.mutatesBeforeLoad = "yes";
886
+ }
887
+ // Validate transitions to dirty (raises in-memory dirty); doesn't lower
888
+ // `persisted` because once persisted the Modify+Validate sequence is its
889
+ // own dirty cycle. Spec wording: "Validate → dirty (if was pristine) /
890
+ // unchanged (otherwise)". We model "unchanged" conservatively here so
891
+ // `persisted → Validate` stays `persisted` (downgrading to dirty would
892
+ // invent a may-fact); but if prior was pristine OR unknown we raise to
893
+ // dirty. This is the sound choice.
894
+ if (out.dirty === "pristine") out.dirty = "dirty";
895
+ else if (out.dirty === "unknown") out.dirty = "dirty";
896
+ break;
897
+ }
898
+ case "setBasedWrite": {
899
+ // ModifyAll / DeleteAll are set-based and do NOT transition the current
900
+ // record's dirty state. They DO require the record to be in a valid
901
+ // filter-state — but that's covered by D41, not the dirty lattice.
902
+ break;
903
+ }
904
+ case "resetsFilter": {
905
+ out.pendingNarrow = "none";
906
+ // Reset does NOT reload; currentLoadedFields unchanged.
907
+ break;
908
+ }
909
+ case "neutral": {
910
+ if (op.op === "SetLoadFields") {
911
+ const fields = [...new Set(op.fieldArguments ?? [])].sort();
912
+ out.pendingNarrow = fields as FieldId[];
913
+ } else if (op.op === "AddLoadFields") {
914
+ const additions = op.fieldArguments ?? [];
915
+ if (out.pendingNarrow === "unknown") {
916
+ // stay unknown
917
+ } else if (out.pendingNarrow === "none") {
918
+ out.pendingNarrow = [...new Set(additions)].sort() as FieldId[];
919
+ } else {
920
+ out.pendingNarrow = [
921
+ ...new Set([...out.pendingNarrow, ...additions]),
922
+ ].sort() as FieldId[];
923
+ }
924
+ }
925
+ // Other neutral ops (SetRange/SetFilter/TestField/SetCurrentKey/Count/etc.)
926
+ // — no state change here.
927
+ break;
928
+ }
929
+ }
930
+ return out;
931
+ }
932
+
933
+ function applyCall(
934
+ state: PerParamState,
935
+ cs: CallSite,
936
+ param: ParamCtx,
937
+ ctx: SummaryContext,
938
+ lookup: ((id: RoutineId) => RoutineSummary | undefined) | undefined,
939
+ snapshots: Map<CallsiteId, LoadedFields>,
940
+ ): PerParamState {
941
+ // Find the binding that forwards THIS param to the callee.
942
+ const binding = cs.argumentBindings.find((b) => {
943
+ if (b.bindingResolution !== "resolved") return false;
944
+ if (param.recVarId !== undefined && b.sourceRecordVariableId === param.recVarId) return true;
945
+ return b.sourceVariableName === param.nameLc;
946
+ });
947
+
948
+ // Even if no binding for this param, the call might still be a relevant snapshot
949
+ // for OTHER params; but we only record snapshots when this param IS forwarded.
950
+ if (binding === undefined) return state;
951
+
952
+ // Snapshot the current loaded fields at this callsite for D42's use.
953
+ snapshots.set(cs.id, state.currentLoadedFields);
954
+
955
+ const edge = ctx.resolvedCallEdgeByCallsite.get(cs.id);
956
+ const callee = edge?.to !== undefined ? ctx.routineById.get(edge.to) : undefined;
957
+ if (callee === undefined || callee.bodyAvailable === false) {
958
+ // Opaque callee — sound c1b: var-on-var means we lose state. Entry-req
959
+ // composition (c1a) is also unknown for an opaque callee — JOIN unknown.
960
+ const out: PerParamState = {
961
+ ...state,
962
+ requiredFields:
963
+ state.requiredFields === "unknown" ? "unknown" : new Set(state.requiredFields),
964
+ };
965
+ if (state.loaded !== "yes") {
966
+ out.requiresLoadedAtEntry = joinPresence(state.requiresLoadedAtEntry, "unknown");
967
+ out.mutatesBeforeLoad = joinPresence(state.mutatesBeforeLoad, "unknown");
968
+ }
969
+ if (binding.callerSourceParameterIsVar && binding.calleeParameterIsVar) {
970
+ out.loaded = "unknown";
971
+ out.dirty = "unknown";
972
+ out.pendingNarrow = "unknown";
973
+ out.currentLoadedFields = "unknown";
974
+ }
975
+ return out;
976
+ }
977
+
978
+ const calleeSummary = lookup !== undefined ? lookup(callee.id) : callee.summary;
979
+ const calleeRole = calleeSummary?.parameterRoles.find(
980
+ (r) => r.parameterIndex === binding.parameterIndex,
981
+ );
982
+ if (calleeRole === undefined) {
983
+ // Callee body available but no role for this param — treat as opaque-ish.
984
+ const out: PerParamState = {
985
+ ...state,
986
+ requiredFields:
987
+ state.requiredFields === "unknown" ? "unknown" : new Set(state.requiredFields),
988
+ };
989
+ if (state.loaded !== "yes") {
990
+ out.requiresLoadedAtEntry = joinPresence(state.requiresLoadedAtEntry, "unknown");
991
+ out.mutatesBeforeLoad = joinPresence(state.mutatesBeforeLoad, "unknown");
992
+ }
993
+ if (binding.callerSourceParameterIsVar && binding.calleeParameterIsVar) {
994
+ out.loaded = "unknown";
995
+ out.dirty = "unknown";
996
+ out.pendingNarrow = "unknown";
997
+ out.currentLoadedFields = "unknown";
998
+ }
999
+ return out;
1000
+ }
1001
+
1002
+ const out: PerParamState = {
1003
+ ...state,
1004
+ requiredFields: state.requiredFields === "unknown" ? "unknown" : new Set(state.requiredFields),
1005
+ };
1006
+
1007
+ // c1a — entry requirements compose regardless of var-ness, but only when
1008
+ // we haven't loaded yet on this path.
1009
+ if (state.loaded !== "yes") {
1010
+ out.requiresLoadedAtEntry = joinPresence(
1011
+ state.requiresLoadedAtEntry,
1012
+ calleeRole.requiresLoadedAtEntry,
1013
+ );
1014
+ out.mutatesBeforeLoad = joinPresence(state.mutatesBeforeLoad, calleeRole.mutatesBeforeLoad);
1015
+ if (out.requiredFields !== "unknown") {
1016
+ if (calleeRole.requiredLoadedFieldsAtEntry === "unknown") {
1017
+ out.requiredFields = "unknown";
1018
+ } else {
1019
+ for (const f of calleeRole.requiredLoadedFieldsAtEntry) out.requiredFields.add(f);
1020
+ }
1021
+ }
1022
+ }
1023
+
1024
+ // c1b — exit effects compose only when BOTH the caller-side source and the
1025
+ // callee-side parameter are var.
1026
+ if (binding.callerSourceParameterIsVar && binding.calleeParameterIsVar) {
1027
+ // loadsFromDbParam / initialisesParam / copiesIntoParam all establish
1028
+ // loaded=yes on the caller's record handle when the callee runs.
1029
+ if (
1030
+ calleeRole.loadsFromDbParam === "yes" ||
1031
+ calleeRole.initialisesParam === "yes" ||
1032
+ calleeRole.copiesIntoParam === "yes"
1033
+ ) {
1034
+ out.loaded = "yes";
1035
+ // We don't know what fields the callee loaded — conservatively trust
1036
+ // the callee's exit-loaded set if it's not unknown, else mark unknown.
1037
+ out.currentLoadedFields = calleeRole.currentLoadedFieldsAtExit;
1038
+ out.pendingNarrow = "none";
1039
+ } else if (
1040
+ calleeRole.loadsFromDbParam === "unknown" ||
1041
+ calleeRole.initialisesParam === "unknown" ||
1042
+ calleeRole.copiesIntoParam === "unknown"
1043
+ ) {
1044
+ out.loaded = "unknown";
1045
+ out.currentLoadedFields = "unknown";
1046
+ out.pendingNarrow = "unknown";
1047
+ }
1048
+
1049
+ // dirty state transitions: persisted dominates dirty dominates pristine.
1050
+ // We must respect the spec's "Validate dominates if any branch dirty" rule
1051
+ // at branch joins; here at a callsite, the callee's may-facts are an OR.
1052
+ // Conservative rules:
1053
+ // - persistsCurrentRecord=yes (may persist): for non-[TryFunction] callees,
1054
+ // transition out.dirty -> "persisted" (best-case we trust the persist).
1055
+ // For [TryFunction] callees, the persist may not happen — keep as unknown.
1056
+ // v1 simplification: we conservatively join, since we cannot tell
1057
+ // [TryFunction] from here. The spec calls this out as a documented
1058
+ // unknown-clearing case; we mirror it by NOT downgrading dirty just
1059
+ // because callee may persist (sound-but-imprecise).
1060
+ if (calleeRole.persistsCurrentRecord === "yes") {
1061
+ // Spec: treat persist-contribution conservatively. Keep dirty if it's
1062
+ // already dirty (this is the dirtyAtExit soundness anchor); transition
1063
+ // pristine -> persisted; unknown -> unknown.
1064
+ if (out.dirty === "pristine") out.dirty = "persisted";
1065
+ // dirty stays dirty (don't optimistically clear)
1066
+ // persisted stays persisted
1067
+ // unknown stays unknown
1068
+ }
1069
+ if (calleeRole.validatesParam === "yes" || calleeRole.copiesIntoParam === "yes") {
1070
+ // Validate raises dirty if was pristine; otherwise keep.
1071
+ if (out.dirty === "pristine") out.dirty = "dirty";
1072
+ else if (out.dirty === "unknown") out.dirty = "dirty";
1073
+ }
1074
+ if (calleeRole.resetsFiltersOnParam === "yes") {
1075
+ out.pendingNarrow = "none";
1076
+ }
1077
+
1078
+ // If any of the dirty-affecting may-facts are unknown, conservatively
1079
+ // raise out.dirty to unknown (don't claim certainty).
1080
+ if (
1081
+ calleeRole.persistsCurrentRecord === "unknown" ||
1082
+ calleeRole.validatesParam === "unknown" ||
1083
+ calleeRole.copiesIntoParam === "unknown"
1084
+ ) {
1085
+ if (out.dirty === "pristine" || out.dirty === "persisted") out.dirty = "unknown";
1086
+ }
1087
+ }
1088
+
1089
+ return out;
1090
+ }
1091
+
1092
+ // ============================================================================
1093
+ // Field-read accumulation (sound-but-imprecise — see indexFieldAccesses doc)
1094
+ // ============================================================================
1095
+
1096
+ /**
1097
+ * Collect field accesses on `param` that fall inside this block's source range
1098
+ * but NOT inside any of its direct children's ranges. These are the "bare" FAs
1099
+ * that belong to this block level (e.g. `Message(Cust.Name)` in a routine body).
1100
+ *
1101
+ * FAs nested inside a child node (e.g. inside an `if` branch body block) are
1102
+ * attributed by THAT child's recursive walk, not here.
1103
+ */
1104
+ function collectFieldAccessesInBlock(
1105
+ block: ControlFlowNode,
1106
+ children: ControlFlowNode[],
1107
+ param: ParamCtx,
1108
+ faIndex: FaIndex,
1109
+ ): FieldAccess[] {
1110
+ const result: FieldAccess[] = [];
1111
+ const blockRange = block.sourceAnchor.range;
1112
+
1113
+ function fallsInRange(
1114
+ faLine: number,
1115
+ faCol: number,
1116
+ startLine: number,
1117
+ startCol: number,
1118
+ endLine: number,
1119
+ endCol: number,
1120
+ ): boolean {
1121
+ if (faLine < startLine || faLine > endLine) return false;
1122
+ if (faLine === startLine && faCol < startCol) return false;
1123
+ if (faLine === endLine && faCol > endCol) return false;
1124
+ return true;
1125
+ }
1126
+
1127
+ for (const list of faIndex.values()) {
1128
+ for (const fa of list) {
1129
+ if (fa.recordVariableName.toLowerCase() !== param.nameLc) continue;
1130
+ const fr = fa.sourceAnchor.range;
1131
+ // Must lie inside the block range.
1132
+ if (
1133
+ !fallsInRange(
1134
+ fr.startLine,
1135
+ fr.startColumn,
1136
+ blockRange.startLine,
1137
+ blockRange.startColumn,
1138
+ blockRange.endLine,
1139
+ blockRange.endColumn,
1140
+ )
1141
+ )
1142
+ continue;
1143
+ // MUST NOT lie inside any direct child that ITSELF recurses into FAs.
1144
+ // Leaf nodes (op/call/exit/error, "other" without children) do not
1145
+ // recurse, so an FA at e.g. `Message(Cust.Name)` (which becomes a
1146
+ // call leaf covering the FA's position) MUST be attributed here.
1147
+ // Composite children (block / if / case / case-branch / loops /
1148
+ // try / "other"-with-children) WILL recurse and attribute the FA
1149
+ // themselves; exclude those.
1150
+ let inRecursiveChild = false;
1151
+ for (const c of children) {
1152
+ if (!childRecursesIntoFAs(c)) continue;
1153
+ const cr = c.sourceAnchor.range;
1154
+ if (
1155
+ fallsInRange(
1156
+ fr.startLine,
1157
+ fr.startColumn,
1158
+ cr.startLine,
1159
+ cr.startColumn,
1160
+ cr.endLine,
1161
+ cr.endColumn,
1162
+ )
1163
+ ) {
1164
+ inRecursiveChild = true;
1165
+ break;
1166
+ }
1167
+ }
1168
+ if (inRecursiveChild) continue;
1169
+ result.push(fa);
1170
+ }
1171
+ }
1172
+ return result;
1173
+ }
1174
+
1175
+ /**
1176
+ * True iff `walkCFG` would recurse into a child of this node — implying it
1177
+ * could re-attribute FAs inside its range. False for terminal/opaque nodes
1178
+ * (op/call/exit/error and "other" without children) — FAs inside their range
1179
+ * must be attributed by the enclosing block.
1180
+ */
1181
+ function childRecursesIntoFAs(c: ControlFlowNode): boolean {
1182
+ switch (c.kind) {
1183
+ case "op":
1184
+ case "call":
1185
+ case "exit":
1186
+ case "error":
1187
+ return false;
1188
+ case "other":
1189
+ return (c.children ?? []).length > 0;
1190
+ default:
1191
+ // block / if / case / case-branch / while / repeat / for / foreach / try
1192
+ return true;
1193
+ }
1194
+ }
1195
+
1196
+ function applyFieldRead(state: PerParamState, fa: FieldAccess): PerParamState {
1197
+ if (state.loaded === "yes") return state;
1198
+ const out: PerParamState = {
1199
+ ...state,
1200
+ requiredFields: state.requiredFields === "unknown" ? "unknown" : new Set(state.requiredFields),
1201
+ };
1202
+ out.requiresLoadedAtEntry = "yes";
1203
+ if (out.requiredFields !== "unknown") out.requiredFields.add(fa.fieldName);
1204
+ return out;
1205
+ }
1206
+
1207
+ // ============================================================================
1208
+ // Exit-fact aggregation
1209
+ // ============================================================================
1210
+
1211
+ function computeDirtyAtExit(states: PerParamState[]): EffectPresence {
1212
+ // Spec §(b): if any exit state has dirty -> "yes"; else if any has unknown
1213
+ // -> "unknown"; else "no". `persisted` and `pristine` are clean.
1214
+ let anyDirty = false;
1215
+ let anyUnknown = false;
1216
+ for (const s of states) {
1217
+ if (s.dirty === "dirty") {
1218
+ anyDirty = true;
1219
+ break;
1220
+ }
1221
+ if (s.dirty === "unknown") anyUnknown = true;
1222
+ }
1223
+ if (anyDirty) return "yes";
1224
+ if (anyUnknown) return "unknown";
1225
+ return "no";
1226
+ }
1227
+
1228
+ function computeCurrentLoadedAtExit(states: PerParamState[]): LoadedFields {
1229
+ let acc: LoadedFields | undefined;
1230
+ for (const s of states) {
1231
+ acc = acc === undefined ? s.currentLoadedFields : joinLoadedFields(acc, s.currentLoadedFields);
1232
+ }
1233
+ return acc ?? "unknown";
1234
+ }
1235
+
1236
+ // ============================================================================
1237
+ // Flat fallback (no statementTree available)
1238
+ // ============================================================================
1239
+
1240
+ type FlatEvent =
1241
+ | { kind: "op"; op: RecordOperation; line: number; col: number }
1242
+ | { kind: "field"; fa: FieldAccess; line: number; col: number }
1243
+ | {
1244
+ kind: "call";
1245
+ cs: CallSite;
1246
+ binding: CallArgumentBinding;
1247
+ line: number;
1248
+ col: number;
1249
+ };
1250
+
1251
+ function walkFlat(
1252
+ routine: Routine,
1253
+ param: ParamCtx,
1254
+ ctx: SummaryContext,
1255
+ lookup: ((id: RoutineId) => RoutineSummary | undefined) | undefined,
1256
+ pre: PerParamState,
1257
+ snapshots: Map<CallsiteId, LoadedFields>,
1258
+ ): PerParamState {
1259
+ const events: FlatEvent[] = [];
1260
+ for (const op of routine.features.recordOperations) {
1261
+ if (!opAffectsParam(op, param)) continue;
1262
+ events.push({
1263
+ kind: "op",
1264
+ op,
1265
+ line: op.sourceAnchor.range.startLine,
1266
+ col: op.sourceAnchor.range.startColumn,
1267
+ });
1268
+ }
1269
+ for (const fa of routine.features.fieldAccesses) {
1270
+ if (fa.recordVariableName.toLowerCase() !== param.nameLc) continue;
1271
+ events.push({
1272
+ kind: "field",
1273
+ fa,
1274
+ line: fa.sourceAnchor.range.startLine,
1275
+ col: fa.sourceAnchor.range.startColumn,
1276
+ });
1277
+ }
1278
+ for (const cs of routine.features.callSites) {
1279
+ for (const binding of cs.argumentBindings) {
1280
+ if (binding.bindingResolution !== "resolved") continue;
1281
+ const byId =
1282
+ param.recVarId !== undefined && binding.sourceRecordVariableId === param.recVarId;
1283
+ const byName = binding.sourceVariableName === param.nameLc;
1284
+ if (!byId && !byName) continue;
1285
+ events.push({
1286
+ kind: "call",
1287
+ cs,
1288
+ binding,
1289
+ line: cs.sourceAnchor.range.startLine,
1290
+ col: cs.sourceAnchor.range.startColumn,
1291
+ });
1292
+ break;
1293
+ }
1294
+ }
1295
+ events.sort((a, b) => (a.line !== b.line ? a.line - b.line : a.col - b.col));
1296
+
1297
+ let state = pre;
1298
+ for (const e of events) {
1299
+ if (e.kind === "op") {
1300
+ state = applyOp(state, e.op, param);
1301
+ } else if (e.kind === "field") {
1302
+ if (state.loaded !== "yes") {
1303
+ const out: PerParamState = {
1304
+ ...state,
1305
+ requiredFields:
1306
+ state.requiredFields === "unknown" ? "unknown" : new Set(state.requiredFields),
1307
+ };
1308
+ out.requiresLoadedAtEntry = "yes";
1309
+ if (out.requiredFields !== "unknown") out.requiredFields.add(e.fa.fieldName);
1310
+ state = out;
1311
+ }
1312
+ } else {
1313
+ state = applyCall(state, e.cs, param, ctx, lookup, snapshots);
1314
+ }
1315
+ }
1316
+ return state;
1317
+ }