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,457 @@
1
+ import type { CombinedGraph } from "../engine/combined-graph.ts";
2
+ import { classifyOp, isDbTouchingClass } from "../engine/op-classification.ts";
3
+ import type { Terminal, WalkPolicy, WalkResult } from "../engine/path-walker.ts";
4
+ import { walkEvidence } from "../engine/path-walker.ts";
5
+ import { compareStrings } from "../engine/uncertainty-util.ts";
6
+ import { roleOf } from "../model/entities.ts";
7
+ import type { RecordOperation, Routine, Table } from "../model/entities.ts";
8
+ import type { DetectorStats, EvidenceStep, Finding } from "../model/finding.ts";
9
+ import type { LoopId, RoutineId, TableId } from "../model/ids.ts";
10
+ import type { SemanticModel } from "../model/model.ts";
11
+ import type { DbEffect } from "../model/summary.ts";
12
+ import { pickActionableAnchor } from "../projection/actionable-anchor.ts";
13
+ import { fingerprintOf } from "../projection/finding-fingerprint.ts";
14
+ import { touchesDbOf } from "./capability-query.ts";
15
+ import { toConfidence } from "./confidence.ts";
16
+ import type { DetectorContext } from "./detector-context.ts";
17
+ import { mergeByTerminal } from "./path-merge.ts";
18
+ import { describeTable } from "./table-display.ts";
19
+
20
+ // The path-walker's depth/node budget for the interprocedural call-chain walk.
21
+ const BOUNDS = { maxDepth: 20, maxNodes: 500 };
22
+
23
+ const WRITE_OPS = new Set(["Modify", "ModifyAll", "Insert", "Delete", "DeleteAll"]);
24
+ const HEAVY_READ_OPS = new Set(["CalcFields", "CalcSums"]); // FlowField materialisation = high cost
25
+ const RETRIEVAL_OPS = new Set(["FindSet", "FindFirst", "FindLast", "Find", "Get", "Next"]);
26
+ /**
27
+ * Ops that open a recordset cursor BEFORE a `repeat..until` loop. When an in-loop `Next`
28
+ * has the same record-var as one of these earlier ops, the Next IS the cursor advance —
29
+ * not an N+1 antipattern. Without this filter `Next` produced ~28% of D1's findings on
30
+ * real workspaces, all on legitimate FindSet+repeat patterns.
31
+ */
32
+ const CURSOR_OPENER_OPS = new Set(["FindSet", "FindFirst", "FindLast", "Find"]);
33
+
34
+ interface D1Terminal extends Terminal {
35
+ op: RecordOperation;
36
+ }
37
+
38
+ /**
39
+ * BC "setup singleton" pattern: tables whose name ends in `Setup` are by AL convention
40
+ * single-record config tables (General Ledger Setup, Sales & Receivables Setup, custom
41
+ * `CDO Setup`, etc.). BC caches `<Setup>.Get()` per session, so an in-loop Get on such a
42
+ * table is typically O(1) after the first hit — actionably weak as an N+1 warning.
43
+ *
44
+ * We downgrade these findings to `info` rather than suppressing entirely: the call is
45
+ * still technically a DB op inside a loop, and a strict consumer can opt back in by
46
+ * lowering `--min-severity` (info is below the usual `--min-severity high` threshold).
47
+ *
48
+ * Narrow conditions for the heuristic to apply:
49
+ * - op kind is `Get` (the by-PK lookup that participates in BC's singleton cache);
50
+ * - the rendered table-display name ends in `Setup` (case-insensitive, after stripping
51
+ * the `(type not loaded)` suffix that `describeTable` adds when only the variable's
52
+ * declared type is known).
53
+ *
54
+ * `Find*` ops on the same table do not trigger the heuristic — they imply a multi-record
55
+ * scan and are legitimate D1 signal.
56
+ */
57
+ function isSetupSingletonGet(
58
+ op: RecordOperation,
59
+ routine: Routine | undefined,
60
+ tableById: Map<TableId, Table>,
61
+ ): boolean {
62
+ if (op.op !== "Get") return false;
63
+ const display = describeTable(op, routine, tableById);
64
+ // Strip the `(type not loaded)` suffix so both the resolved-table and type-only paths
65
+ // land on the same naming check. `var <name>` and `unknown table` fall through to false.
66
+ const name = display.replace(/\s*\(type not loaded\)$/i, "").trim();
67
+ if (name === "" || name.startsWith("var ") || name === "unknown table") return false;
68
+ return /\bSetup$/i.test(name);
69
+ }
70
+
71
+ /**
72
+ * The representative loop of a loopStack — the innermost loop the op/callsite sits in.
73
+ * `loopStack` is outermost-first (see test/intraprocedural-ops.test.ts), so the innermost
74
+ * loop is the LAST element. Findings are keyed on this so a deeply nested op reports once.
75
+ */
76
+ function representativeLoopId(loopStack: LoopId[]): LoopId | undefined {
77
+ return loopStack.at(-1);
78
+ }
79
+
80
+ function severityFor(
81
+ op: RecordOperation,
82
+ effectiveLoopDepth: number,
83
+ isSetupSingleton: boolean,
84
+ ): Finding["severity"] {
85
+ if (op.tempState.kind === "known" && op.tempState.value === true) return "info";
86
+ if (isSetupSingleton) return "info";
87
+ let base: Finding["severity"];
88
+ if (WRITE_OPS.has(op.op))
89
+ base = "high"; // write inside loop = always high
90
+ else if (HEAVY_READ_OPS.has(op.op))
91
+ base = "high"; // FlowField materialisation = high
92
+ else if (RETRIEVAL_OPS.has(op.op))
93
+ base = "medium"; // pure retrieval = medium
94
+ else if (classifyOp(op.op) === "db-lock") base = "low";
95
+ else base = "medium";
96
+ if (effectiveLoopDepth >= 2) {
97
+ // nested loop escalates one level
98
+ if (base === "high") base = "critical";
99
+ else if (base === "medium") base = "high";
100
+ }
101
+ return base;
102
+ }
103
+
104
+ /**
105
+ * Render the terminal op's target table for the rootCause string. Looks up the
106
+ * table NAME via `tableById` so the user sees `"Modify on Customer"` instead of
107
+ * the unhelpful internal id `"Modify on 437dbf0e-…/table/18"`. Falls back to
108
+ * the receiver's declared type name (with a `(type not loaded)` hint) when the
109
+ * tableId can't be resolved — see describeTable for the full tier list.
110
+ */
111
+ function tableNote(
112
+ op: RecordOperation,
113
+ routine: Routine | undefined,
114
+ tableById: Map<TableId, Table>,
115
+ ): string {
116
+ return `${op.op} on ${describeTable(op, routine, tableById)}`;
117
+ }
118
+
119
+ /**
120
+ * Synthesise a RecordOperation from a DbEffect for routines whose raw features have been
121
+ * stripped (dependency-role artifact projections). The loopStack is empty because the
122
+ * depth is tracked by the path-walker's initialLoopDepth / localLoopDepth accounting.
123
+ */
124
+ function synthRecordOpFromEffect(
125
+ routineId: RoutineId,
126
+ routine: Routine,
127
+ effect: DbEffect,
128
+ ): RecordOperation {
129
+ return {
130
+ id: effect.operationId,
131
+ routineId,
132
+ op: effect.op,
133
+ recordVariableName: "",
134
+ tableId: effect.tableId === "unknown" ? undefined : effect.tableId,
135
+ tempState: effect.tempState,
136
+ loopStack: [],
137
+ sourceAnchor: { ...routine.sourceAnchor, enclosingRoutineId: routineId },
138
+ };
139
+ }
140
+
141
+ function buildFinding(
142
+ loopRoutine: Routine,
143
+ representativeLoop: LoopId,
144
+ result: WalkResult,
145
+ terminalOp: RecordOperation,
146
+ routineById: Map<RoutineId, Routine>,
147
+ tableById: Map<TableId, Table>,
148
+ model: SemanticModel,
149
+ ): Finding {
150
+ const terminalRoutine = routineById.get(terminalOp.routineId);
151
+ const setupSingleton = isSetupSingletonGet(terminalOp, terminalRoutine, tableById);
152
+ const severity = severityFor(terminalOp, result.effectiveLoopDepth, setupSingleton);
153
+ const tempNote =
154
+ terminalOp.tempState.kind === "known" && terminalOp.tempState.value === true
155
+ ? " (temporary record — not a SQL round-trip)"
156
+ : terminalOp.tempState.kind !== "known"
157
+ ? " (temp state uncertain)"
158
+ : "";
159
+ const setupNote = setupSingleton
160
+ ? " (Setup singleton — BC caches Get() per session, so the round-trip happens at most once.)"
161
+ : "";
162
+
163
+ // Two keys, two purposes:
164
+ // `id` per-(loop, op) — used by the existing within-walker dedup that
165
+ // drops a path the path-walker enumerated twice via different
166
+ // call-site branches.
167
+ // `rootCauseKey` per-(terminal-op) — used by mergeByTerminal at the end of
168
+ // detectD1 to fold M different ancestor loops reaching the same
169
+ // op into ONE finding with the others in additionalPaths. The
170
+ // bug entity is the terminal DB op, not the (loop, op) pair.
171
+ const finding: Finding = {
172
+ id: `d1/${representativeLoop}/${terminalOp.routineId}/${terminalOp.id}`,
173
+ rootCauseKey: `d1/${terminalOp.routineId}/${terminalOp.id}`,
174
+ detector: "d1-db-op-in-loop",
175
+ title: "Database operation inside a loop",
176
+ rootCause: `A loop in ${loopRoutine.name} reaches ${tableNote(terminalOp, terminalRoutine, tableById)}${tempNote}${setupNote}.`,
177
+ severity,
178
+ confidence: toConfidence(result.uncertainties, "likely"),
179
+ primaryLocation: terminalOp.sourceAnchor,
180
+ evidencePath: result.path,
181
+ affectedObjects: [
182
+ ...new Set(
183
+ [loopRoutine.objectId, terminalRoutine?.objectId].filter(
184
+ (x): x is string => x !== undefined,
185
+ ),
186
+ ),
187
+ ].sort(),
188
+ affectedTables: terminalOp.tableId !== undefined ? [terminalOp.tableId] : [],
189
+ fixOptions: setupSingleton
190
+ ? [
191
+ {
192
+ description:
193
+ "Setup tables are session-cached by BC, so a Get() inside a loop is typically O(1) after the first hit. Hoist the Get() outside the loop only if the call site shows up in a CPU profile.",
194
+ safety: "high",
195
+ },
196
+ ]
197
+ : [
198
+ {
199
+ description:
200
+ "Move the database operation outside the loop, or batch it into a set-based operation.",
201
+ safety: "medium",
202
+ },
203
+ ],
204
+ provenance: [{ source: "tree-sitter" }],
205
+ };
206
+
207
+ const actionable = pickActionableAnchor(finding, model);
208
+ if (actionable !== undefined) finding.actionableAnchor = actionable;
209
+ // Fingerprint deferred until AFTER mergeByTerminal — the merged finding's
210
+ // affectedObjects/affectedTables can grow (union across paths), and fingerprint
211
+ // includes affectedTables for edit-survival stability.
212
+ return finding;
213
+ }
214
+
215
+ /** D1: find DB operations executed inside a loop — directly or through an in-loop call chain. */
216
+ export function detectD1(
217
+ model: SemanticModel,
218
+ graph: CombinedGraph,
219
+ ctx: DetectorContext,
220
+ ): { findings: Finding[]; stats: DetectorStats } {
221
+ const findings: Finding[] = [];
222
+ const { routineById } = ctx;
223
+ let candidatesConsidered = 0;
224
+ let skippedParseIncomplete = 0;
225
+ let downgradedToInfo = 0;
226
+ let downgradedSetupSingleton = 0;
227
+ let skippedOpaqueCallee = 0;
228
+ let skippedDynamicDispatch = 0;
229
+
230
+ const policy: WalkPolicy<D1Terminal> = {
231
+ terminalsAt: (node) => {
232
+ const r = routineById.get(node);
233
+ if (r === undefined) return [];
234
+ // Dep routines ship with EMPTY_FEATURES (artifact projection strips them);
235
+ // reconstruct in-loop DB terminals from their summary.dbEffects.
236
+ if (roleOf(r) !== "dependency") {
237
+ return r.features.recordOperations
238
+ .filter((op) => isDbTouchingClass(classifyOp(op.op)))
239
+ .map((op) => ({ routineId: node, localLoopDepth: op.loopStack.length, op }));
240
+ }
241
+ // Dependency routines have their raw features stripped in the artifact. Synthesize
242
+ // terminals from summary.dbEffects (direct effects only — transitive ones are not
243
+ // re-emitted at this node; they are accessible by expanding further).
244
+ const effects = r.summary?.dbEffects.filter(
245
+ (e) => e.via === "direct" && isDbTouchingClass(classifyOp(e.op)),
246
+ );
247
+ if (!effects || effects.length === 0) return [];
248
+ return effects.map((e) => ({
249
+ routineId: node,
250
+ localLoopDepth: 0,
251
+ op: synthRecordOpFromEffect(node, r, e),
252
+ }));
253
+ },
254
+ expand: (node) =>
255
+ (graph.edgesByFrom.get(node) ?? []).filter((e) => {
256
+ // event fan-out is D2's job
257
+ if (e.kind === "event-dispatch") return false;
258
+ const to = routineById.get(e.to);
259
+ return to?.summary !== undefined && touchesDbOf(to.summary) !== "no";
260
+ }),
261
+ buildHopStep: (edge) => {
262
+ const fromRoutine = routineById.get(edge.from);
263
+ const cs = fromRoutine?.features.callSites.find((c) => c.id === edge.callsiteId);
264
+ const toName = routineById.get(edge.to)?.name ?? edge.to;
265
+ const triggerNote =
266
+ edge.kind === "implicit-trigger" ? ` (via implicit ${toName} trigger)` : "";
267
+ return {
268
+ routineId: edge.from,
269
+ callsiteId: edge.callsiteId,
270
+ sourceAnchor: cs?.sourceAnchor ??
271
+ fromRoutine?.sourceAnchor ?? {
272
+ sourceUnitId: "",
273
+ range: { startLine: 0, startColumn: 0, endLine: 0, endColumn: 0 },
274
+ enclosingRoutineId: edge.from,
275
+ syntaxKind: "call",
276
+ },
277
+ note: `calls ${toName}${triggerNote}`,
278
+ };
279
+ },
280
+ buildTerminalStep: (t) => ({
281
+ routineId: t.routineId,
282
+ operationId: t.op.id,
283
+ sourceAnchor: t.op.sourceAnchor,
284
+ note: tableNote(t.op, routineById.get(t.routineId), ctx.tableById),
285
+ }),
286
+ };
287
+
288
+ for (const routine of model.routines) {
289
+ if (roleOf(routine) !== "primary") continue;
290
+ if (!routine.bodyAvailable) continue;
291
+ if (routine.parseIncomplete) {
292
+ skippedParseIncomplete++;
293
+ continue;
294
+ }
295
+ candidatesConsidered++;
296
+ const loopById = new Map(routine.features.loops.map((l) => [l.id, l]));
297
+
298
+ // Record-vars that had a cursor opened before any loop — used to suppress in-loop
299
+ // `Next` on the same var (the cursor's natural advance, not N+1).
300
+ const cursorOpenedRecordVars = new Set<string>();
301
+ for (const op of routine.features.recordOperations) {
302
+ if (op.loopStack.length !== 0) continue;
303
+ if (!CURSOR_OPENER_OPS.has(op.op)) continue;
304
+ cursorOpenedRecordVars.add(op.recordVariableName.toLowerCase());
305
+ }
306
+
307
+ // (a) Direct in-loop DB ops within this routine — iterate ops, key on representative loop.
308
+ for (const op of routine.features.recordOperations) {
309
+ if (op.loopStack.length === 0) continue;
310
+ if (!isDbTouchingClass(classifyOp(op.op))) continue;
311
+ if (op.op === "Next" && cursorOpenedRecordVars.has(op.recordVariableName.toLowerCase())) {
312
+ // FindSet/FindFirst/Find/FindLast on this var earlier → Next is the cursor advance.
313
+ continue;
314
+ }
315
+ const representativeLoop = representativeLoopId(op.loopStack);
316
+ if (representativeLoop === undefined) continue;
317
+ const loop = loopById.get(representativeLoop);
318
+ if (loop === undefined) continue;
319
+ if (op.tempState.kind === "known" && op.tempState.value === true) {
320
+ downgradedToInfo++;
321
+ }
322
+
323
+ const loopStep: EvidenceStep = {
324
+ routineId: routine.id,
325
+ loopId: loop.id,
326
+ sourceAnchor: loop.sourceAnchor,
327
+ note: `${loop.type} loop`,
328
+ };
329
+ const opStep: EvidenceStep = {
330
+ routineId: routine.id,
331
+ operationId: op.id,
332
+ sourceAnchor: op.sourceAnchor,
333
+ note: tableNote(op, routine, ctx.tableById),
334
+ };
335
+ const result: WalkResult = {
336
+ path: [loopStep, opStep],
337
+ effectiveLoopDepth: op.loopStack.length,
338
+ // Always [] here: the op is directly observed in this routine — no call resolution.
339
+ uncertainties: [],
340
+ stop: "complete",
341
+ };
342
+ findings.push(
343
+ buildFinding(routine, representativeLoop, result, op, routineById, ctx.tableById, model),
344
+ );
345
+ }
346
+
347
+ // (b) In-loop calls to DB-touching callees — walk the call chain.
348
+ for (const cs of routine.features.callSites) {
349
+ if (cs.loopStack.length === 0) continue;
350
+ const representativeLoop = representativeLoopId(cs.loopStack);
351
+ if (representativeLoop === undefined) continue;
352
+ const loop = loopById.get(representativeLoop);
353
+ if (loop === undefined) continue;
354
+
355
+ const edge = (graph.edgesByFrom.get(routine.id) ?? []).find((e) => e.callsiteId === cs.id);
356
+ if (edge === undefined) {
357
+ // No resolved edge — opaque callee
358
+ skippedOpaqueCallee++;
359
+ continue;
360
+ }
361
+ if (edge.kind === "interface" || edge.kind === "dynamic") {
362
+ skippedDynamicDispatch++;
363
+ continue;
364
+ }
365
+ const callsiteTo = routineById.get(edge.to);
366
+ if (callsiteTo?.summary === undefined || touchesDbOf(callsiteTo.summary) === "no") continue;
367
+
368
+ const loopStep: EvidenceStep = {
369
+ routineId: routine.id,
370
+ loopId: loop.id,
371
+ sourceAnchor: loop.sourceAnchor,
372
+ note: `${loop.type} loop`,
373
+ };
374
+ const callStep: EvidenceStep = {
375
+ routineId: routine.id,
376
+ callsiteId: cs.id,
377
+ sourceAnchor: cs.sourceAnchor,
378
+ note: `calls ${routineById.get(edge.to)?.name ?? edge.to}`,
379
+ };
380
+
381
+ const results = walkEvidence(edge.to, policy, BOUNDS, graph, model, {
382
+ initialLoopDepth: cs.loopStack.length,
383
+ initialSteps: [loopStep, callStep],
384
+ routineById,
385
+ uncertaintyEdgesByFrom: ctx.uncertaintyEdgesByFrom,
386
+ callSiteById: ctx.callSiteById,
387
+ });
388
+
389
+ for (const result of results) {
390
+ if (result.stop !== "complete") continue;
391
+ const lastStep = result.path.at(-1);
392
+ if (lastStep?.operationId === undefined) continue;
393
+ const terminalRoutine = routineById.get(lastStep.routineId);
394
+ // Primary routines have real RecordOperations; dependency routines have theirs stripped
395
+ // in the artifact but preserve the operationId in summary.dbEffects.
396
+ const terminalOp: RecordOperation | undefined =
397
+ terminalRoutine?.features.recordOperations.find((o) => o.id === lastStep.operationId) ??
398
+ (() => {
399
+ const effect = terminalRoutine?.summary?.dbEffects.find(
400
+ (e) => e.operationId === lastStep.operationId,
401
+ );
402
+ if (!effect || !terminalRoutine) return undefined;
403
+ return synthRecordOpFromEffect(lastStep.routineId, terminalRoutine, effect);
404
+ })();
405
+ if (terminalOp === undefined) continue;
406
+ findings.push(
407
+ buildFinding(
408
+ routine,
409
+ representativeLoop,
410
+ result,
411
+ terminalOp,
412
+ routineById,
413
+ ctx.tableById,
414
+ model,
415
+ ),
416
+ );
417
+ }
418
+ }
419
+ }
420
+
421
+ // Two-stage collapse:
422
+ // 1. Dedupe by id (loop+op pair) — drops within-walker duplicates when the
423
+ // path-walker enumerates the same (loop, op) via different branches.
424
+ // 2. mergeByTerminal — folds different loops on the same terminal op into a
425
+ // single Finding with additionalPaths. Sorts by canonical id for
426
+ // determinism (a `rootCauseKey`-keyed sort is equivalent here).
427
+ const seen = new Set<string>();
428
+ const deduped: Finding[] = [];
429
+ for (const f of findings) {
430
+ if (seen.has(f.id)) continue;
431
+ seen.add(f.id);
432
+ deduped.push(f);
433
+ }
434
+ const merged = mergeByTerminal(deduped);
435
+ for (const f of merged) {
436
+ // Setup-singleton downgrades carry their note in rootCause — count them for stats
437
+ // (cheap signature check vs threading a counter through buildFinding + merge).
438
+ if (f.rootCause.includes("Setup singleton")) downgradedSetupSingleton++;
439
+ }
440
+ // Fingerprint AFTER merge — affectedObjects/affectedTables are unioned across
441
+ // paths, so the fingerprint needs the final values to be edit-stable.
442
+ for (const f of merged) f.fingerprint = fingerprintOf(f, model);
443
+ const sorted = merged.sort((a, b) => compareStrings(a.id, b.id));
444
+ const stats: DetectorStats = {
445
+ detector: "d1-db-op-in-loop",
446
+ candidatesConsidered,
447
+ findingsEmitted: sorted.length,
448
+ skipped: {
449
+ ...(skippedOpaqueCallee > 0 ? { opaqueCallee: skippedOpaqueCallee } : {}),
450
+ ...(skippedDynamicDispatch > 0 ? { dynamicDispatch: skippedDynamicDispatch } : {}),
451
+ ...(skippedParseIncomplete > 0 ? { parseIncomplete: skippedParseIncomplete } : {}),
452
+ ...(downgradedToInfo > 0 ? { downgradedToInfo } : {}),
453
+ ...(downgradedSetupSingleton > 0 ? { downgradedSetupSingleton } : {}),
454
+ },
455
+ };
456
+ return { findings: sorted, stats };
457
+ }
@@ -0,0 +1,114 @@
1
+ import type { CombinedGraph } from "../engine/combined-graph.ts";
2
+ import { compareStrings } from "../engine/uncertainty-util.ts";
3
+ import { roleOf } from "../model/entities.ts";
4
+ import type { DetectorStats, EvidenceStep, Finding } from "../model/finding.ts";
5
+ import type { SemanticModel } from "../model/model.ts";
6
+ import { fingerprintOf } from "../projection/finding-fingerprint.ts";
7
+ import { toConfidence } from "./confidence.ts";
8
+ import type { DetectorContext } from "./detector-context.ts";
9
+
10
+ const MUTATING_OPS: ReadonlySet<string> = new Set([
11
+ "Modify",
12
+ "ModifyAll",
13
+ "Validate",
14
+ "Delete",
15
+ "DeleteAll",
16
+ ]);
17
+
18
+ /**
19
+ * The operation that drives cursor advancement in a repeat/until loop.
20
+ * `Next()` is always emitted inside the loop body, so it carries a non-empty
21
+ * loopStack — unlike `FindSet`/`FindFirst` which appear in the `if` guard
22
+ * before the `repeat` keyword and therefore have loopStack === [].
23
+ */
24
+ const LOOP_DRIVER_OPS: ReadonlySet<string> = new Set(["Next"]);
25
+
26
+ export function detectD10(
27
+ model: SemanticModel,
28
+ _graph: CombinedGraph,
29
+ _ctx: DetectorContext,
30
+ ): { findings: Finding[]; stats: DetectorStats } {
31
+ const findings: Finding[] = [];
32
+ let candidatesConsidered = 0;
33
+ let skippedParseIncomplete = 0;
34
+
35
+ for (const routine of model.routines) {
36
+ if (roleOf(routine) !== "primary") continue;
37
+ if (!routine.bodyAvailable) continue;
38
+ if (routine.parseIncomplete) {
39
+ skippedParseIncomplete++;
40
+ continue;
41
+ }
42
+ candidatesConsidered++;
43
+
44
+ // Map loopId → record variable that drives the loop.
45
+ // We use Next() as the signal: it is always emitted inside the repeat/until body
46
+ // (loopStack is non-empty), whereas FindSet/FindFirst appear in the `if` guard
47
+ // before the `repeat` keyword and therefore have loopStack === [].
48
+ const loopDriver = new Map<string, string>();
49
+ for (const op of routine.features.recordOperations) {
50
+ if (!LOOP_DRIVER_OPS.has(op.op)) continue;
51
+ const loop = op.loopStack[op.loopStack.length - 1];
52
+ if (loop === undefined) continue;
53
+ if (!loopDriver.has(loop)) loopDriver.set(loop, op.recordVariableName.toLowerCase());
54
+ }
55
+
56
+ for (const op of routine.features.recordOperations) {
57
+ if (!MUTATING_OPS.has(op.op)) continue;
58
+ const loop = op.loopStack[op.loopStack.length - 1];
59
+ if (loop === undefined) continue;
60
+ const driver = loopDriver.get(loop);
61
+ if (driver === undefined) continue;
62
+ if (op.recordVariableName.toLowerCase() !== driver) continue;
63
+
64
+ const loopNode = routine.features.loops.find((l) => l.id === loop);
65
+ const path: EvidenceStep[] = [];
66
+ if (loopNode) {
67
+ path.push({
68
+ routineId: routine.id,
69
+ loopId: loopNode.id,
70
+ sourceAnchor: loopNode.sourceAnchor,
71
+ note: `${loopNode.type} loop iterating ${op.recordVariableName}`,
72
+ });
73
+ }
74
+ path.push({
75
+ routineId: routine.id,
76
+ operationId: op.id,
77
+ sourceAnchor: op.sourceAnchor,
78
+ note: `${op.op} on iterating record ${op.recordVariableName}`,
79
+ });
80
+
81
+ const finding: Finding = {
82
+ id: `d10/${routine.id}/${op.id}`,
83
+ rootCauseKey: `d10/${routine.id}/${op.id}`,
84
+ detector: "d10-self-modifying-loop",
85
+ title: "Self-modifying loop",
86
+ rootCause: `${routine.name} runs ${op.op} on the iterating record ${op.recordVariableName} inside its own loop — the cursor's snapshot may be corrupted.`,
87
+ severity: "high",
88
+ confidence: toConfidence([], "likely"),
89
+ primaryLocation: op.sourceAnchor,
90
+ evidencePath: path,
91
+ affectedObjects: [routine.objectId],
92
+ affectedTables: op.tableId !== undefined ? [op.tableId] : [],
93
+ fixOptions: [
94
+ {
95
+ description:
96
+ "Collect the keys first, then iterate a fresh recordset to perform the modifications; or use ModifyAll with a filter.",
97
+ safety: "medium",
98
+ },
99
+ ],
100
+ provenance: [{ source: "tree-sitter" }],
101
+ };
102
+ finding.fingerprint = fingerprintOf(finding, model);
103
+ findings.push(finding);
104
+ }
105
+ }
106
+
107
+ const stats: DetectorStats = {
108
+ detector: "d10-self-modifying-loop",
109
+ candidatesConsidered,
110
+ findingsEmitted: findings.length,
111
+ skipped: { parseIncomplete: skippedParseIncomplete > 0 ? skippedParseIncomplete : undefined },
112
+ };
113
+ return { findings: findings.sort((a, b) => compareStrings(a.id, b.id)), stats };
114
+ }