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,92 @@
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 EXIT_DESCRIPTION: Record<"exit" | "error" | "currreport-quit", string> = {
11
+ exit: "Exit",
12
+ error: "Error",
13
+ "currreport-quit": "CurrReport.Quit",
14
+ };
15
+
16
+ /**
17
+ * D20 — a statement that immediately follows an unconditional exit
18
+ * (`Exit;` / `Error(...)` / `CurrReport.Quit`) inside the same code block.
19
+ * Control reaches the exit and never returns to the next statement.
20
+ *
21
+ * The indexer captures the exit anchor and the first unreachable sibling
22
+ * during the body DFS (`Routine.features.unreachableStatements`); this
23
+ * detector just emits one finding per recorded pair. Nested blocks
24
+ * (inside `if`, loops, etc.) are tracked independently because the
25
+ * indexer walks every code_block.
26
+ */
27
+ export function detectD20(
28
+ model: SemanticModel,
29
+ _graph: CombinedGraph,
30
+ _ctx: DetectorContext,
31
+ ): { findings: Finding[]; stats: DetectorStats } {
32
+ const findings: Finding[] = [];
33
+ let candidatesConsidered = 0;
34
+
35
+ for (const routine of model.routines) {
36
+ if (roleOf(routine) !== "primary") continue;
37
+ if (!routine.bodyAvailable) continue;
38
+ if (routine.parseIncomplete) continue;
39
+ candidatesConsidered++;
40
+
41
+ for (const u of routine.features.unreachableStatements) {
42
+ const kindLabel = EXIT_DESCRIPTION[u.exitKind];
43
+ const path: EvidenceStep[] = [
44
+ {
45
+ routineId: routine.id,
46
+ sourceAnchor: u.exitAnchor,
47
+ note: `${kindLabel} statement — control leaves the routine here`,
48
+ },
49
+ {
50
+ routineId: routine.id,
51
+ sourceAnchor: u.unreachableAnchor,
52
+ note: "this statement (and any siblings after it) is never executed",
53
+ },
54
+ ];
55
+
56
+ const finding: Finding = {
57
+ id: `d20/${u.id}`,
58
+ rootCauseKey: `d20/${u.id}`,
59
+ detector: "d20-unreachable-after-exit",
60
+ title: "Unreachable statement after unconditional exit",
61
+ rootCause: `${routine.name}: the statement after \`${kindLabel}\` is unreachable — control leaves the routine before it can run.`,
62
+ severity: "low",
63
+ confidence: toConfidence([], "likely"),
64
+ primaryLocation: u.unreachableAnchor,
65
+ evidencePath: path,
66
+ affectedObjects: [routine.objectId],
67
+ affectedTables: [],
68
+ fixOptions: [
69
+ {
70
+ description:
71
+ "Remove the unreachable statement, or move the preceding exit / Error / Quit inside a conditional so the later code can run.",
72
+ safety: "high",
73
+ },
74
+ ],
75
+ provenance: [{ source: "tree-sitter" }],
76
+ };
77
+ finding.fingerprint = fingerprintOf(finding, model);
78
+ findings.push(finding);
79
+ }
80
+ }
81
+
82
+ const sorted = findings.sort((a, b) => compareStrings(a.id, b.id));
83
+ return {
84
+ findings: sorted,
85
+ stats: {
86
+ detector: "d20-unreachable-after-exit",
87
+ candidatesConsidered,
88
+ findingsEmitted: sorted.length,
89
+ skipped: {},
90
+ },
91
+ };
92
+ }
@@ -0,0 +1,128 @@
1
+ import type { CombinedGraph } from "../engine/combined-graph.ts";
2
+ import { beforeAnchor } from "../engine/source-anchor.ts";
3
+ import { compareStrings } from "../engine/uncertainty-util.ts";
4
+ import { roleOf } from "../model/entities.ts";
5
+ import type { DetectorStats, EvidenceStep, Finding } from "../model/finding.ts";
6
+ import type { SemanticModel } from "../model/model.ts";
7
+ import { fingerprintOf } from "../projection/finding-fingerprint.ts";
8
+ import { toConfidence } from "./confidence.ts";
9
+ import type { DetectorContext } from "./detector-context.ts";
10
+
11
+ /**
12
+ * Ops that legitimately put a record into a "loaded / initialised" state in memory.
13
+ * Same set as D11 (the write-side sibling of this detector). A by-var parameter is
14
+ * also treated as loaded (the caller is responsible).
15
+ */
16
+ const LOAD_OPS: ReadonlySet<string> = new Set([
17
+ "Get",
18
+ "FindFirst",
19
+ "FindLast",
20
+ "FindSet",
21
+ "Find",
22
+ "Next",
23
+ "Init",
24
+ "Insert",
25
+ "Copy",
26
+ ]);
27
+
28
+ /**
29
+ * Read-side ops that read the CURRENT record's fields and therefore require a prior load:
30
+ * - `TestField` — runtime check that a field is not blank; raises Error otherwise.
31
+ * - `CalcFields` / `CalcSums` — materialise FlowFields from SQL.
32
+ *
33
+ * Without a prior load these operate on the in-memory default record (a zero-initialised
34
+ * row), which is at best a no-op (`TestField` always fires) and at worst hides real
35
+ * loading bugs.
36
+ */
37
+ const READING_OPS: ReadonlySet<string> = new Set(["TestField", "CalcFields", "CalcSums"]);
38
+
39
+ /**
40
+ * D21 — read-side sibling of D11. Flags `TestField` / `CalcFields` / `CalcSums` on a
41
+ * record variable that was never loaded earlier in the same routine. Skips by-var
42
+ * parameter records (caller-loaded).
43
+ */
44
+ export function detectD21(
45
+ model: SemanticModel,
46
+ _graph: CombinedGraph,
47
+ _ctx: DetectorContext,
48
+ ): { findings: Finding[]; stats: DetectorStats } {
49
+ const findings: Finding[] = [];
50
+ let candidatesConsidered = 0;
51
+ let skippedParseIncomplete = 0;
52
+ let skippedParameter = 0;
53
+
54
+ for (const routine of model.routines) {
55
+ if (roleOf(routine) !== "primary") continue;
56
+ if (!routine.bodyAvailable) continue;
57
+ if (routine.parseIncomplete) {
58
+ skippedParseIncomplete++;
59
+ continue;
60
+ }
61
+ candidatesConsidered++;
62
+
63
+ const paramRecordNames = new Set(
64
+ routine.features.recordVariables
65
+ .filter((rv) => rv.isParameter)
66
+ .map((rv) => rv.name.toLowerCase()),
67
+ );
68
+
69
+ for (const op of routine.features.recordOperations) {
70
+ if (!READING_OPS.has(op.op)) continue;
71
+ const varKey = op.recordVariableName.toLowerCase();
72
+ if (paramRecordNames.has(varKey)) {
73
+ skippedParameter++;
74
+ continue;
75
+ }
76
+ const loadedBefore = routine.features.recordOperations.some(
77
+ (other) =>
78
+ LOAD_OPS.has(other.op) &&
79
+ other.recordVariableName.toLowerCase() === varKey &&
80
+ beforeAnchor(other.sourceAnchor, op.sourceAnchor),
81
+ );
82
+ if (loadedBefore) continue;
83
+
84
+ const path: EvidenceStep[] = [
85
+ {
86
+ routineId: routine.id,
87
+ operationId: op.id,
88
+ sourceAnchor: op.sourceAnchor,
89
+ note: `${op.op} on ${op.recordVariableName} with no prior Get/Find/Init in this routine`,
90
+ },
91
+ ];
92
+ const finding: Finding = {
93
+ id: `d21/${routine.id}/${op.id}`,
94
+ rootCauseKey: `d21/${routine.id}/${op.id}`,
95
+ detector: "d21-read-without-load",
96
+ title: "Read on uninitialised record",
97
+ rootCause: `${routine.name} calls ${op.op} on ${op.recordVariableName} but never loaded it — the call operates on a default record and may silently succeed or always raise.`,
98
+ severity: "medium",
99
+ confidence: toConfidence([], "likely"),
100
+ primaryLocation: op.sourceAnchor,
101
+ evidencePath: path,
102
+ affectedObjects: [routine.objectId],
103
+ affectedTables: op.tableId !== undefined ? [op.tableId] : [],
104
+ fixOptions: [
105
+ {
106
+ description:
107
+ "Load the record with Get / FindFirst before TestField/CalcFields, or pass it in as a var parameter from a caller that loaded it.",
108
+ safety: "high",
109
+ },
110
+ ],
111
+ provenance: [{ source: "tree-sitter" }],
112
+ };
113
+ finding.fingerprint = fingerprintOf(finding, model);
114
+ findings.push(finding);
115
+ }
116
+ }
117
+
118
+ const stats: DetectorStats = {
119
+ detector: "d21-read-without-load",
120
+ candidatesConsidered,
121
+ findingsEmitted: findings.length,
122
+ skipped: {
123
+ ...(skippedParseIncomplete > 0 ? { parseIncomplete: skippedParseIncomplete } : {}),
124
+ ...(skippedParameter > 0 ? { parameter: skippedParameter } : {}),
125
+ },
126
+ };
127
+ return { findings: findings.sort((a, b) => compareStrings(a.id, b.id)), stats };
128
+ }
@@ -0,0 +1,168 @@
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 { Field, FieldAccess, RecordOperation, Table } from "../model/entities.ts";
6
+ import { unquotedFieldName } from "../model/expression.ts";
7
+ import type { DetectorStats, EvidenceStep, Finding } from "../model/finding.ts";
8
+ import type { TableId } from "../model/ids.ts";
9
+ import type { SemanticModel } from "../model/model.ts";
10
+ import { fingerprintOf } from "../projection/finding-fingerprint.ts";
11
+ import { toConfidence } from "./confidence.ts";
12
+ import type { DetectorContext } from "./detector-context.ts";
13
+
14
+ /**
15
+ * D22 — accessing a FlowField on a record without calling `CalcFields(<field>)`
16
+ * earlier in the routine. In AL, a FlowField is not materialised by a regular SQL
17
+ * load (Get/Find*); reading it returns the AL default (0 for numeric, '' for text)
18
+ * until `CalcFields` runs against that specific field. The cost of mis-reading a
19
+ * FlowField is silent: the calling code looks correct but treats every customer as
20
+ * having a zero balance, etc.
21
+ *
22
+ * Detection (intraprocedural):
23
+ * 1. Resolve the record-var to its table (via `recordVariables[].tableId`).
24
+ * 2. Resolve the field name to a `Field` and test `fieldClass === "FlowField"`.
25
+ * 3. Check whether any `CalcFields(...)` call on the same record-var earlier in the
26
+ * same routine lists the field name (case-insensitive) in its `fieldArguments`.
27
+ * 4. If no covering CalcFields, emit a finding anchored on the field access.
28
+ *
29
+ * Skipped:
30
+ * - record-vars whose tableId did not resolve (the field's fieldClass is unknown).
31
+ * - by-var parameter records (caller might have already called CalcFields).
32
+ */
33
+ export function detectD22(
34
+ model: SemanticModel,
35
+ _graph: CombinedGraph,
36
+ ctx: DetectorContext,
37
+ ): { findings: Finding[]; stats: DetectorStats } {
38
+ const findings: Finding[] = [];
39
+ const { tableById } = ctx;
40
+ let candidatesConsidered = 0;
41
+ let skippedUnresolvedTable = 0;
42
+ let skippedParameter = 0;
43
+
44
+ for (const routine of model.routines) {
45
+ if (roleOf(routine) !== "primary") continue;
46
+ if (!routine.bodyAvailable) continue;
47
+ if (routine.parseIncomplete) continue;
48
+ candidatesConsidered++;
49
+
50
+ const recordVarByNameLc = new Map(
51
+ routine.features.recordVariables.map((rv) => [rv.name.toLowerCase(), rv]),
52
+ );
53
+ const paramRecordNames = new Set(
54
+ routine.features.recordVariables
55
+ .filter((rv) => rv.isParameter)
56
+ .map((rv) => rv.name.toLowerCase()),
57
+ );
58
+
59
+ for (const fa of routine.features.fieldAccesses) {
60
+ const recordVarKey = fa.recordVariableName.toLowerCase();
61
+ if (paramRecordNames.has(recordVarKey)) {
62
+ skippedParameter++;
63
+ continue;
64
+ }
65
+ const recordVar = recordVarByNameLc.get(recordVarKey);
66
+ const tableId: TableId | undefined = recordVar?.tableId;
67
+ if (tableId === undefined) {
68
+ skippedUnresolvedTable++;
69
+ continue;
70
+ }
71
+ const table = tableById.get(tableId);
72
+ if (table === undefined) {
73
+ skippedUnresolvedTable++;
74
+ continue;
75
+ }
76
+ const field = lookupField(table, fa.fieldName);
77
+ if (field === undefined) continue;
78
+ if (field.fieldClass !== "FlowField") continue;
79
+
80
+ // Was the field calc'd earlier in this routine on the same record-var?
81
+ if (covered(routine.features.recordOperations, recordVarKey, fa.fieldName, fa.sourceAnchor))
82
+ continue;
83
+
84
+ emit(routine, fa, field, table, findings, model);
85
+ }
86
+ }
87
+
88
+ const sorted = findings.sort((a, b) => compareStrings(a.id, b.id));
89
+ return {
90
+ findings: sorted,
91
+ stats: {
92
+ detector: "d22-flowfield-without-calcfields",
93
+ candidatesConsidered,
94
+ findingsEmitted: sorted.length,
95
+ skipped: {
96
+ ...(skippedUnresolvedTable > 0 ? { unresolvedTable: skippedUnresolvedTable } : {}),
97
+ ...(skippedParameter > 0 ? { parameter: skippedParameter } : {}),
98
+ },
99
+ },
100
+ };
101
+ }
102
+
103
+ function lookupField(table: Table, fieldName: string): Field | undefined {
104
+ // `FieldAccess.fieldName` is already the unquoted name — the body indexer
105
+ // strips `quoted_identifier` quotes at extraction time, so a plain
106
+ // case-insensitive compare suffices here.
107
+ const wanted = fieldName.toLowerCase();
108
+ return table.fields.find((f) => f.name.toLowerCase() === wanted);
109
+ }
110
+
111
+ function covered(
112
+ ops: RecordOperation[],
113
+ recordVarKey: string,
114
+ fieldName: string,
115
+ accessAnchor: FieldAccess["sourceAnchor"],
116
+ ): boolean {
117
+ const wanted = fieldName.toLowerCase();
118
+ for (const op of ops) {
119
+ if (op.op !== "CalcFields") continue;
120
+ if (op.recordVariableName.toLowerCase() !== recordVarKey) continue;
121
+ if (!beforeAnchor(op.sourceAnchor, accessAnchor)) continue;
122
+ // Each CalcFields arg is structured — `unquotedFieldName` returns the
123
+ // `value` (unquoted) for quoted identifiers / string literals, or the
124
+ // bare text otherwise. No regex shred.
125
+ const infos = op.fieldArgumentInfos ?? [];
126
+ if (infos.some((info) => unquotedFieldName(info) === wanted)) return true;
127
+ }
128
+ return false;
129
+ }
130
+
131
+ function emit(
132
+ routine: { id: string; objectId: string; name: string },
133
+ fa: FieldAccess,
134
+ field: Field,
135
+ table: Table,
136
+ findings: Finding[],
137
+ model: SemanticModel,
138
+ ): void {
139
+ const path: EvidenceStep[] = [
140
+ {
141
+ routineId: routine.id,
142
+ sourceAnchor: fa.sourceAnchor,
143
+ note: `reads FlowField ${table.name}.${field.name} without a prior CalcFields(${field.name}) on ${fa.recordVariableName}`,
144
+ },
145
+ ];
146
+ const finding: Finding = {
147
+ id: `d22/${routine.id}/${fa.recordVariableName.toLowerCase()}/${field.name.toLowerCase()}/${fa.sourceAnchor.range.startLine}:${fa.sourceAnchor.range.startColumn}`,
148
+ rootCauseKey: `d22/${routine.id}/${fa.recordVariableName.toLowerCase()}/${field.name.toLowerCase()}`,
149
+ detector: "d22-flowfield-without-calcfields",
150
+ title: "FlowField read without prior CalcFields",
151
+ rootCause: `${routine.name} reads ${table.name}.${field.name} (a FlowField) but never called CalcFields(${field.name}) on ${fa.recordVariableName} — the read returns the AL default (0 / empty), not the live value.`,
152
+ severity: "medium",
153
+ confidence: toConfidence([], "likely"),
154
+ primaryLocation: fa.sourceAnchor,
155
+ evidencePath: path,
156
+ affectedObjects: [routine.objectId],
157
+ affectedTables: [table.id],
158
+ fixOptions: [
159
+ {
160
+ description: `Call \`${fa.recordVariableName}.CalcFields(${field.name});\` before reading the field. Hoist the CalcFields out of any tight loop to avoid an N+1.`,
161
+ safety: "high",
162
+ },
163
+ ],
164
+ provenance: [{ source: "tree-sitter" }],
165
+ };
166
+ finding.fingerprint = fingerprintOf(finding, model);
167
+ findings.push(finding);
168
+ }
@@ -0,0 +1,163 @@
1
+ import { parseRoutineAttributes } from "../engine/attribute-parser.ts";
2
+ import type { CombinedGraph } from "../engine/combined-graph.ts";
3
+ import { compareStrings } from "../engine/uncertainty-util.ts";
4
+ import { findAttribute, stringArg } from "../model/attributes.ts";
5
+ import { roleOf } from "../model/entities.ts";
6
+ import type { ParameterSymbol, RecordOperation, Routine } from "../model/entities.ts";
7
+ import type { DetectorStats, EvidenceStep, Finding } from "../model/finding.ts";
8
+ import type { SemanticModel } from "../model/model.ts";
9
+ import { fingerprintOf } from "../projection/finding-fingerprint.ts";
10
+ import { toConfidence } from "./confidence.ts";
11
+ import type { DetectorContext } from "./detector-context.ts";
12
+
13
+ const MUTATING_OPS: ReadonlySet<string> = new Set(["Modify", "ModifyAll", "Delete", "DeleteAll"]);
14
+
15
+ /** Extract the publisher-event name from a routine's `[EventSubscriber(...)]` attribute.
16
+ * Returns the third positional arg (the event name), lowercased. Returns undefined when
17
+ * the routine isn't a subscriber or the event-name arg is missing/malformed. */
18
+ function eventName(routine: Routine): string | undefined {
19
+ const attr = findAttribute(routine.attributesParsed, "EventSubscriber");
20
+ if (attr === undefined) return undefined;
21
+ return stringArg(attr, 2)?.toLowerCase();
22
+ }
23
+
24
+ /** The publisher-side event names that fire AROUND a record Modify/Delete and pass the
25
+ * affected record as the first parameter. Subscribing and then mutating that parameter
26
+ * is the recursive-trigger hazard D29 catches. */
27
+ const MODIFY_EVENT_PATTERNS: RegExp[] = [
28
+ /onafter(?:validate)?modify(?:event)?/i,
29
+ /onbefore(?:validate)?modify(?:event)?/i,
30
+ /onaftermodifyevent/i,
31
+ /onbeforemodifyevent/i,
32
+ /onafterdelete(?:event)?/i,
33
+ /onbeforedelete(?:event)?/i,
34
+ ];
35
+
36
+ function isModifyEvent(name: string | undefined): boolean {
37
+ if (name === undefined) return false;
38
+ return MODIFY_EVENT_PATTERNS.some((re) => re.test(name));
39
+ }
40
+
41
+ /**
42
+ * D29 — an `[EventSubscriber]` of a record-modify (or delete) event that calls
43
+ * `Modify` / `ModifyAll` / `Delete` / `DeleteAll` on the inbound record parameter
44
+ * re-fires the same publisher event, opening a recursive-trigger loop.
45
+ *
46
+ * Two conditions:
47
+ * 1. Routine carries `[EventSubscriber(ObjectType::..., ..., '<eventName>', ...)]`
48
+ * and `<eventName>` matches a Modify/Delete-shaped pattern
49
+ * (OnAfter*Modify*, OnBefore*Modify*, OnAfterDelete*, OnBeforeDelete*).
50
+ * 2. Body has a Modify/ModifyAll/Delete/DeleteAll on a record-typed parameter
51
+ * (typically the first record parameter — the "Rec" the event handed over).
52
+ *
53
+ * Severity: `medium`. Real bugs of this shape can be `high`, but al-sem can't yet
54
+ * distinguish `Modify(true)` (re-fires events, definitely recursive) from
55
+ * `Modify(false)` (skips triggers). Future work: capture Modify arguments and
56
+ * escalate when the `RunTrigger` flag is true or omitted.
57
+ *
58
+ * Skipped:
59
+ * - non-subscribers / non-Modify events;
60
+ * - routines without a record-typed parameter (nothing to mutate);
61
+ * - mutations on non-parameter local records (those are D11/D10 territory).
62
+ */
63
+ export function detectD29(
64
+ model: SemanticModel,
65
+ _graph: CombinedGraph,
66
+ _ctx: DetectorContext,
67
+ ): { findings: Finding[]; stats: DetectorStats } {
68
+ const findings: Finding[] = [];
69
+ let candidatesConsidered = 0;
70
+ let skippedNonModifyEvent = 0;
71
+ let skippedNoRecordParam = 0;
72
+
73
+ for (const routine of model.routines) {
74
+ if (roleOf(routine) !== "primary") continue;
75
+ if (routine.kind !== "event-subscriber") continue;
76
+ if (!routine.bodyAvailable) continue;
77
+ if (routine.parseIncomplete) continue;
78
+ // Cross-check via the attribute parser so we don't double-flag if the routine has
79
+ // `[Obsolete]` or weird modifiers — parseRoutineAttributes ignores EventSubscriber
80
+ // but it's a cheap consistency check.
81
+ void parseRoutineAttributes(routine);
82
+
83
+ const evt = eventName(routine);
84
+ if (!isModifyEvent(evt)) {
85
+ skippedNonModifyEvent++;
86
+ continue;
87
+ }
88
+ const recordParams = routine.parameters.filter((p): p is ParameterSymbol => p.isRecord);
89
+ if (recordParams.length === 0) {
90
+ skippedNoRecordParam++;
91
+ continue;
92
+ }
93
+ candidatesConsidered++;
94
+
95
+ const paramNamesLc = new Set(recordParams.map((p) => p.name.toLowerCase()));
96
+ for (const op of routine.features.recordOperations) {
97
+ if (!MUTATING_OPS.has(op.op)) continue;
98
+ const varKey = op.recordVariableName.toLowerCase();
99
+ if (!paramNamesLc.has(varKey)) continue;
100
+
101
+ emit(routine, op, evt ?? "<unknown event>", findings, model);
102
+ }
103
+ }
104
+
105
+ const sorted = findings.sort((a, b) => compareStrings(a.id, b.id));
106
+ return {
107
+ findings: sorted,
108
+ stats: {
109
+ detector: "d29-subscriber-modify-on-event-record",
110
+ candidatesConsidered,
111
+ findingsEmitted: sorted.length,
112
+ skipped: {
113
+ ...(skippedNonModifyEvent > 0 ? { nonModifyEvent: skippedNonModifyEvent } : {}),
114
+ ...(skippedNoRecordParam > 0 ? { noRecordParam: skippedNoRecordParam } : {}),
115
+ },
116
+ },
117
+ };
118
+ }
119
+
120
+ function emit(
121
+ routine: Routine,
122
+ op: RecordOperation,
123
+ eventNameLc: string,
124
+ findings: Finding[],
125
+ model: SemanticModel,
126
+ ): void {
127
+ const path: EvidenceStep[] = [
128
+ {
129
+ routineId: routine.id,
130
+ sourceAnchor: routine.sourceAnchor,
131
+ note: `[EventSubscriber] ${routine.name} on '${eventNameLc}'`,
132
+ },
133
+ {
134
+ routineId: routine.id,
135
+ operationId: op.id,
136
+ sourceAnchor: op.sourceAnchor,
137
+ note: `${op.op} on ${op.recordVariableName} — the event's inbound record parameter`,
138
+ },
139
+ ];
140
+ const finding: Finding = {
141
+ id: `d29/${routine.id}/${op.id}`,
142
+ rootCauseKey: `d29/${routine.id}/${op.id}`,
143
+ detector: "d29-subscriber-modify-on-event-record",
144
+ title: "Event subscriber mutates the inbound record",
145
+ rootCause: `${routine.name} subscribes to '${eventNameLc}' and calls ${op.op} on ${op.recordVariableName}, the inbound record parameter — re-firing the same event from a subscriber can recurse.`,
146
+ severity: "medium",
147
+ confidence: toConfidence([], "likely"),
148
+ primaryLocation: op.sourceAnchor,
149
+ evidencePath: path,
150
+ affectedObjects: [routine.objectId],
151
+ affectedTables: op.tableId !== undefined ? [op.tableId] : [],
152
+ fixOptions: [
153
+ {
154
+ description:
155
+ "Either use Modify(false) to suppress trigger re-firing, perform the mutation on a fresh record loaded by primary key, or move the work outside the subscriber path.",
156
+ safety: "medium",
157
+ },
158
+ ],
159
+ provenance: [{ source: "tree-sitter" }],
160
+ };
161
+ finding.fingerprint = fingerprintOf(finding, model);
162
+ findings.push(finding);
163
+ }
@@ -0,0 +1,72 @@
1
+ import type { RecordOperation, Routine } from "../model/entities.ts";
2
+
3
+ /** The load-field state of a record variable at a point in the operation stream. */
4
+ export type LoadState =
5
+ | { kind: "none" } // no SetLoadFields seen — the full record is loaded
6
+ | { kind: "loaded"; fields: Set<string> } // a partial load set is active
7
+ | { kind: "invalidated" }; // Reset / Copy / TransferFields cleared the analysable state
8
+
9
+ /** A retrieval op paired with the load state of its record variable at that site. */
10
+ export interface LoadStateAtRetrieval {
11
+ retrievalOp: RecordOperation;
12
+ recordVariableName: string;
13
+ loadState: LoadState;
14
+ }
15
+
16
+ const RETRIEVAL_OPS = new Set(["FindSet", "FindFirst", "FindLast", "Get"]);
17
+
18
+ /** Source order: line then column. */
19
+ function inSourceOrder(a: RecordOperation, b: RecordOperation): number {
20
+ const ra = a.sourceAnchor.range;
21
+ const rb = b.sourceAnchor.range;
22
+ if (ra.startLine !== rb.startLine) return ra.startLine - rb.startLine;
23
+ return ra.startColumn - rb.startColumn;
24
+ }
25
+
26
+ /**
27
+ * Reconstruct per-record-variable load-field state by walking the routine's record
28
+ * operations in source order. Returns one entry per retrieval op (`FindSet` / `FindFirst` /
29
+ * `FindLast` / `Get`) with the load state of its record variable at that point.
30
+ *
31
+ * `SetLoadFields` sets a partial load set; `AddLoadFields` unions; `Reset` / `Copy` /
32
+ * `TransferFields` invalidate the analysable state. All record-variable names are compared
33
+ * case-insensitively (AL identifiers are case-insensitive).
34
+ */
35
+ export function deriveLoadStates(routine: Routine): LoadStateAtRetrieval[] {
36
+ const ops = [...routine.features.recordOperations].sort(inSourceOrder);
37
+ const stateByVar = new Map<string, LoadState>();
38
+ const out: LoadStateAtRetrieval[] = [];
39
+
40
+ for (const op of ops) {
41
+ const varKey = op.recordVariableName.toLowerCase();
42
+ const current = stateByVar.get(varKey) ?? { kind: "none" };
43
+
44
+ if (op.op === "SetLoadFields") {
45
+ stateByVar.set(varKey, {
46
+ kind: "loaded",
47
+ fields: new Set((op.fieldArguments ?? []).map((f) => f.toLowerCase())),
48
+ });
49
+ continue;
50
+ }
51
+ if (op.op === "AddLoadFields") {
52
+ const next = new Set(current.kind === "loaded" ? current.fields : []);
53
+ for (const f of op.fieldArguments ?? []) next.add(f.toLowerCase());
54
+ stateByVar.set(varKey, { kind: "loaded", fields: next });
55
+ continue;
56
+ }
57
+ if (op.op === "Reset" || op.op === "Copy" || op.op === "TransferFields") {
58
+ stateByVar.set(varKey, { kind: "invalidated" });
59
+ continue;
60
+ }
61
+ if (RETRIEVAL_OPS.has(op.op)) {
62
+ out.push({
63
+ retrievalOp: op,
64
+ recordVariableName: op.recordVariableName,
65
+ loadState:
66
+ current.kind === "loaded" ? { kind: "loaded", fields: new Set(current.fields) } : current,
67
+ });
68
+ }
69
+ }
70
+
71
+ return out;
72
+ }