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,243 @@
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 { type RecordOperation, roleOf } from "../model/entities.ts";
5
+ import type { DetectorStats, EvidenceStep, Finding } from "../model/finding.ts";
6
+ import type { FieldId } from "../model/ids.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 RELOAD_OPS: ReadonlySet<string> = new Set(["Reset"]);
13
+
14
+ /**
15
+ * Compute the source-ordered narrowed-load fingerprint for a record variable AT a
16
+ * given anchor (typically a callsite's `argumentAnchor`). Returns:
17
+ * - `"full"` — no prior SetLoadFields/AddLoadFields op exists on this var before
18
+ * the anchor (the variable carries the full record);
19
+ * - `string[]` — the cumulative narrow that the most recent SetLoadFields plus any
20
+ * subsequent AddLoadFields imposed;
21
+ * - `"unknown"` — a `Reset` between the last narrow and the anchor (Reset wipes
22
+ * the pending-narrow per spec) leaves the load shape unknowable
23
+ * here without walker support; treat as unknown to stay sound.
24
+ *
25
+ * Source-ordered, intra-routine. Mirrors D36's approach (the post-load placement
26
+ * detector) so detectors operating on the same flat features stay consistent.
27
+ *
28
+ * NOTE: this is a straight-line approximation. A `SetLoadFields` placed inside one
29
+ * branch of an `if`/`case` is treated as if it always applies — same control-flow-
30
+ * blindness carry-forward as D39/D41/D36. The Phase 6 walker handles this correctly
31
+ * for parameters; the per-callsite snapshot is not yet propagated to RoutineSummary
32
+ * (see the detector docstring's carry-forward note).
33
+ */
34
+ function computeNarrowAtCallsite(
35
+ ops: readonly RecordOperation[],
36
+ varNameLc: string,
37
+ callsiteAnchor: { range: { startLine: number; startColumn: number } },
38
+ ): FieldId[] | "full" | "unknown" {
39
+ let pending: FieldId[] | "none" | "unknown" = "none";
40
+ for (const op of ops) {
41
+ if (op.recordVariableName.toLowerCase() !== varNameLc) continue;
42
+ if (!beforeAnchor(op.sourceAnchor, callsiteAnchor)) continue;
43
+ if (op.op === "SetLoadFields") {
44
+ const fields = [...new Set(op.fieldArguments ?? [])].sort() as FieldId[];
45
+ pending = fields;
46
+ } else if (op.op === "AddLoadFields") {
47
+ const additions = op.fieldArguments ?? [];
48
+ if (pending === "none") {
49
+ pending = [...new Set(additions)].sort() as FieldId[];
50
+ } else if (pending === "unknown") {
51
+ // stay unknown
52
+ } else {
53
+ pending = [...new Set([...pending, ...additions])].sort() as FieldId[];
54
+ }
55
+ } else if (RELOAD_OPS.has(op.op)) {
56
+ // Reset wipes pendingNarrow (spec §(b1) / control-flow-walker.ts:904-907).
57
+ pending = "unknown";
58
+ }
59
+ }
60
+ if (pending === "none") return "full";
61
+ if (pending === "unknown") return "unknown";
62
+ return pending;
63
+ }
64
+
65
+ /**
66
+ * D42 — cross-call wrong SetLoadFields.
67
+ *
68
+ * At each resolved call edge that forwards a record to a callee var-parameter:
69
+ * - caller-side narrow LF (computed as the source-ordered cumulative
70
+ * SetLoadFields/AddLoadFields on the forwarded variable at the callsite, or
71
+ * falling back to caller-parameter `currentLoadedFieldsAtExit` from the walker
72
+ * when the source is the routine's own parameter) is a concrete list;
73
+ * - callee `parameterRoles[Q].requiredLoadedFieldsAtEntry` is a non-empty
74
+ * concrete list RF;
75
+ * - RF \ LF is non-empty;
76
+ * then the runtime will issue an extra SQL round-trip to fetch the missing fields,
77
+ * silently defeating the partial-load optimisation the caller was paying the
78
+ * complexity for.
79
+ *
80
+ * Severity: low (perf hygiene — measurable but small cost). Confidence: likely
81
+ * (both sides concrete; opaque callees and "unknown" walker outputs are skipped).
82
+ *
83
+ * Anchor: caller's argumentAnchor. rootCauseKey: routine + callsite + parameter index
84
+ * (stable across edits that move lines, unstable only if the routine or call boundary
85
+ * itself changes).
86
+ *
87
+ * Skip counters:
88
+ * - `callerFull` — caller-side LF is the "full" sentinel (no narrow at all);
89
+ * - `calleeRequiresNone` — callee's RF is empty or `"unknown"`;
90
+ * - `calleeUnknown` — callee has no parameterRole for this binding index.
91
+ *
92
+ * Carry-forward (precision TODO): the Phase 6 walker computes a per-callsite
93
+ * snapshot `currentLoadedFieldsAtCallsite` on `PathAwareFacts` (control-flow-
94
+ * walker.ts), but `summary-runner.ts` does NOT propagate it to
95
+ * `RoutineSummary.parameterRoles`. The detector therefore uses (a) a local
96
+ * source-ordered scan over `recordOperations` (this routine's load-narrow history
97
+ * on the forwarded variable) for local-record sources, and (b) the conservative
98
+ * `currentLoadedFieldsAtExit` fallback when the source IS the routine's own
99
+ * parameter. The latter is sound for narrows not subsequently widened (the
100
+ * dominant pattern) but can miss a narrow-then-widen-then-forward sequence (FN).
101
+ * Tightening this is a Phase 6 follow-on: surface the per-callsite snapshot on
102
+ * RecordRoleSummary and prefer it when present.
103
+ *
104
+ * Carry-forward (case-folded field-name comparison): the `RF.filter((f) =>
105
+ * !loaded.includes(f))` check below compares FieldId strings case-sensitively.
106
+ * AL field identifiers are case-insensitive at the language level, so a
107
+ * SetLoadFields list spelled `"no."` and a callee `requiredLoadedFieldsAtEntry`
108
+ * entry of `"No."` (or vice versa) would currently look like a missing field
109
+ * and emit a false positive. Field names are sourced from table metadata on
110
+ * both sides today so the mismatch is rare in practice, but a case-folded
111
+ * compare would harden the detector against author-style FPs. Tracked in
112
+ * STATUS.md Phase 4 carry-forwards; intentionally NOT implemented here so the
113
+ * change ships in its own scoped commit with targeted fixtures.
114
+ *
115
+ * Why this is al-sem-only:
116
+ * - Requires resolved call graph + per-callsite argument binding to know who
117
+ * forwards what.
118
+ * - Requires the callee's `requiredLoadedFieldsAtEntry` — a path-aware walker
119
+ * fact composed bottom-up over the SCC condensation. A per-file analyzer has
120
+ * no view of either side of the boundary.
121
+ * - Compares concrete FieldId lists derived from `op.fieldArguments[]` resolved
122
+ * against table metadata, not bare identifier text.
123
+ */
124
+ export function detectD42(
125
+ model: SemanticModel,
126
+ _graph: CombinedGraph,
127
+ ctx: DetectorContext,
128
+ ): { findings: Finding[]; stats: DetectorStats } {
129
+ const findings: Finding[] = [];
130
+ const { routineById, resolvedCallEdgeByCallsite } = ctx;
131
+
132
+ let candidatesConsidered = 0;
133
+ let skippedCallerFull = 0;
134
+ let skippedCalleeRequiresNone = 0;
135
+ let skippedCalleeUnknown = 0;
136
+
137
+ for (const routine of model.routines) {
138
+ if (roleOf(routine) !== "primary") continue;
139
+ if (!routine.bodyAvailable) continue;
140
+ if (routine.parseIncomplete) continue;
141
+ const ownRole = routine.summary?.parameterRoles ?? [];
142
+ const ops = routine.features.recordOperations;
143
+
144
+ for (const cs of routine.features.callSites) {
145
+ const edge = resolvedCallEdgeByCallsite.get(cs.id);
146
+ if (edge?.to === undefined) continue;
147
+ const callee = routineById.get(edge.to);
148
+ if (callee === undefined) continue;
149
+
150
+ for (const binding of cs.argumentBindings) {
151
+ if (binding.bindingResolution !== "resolved") continue;
152
+ const calleeRole = callee.summary?.parameterRoles.find(
153
+ (r) => r.parameterIndex === binding.parameterIndex,
154
+ );
155
+ if (calleeRole === undefined) {
156
+ skippedCalleeUnknown++;
157
+ continue;
158
+ }
159
+ const RF = calleeRole.requiredLoadedFieldsAtEntry;
160
+ if (RF === "unknown" || RF.length === 0) {
161
+ skippedCalleeRequiresNone++;
162
+ continue;
163
+ }
164
+
165
+ // Caller-side LF — two-tier resolution. Prefer the intra-routine
166
+ // source-ordered scan when the source is a local record-var (the
167
+ // walker doesn't track locals). Fall back to the caller-parameter
168
+ // walker fact `currentLoadedFieldsAtExit` when the source is the
169
+ // routine's own parameter.
170
+ let LF: FieldId[] | "full" | "unknown" = "unknown";
171
+ const sourceNameLc = binding.sourceVariableName;
172
+ if (sourceNameLc !== undefined) {
173
+ LF = computeNarrowAtCallsite(ops, sourceNameLc, cs.sourceAnchor);
174
+ }
175
+ if (LF === "unknown" && binding.sourceParameterIndex !== undefined) {
176
+ const callerRole = ownRole.find((r) => r.parameterIndex === binding.sourceParameterIndex);
177
+ LF = callerRole?.currentLoadedFieldsAtExit ?? "unknown";
178
+ }
179
+ if (LF === "unknown") continue;
180
+ if (LF === "full") {
181
+ skippedCallerFull++;
182
+ continue;
183
+ }
184
+ const loaded = LF; // narrow to FieldId[]
185
+ const missing = RF.filter((f) => !loaded.includes(f));
186
+ if (missing.length === 0) continue;
187
+ candidatesConsidered++;
188
+
189
+ const path: EvidenceStep[] = [
190
+ {
191
+ routineId: routine.id,
192
+ callsiteId: cs.id,
193
+ sourceAnchor: binding.argumentAnchor,
194
+ note: `forwards ${binding.sourceVariableName ?? "record"} (narrowed to ${loaded.join(", ")}) to ${callee.name}`,
195
+ },
196
+ {
197
+ routineId: callee.id,
198
+ sourceAnchor: callee.sourceAnchor,
199
+ note: `${callee.name} requires ${missing.join(", ")} loaded; the runtime will issue an extra SQL round-trip`,
200
+ },
201
+ ];
202
+
203
+ const finding: Finding = {
204
+ id: `d42/${routine.id}/${cs.id}/${binding.parameterIndex}`,
205
+ rootCauseKey: `d42/${routine.id}/${cs.id}/${binding.parameterIndex}`,
206
+ detector: "d42-cross-call-wrong-setloadfields",
207
+ title: "Forwarded record's narrowed load misses a field the callee reads",
208
+ rootCause: `${routine.name} narrowed ${binding.sourceVariableName ?? "the record"}'s load to ${loaded.join(", ")} but forwards it to ${callee.name}, which reads ${missing.join(", ")} — defeats the partial-load optimisation.`,
209
+ severity: "low",
210
+ confidence: toConfidence([], "likely"),
211
+ primaryLocation: binding.argumentAnchor,
212
+ evidencePath: path,
213
+ affectedObjects: [routine.objectId, callee.objectId].sort(),
214
+ affectedTables: [],
215
+ fixOptions: [
216
+ {
217
+ description: `Add ${missing.join(", ")} to the SetLoadFields/AddLoadFields call on ${binding.sourceVariableName ?? "the record"} before forwarding to ${callee.name}.`,
218
+ safety: "high",
219
+ },
220
+ ],
221
+ provenance: [{ source: "tree-sitter" }],
222
+ };
223
+ finding.fingerprint = fingerprintOf(finding, model);
224
+ findings.push(finding);
225
+ }
226
+ }
227
+ }
228
+
229
+ const sorted = findings.sort((a, b) => compareStrings(a.id, b.id));
230
+ return {
231
+ findings: sorted,
232
+ stats: {
233
+ detector: "d42-cross-call-wrong-setloadfields",
234
+ candidatesConsidered,
235
+ findingsEmitted: sorted.length,
236
+ skipped: {
237
+ ...(skippedCallerFull > 0 ? { callerFull: skippedCallerFull } : {}),
238
+ ...(skippedCalleeRequiresNone > 0 ? { calleeRequiresNone: skippedCalleeRequiresNone } : {}),
239
+ ...(skippedCalleeUnknown > 0 ? { calleeUnknown: skippedCalleeUnknown } : {}),
240
+ },
241
+ },
242
+ };
243
+ }
@@ -0,0 +1,257 @@
1
+ import type { CombinedGraph } from "../engine/combined-graph.ts";
2
+ import { type DispatchSite, enumerateDispatchSites } from "../engine/dispatch-sites.ts";
3
+ import {
4
+ IS_HANDLED_RE,
5
+ buildCrossExtensionSubscribers,
6
+ eventKindOf,
7
+ } from "../engine/event-flow.ts";
8
+ import { compareStrings } from "../engine/uncertainty-util.ts";
9
+ import type { ControlFlowNode, Routine } from "../model/entities.ts";
10
+ import type { DetectorStats, EvidenceStep, Finding } from "../model/finding.ts";
11
+ import type { SourceAnchor } from "../model/identity.ts";
12
+ import type { EventId, RoutineId, TableId } from "../model/ids.ts";
13
+ import type { SemanticModel } from "../model/model.ts";
14
+ import { fingerprintOf } from "../projection/finding-fingerprint.ts";
15
+ import { writesTablesOf } from "./capability-query.ts";
16
+ import type { DetectorContext } from "./detector-context.ts";
17
+
18
+ export const D43_NAME = "d43-event-ishandled-skip";
19
+
20
+ type SetterClassification = "mustSetTrue" | "maySetTrue" | "noSetTrue";
21
+
22
+ /**
23
+ * Returns true when the assignment's source range is found inside a conditional
24
+ * branch (if/while/case/case-branch/repeat/for/foreach) in the routine's
25
+ * `statementTree`. Top-level assignments — direct children of the root block —
26
+ * return false (not nested).
27
+ *
28
+ * Algorithm: depth-first walk of the CFN tree. We track whether we are currently
29
+ * descending into the BODY of a conditional/loop node. When we encounter a node
30
+ * whose `sourceAnchor` matches the assignment anchor by start position, we record
31
+ * whether we are in a conditional context at that point.
32
+ *
33
+ * "Matches" is start-position equality (line + column) — the tree guarantees
34
+ * unique statement positions within a routine body.
35
+ */
36
+ function isAssignmentNestedInTree(
37
+ assignmentAnchor: SourceAnchor,
38
+ tree: ControlFlowNode | undefined,
39
+ ): boolean {
40
+ if (!tree) return false; // no tree → can't determine → conservative: NOT nested
41
+ const a = assignmentAnchor.range;
42
+ let result = false;
43
+
44
+ const CONDITIONAL_KINDS = new Set([
45
+ "if",
46
+ "while",
47
+ "repeat",
48
+ "for",
49
+ "foreach",
50
+ "case",
51
+ "case-branch",
52
+ ]);
53
+
54
+ function visit(node: ControlFlowNode, inConditional: boolean): void {
55
+ if (result) return; // early exit once found
56
+ const r = node.sourceAnchor.range;
57
+ if (r.startLine === a.startLine && r.startColumn === a.startColumn) {
58
+ // This node IS the assignment statement.
59
+ result = inConditional;
60
+ return;
61
+ }
62
+ // Recurse into children. Children of a conditional/loop body are inside a branch.
63
+ const childConditional = inConditional || CONDITIONAL_KINDS.has(node.kind);
64
+ for (const c of node.children ?? []) {
65
+ visit(c, childConditional);
66
+ if (result) return;
67
+ }
68
+ for (const c of node.elseChildren ?? []) {
69
+ visit(c, true); // else-branch is always conditional
70
+ if (result) return;
71
+ }
72
+ }
73
+
74
+ visit(tree, false);
75
+ return result;
76
+ }
77
+
78
+ function classifySubscriber(
79
+ subscriber: RoutineId,
80
+ routineById: Map<RoutineId, Routine>,
81
+ ): SetterClassification {
82
+ const r = routineById.get(subscriber);
83
+ if (!r) return "noSetTrue";
84
+ const sets = (r.features.varAssignments ?? []).filter(
85
+ (a) => IS_HANDLED_RE.test(a.lhsName) && a.rhsLiteralValue === "true",
86
+ );
87
+ if (sets.length === 0) return "noSetTrue";
88
+ // Phase 3.x refinement: classify as mustSetTrue when the assignment is at top
89
+ // level (not nested in any if/while/case/repeat-until/loop). hasBranching === false
90
+ // trivially means top-level — fast-path without CFN traversal.
91
+ if (r.features.hasBranching === false) return "mustSetTrue";
92
+ // If ANY setter is at top level (not nested in a conditional), the routine
93
+ // guarantees IsHandled=true on every path → mustSetTrue.
94
+ for (const setter of sets) {
95
+ if (!isAssignmentNestedInTree(setter.sourceAnchor, r.features.statementTree)) {
96
+ return "mustSetTrue";
97
+ }
98
+ }
99
+ return "maySetTrue";
100
+ }
101
+
102
+ function classifyConfidence(
103
+ site: DispatchSite,
104
+ setter: SetterClassification,
105
+ ): Finding["confidence"]["level"] {
106
+ if (site.postCallGuards.length === 0) return "possible";
107
+ if (setter === "mustSetTrue" && site.handledActual !== undefined) return "confirmed";
108
+ if (setter === "maySetTrue") return "likely";
109
+ return "possible";
110
+ }
111
+
112
+ export function detectD43(
113
+ model: SemanticModel,
114
+ _graph: CombinedGraph,
115
+ ctx: DetectorContext,
116
+ ): { findings: Finding[]; stats: DetectorStats } {
117
+ const ix = ctx.getEventFlowIndexes();
118
+ const findings: Finding[] = [];
119
+ let candidates = 0;
120
+ let skippedNoGuard = 0;
121
+ let skippedNoSetter = 0;
122
+
123
+ // Substrate guard: if no routine has any conditionReference AND there are
124
+ // event subscribers (Phase 3 T10 idiom), emit ONE warning + bail.
125
+ const sawAnyConditionRef = model.routines.some(
126
+ (r) => (r.features.conditionReferences?.length ?? 0) > 0,
127
+ );
128
+ const eventSubscriberCount = model.routines.filter((r) => r.kind === "event-subscriber").length;
129
+ if (!sawAnyConditionRef && eventSubscriberCount > 0) {
130
+ ctx.diagnostics.push({
131
+ severity: "warning",
132
+ stage: "detect",
133
+ message: `${D43_NAME}: conditionReferences substrate empty; dispatch-site detection limited`,
134
+ });
135
+ return finalize();
136
+ }
137
+
138
+ const eventKindById = new Map<EventId, "integration" | "business" | "internal">();
139
+ for (const ev of model.eventGraph.events) {
140
+ eventKindById.set(ev.id as EventId, eventKindOf(ev.eventKind));
141
+ }
142
+
143
+ // Phase 3.3: cross-extension subscriber lookup per event.
144
+ const crossExtByEvent = buildCrossExtensionSubscribers(model);
145
+
146
+ const sites = enumerateDispatchSites(model, ix);
147
+ for (const site of sites) {
148
+ if (site.postCallGuards.length === 0) {
149
+ skippedNoGuard++;
150
+ continue;
151
+ }
152
+ if (site.guardedTablesWritten.length === 0) {
153
+ skippedNoGuard++;
154
+ continue;
155
+ }
156
+ const subs = ix.subscribersByEvent.get(site.eventId) ?? [];
157
+ const setters: Array<{ sub: RoutineId; classification: SetterClassification }> = [];
158
+ for (const sub of subs) {
159
+ const c = classifySubscriber(sub, ctx.routineById);
160
+ if (c !== "noSetTrue") setters.push({ sub, classification: c });
161
+ }
162
+ if (setters.length === 0) {
163
+ skippedNoSetter++;
164
+ continue;
165
+ }
166
+ const guardedSet = new Set<string>(site.guardedTablesWritten);
167
+ // Coverage candidates: subs that write at least one of the guarded tables.
168
+ const coverageCandidates: RoutineId[] = [];
169
+ for (const sub of subs) {
170
+ const r = ctx.routineById.get(sub);
171
+ if (!r?.summary) continue;
172
+ if (writesTablesOf(r.summary).some((t) => guardedSet.has(t))) {
173
+ coverageCandidates.push(sub);
174
+ }
175
+ }
176
+ for (const { sub: setter, classification } of setters) {
177
+ candidates++;
178
+ const r = ctx.routineById.get(setter);
179
+ if (!r?.summary) continue;
180
+ const setterWrites = new Set(writesTablesOf(r.summary));
181
+ const missing = site.guardedTablesWritten.filter((t) => !setterWrites.has(t));
182
+ if (missing.length === 0) continue;
183
+ const coverageStatus: "candidate-coverage" | "no-other-writers" =
184
+ coverageCandidates.length > 0 ? "candidate-coverage" : "no-other-writers";
185
+ const severityBase: Finding["severity"] =
186
+ coverageStatus === "candidate-coverage" ? "medium" : "high";
187
+ const confidenceLevel = classifyConfidence(site, classification);
188
+ const severity: Finding["severity"] =
189
+ confidenceLevel === "possible"
190
+ ? severityBase === "high"
191
+ ? "medium"
192
+ : severityBase === "medium"
193
+ ? "low"
194
+ : severityBase
195
+ : severityBase;
196
+ const caller = ctx.routineById.get(site.callerRoutine);
197
+ for (const table of missing) {
198
+ const rootCauseKey = `d43/${site.eventId}|${site.callerRoutine}|${site.callsiteId}|${setter}|${table}`;
199
+ const evidence: EvidenceStep[] = [
200
+ {
201
+ routineId: site.callerRoutine,
202
+ callsiteId: site.callsiteId,
203
+ sourceAnchor: caller?.sourceAnchor ?? r.sourceAnchor,
204
+ note: `dispatch site for ${site.eventId}; guard via ${site.handledActual?.variableName ?? "IsHandled"}`,
205
+ },
206
+ {
207
+ routineId: setter,
208
+ sourceAnchor: r.sourceAnchor,
209
+ note: `subscriber ${classification === "mustSetTrue" ? "always" : "may"} set IsHandled := true`,
210
+ },
211
+ ];
212
+ const crossExtSubs = crossExtByEvent.get(site.eventId as EventId);
213
+ const finding: Finding = {
214
+ id: rootCauseKey,
215
+ rootCauseKey,
216
+ detector: D43_NAME,
217
+ title:
218
+ "Event subscriber sets IsHandled but does not perform the publisher's default write",
219
+ rootCause: `Caller ${site.callerRoutine} guards table writes on IsHandled; subscriber ${setter} sets it true but doesn't write ${table}. coverage=${coverageStatus}`,
220
+ severity,
221
+ confidence: { level: confidenceLevel, evidence: [] },
222
+ primaryLocation: r.sourceAnchor,
223
+ evidencePath: evidence,
224
+ affectedObjects: [],
225
+ affectedTables: [table] as TableId[],
226
+ eventKind: eventKindById.get(site.eventId as EventId),
227
+ crossExtensionSubscribers:
228
+ crossExtSubs !== undefined && crossExtSubs.length > 0 ? crossExtSubs : undefined,
229
+ fixOptions: [
230
+ {
231
+ description:
232
+ "Either perform the missing write in the subscriber, or stop setting IsHandled := true.",
233
+ safety: "high",
234
+ },
235
+ ],
236
+ provenance: [{ source: "tree-sitter" }],
237
+ };
238
+ finding.fingerprint = fingerprintOf(finding, model);
239
+ findings.push(finding);
240
+ }
241
+ }
242
+ }
243
+ return finalize();
244
+
245
+ function finalize(): { findings: Finding[]; stats: DetectorStats } {
246
+ findings.sort((a, b) => compareStrings(a.id, b.id));
247
+ return {
248
+ findings,
249
+ stats: {
250
+ detector: D43_NAME,
251
+ candidatesConsidered: candidates,
252
+ findingsEmitted: findings.length,
253
+ skipped: { other: skippedNoGuard + skippedNoSetter },
254
+ },
255
+ };
256
+ }
257
+ }