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,234 @@
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 { SourceAnchor } from "../model/identity.ts";
6
+ import type { SemanticModel } from "../model/model.ts";
7
+ import type { Uncertainty } from "../model/summary.ts";
8
+ import { fingerprintOf } from "../projection/finding-fingerprint.ts";
9
+ import { toConfidence } from "./confidence.ts";
10
+ import { deriveLoadStates } from "./d3-load-state.ts";
11
+ import type { DetectorContext } from "./detector-context.ts";
12
+
13
+ const INVALIDATING_OPS = new Set(["Reset", "Copy", "TransferFields"]);
14
+
15
+ /** Is anchor `a` strictly before anchor `b` in source order? */
16
+ function before(a: SourceAnchor, b: SourceAnchor): boolean {
17
+ if (a.range.startLine !== b.range.startLine) return a.range.startLine < b.range.startLine;
18
+ return a.range.startColumn < b.range.startColumn;
19
+ }
20
+
21
+ /**
22
+ * D3: detect retrievals (`FindSet` / `FindFirst` / `FindLast` / `Get`) whose loaded field set
23
+ * does not cover the fields later accessed — same-routine, and through directly-resolved
24
+ * callees via `RecordRoleSummary`. Emits only on a complete witness (a concrete
25
+ * retrieval + a concrete access); bails conservatively, never claiming a false "clean".
26
+ */
27
+ export function detectD3(
28
+ model: SemanticModel,
29
+ graph: CombinedGraph,
30
+ ctx: DetectorContext,
31
+ ): { findings: Finding[]; stats: DetectorStats } {
32
+ const findings: Finding[] = [];
33
+ const { routineById, tableById } = ctx;
34
+ let candidatesConsidered = 0;
35
+ let skippedTemporaryRecord = 0;
36
+ let skippedParseIncomplete = 0;
37
+ let skippedUnknownReads = 0;
38
+
39
+ for (const routine of model.routines) {
40
+ if (roleOf(routine) !== "primary") continue;
41
+ if (!routine.bodyAvailable) continue;
42
+ if (routine.parseIncomplete) {
43
+ skippedParseIncomplete++;
44
+ continue;
45
+ }
46
+ candidatesConsidered++;
47
+
48
+ for (const state of deriveLoadStates(routine)) {
49
+ const loadState = state.loadState;
50
+ if (loadState.kind === "invalidated") continue; // bailout — cannot prove
51
+
52
+ const varKey = state.recordVariableName.toLowerCase();
53
+ const recVar = routine.features.recordVariables.find(
54
+ (rv) => rv.name.toLowerCase() === varKey,
55
+ );
56
+ // Temp records live in memory; SetLoadFields has no SQL benefit.
57
+ if (recVar?.tempState.kind === "known" && recVar.tempState.value === true) {
58
+ skippedTemporaryRecord++;
59
+ continue;
60
+ }
61
+ const tableId = recVar?.tableId;
62
+ if (tableId === undefined) continue; // unresolved table — bailout
63
+ const table = tableById.get(tableId);
64
+ if (table === undefined) continue;
65
+ const fieldNameById = new Map(table.fields.map((f) => [f.id, f.name.toLowerCase()]));
66
+
67
+ const retrievalAnchor = state.retrievalOp.sourceAnchor;
68
+
69
+ // The window closes at the first invalidating op on this record var after the retrieval.
70
+ const invalidatingAfter = routine.features.recordOperations
71
+ .filter(
72
+ (op) =>
73
+ op.recordVariableName.toLowerCase() === varKey &&
74
+ INVALIDATING_OPS.has(op.op) &&
75
+ before(retrievalAnchor, op.sourceAnchor),
76
+ )
77
+ .sort((a, b) => {
78
+ if (before(a.sourceAnchor, b.sourceAnchor)) return -1;
79
+ if (before(b.sourceAnchor, a.sourceAnchor)) return 1;
80
+ return 0;
81
+ })[0];
82
+ const windowEnd = invalidatingAfter?.sourceAnchor;
83
+ const inWindow = (anchor: SourceAnchor): boolean =>
84
+ before(retrievalAnchor, anchor) && (windowEnd === undefined || before(anchor, windowEnd));
85
+
86
+ const accessedFields = new Set<string>();
87
+ const accessSteps: EvidenceStep[] = [];
88
+ const uncertainties: Uncertainty[] = [];
89
+ let bailout = false;
90
+
91
+ // --- same-routine field accesses in the window ---
92
+ for (const fa of routine.features.fieldAccesses) {
93
+ if (fa.recordVariableName.toLowerCase() !== varKey) continue;
94
+ if (!inWindow(fa.sourceAnchor)) continue;
95
+ accessedFields.add(fa.fieldName.toLowerCase());
96
+ accessSteps.push({
97
+ routineId: routine.id,
98
+ sourceAnchor: fa.sourceAnchor,
99
+ note: `accesses ${state.recordVariableName}.${fa.fieldName}`,
100
+ });
101
+ }
102
+
103
+ // --- cross-routine: record passed by simple identifier to a directly-resolved callee ---
104
+ for (const cs of routine.features.callSites) {
105
+ if (!inWindow(cs.sourceAnchor)) continue;
106
+ const argIndex = cs.argumentTexts.findIndex((a) => a.trim().toLowerCase() === varKey);
107
+ if (argIndex < 0) continue;
108
+ const edge = (graph.edgesByFrom.get(routine.id) ?? []).find((e) => e.callsiteId === cs.id);
109
+ if (edge === undefined || edge.kind === "interface" || edge.kind === "dynamic") {
110
+ bailout = true;
111
+ uncertainties.push({ kind: "interface-dispatch", callsiteId: cs.id });
112
+ continue;
113
+ }
114
+ const callee = routineById.get(edge.to);
115
+ const paramEffect = callee?.summary?.parameterRoles.find(
116
+ (pe) => pe.parameterIndex === argIndex,
117
+ );
118
+ if (callee === undefined || paramEffect === undefined) continue;
119
+ const calleeParam = callee.parameters[argIndex];
120
+ const passedByVar = calleeParam?.isVar === true;
121
+ // A by-var callee that resets/changes load fields / assigns / uses RecordRef
122
+ // invalidates the caller's state — bail. By-value callees do not.
123
+ if (
124
+ passedByVar &&
125
+ (paramEffect.mayResetFilters ||
126
+ paramEffect.mayChangeLoadFields ||
127
+ paramEffect.mayAssignRecord ||
128
+ paramEffect.mayUseRecordRef)
129
+ ) {
130
+ bailout = true;
131
+ uncertainties.push({ kind: "recordref-or-variant", operationId: cs.operationId });
132
+ continue;
133
+ }
134
+ const reads = paramEffect.readsFields;
135
+ if (reads === "unknown") {
136
+ skippedUnknownReads++;
137
+ continue;
138
+ }
139
+ for (const fid of reads) {
140
+ const name = fieldNameById.get(fid);
141
+ if (name !== undefined) accessedFields.add(name);
142
+ }
143
+ if (reads.length > 0) {
144
+ const triggerNote =
145
+ edge.kind === "implicit-trigger" ? ` (via implicit ${callee.name} trigger)` : "";
146
+ accessSteps.push({
147
+ routineId: routine.id,
148
+ callsiteId: cs.id,
149
+ sourceAnchor: cs.sourceAnchor,
150
+ note: `passes ${state.recordVariableName} to ${callee.name}${triggerNote}, which reads ${reads.length} field(s)`,
151
+ });
152
+ }
153
+ }
154
+
155
+ if (accessedFields.size === 0) continue; // no concrete access — no witness, no emit
156
+
157
+ // --- determination ---
158
+ let kind: "missing" | "incomplete" | undefined;
159
+ let missingList: string[] = [];
160
+ if (loadState.kind === "none") {
161
+ kind = "missing";
162
+ missingList = [...accessedFields].sort();
163
+ } else if (loadState.kind === "loaded") {
164
+ const missing = [...accessedFields].filter((f) => !loadState.fields.has(f));
165
+ if (missing.length > 0) {
166
+ kind = "incomplete";
167
+ missingList = missing.sort();
168
+ }
169
+ }
170
+ if (kind === undefined) continue; // loaded set covers all accesses — silent
171
+
172
+ const retrievalStep: EvidenceStep = {
173
+ routineId: routine.id,
174
+ operationId: state.retrievalOp.id,
175
+ sourceAnchor: retrievalAnchor,
176
+ note: `${state.retrievalOp.op} on ${state.recordVariableName}${
177
+ kind === "missing" ? " with no SetLoadFields" : " with a partial SetLoadFields"
178
+ }`,
179
+ };
180
+ const d3finding: Finding = {
181
+ id: `d3/${state.retrievalOp.id}`,
182
+ rootCauseKey: `d3/${state.retrievalOp.id}`,
183
+ detector: "d3-missing-setloadfields",
184
+ title:
185
+ kind === "missing"
186
+ ? "Missing SetLoadFields before a record retrieval"
187
+ : "Incomplete SetLoadFields — accessed fields not loaded",
188
+ rootCause: `${routine.name} runs ${state.retrievalOp.op} on ${state.recordVariableName} and then accesses field(s) [${missingList.join(", ")}] — ${
189
+ kind === "missing" ? "no SetLoadFields was set" : "an incomplete SetLoadFields"
190
+ }.`,
191
+ severity: "medium",
192
+ confidence: toConfidence(uncertainties, bailout ? "possible" : "likely"),
193
+ primaryLocation: retrievalAnchor,
194
+ evidencePath: [retrievalStep, ...accessSteps],
195
+ affectedObjects: [routine.objectId],
196
+ affectedTables: [tableId],
197
+ fixOptions: [
198
+ {
199
+ description:
200
+ kind === "missing"
201
+ ? `Add SetLoadFields(${missingList.join(", ")}) before the retrieval.`
202
+ : `Extend SetLoadFields to include: ${missingList.join(", ")}.`,
203
+ safety: "high",
204
+ },
205
+ ],
206
+ provenance: [{ source: "tree-sitter" }],
207
+ };
208
+ d3finding.fingerprint = fingerprintOf(d3finding, model);
209
+ findings.push(d3finding);
210
+ }
211
+ }
212
+
213
+ // Dedupe by id (keep first-seen): deriveLoadStates yields at most one state per retrieval op,
214
+ // so ids are unique by construction — but dedupe defensively anyway.
215
+ const seen = new Set<string>();
216
+ const deduped: Finding[] = [];
217
+ for (const f of findings) {
218
+ if (seen.has(f.id)) continue;
219
+ seen.add(f.id);
220
+ deduped.push(f);
221
+ }
222
+ const sorted = deduped.sort((a, b) => compareStrings(a.id, b.id));
223
+ const stats: DetectorStats = {
224
+ detector: "d3-missing-setloadfields",
225
+ candidatesConsidered,
226
+ findingsEmitted: sorted.length,
227
+ skipped: {
228
+ ...(skippedTemporaryRecord > 0 ? { temporaryRecord: skippedTemporaryRecord } : {}),
229
+ ...(skippedParseIncomplete > 0 ? { parseIncomplete: skippedParseIncomplete } : {}),
230
+ ...(skippedUnknownReads > 0 ? { unknownReads: skippedUnknownReads } : {}),
231
+ },
232
+ };
233
+ return { findings: sorted, stats };
234
+ }
@@ -0,0 +1,185 @@
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 { ParameterSymbol, Routine } 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
+ /** Match `true` / `false` (case-insensitive) — and only those — as the entire argument
12
+ * text. Whitespace tolerated. */
13
+ function isBooleanLiteral(arg: string): "true" | "false" | undefined {
14
+ const t = arg.trim().toLowerCase();
15
+ if (t === "true") return "true";
16
+ if (t === "false") return "false";
17
+ return undefined;
18
+ }
19
+
20
+ /**
21
+ * D32 — a `local procedure` parameter of type `Boolean` is passed the same literal
22
+ * (`true` or `false`) at every resolved primary-app call site. The parameter is dead:
23
+ * either the value is never actually variable, or the branches it gates can be flattened
24
+ * out of the procedure.
25
+ *
26
+ * Tight scope to keep the signal precise:
27
+ * - `accessModifier === "local"` only — public/internal/protected procedures may have
28
+ * callers outside the workspace we cannot see; non-local routines stay out.
29
+ * - `kind === "procedure"` only — triggers and event subscribers have publisher-dictated
30
+ * signatures; flattening is not an option.
31
+ * - parameter type contains `Boolean` (case-insensitive); other scalar params are out of
32
+ * scope (we can't reason about non-Boolean literal-equality without dataflow).
33
+ * - at least 2 resolved primary-app call sites; the routine isn't reached at all means
34
+ * D14 territory, and a single call site is not enough evidence of "always".
35
+ * - every reaching edge must be a `direct` kind (not `interface` / `dynamic` /
36
+ * `event-dispatch`) and resolve to a callsite in a primary-app routine; mixed call
37
+ * shapes leave too much uncertainty.
38
+ *
39
+ * Severity: `info` — refactoring signal, not a correctness break.
40
+ */
41
+ export function detectD32(
42
+ model: SemanticModel,
43
+ _graph: CombinedGraph,
44
+ ctx: DetectorContext,
45
+ ): { findings: Finding[]; stats: DetectorStats } {
46
+ const findings: Finding[] = [];
47
+ const { routineById } = ctx;
48
+ let candidatesConsidered = 0;
49
+ let skippedAccessModifier = 0;
50
+ let skippedNoBooleanParams = 0;
51
+ let skippedTooFewCallers = 0;
52
+ let skippedUnresolvedOrMixedEdges = 0;
53
+ let skippedVariesAcrossCallers = 0;
54
+
55
+ for (const callee of model.routines) {
56
+ if (roleOf(callee) !== "primary") continue;
57
+ if (!callee.bodyAvailable) continue;
58
+ if (callee.kind !== "procedure") continue;
59
+ if (callee.accessModifier !== "local") {
60
+ skippedAccessModifier++;
61
+ continue;
62
+ }
63
+ const boolParams = callee.parameters.filter((p): p is ParameterSymbol =>
64
+ /\bBoolean\b/i.test(p.typeText),
65
+ );
66
+ if (boolParams.length === 0) {
67
+ skippedNoBooleanParams++;
68
+ continue;
69
+ }
70
+
71
+ // Collect every primary-app, direct edge into this routine and the matching callsite.
72
+ const incoming = ctx.reverseCallGraph.get(callee.id) ?? [];
73
+ const directEdges = incoming.filter((e) => e.kind === "direct");
74
+ if (directEdges.length < 2) {
75
+ skippedTooFewCallers++;
76
+ continue;
77
+ }
78
+ // If any reaching edge is non-direct (interface / dynamic / event-dispatch), the
79
+ // callsite arg story is incomplete — bail rather than risk a false positive.
80
+ const mixed = incoming.some((e) => e.kind !== "direct");
81
+ if (mixed) {
82
+ skippedUnresolvedOrMixedEdges++;
83
+ continue;
84
+ }
85
+ candidatesConsidered++;
86
+
87
+ for (const param of boolParams) {
88
+ const values = new Set<"true" | "false">();
89
+ let bailed = false;
90
+ for (const e of directEdges) {
91
+ const caller = routineById.get(e.from);
92
+ if (!caller) {
93
+ bailed = true;
94
+ break;
95
+ }
96
+ if (roleOf(caller) !== "primary") {
97
+ bailed = true;
98
+ break;
99
+ }
100
+ const cs = caller.features.callSites.find((c) => c.id === e.callsiteId);
101
+ const argText = cs?.argumentTexts[param.index];
102
+ if (argText === undefined) {
103
+ bailed = true;
104
+ break;
105
+ }
106
+ const lit = isBooleanLiteral(argText);
107
+ if (lit === undefined) {
108
+ bailed = true;
109
+ break;
110
+ }
111
+ values.add(lit);
112
+ if (values.size > 1) {
113
+ bailed = true;
114
+ break;
115
+ }
116
+ }
117
+ if (bailed) {
118
+ skippedVariesAcrossCallers++;
119
+ continue;
120
+ }
121
+ const constant = [...values][0];
122
+ if (constant === undefined) continue;
123
+
124
+ emit(callee, param, constant, directEdges.length, findings, model);
125
+ }
126
+ }
127
+
128
+ const sorted = findings.sort((a, b) => compareStrings(a.id, b.id));
129
+ return {
130
+ findings: sorted,
131
+ stats: {
132
+ detector: "d32-constant-boolean-parameter",
133
+ candidatesConsidered,
134
+ findingsEmitted: sorted.length,
135
+ skipped: {
136
+ ...(skippedAccessModifier > 0 ? { nonLocal: skippedAccessModifier } : {}),
137
+ ...(skippedNoBooleanParams > 0 ? { noBooleanParams: skippedNoBooleanParams } : {}),
138
+ ...(skippedTooFewCallers > 0 ? { tooFewCallers: skippedTooFewCallers } : {}),
139
+ ...(skippedUnresolvedOrMixedEdges > 0
140
+ ? { unresolvedOrMixedEdges: skippedUnresolvedOrMixedEdges }
141
+ : {}),
142
+ ...(skippedVariesAcrossCallers > 0 ? { varies: skippedVariesAcrossCallers } : {}),
143
+ },
144
+ },
145
+ };
146
+ }
147
+
148
+ function emit(
149
+ callee: Routine,
150
+ param: ParameterSymbol,
151
+ constantValue: "true" | "false",
152
+ callerCount: number,
153
+ findings: Finding[],
154
+ model: SemanticModel,
155
+ ): void {
156
+ const path: EvidenceStep[] = [
157
+ {
158
+ routineId: callee.id,
159
+ sourceAnchor: callee.sourceAnchor,
160
+ note: `local procedure ${callee.name}(${param.name}: ${param.typeText}) — parameter at position ${param.index}`,
161
+ },
162
+ ];
163
+ const finding: Finding = {
164
+ id: `d32/${callee.id}/p${param.index}`,
165
+ rootCauseKey: `d32/${callee.id}/p${param.index}`,
166
+ detector: "d32-constant-boolean-parameter",
167
+ title: "Boolean parameter is always passed the same literal",
168
+ rootCause: `${callee.name} declares Boolean parameter '${param.name}' at position ${param.index}, but all ${callerCount} resolved primary-app callers pass \`${constantValue}\` — the parameter is dead.`,
169
+ severity: "info",
170
+ confidence: toConfidence([], "likely"),
171
+ primaryLocation: callee.sourceAnchor,
172
+ evidencePath: path,
173
+ affectedObjects: [callee.objectId],
174
+ affectedTables: [],
175
+ fixOptions: [
176
+ {
177
+ description: `Flatten the procedure: assume '${param.name} = ${constantValue}' at every site, simplify the body, and remove the parameter. If the parameter is intended for future use, leave a TODO documenting it.`,
178
+ safety: "medium",
179
+ },
180
+ ],
181
+ provenance: [{ source: "tree-sitter" }],
182
+ };
183
+ finding.fingerprint = fingerprintOf(finding, model);
184
+ findings.push(finding);
185
+ }
@@ -0,0 +1,173 @@
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 { 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 FILTER_OPS: ReadonlySet<string> = new Set(["SetRange", "SetFilter"]);
13
+
14
+ /**
15
+ * D33 — `DeleteAll` / `ModifyAll` on a local record variable with no narrowing filter
16
+ * applied earlier in the routine since the last `Reset` on that same variable.
17
+ *
18
+ * Blast radius justifies treating this as a guardrail: an accidental unfiltered
19
+ * `Customer.DeleteAll();` walks the whole table. Built-in AL analyzers don't model
20
+ * filter state across record operations; al-sem can because it tracks ops in source
21
+ * order on each record variable.
22
+ *
23
+ * Decision is on source-ordered intra-routine evidence only:
24
+ * - start with `filtered = false`;
25
+ * - `SetRange` / `SetFilter` on the same variable → `filtered = true`;
26
+ * - `Reset` on the same variable → `filtered = false` (wipes prior filters);
27
+ * - at the bulk write, if `filtered` is still false → flag.
28
+ *
29
+ * Skipped:
30
+ * - by-var parameter record variables (caller may have applied filters);
31
+ * - temporary records (operations are in-memory; no blast radius) — detected
32
+ * structurally via the `temporary` keyword on the var declaration, propagated
33
+ * onto each RecordOperation by routine-indexer.ts (not a naming convention);
34
+ * - record variables whose tableId did not resolve (we cannot identify the table
35
+ * in the rootCause; finding would be too vague).
36
+ *
37
+ * Severity:
38
+ * - `DeleteAll` without filter: `critical` — irrecoverable, whole-table impact.
39
+ * - `ModifyAll` without filter: `high` — whole-table state change.
40
+ *
41
+ * Known precision gaps (documented for future work):
42
+ * - filters inherited via `Copy(R)` / `CopyFilters(R)` are not tracked; the indexer
43
+ * captures `Copy` but doesn't carry the source variable, so we cannot follow them.
44
+ * - filters set in a callee on a by-var parameter are not tracked (would require
45
+ * interprocedural filter-state analysis).
46
+ */
47
+ export function detectD33(
48
+ model: SemanticModel,
49
+ _graph: CombinedGraph,
50
+ ctx: DetectorContext,
51
+ ): { findings: Finding[]; stats: DetectorStats } {
52
+ const findings: Finding[] = [];
53
+ const { tableById } = ctx;
54
+ let candidatesConsidered = 0;
55
+ let skippedTempRecord = 0;
56
+ let skippedParameter = 0;
57
+ let skippedUnresolvedTable = 0;
58
+ let skippedFiltered = 0;
59
+ let skippedParseIncomplete = 0;
60
+
61
+ for (const routine of model.routines) {
62
+ if (roleOf(routine) !== "primary") continue;
63
+ if (!routine.bodyAvailable) continue;
64
+ if (routine.parseIncomplete) {
65
+ skippedParseIncomplete++;
66
+ continue;
67
+ }
68
+
69
+ const paramRecordNames = new Set(
70
+ routine.features.recordVariables
71
+ .filter((rv) => rv.isParameter)
72
+ .map((rv) => rv.name.toLowerCase()),
73
+ );
74
+
75
+ for (const op of routine.features.recordOperations) {
76
+ if (op.op !== "DeleteAll" && op.op !== "ModifyAll") continue;
77
+ candidatesConsidered++;
78
+ const varKey = op.recordVariableName.toLowerCase();
79
+ if (op.tempState.kind === "known" && op.tempState.value === true) {
80
+ skippedTempRecord++;
81
+ continue;
82
+ }
83
+ if (paramRecordNames.has(varKey)) {
84
+ skippedParameter++;
85
+ continue;
86
+ }
87
+ if (op.tableId === undefined) {
88
+ skippedUnresolvedTable++;
89
+ continue;
90
+ }
91
+
92
+ if (wasFilteredBefore(routine.features.recordOperations, varKey, op)) {
93
+ skippedFiltered++;
94
+ continue;
95
+ }
96
+
97
+ emit(routine, op, tableById.get(op.tableId)?.name ?? op.tableId, findings, model);
98
+ }
99
+ }
100
+
101
+ const sorted = findings.sort((a, b) => compareStrings(a.id, b.id));
102
+ return {
103
+ findings: sorted,
104
+ stats: {
105
+ detector: "d33-unfiltered-bulk-write",
106
+ candidatesConsidered,
107
+ findingsEmitted: sorted.length,
108
+ skipped: {
109
+ ...(skippedFiltered > 0 ? { filtered: skippedFiltered } : {}),
110
+ ...(skippedTempRecord > 0 ? { tempRecord: skippedTempRecord } : {}),
111
+ ...(skippedParameter > 0 ? { parameter: skippedParameter } : {}),
112
+ ...(skippedUnresolvedTable > 0 ? { unresolvedTable: skippedUnresolvedTable } : {}),
113
+ ...(skippedParseIncomplete > 0 ? { parseIncomplete: skippedParseIncomplete } : {}),
114
+ },
115
+ },
116
+ };
117
+ }
118
+
119
+ function wasFilteredBefore(
120
+ ops: RecordOperation[],
121
+ varKey: string,
122
+ bulkOp: RecordOperation,
123
+ ): boolean {
124
+ let filtered = false;
125
+ for (const other of ops) {
126
+ if (other === bulkOp) continue;
127
+ if (other.recordVariableName.toLowerCase() !== varKey) continue;
128
+ if (!beforeAnchor(other.sourceAnchor, bulkOp.sourceAnchor)) continue;
129
+ if (FILTER_OPS.has(other.op)) filtered = true;
130
+ else if (other.op === "Reset") filtered = false;
131
+ }
132
+ return filtered;
133
+ }
134
+
135
+ function emit(
136
+ routine: { id: string; objectId: string; name: string },
137
+ op: RecordOperation,
138
+ tableName: string,
139
+ findings: Finding[],
140
+ model: SemanticModel,
141
+ ): void {
142
+ const severity: Finding["severity"] = op.op === "DeleteAll" ? "critical" : "high";
143
+ const path: EvidenceStep[] = [
144
+ {
145
+ routineId: routine.id,
146
+ operationId: op.id,
147
+ sourceAnchor: op.sourceAnchor,
148
+ note: `${op.op} on ${op.recordVariableName} (${tableName}) with no prior SetRange/SetFilter in this routine`,
149
+ },
150
+ ];
151
+ const finding: Finding = {
152
+ id: `d33/${routine.id}/${op.id}`,
153
+ rootCauseKey: `d33/${routine.id}/${op.id}`,
154
+ detector: "d33-unfiltered-bulk-write",
155
+ title: `Unfiltered ${op.op}`,
156
+ rootCause: `${routine.name} calls ${op.op} on ${op.recordVariableName} (${tableName}) with no SetRange/SetFilter applied since the last Reset — the operation affects every row in the table.`,
157
+ severity,
158
+ confidence: toConfidence([], "likely"),
159
+ primaryLocation: op.sourceAnchor,
160
+ evidencePath: path,
161
+ affectedObjects: [routine.objectId],
162
+ affectedTables: op.tableId !== undefined ? [op.tableId] : [],
163
+ fixOptions: [
164
+ {
165
+ description: `Apply a SetRange / SetFilter on ${op.recordVariableName} before calling ${op.op}, or confirm the unconditional whole-table operation is intentional and document it.`,
166
+ safety: "high",
167
+ },
168
+ ],
169
+ provenance: [{ source: "tree-sitter" }],
170
+ };
171
+ finding.fingerprint = fingerprintOf(finding, model);
172
+ findings.push(finding);
173
+ }