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,271 @@
1
+ import type { CombinedGraph } from "../engine/combined-graph.ts";
2
+ import { beforeAnchor } from "../engine/source-anchor.ts";
3
+ import { compareStrings } from "../engine/uncertainty-util.ts";
4
+ import { roleOf } from "../model/entities.ts";
5
+ import type { CallSite, RecordOperation } from "../model/entities.ts";
6
+ import type { DetectorStats, EvidenceStep, Finding } from "../model/finding.ts";
7
+ import type { SemanticModel } from "../model/model.ts";
8
+ import { fingerprintOf } from "../projection/finding-fingerprint.ts";
9
+ import { toConfidence } from "./confidence.ts";
10
+ import type { DetectorContext } from "./detector-context.ts";
11
+
12
+ const PERSIST_OPS: ReadonlySet<string> = new Set(["Modify", "ModifyAll", "Insert"]);
13
+ const RESET_LIKE_OPS: ReadonlySet<string> = new Set([
14
+ "Init", // reinitialises the in-memory record (drops pending Validate changes)
15
+ "Reset",
16
+ "Get", // reloads — pending Validate state is overwritten
17
+ "FindFirst",
18
+ "FindLast",
19
+ "FindSet",
20
+ "Find",
21
+ "Next",
22
+ "Copy", // overwrites with another record's state
23
+ "TransferFields",
24
+ ]);
25
+
26
+ /**
27
+ * D37 — `Validate(...)` on a record variable with no subsequent `Modify` / `ModifyAll`
28
+ * / `Insert` to persist the change before the record's state is overwritten or the
29
+ * routine exits. `Validate` runs field-validation logic and changes the IN-MEMORY
30
+ * record; without a later persist call, the field write is silently discarded.
31
+ *
32
+ * Detection (intra-routine, source-ordered):
33
+ * - for each Validate op V on record-var R;
34
+ * - walk subsequent ops on R in source order;
35
+ * - if a Modify/ModifyAll/Insert appears before any state-overwriting op
36
+ * (Init/Reset/Get/Find/FindFirst/FindLast/FindSet/Next/Copy/TransferFields),
37
+ * the Validate is persisted — skip;
38
+ * - otherwise the Validate is unpersisted — flag.
39
+ *
40
+ * Suppressions:
41
+ * - temporary records (no DB persistence concept) — skippedTempRecord
42
+ * - by-var parameter records (caller may persist after returning) — skippedParameter
43
+ * - calls passing R to a helper whose summary may persist — skippedHelperMayPersist
44
+ * Note: persistsCurrentRecord = "yes" is a MAY fact (helper persists on at least
45
+ * one path). True must-persist tracking is a documented carry-forward — see
46
+ * docs/superpowers/specs/2026-05-17-al-sem-record-flow-framework-design.md.
47
+ * - calls passing R to an opaque or unresolved helper — skippedHelperPersistsUnknown
48
+ *
49
+ * The forwardedAfter regex from the original implementation is replaced with
50
+ * structural CallSite.argumentBindings (Phase 1 of the record-flow framework).
51
+ *
52
+ * Severity: `medium`. Real bug but the conservative suppressions keep it actionable.
53
+ * Confidence: `possible` (we don't have interprocedural persist tracking).
54
+ *
55
+ * Known precision gaps:
56
+ * - The `helperMayPersist` suppression is conservative — persistsCurrentRecord = "yes"
57
+ * is a MAY fact; if the helper only persists on some paths, a Validate in the caller
58
+ * that's followed by a non-persisting path through the helper is still flagged
59
+ * incorrectly. Must-persist tracking is the documented carry-forward for this.
60
+ * - We don't track `TransferFields` direction (it's a write to the target record).
61
+ * We treat it as state-overwriting on the receiver, which suppresses correctly when
62
+ * R is the SOURCE; if R is the TARGET, we conservatively still treat it as overwriting
63
+ * its prior in-memory state, which is the safe call.
64
+ */
65
+ export function detectD37(
66
+ model: SemanticModel,
67
+ graph: CombinedGraph,
68
+ ctx: DetectorContext,
69
+ ): { findings: Finding[]; stats: DetectorStats } {
70
+ const findings: Finding[] = [];
71
+ let candidatesConsidered = 0;
72
+ let skippedPersisted = 0;
73
+ let skippedHelperMayPersist = 0;
74
+ let skippedHelperPersistsUnknown = 0;
75
+ let skippedTempRecord = 0;
76
+ let skippedParameter = 0;
77
+
78
+ for (const routine of model.routines) {
79
+ if (roleOf(routine) !== "primary") continue;
80
+ if (!routine.bodyAvailable) continue;
81
+ if (routine.parseIncomplete) continue;
82
+
83
+ const paramRecordNames = new Set(
84
+ routine.features.recordVariables
85
+ .filter((rv) => rv.isParameter)
86
+ .map((rv) => rv.name.toLowerCase()),
87
+ );
88
+
89
+ for (const op of routine.features.recordOperations) {
90
+ if (op.op !== "Validate") continue;
91
+ candidatesConsidered++;
92
+ const varKey = op.recordVariableName.toLowerCase();
93
+ if (op.tempState.kind === "known" && op.tempState.value === true) {
94
+ skippedTempRecord++;
95
+ continue;
96
+ }
97
+ if (paramRecordNames.has(varKey)) {
98
+ skippedParameter++;
99
+ continue;
100
+ }
101
+
102
+ // Walk subsequent ops in source order, looking for persistence vs reset.
103
+ const verdict = laterPersisted(routine.features.recordOperations, varKey, op);
104
+ if (verdict === "persisted") {
105
+ skippedPersisted++;
106
+ continue;
107
+ }
108
+
109
+ // Phase 3: precise verdict using callee.persistsCurrentRecord summary.
110
+ const sourceVariableNameLc = op.recordVariableName.toLowerCase();
111
+ const sourceRecordVariableId = op.recordVariableId;
112
+ const helperVerdict = postValidateHelperVerdict(
113
+ routine,
114
+ sourceRecordVariableId,
115
+ sourceVariableNameLc,
116
+ op,
117
+ graph,
118
+ ctx,
119
+ );
120
+ if (helperVerdict === "suppress-may-persist") {
121
+ skippedHelperMayPersist++;
122
+ continue;
123
+ }
124
+ if (helperVerdict === "suppress-unknown") {
125
+ skippedHelperPersistsUnknown++;
126
+ continue;
127
+ }
128
+ // "do-not-suppress" — helper provably doesn't persist; fall through to emit.
129
+
130
+ emit(routine, op, findings, model);
131
+ }
132
+ }
133
+
134
+ const sorted = findings.sort((a, b) => compareStrings(a.id, b.id));
135
+ return {
136
+ findings: sorted,
137
+ stats: {
138
+ detector: "d37-validate-without-persist",
139
+ candidatesConsidered,
140
+ findingsEmitted: sorted.length,
141
+ skipped: {
142
+ ...(skippedPersisted > 0 ? { persisted: skippedPersisted } : {}),
143
+ ...(skippedHelperMayPersist > 0 ? { helperMayPersist: skippedHelperMayPersist } : {}),
144
+ ...(skippedHelperPersistsUnknown > 0
145
+ ? { helperPersistsUnknown: skippedHelperPersistsUnknown }
146
+ : {}),
147
+ ...(skippedTempRecord > 0 ? { tempRecord: skippedTempRecord } : {}),
148
+ ...(skippedParameter > 0 ? { parameter: skippedParameter } : {}),
149
+ },
150
+ },
151
+ };
152
+ }
153
+
154
+ function laterPersisted(
155
+ ops: RecordOperation[],
156
+ varKey: string,
157
+ validateOp: RecordOperation,
158
+ ): "persisted" | "unpersisted" {
159
+ // Iterate ops in source order; the first persist OR reset wins.
160
+ const sorted = ops
161
+ .filter((o) => o.recordVariableName.toLowerCase() === varKey)
162
+ .filter((o) => beforeAnchor(validateOp.sourceAnchor, o.sourceAnchor))
163
+ .sort((a, b) => (beforeAnchor(a.sourceAnchor, b.sourceAnchor) ? -1 : 1));
164
+ for (const o of sorted) {
165
+ if (PERSIST_OPS.has(o.op)) return "persisted";
166
+ if (RESET_LIKE_OPS.has(o.op)) return "unpersisted";
167
+ }
168
+ return "unpersisted";
169
+ }
170
+
171
+ /**
172
+ * After the Validate op, walk callsites in source order. For each callsite that
173
+ * forwards the same record variable to a callee, look at the callee's summary:
174
+ * - persistsCurrentRecord === "yes" → may persist (may-fact); suppress with
175
+ * helperMayPersist counter. Must-persist tracking is a documented
176
+ * carry-forward — would close this conservative suppression.
177
+ * - persistsCurrentRecord === "no" → helper provably doesn't persist; do not
178
+ * suppress (the Validate's unpersistence is the real bug).
179
+ * - persistsCurrentRecord === "unknown" → conservative suppress with
180
+ * helperPersistsUnknown counter.
181
+ *
182
+ * Returns the verdict for the entire post-Validate sequence: if any post-Validate
183
+ * helper might persist (yes or unknown), the Validate is suppressed; only when
184
+ * EVERY forwarding helper provably doesn't persist do we fall through to emit.
185
+ */
186
+ function postValidateHelperVerdict(
187
+ routine: { id: string; features: { callSites: CallSite[] } },
188
+ sourceRecordVariableId: string | undefined,
189
+ sourceVariableNameLc: string,
190
+ validateOp: { sourceAnchor: { range: { startLine: number; startColumn: number } } },
191
+ graph: CombinedGraph,
192
+ ctx: DetectorContext,
193
+ ): "suppress-may-persist" | "suppress-unknown" | "do-not-suppress" {
194
+ for (const cs of routine.features.callSites) {
195
+ if (!beforeAnchor(validateOp.sourceAnchor, cs.sourceAnchor)) continue;
196
+ for (const binding of cs.argumentBindings) {
197
+ // Literals / non-record expressions can't forward a record — skip silently.
198
+ if (binding.bindingResolution === "non-record-arg") continue;
199
+ // Match by stable id when both sides have one; fall back to name (lowercased).
200
+ const matchesById =
201
+ binding.sourceRecordVariableId !== undefined &&
202
+ sourceRecordVariableId !== undefined &&
203
+ binding.sourceRecordVariableId === sourceRecordVariableId;
204
+ const matchesByName = binding.sourceVariableName === sourceVariableNameLc;
205
+ if (!matchesById && !matchesByName) continue;
206
+ // Unresolved-callee / ambiguous: we cannot trust calleeParameterIsVar (it's left
207
+ // false as a placeholder by the indexer) nor look up a callee summary. The old
208
+ // regex-based forwardedAfter suppressed every callsite regardless of resolution;
209
+ // preserve that conservatism for unresolved bindings that DO target our record.
210
+ if (binding.bindingResolution !== "resolved") return "suppress-unknown";
211
+ // By-value callees can't persist the caller's record (the in-memory copy stays in callee).
212
+ if (!binding.calleeParameterIsVar) continue;
213
+ const edge = (graph.edgesByFrom.get(routine.id) ?? []).find((e) => e.callsiteId === cs.id);
214
+ if (edge?.to === undefined) return "suppress-unknown";
215
+ const callee = ctx.routineById.get(edge.to);
216
+ const calleeRole = callee?.summary?.parameterRoles.find(
217
+ (r) => r.parameterIndex === binding.parameterIndex,
218
+ );
219
+ if (calleeRole === undefined || callee?.bodyAvailable === false) {
220
+ return "suppress-unknown";
221
+ }
222
+ switch (calleeRole.persistsCurrentRecord) {
223
+ case "yes":
224
+ return "suppress-may-persist";
225
+ case "unknown":
226
+ return "suppress-unknown";
227
+ case "no" /* helper provably doesn't persist — keep walking */:
228
+ break;
229
+ }
230
+ }
231
+ }
232
+ return "do-not-suppress";
233
+ }
234
+
235
+ function emit(
236
+ routine: { id: string; objectId: string; name: string },
237
+ op: RecordOperation,
238
+ findings: Finding[],
239
+ model: SemanticModel,
240
+ ): void {
241
+ const path: EvidenceStep[] = [
242
+ {
243
+ routineId: routine.id,
244
+ operationId: op.id,
245
+ sourceAnchor: op.sourceAnchor,
246
+ note: `Validate on ${op.recordVariableName} with no later Modify/Insert before the record is reloaded or the routine exits`,
247
+ },
248
+ ];
249
+ const finding: Finding = {
250
+ id: `d37/${routine.id}/${op.id}`,
251
+ rootCauseKey: `d37/${routine.id}/${op.id}`,
252
+ detector: "d37-validate-without-persist",
253
+ title: "Validate changes are not persisted",
254
+ rootCause: `${routine.name} calls Validate on ${op.recordVariableName} but never persists the change with Modify / ModifyAll / Insert before the record is reloaded or the routine returns — the field write is discarded.`,
255
+ severity: "medium",
256
+ confidence: toConfidence([], "possible"),
257
+ primaryLocation: op.sourceAnchor,
258
+ evidencePath: path,
259
+ affectedObjects: [routine.objectId],
260
+ affectedTables: op.tableId !== undefined ? [op.tableId] : [],
261
+ fixOptions: [
262
+ {
263
+ description: `Add ${op.recordVariableName}.Modify() after the Validate (or ${op.recordVariableName}.Insert() if the record is new). If the Validate is intentional (only running validation logic, not persisting), document the intent.`,
264
+ safety: "high",
265
+ },
266
+ ],
267
+ provenance: [{ source: "tree-sitter" }],
268
+ };
269
+ finding.fingerprint = fingerprintOf(finding, model);
270
+ findings.push(finding);
271
+ }
@@ -0,0 +1,140 @@
1
+ import { parseRoutineAttributes } from "../engine/attribute-parser.ts";
2
+ import type { CombinedGraph } from "../engine/combined-graph.ts";
3
+ import { compareStrings } from "../engine/uncertainty-util.ts";
4
+ import { roleOf } from "../model/entities.ts";
5
+ import type { DetectorStats, EvidenceStep, Finding } from "../model/finding.ts";
6
+ import type { SemanticModel } from "../model/model.ts";
7
+ import { fingerprintOf } from "../projection/finding-fingerprint.ts";
8
+ import { toConfidence } from "./confidence.ts";
9
+ import type { DetectorContext } from "./detector-context.ts";
10
+
11
+ /**
12
+ * D38 — primary-app `[EventSubscriber(...)]` bound to a publisher whose publisher
13
+ * routine carries `[Obsolete(...)]`.
14
+ *
15
+ * Why al-sem: needs the **event graph** (publisher routine ↔ subscriber routine edges
16
+ * resolved across apps, including into dep `.app` packages) and per-routine attribute
17
+ * parsing. A per-file scanner can read the subscriber's attribute string but cannot
18
+ * resolve the publisher's routine — that requires the cross-app event resolver.
19
+ *
20
+ * Closest neighbour: D16 (call to obsolete routine) — different surface. D16 flags a
21
+ * *call* edge; D38 flags an *event subscription* edge. The subscriber is never called
22
+ * by primary code (the publisher dispatches it), so D16 misses it.
23
+ *
24
+ * Severity by publisher's obsolete state:
25
+ * - Pending → info (deprecated, plan migration; subscriber still fires today)
26
+ * - Removed → high (publisher is gone or about to be — subscriber will never fire)
27
+ *
28
+ * Skipped:
29
+ * - non-resolved edges (resolution !== "resolved"): can't trust the publisher
30
+ * identity, so can't reason about its attributes.
31
+ * - publisher symbol with no publisherRoutineId: trigger-style or synthetic events
32
+ * don't have a routine carrying [Obsolete].
33
+ * - subscriber not in primary app: user can't edit dep subscribers.
34
+ */
35
+ export function detectD38(
36
+ model: SemanticModel,
37
+ _graph: CombinedGraph,
38
+ ctx: DetectorContext,
39
+ ): { findings: Finding[]; stats: DetectorStats } {
40
+ const findings: Finding[] = [];
41
+ const { routineById } = ctx;
42
+
43
+ const eventById = new Map(model.eventGraph.events.map((e) => [e.id, e]));
44
+
45
+ let candidatesConsidered = 0;
46
+ let skippedUnresolved = 0;
47
+ let skippedNoPublisherRoutine = 0;
48
+ let skippedPublisherMissing = 0;
49
+ let skippedPublisherNotObsolete = 0;
50
+
51
+ for (const edge of model.eventGraph.edges) {
52
+ if (edge.resolution !== "resolved") {
53
+ skippedUnresolved++;
54
+ continue;
55
+ }
56
+ const subscriber = routineById.get(edge.subscriberRoutineId);
57
+ if (subscriber === undefined) continue;
58
+ if (roleOf(subscriber) !== "primary") continue;
59
+ if (subscriber.parseIncomplete) continue;
60
+
61
+ const event = eventById.get(edge.eventId);
62
+ if (event === undefined) continue;
63
+ if (event.publisherRoutineId === undefined) {
64
+ skippedNoPublisherRoutine++;
65
+ continue;
66
+ }
67
+ const publisher = routineById.get(event.publisherRoutineId);
68
+ if (publisher === undefined) {
69
+ skippedPublisherMissing++;
70
+ continue;
71
+ }
72
+ candidatesConsidered++;
73
+
74
+ const attrs = parseRoutineAttributes(publisher);
75
+ if (attrs.obsoleteState === undefined) {
76
+ skippedPublisherNotObsolete++;
77
+ continue;
78
+ }
79
+
80
+ const sev: Finding["severity"] = attrs.obsoleteState === "Removed" ? "high" : "info";
81
+ const path: EvidenceStep[] = [
82
+ {
83
+ routineId: subscriber.id,
84
+ sourceAnchor: subscriber.sourceAnchor,
85
+ note: `[EventSubscriber] subscribes to '${event.eventName}'`,
86
+ },
87
+ {
88
+ routineId: publisher.id,
89
+ sourceAnchor: publisher.sourceAnchor,
90
+ note: `publisher ${publisher.name} is [Obsolete(${attrs.obsoleteState})]${attrs.obsoleteReason ? ` — ${attrs.obsoleteReason}` : ""}`,
91
+ },
92
+ ];
93
+
94
+ const finding: Finding = {
95
+ id: `d38/${subscriber.id}/${event.id}`,
96
+ rootCauseKey: `d38/${subscriber.id}/${event.id}`,
97
+ detector: "d38-subscriber-to-obsolete-event",
98
+ title: `Subscriber bound to obsolete event (${attrs.obsoleteState})`,
99
+ rootCause:
100
+ attrs.obsoleteState === "Removed"
101
+ ? `${subscriber.name} subscribes to '${event.eventName}', whose publisher ${publisher.name} is [Obsolete(Removed)] — the subscriber will stop firing once the publisher is removed.`
102
+ : `${subscriber.name} subscribes to '${event.eventName}', whose publisher ${publisher.name} is [Obsolete(Pending)] — plan a migration to the successor before the publisher is removed.`,
103
+ severity: sev,
104
+ confidence: toConfidence([], "confirmed"),
105
+ primaryLocation: subscriber.sourceAnchor,
106
+ evidencePath: path,
107
+ affectedObjects: [subscriber.objectId, publisher.objectId].sort(),
108
+ affectedTables: [],
109
+ fixOptions: [
110
+ {
111
+ description:
112
+ attrs.obsoleteReason ??
113
+ "Migrate the subscriber to the documented successor event; if none exists, remove the subscription once the publisher is gone.",
114
+ safety: "high",
115
+ },
116
+ ],
117
+ provenance: [{ source: "tree-sitter" }],
118
+ };
119
+ finding.fingerprint = fingerprintOf(finding, model);
120
+ findings.push(finding);
121
+ }
122
+
123
+ const sorted = findings.sort((a, b) => compareStrings(a.id, b.id));
124
+ return {
125
+ findings: sorted,
126
+ stats: {
127
+ detector: "d38-subscriber-to-obsolete-event",
128
+ candidatesConsidered,
129
+ findingsEmitted: sorted.length,
130
+ skipped: {
131
+ ...(skippedUnresolved > 0 ? { unresolved: skippedUnresolved } : {}),
132
+ ...(skippedNoPublisherRoutine > 0 ? { noPublisherRoutine: skippedNoPublisherRoutine } : {}),
133
+ ...(skippedPublisherMissing > 0 ? { publisherMissing: skippedPublisherMissing } : {}),
134
+ ...(skippedPublisherNotObsolete > 0
135
+ ? { publisherNotObsolete: skippedPublisherNotObsolete }
136
+ : {}),
137
+ },
138
+ },
139
+ };
140
+ }
@@ -0,0 +1,165 @@
1
+ import type { CombinedGraph } from "../engine/combined-graph.ts";
2
+ import { beforeAnchor } from "../engine/source-anchor.ts";
3
+ import { compareStrings } from "../engine/uncertainty-util.ts";
4
+ import { roleOf } from "../model/entities.ts";
5
+ import type { DetectorStats, EvidenceStep, Finding } from "../model/finding.ts";
6
+ import type { SemanticModel } from "../model/model.ts";
7
+ import { fingerprintOf } from "../projection/finding-fingerprint.ts";
8
+ import { toConfidence } from "./confidence.ts";
9
+ import type { DetectorContext } from "./detector-context.ts";
10
+
11
+ const PERSIST_OPS: ReadonlySet<string> = new Set(["Modify", "Insert", "Rename"]);
12
+
13
+ /**
14
+ * D39 — record left dirty across helper chain.
15
+ *
16
+ * For each var-param P of every primary callee where the path-aware walker PROVED
17
+ * `dirtyAtExit[P] === "yes"` (at least one exit path leaves the record dirty after a
18
+ * Validate without a subsequent persist), walk the reverse call graph. Every primary
19
+ * caller that:
20
+ * (a) forwards a record to P (binding resolves to a local/parameter/implicit-rec source),
21
+ * (b) passes by-var (calleeParameterIsVar === true),
22
+ * (c) does NOT persist the same source variable after the callsite, and
23
+ * (d) does NOT pass it from a by-value parameter (parameter source that is not var)
24
+ * …is flagged: the Validate's field write is silently discarded across the chain.
25
+ *
26
+ * `dirtyAtExit === "unknown"` cases are counted in `unknownDirtyCallee` and NOT emitted;
27
+ * this protects D39 from the alert-fatigue regression D40 hit when the walker bailed to
28
+ * unknown on complex control flow.
29
+ *
30
+ * Skipped:
31
+ * - `callerPersists` — caller persists the source variable after the callsite
32
+ * - `unknownDirtyCallee` — callee's dirtyAtExit === "unknown" (counted but not emitted)
33
+ * - non-persisting source kinds (expression, global, unresolved — can't persist these)
34
+ * - by-value parameter source (callerSourceParameterIsVar === false) — bug is in the callee
35
+ *
36
+ * Severity: `medium`. Confidence: `likely`.
37
+ */
38
+ export function detectD39(
39
+ model: SemanticModel,
40
+ _graph: CombinedGraph,
41
+ ctx: DetectorContext,
42
+ ): { findings: Finding[]; stats: DetectorStats } {
43
+ const findings: Finding[] = [];
44
+ const { routineById, reverseCallGraph } = ctx;
45
+
46
+ let candidatesConsidered = 0;
47
+ let skippedCallerPersists = 0;
48
+ let unknownDirtyCallee = 0;
49
+
50
+ for (const callee of model.routines) {
51
+ if (!callee.bodyAvailable) continue;
52
+ for (const role of callee.summary?.parameterRoles ?? []) {
53
+ if (role.dirtyAtExit === "unknown") {
54
+ unknownDirtyCallee++;
55
+ continue;
56
+ }
57
+ if (role.dirtyAtExit !== "yes") continue;
58
+
59
+ // Find all resolved callers that forward a record to this var-parameter.
60
+ const callerEdges = reverseCallGraph.get(callee.id) ?? [];
61
+ for (const edge of callerEdges) {
62
+ if (edge.callsiteId === undefined) continue;
63
+ const caller = routineById.get(edge.from);
64
+ if (caller === undefined) continue;
65
+ if (roleOf(caller) !== "primary") continue;
66
+ if (!caller.bodyAvailable) continue;
67
+
68
+ const cs = caller.features.callSites.find((c) => c.id === edge.callsiteId);
69
+ if (cs === undefined) continue;
70
+
71
+ const binding = cs.argumentBindings.find(
72
+ (b) =>
73
+ b.parameterIndex === role.parameterIndex &&
74
+ b.bindingResolution === "resolved" &&
75
+ b.calleeParameterIsVar,
76
+ );
77
+ if (binding === undefined) continue;
78
+
79
+ // Only source kinds the caller can actually persist.
80
+ if (
81
+ binding.sourceKind !== "parameter" &&
82
+ binding.sourceKind !== "local" &&
83
+ binding.sourceKind !== "implicit-rec"
84
+ ) {
85
+ continue;
86
+ }
87
+
88
+ // For parameter sources, require the caller-side parameter to be var (otherwise
89
+ // the caller's copy is local — the bug is real but is D37's domain inside the callee).
90
+ if (binding.sourceKind === "parameter" && !binding.callerSourceParameterIsVar) {
91
+ continue;
92
+ }
93
+
94
+ const sourceNameLc = binding.sourceVariableName;
95
+ if (sourceNameLc === undefined) continue;
96
+
97
+ candidatesConsidered++;
98
+
99
+ // Did caller persist the source variable after the callsite?
100
+ const persistedAfter = caller.features.recordOperations.some(
101
+ (op) =>
102
+ PERSIST_OPS.has(op.op) &&
103
+ op.recordVariableName.toLowerCase() === sourceNameLc &&
104
+ beforeAnchor(cs.sourceAnchor, op.sourceAnchor),
105
+ );
106
+ if (persistedAfter) {
107
+ skippedCallerPersists++;
108
+ continue;
109
+ }
110
+
111
+ // Emit.
112
+ const path: EvidenceStep[] = [
113
+ {
114
+ routineId: caller.id,
115
+ callsiteId: cs.id,
116
+ sourceAnchor: binding.argumentAnchor,
117
+ note: `forwards ${binding.sourceVariableName} to ${callee.name}; never persists after the call`,
118
+ },
119
+ {
120
+ routineId: callee.id,
121
+ sourceAnchor: callee.sourceAnchor,
122
+ note: `${callee.name} validates and exits dirty on at least one path`,
123
+ },
124
+ ];
125
+
126
+ const finding: Finding = {
127
+ id: `d39/${caller.id}/${cs.id}/${role.parameterIndex}`,
128
+ rootCauseKey: `d39/${caller.id}/${cs.id}/${role.parameterIndex}`,
129
+ detector: "d39-record-left-dirty-across-chain",
130
+ title: "Record left dirty across helper chain",
131
+ rootCause: `${caller.name} forwards ${binding.sourceVariableName} to ${callee.name}, which leaves the record in a Validate-dirty state on at least one exit path. ${caller.name} never persists after the call — the field write is silently discarded.`,
132
+ severity: "medium",
133
+ confidence: toConfidence([], "likely"),
134
+ primaryLocation: binding.argumentAnchor,
135
+ evidencePath: path,
136
+ affectedObjects: [caller.objectId, callee.objectId].sort(),
137
+ affectedTables: [],
138
+ fixOptions: [
139
+ {
140
+ description: `Add ${binding.sourceVariableName}.Modify() in ${caller.name} after the call to ${callee.name}, or have ${callee.name} persist before returning.`,
141
+ safety: "high",
142
+ },
143
+ ],
144
+ provenance: [{ source: "tree-sitter" }],
145
+ };
146
+ finding.fingerprint = fingerprintOf(finding, model);
147
+ findings.push(finding);
148
+ }
149
+ }
150
+ }
151
+
152
+ const sorted = findings.sort((a, b) => compareStrings(a.id, b.id));
153
+ return {
154
+ findings: sorted,
155
+ stats: {
156
+ detector: "d39-record-left-dirty-across-chain",
157
+ candidatesConsidered,
158
+ findingsEmitted: sorted.length,
159
+ skipped: {
160
+ ...(skippedCallerPersists > 0 ? { callerPersists: skippedCallerPersists } : {}),
161
+ ...(unknownDirtyCallee > 0 ? { unknownDirtyCallee } : {}),
162
+ },
163
+ },
164
+ };
165
+ }