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,223 @@
1
+ import type { CombinedGraph } from "../engine/combined-graph.ts";
2
+ import { buildCrossExtensionSubscribers, eventKindOf } from "../engine/event-flow.ts";
3
+ import { compareStrings } from "../engine/uncertainty-util.ts";
4
+ import type { CapabilityOp } from "../model/capability.ts";
5
+ import type { DetectorStats, EvidenceStep, Finding } from "../model/finding.ts";
6
+ import type { EventId, RoutineId, TableId } from "../model/ids.ts";
7
+ import type { SemanticModel } from "../model/model.ts";
8
+ import { fingerprintOf } from "../projection/finding-fingerprint.ts";
9
+ import { findCapabilities } from "./capability-query.ts";
10
+ import type { DetectorContext } from "./detector-context.ts";
11
+ import { groupAndCap, truncationDiagnostic } from "./finding-grouping.ts";
12
+
13
+ export const D44_NAME = "d44-event-multi-subscriber-overlap";
14
+
15
+ const WRITE_OPS = new Set<CapabilityOp>(["insert", "modify", "delete"]);
16
+
17
+ const D44_MAX_PER_EVENT = 32;
18
+
19
+ interface SubWrite {
20
+ subscriber: string;
21
+ table: string;
22
+ op: CapabilityOp;
23
+ }
24
+
25
+ export function detectD44(
26
+ model: SemanticModel,
27
+ _graph: CombinedGraph,
28
+ ctx: DetectorContext,
29
+ ): { findings: Finding[]; stats: DetectorStats } {
30
+ const ix = ctx.getEventFlowIndexes();
31
+ const findings: Finding[] = [];
32
+ let candidates = 0;
33
+
34
+ const eventKindById = new Map<EventId, "integration" | "business" | "internal">();
35
+ for (const ev of model.eventGraph.events) {
36
+ eventKindById.set(ev.id as EventId, eventKindOf(ev.eventKind));
37
+ }
38
+
39
+ // Phase 3.3: cross-extension subscriber lookup per event.
40
+ const crossExtByEvent = buildCrossExtensionSubscribers(model);
41
+
42
+ // Build (event, table) → subscriber writes
43
+ const grouped = new Map<string, SubWrite[]>(); // key = `${eventId}|${tableId}`
44
+ for (const [eventId, subs] of ix.subscribersByEvent) {
45
+ for (const sub of subs) {
46
+ const r = ctx.routineById.get(sub);
47
+ if (!r?.summary) continue;
48
+ const writes = findCapabilities(
49
+ r.summary,
50
+ (f) =>
51
+ f.resourceKind === "table" && WRITE_OPS.has(f.op) && typeof f.resourceId === "string",
52
+ );
53
+ for (const w of writes) {
54
+ const key = `${eventId}|${w.resourceId}`;
55
+ const bag = grouped.get(key) ?? [];
56
+ bag.push({ subscriber: sub, table: w.resourceId as string, op: w.op });
57
+ grouped.set(key, bag);
58
+ }
59
+ }
60
+ }
61
+
62
+ for (const [key, writes] of grouped) {
63
+ const uniqueSubs = new Set(writes.map((w) => w.subscriber));
64
+ if (uniqueSubs.size < 2) continue;
65
+ candidates++;
66
+ const [eventId, tableId] = key.split("|", 2) as [string, string];
67
+ const subList = [...uniqueSubs].sort(compareStrings);
68
+ const opUnion = [...new Set(writes.map((w) => w.op))].sort(compareStrings);
69
+ const first =
70
+ subList[0] !== undefined ? ctx.routineById.get(subList[0] as RoutineId) : undefined;
71
+ if (!first) continue;
72
+ const rootCauseKey = `d44/${eventId}|${tableId}`;
73
+ const evidence: EvidenceStep[] = subList.map((sub) => {
74
+ const sr = ctx.routineById.get(sub as RoutineId);
75
+ return {
76
+ routineId: sub as never,
77
+ sourceAnchor: sr?.sourceAnchor ?? first.sourceAnchor,
78
+ note: `writes table ${tableId}`,
79
+ };
80
+ });
81
+ const crossExtSubsWW = crossExtByEvent.get(eventId as EventId);
82
+ const finding: Finding = {
83
+ id: rootCauseKey,
84
+ rootCauseKey,
85
+ detector: D44_NAME,
86
+ title: "Multiple event subscribers write the same table",
87
+ rootCause: `${subList.length} subscribers of event ${eventId} write table ${tableId} (ops: ${opUnion.join(", ")})`,
88
+ severity: "medium",
89
+ confidence: { level: "likely", evidence: [] },
90
+ primaryLocation: first.sourceAnchor,
91
+ evidencePath: evidence,
92
+ affectedObjects: [],
93
+ affectedTables: [tableId] as TableId[],
94
+ eventKind: eventKindById.get(eventId as EventId),
95
+ crossExtensionSubscribers:
96
+ crossExtSubsWW !== undefined && crossExtSubsWW.length > 0 ? crossExtSubsWW : undefined,
97
+ fixOptions: [
98
+ {
99
+ description:
100
+ "Coordinate the writes (single subscriber, or merge intent) to avoid lost-update / ordering surprises.",
101
+ safety: "medium",
102
+ },
103
+ ],
104
+ provenance: [{ source: "tree-sitter" }],
105
+ };
106
+ finding.fingerprint = fingerprintOf(finding, model);
107
+ findings.push(finding);
108
+ }
109
+
110
+ // Phase 3.2 read-after-write: find tables where one subscriber writes AND a
111
+ // DIFFERENT subscriber reads on the same event.
112
+ const writersByEventTable = new Map<string, Set<string>>();
113
+ const readersByEventTable = new Map<string, Set<string>>();
114
+ for (const [eventId, subs] of ix.subscribersByEvent) {
115
+ for (const sub of subs) {
116
+ const r = ctx.routineById.get(sub);
117
+ if (!r?.summary) continue;
118
+ const writeFacts = findCapabilities(
119
+ r.summary,
120
+ (f) =>
121
+ f.resourceKind === "table" && WRITE_OPS.has(f.op) && typeof f.resourceId === "string",
122
+ );
123
+ for (const w of writeFacts) {
124
+ const k = `${eventId}|${w.resourceId}`;
125
+ const set = writersByEventTable.get(k) ?? new Set<string>();
126
+ set.add(sub);
127
+ writersByEventTable.set(k, set);
128
+ }
129
+ const readFacts = findCapabilities(
130
+ r.summary,
131
+ (f) => f.resourceKind === "table" && f.op === "read" && typeof f.resourceId === "string",
132
+ );
133
+ for (const rd of readFacts) {
134
+ const k = `${eventId}|${rd.resourceId}`;
135
+ const set = readersByEventTable.get(k) ?? new Set<string>();
136
+ set.add(sub);
137
+ readersByEventTable.set(k, set);
138
+ }
139
+ }
140
+ }
141
+
142
+ for (const [key, writers] of writersByEventTable) {
143
+ const readers = readersByEventTable.get(key) ?? new Set<string>();
144
+ const distinctReaders = [...readers].filter((rd) => !writers.has(rd));
145
+ if (distinctReaders.length === 0) continue;
146
+ const [eventId, tableId] = key.split("|", 2);
147
+ if (eventId === undefined || tableId === undefined) continue;
148
+ const writerList = [...writers].sort(compareStrings);
149
+ const readerList = distinctReaders.sort(compareStrings);
150
+ const first =
151
+ writerList[0] !== undefined ? ctx.routineById.get(writerList[0] as RoutineId) : undefined;
152
+ if (!first) continue;
153
+ const rootCauseKey = `d44-rw/${eventId}|${tableId}`;
154
+ const evidence: EvidenceStep[] = [
155
+ ...writerList.map((sub) => {
156
+ const r = ctx.routineById.get(sub as RoutineId);
157
+ return {
158
+ routineId: sub as never,
159
+ sourceAnchor: r?.sourceAnchor ?? first.sourceAnchor,
160
+ note: `writes ${tableId}`,
161
+ };
162
+ }),
163
+ ...readerList.map((sub) => {
164
+ const r = ctx.routineById.get(sub as RoutineId);
165
+ return {
166
+ routineId: sub as never,
167
+ sourceAnchor: r?.sourceAnchor ?? first.sourceAnchor,
168
+ note: `reads ${tableId}`,
169
+ };
170
+ }),
171
+ ];
172
+ const crossExtSubsRW = crossExtByEvent.get(eventId as EventId);
173
+ const finding: Finding = {
174
+ id: rootCauseKey,
175
+ rootCauseKey,
176
+ detector: D44_NAME,
177
+ title: "Event subscriber reads a table that another subscriber writes",
178
+ rootCause: `On event ${eventId}, subscribers {${writerList.join(", ")}} write ${tableId}; subscribers {${readerList.join(", ")}} read ${tableId}. AL subscriber order is undefined — reads may see pre- or post-mutation state.`,
179
+ severity: "low",
180
+ confidence: { level: "likely", evidence: [] },
181
+ primaryLocation: first.sourceAnchor,
182
+ evidencePath: evidence,
183
+ affectedObjects: [],
184
+ affectedTables: [tableId] as TableId[],
185
+ eventKind: eventKindById.get(eventId as EventId),
186
+ crossExtensionSubscribers:
187
+ crossExtSubsRW !== undefined && crossExtSubsRW.length > 0 ? crossExtSubsRW : undefined,
188
+ fixOptions: [
189
+ {
190
+ description:
191
+ "Make subscriber ordering explicit, or move the read into the writing subscriber.",
192
+ safety: "medium",
193
+ },
194
+ ],
195
+ provenance: [{ source: "tree-sitter" }],
196
+ };
197
+ finding.fingerprint = fingerprintOf(finding, model);
198
+ findings.push(finding);
199
+ }
200
+
201
+ // Apply the output cap across BOTH write/write and r/w findings.
202
+ const grouped2 = groupAndCap(
203
+ findings,
204
+ (f) => {
205
+ const m = f.rootCauseKey.match(/^d44(?:-rw)?\/([^|]+)/);
206
+ return m?.[1] ?? f.rootCauseKey;
207
+ },
208
+ D44_MAX_PER_EVENT,
209
+ );
210
+ if (grouped2.truncated.length > 0) {
211
+ ctx.diagnostics.push(truncationDiagnostic(D44_NAME, "event", grouped2.truncated.length));
212
+ }
213
+
214
+ return {
215
+ findings: [...grouped2.kept].sort((a, b) => compareStrings(a.id, b.id)),
216
+ stats: {
217
+ detector: D44_NAME,
218
+ candidatesConsidered: candidates,
219
+ findingsEmitted: grouped2.kept.length,
220
+ skipped: {},
221
+ },
222
+ };
223
+ }
@@ -0,0 +1,159 @@
1
+ import type { CombinedGraph } from "../engine/combined-graph.ts";
2
+ import {
3
+ type EventFlowIndexes,
4
+ buildCrossExtensionSubscribers,
5
+ eventKindOf,
6
+ } from "../engine/event-flow.ts";
7
+ import { collectRelaySubscribers } from "../engine/event-relay.ts";
8
+ import { compareStrings } from "../engine/uncertainty-util.ts";
9
+ import { roleOf } from "../model/entities.ts";
10
+ import type { DetectorStats, EvidenceStep, Finding } from "../model/finding.ts";
11
+ import type { EventId, RoutineId, TableId } from "../model/ids.ts";
12
+ import type { SemanticModel } from "../model/model.ts";
13
+ import { fingerprintOf } from "../projection/finding-fingerprint.ts";
14
+ import { reachableCoverage, writesTablesOf } from "./capability-query.ts";
15
+ import type { DetectorContext } from "./detector-context.ts";
16
+ import { groupAndCap, truncationDiagnostic } from "./finding-grouping.ts";
17
+
18
+ export const D45_NAME = "d45-event-transitive-table-exposure";
19
+
20
+ const D45_MAX_DEPTH = 4;
21
+ const D45_MAX_NODES = 256;
22
+ const D45_MAX_PER_PUBLISHER = 16;
23
+
24
+ export function detectD45(
25
+ model: SemanticModel,
26
+ graph: CombinedGraph,
27
+ ctx: DetectorContext,
28
+ ): { findings: Finding[]; stats: DetectorStats } {
29
+ const ix = ctx.getEventFlowIndexes();
30
+ const findings: Finding[] = [];
31
+ let candidates = 0;
32
+
33
+ const eventKindById = new Map<EventId, "integration" | "business" | "internal">();
34
+ for (const ev of model.eventGraph.events) {
35
+ eventKindById.set(ev.id as EventId, eventKindOf(ev.eventKind));
36
+ }
37
+
38
+ // Phase 3.3: cross-extension subscriber lookup per event.
39
+ const crossExtByEvent = buildCrossExtensionSubscribers(model);
40
+
41
+ for (const [publisher] of ix.eventsByPublisher) {
42
+ const pubRoutine = ctx.routineById.get(publisher);
43
+ if (!pubRoutine?.summary) continue;
44
+ // Findings are anchored at the publisher; a dependency-app publisher's finding is
45
+ // scoped out by runDetectors (dep-anchored, no primary actionableAnchor). Skip those
46
+ // publishers up front instead of walking thousands of Base App subscriber chains whose
47
+ // findings are discarded. Output-identical; large cold-analyze win.
48
+ if (roleOf(pubRoutine) !== "primary") continue;
49
+ const pubWrites = new Set(writesTablesOf(pubRoutine.summary));
50
+ const pubCov = reachableCoverage(pubRoutine.summary);
51
+
52
+ // Walk the full subscriber chain (N hops via event + call graph).
53
+ const subscribersByDepth = collectRelaySubscribers(publisher, model, ix, graph, {
54
+ maxDepth: D45_MAX_DEPTH,
55
+ maxNodes: D45_MAX_NODES,
56
+ });
57
+
58
+ // Aggregate subscriber-induced writes, tracking worst coverage and writer sets.
59
+ const writerSubsByTable = new Map<string, Set<string>>();
60
+ let subCovWorst: "complete" | "partial" | "unknown" = "complete";
61
+
62
+ for (const [sub] of subscribersByDepth) {
63
+ const r = ctx.routineById.get(sub);
64
+ if (!r?.summary) {
65
+ subCovWorst = "unknown";
66
+ continue;
67
+ }
68
+ const status = reachableCoverage(r.summary);
69
+ if (status === "unknown") subCovWorst = "unknown";
70
+ else if (status === "partial" && subCovWorst !== "unknown") subCovWorst = "partial";
71
+ for (const t of writesTablesOf(r.summary)) {
72
+ const set = writerSubsByTable.get(t) ?? new Set<string>();
73
+ set.add(sub);
74
+ writerSubsByTable.set(t, set);
75
+ }
76
+ }
77
+
78
+ const publisherEvents = [...(ix.eventsByPublisher.get(publisher) ?? [])].sort(compareStrings);
79
+ const firstEvent = publisherEvents[0];
80
+ const publisherEventKind = firstEvent ? eventKindById.get(firstEvent as EventId) : undefined;
81
+
82
+ for (const [table, writerSet] of writerSubsByTable) {
83
+ candidates++;
84
+ const publisherAlsoWrites: "yes" | "no" | "unknown" = pubWrites.has(table)
85
+ ? "yes"
86
+ : pubCov === "complete"
87
+ ? "no"
88
+ : "unknown";
89
+ const writerSubs = [...writerSet].sort(compareStrings);
90
+ // direct = at least one writer is at chain depth ≤ 1 (immediate subscriber).
91
+ let coverageReach: "direct" | "transitive" = "transitive";
92
+ for (const sub of writerSubs) {
93
+ const d = subscribersByDepth.get(sub as RoutineId);
94
+ if (d !== undefined && d <= 1) {
95
+ coverageReach = "direct";
96
+ break;
97
+ }
98
+ }
99
+ const rootCauseKey = `d45/${publisher}|${table}`;
100
+ const evidence: EvidenceStep[] = writerSubs.map((sub) => {
101
+ const sr = ctx.routineById.get(sub as RoutineId);
102
+ return {
103
+ routineId: sub,
104
+ sourceAnchor: sr?.sourceAnchor ?? pubRoutine.sourceAnchor,
105
+ note: `subscriber writes ${table}`,
106
+ };
107
+ });
108
+ const crossExtSubs = firstEvent ? crossExtByEvent.get(firstEvent as EventId) : undefined;
109
+ const finding: Finding = {
110
+ id: rootCauseKey,
111
+ rootCauseKey,
112
+ detector: D45_NAME,
113
+ title: "Event subscribers expose table transitively from publisher",
114
+ rootCause: `Publisher ${publisher} dispatches to ${writerSubs.length} subscriber(s) that write table ${table}; reach=${coverageReach}; publisherAlsoWrites=${publisherAlsoWrites}; subscriberCoverage=${subCovWorst}`,
115
+ severity: "info",
116
+ confidence: { level: "likely", evidence: [] },
117
+ primaryLocation: pubRoutine.sourceAnchor,
118
+ evidencePath: evidence,
119
+ affectedObjects: [],
120
+ affectedTables: [table] as TableId[],
121
+ eventKind: publisherEventKind,
122
+ crossExtensionSubscribers:
123
+ crossExtSubs !== undefined && crossExtSubs.length > 0 ? crossExtSubs : undefined,
124
+ fixOptions: [
125
+ {
126
+ description: `Treat ${table} as part of this event's effect surface for permission/transaction reasoning.`,
127
+ safety: "high",
128
+ },
129
+ ],
130
+ provenance: [{ source: "tree-sitter" }],
131
+ };
132
+ finding.fingerprint = fingerprintOf(finding, model);
133
+ findings.push(finding);
134
+ }
135
+ }
136
+
137
+ // Apply per-publisher cap.
138
+ const grouped = groupAndCap(
139
+ findings,
140
+ (f) => {
141
+ const m = f.rootCauseKey.match(/^d45\/([^|]+)/);
142
+ return m?.[1] ?? f.rootCauseKey;
143
+ },
144
+ D45_MAX_PER_PUBLISHER,
145
+ );
146
+ if (grouped.truncated.length > 0) {
147
+ ctx.diagnostics.push(truncationDiagnostic(D45_NAME, "publisher", grouped.truncated.length));
148
+ }
149
+
150
+ return {
151
+ findings: [...grouped.kept].sort((a, b) => compareStrings(a.id, b.id)),
152
+ stats: {
153
+ detector: D45_NAME,
154
+ candidatesConsidered: candidates,
155
+ findingsEmitted: grouped.kept.length,
156
+ skipped: {},
157
+ },
158
+ };
159
+ }
@@ -0,0 +1,162 @@
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
+ /**
11
+ * Ops that count as "filter / load-state setters" and are allowed alongside a
12
+ * Modify without disqualifying the pattern.
13
+ */
14
+ const ALLOWED_OTHER_OPS: ReadonlySet<string> = new Set([
15
+ "SetRange",
16
+ "SetFilter",
17
+ "SetLoadFields",
18
+ "AddLoadFields",
19
+ "SetCurrentKey",
20
+ "Next",
21
+ ]);
22
+
23
+ /**
24
+ * D5 — narrow heuristic: a loop whose only DB write is Modify on the iterating record,
25
+ * no callSites, no other DB ops outside filter/load-state setters → emit info-level
26
+ * ModifyAll suggestion. Future versions can extend to DeleteAll and to conditional
27
+ * cases via dataflow.
28
+ *
29
+ * Detection strategy mirrors D10: use `Next()` inside the loop body to identify the
30
+ * record variable driving the loop (FindSet/FindFirst appear in the `if` guard before
31
+ * the `repeat` keyword and therefore carry loopStack === []).
32
+ */
33
+ export function detectD5(
34
+ model: SemanticModel,
35
+ _graph: CombinedGraph,
36
+ _ctx: DetectorContext,
37
+ ): { findings: Finding[]; stats: DetectorStats } {
38
+ const findings: Finding[] = [];
39
+ let candidatesConsidered = 0;
40
+ let skippedOther = 0;
41
+
42
+ for (const routine of model.routines) {
43
+ if (roleOf(routine) !== "primary") continue;
44
+ if (!routine.bodyAvailable) continue;
45
+ if (routine.parseIncomplete) continue;
46
+ candidatesConsidered++;
47
+ let emittedForRoutine = 0;
48
+
49
+ // Map loopId → record variable that drives the loop (identified via Next() inside
50
+ // the repeat/until body, which always has a non-empty loopStack).
51
+ const loopDriver = new Map<string, string>();
52
+ for (const op of routine.features.recordOperations) {
53
+ if (op.op !== "Next") continue;
54
+ const loop = op.loopStack[op.loopStack.length - 1];
55
+ if (loop === undefined) continue;
56
+ if (!loopDriver.has(loop)) loopDriver.set(loop, op.recordVariableName.toLowerCase());
57
+ }
58
+
59
+ for (const loop of routine.features.loops) {
60
+ const driver = loopDriver.get(loop.id);
61
+ if (driver === undefined) continue;
62
+
63
+ // All record ops inside this loop.
64
+ const opsInLoop = routine.features.recordOperations.filter((op) =>
65
+ op.loopStack.includes(loop.id),
66
+ );
67
+
68
+ // No callSites inside the loop — interprocedural calls could hide DB ops.
69
+ const callsitesInLoop = routine.features.callSites.filter((cs) =>
70
+ cs.loopStack.includes(loop.id),
71
+ );
72
+ if (callsitesInLoop.length > 0) continue;
73
+
74
+ // Must have exactly one Modify on the iterating record variable inside the loop.
75
+ const modifyOps = opsInLoop.filter(
76
+ (op) => op.op === "Modify" && op.recordVariableName.toLowerCase() === driver,
77
+ );
78
+ if (modifyOps.length !== 1) continue;
79
+ const modify = modifyOps[0];
80
+ if (modify === undefined) continue;
81
+
82
+ // All other ops in the loop must be filter/load-state setters or Next.
83
+ const otherOps = opsInLoop.filter((op) => op !== modify);
84
+ if (!otherOps.every((op) => ALLOWED_OTHER_OPS.has(op.op))) continue;
85
+
86
+ // Find the associated retrieval op for this record variable outside the loop
87
+ // (FindSet/FindFirst/FindLast/Find — they live before the repeat with loopStack=[]).
88
+ const retrievalOp = routine.features.recordOperations.find(
89
+ (op) =>
90
+ (op.op === "FindSet" ||
91
+ op.op === "FindFirst" ||
92
+ op.op === "FindLast" ||
93
+ op.op === "Find") &&
94
+ op.recordVariableName.toLowerCase() === driver &&
95
+ op.loopStack.length === 0,
96
+ );
97
+
98
+ const recordVarDisplay =
99
+ routine.features.recordOperations.find(
100
+ (op) => op.recordVariableName.toLowerCase() === driver,
101
+ )?.recordVariableName ?? driver;
102
+
103
+ const path: EvidenceStep[] = [];
104
+ if (retrievalOp !== undefined) {
105
+ path.push({
106
+ routineId: routine.id,
107
+ operationId: retrievalOp.id,
108
+ sourceAnchor: retrievalOp.sourceAnchor,
109
+ note: `${retrievalOp.op} on ${recordVarDisplay} — loop entry`,
110
+ });
111
+ }
112
+ path.push({
113
+ routineId: routine.id,
114
+ loopId: loop.id,
115
+ sourceAnchor: loop.sourceAnchor,
116
+ note: `${loop.type} loop on ${recordVarDisplay}`,
117
+ });
118
+ path.push({
119
+ routineId: routine.id,
120
+ operationId: modify.id,
121
+ sourceAnchor: modify.sourceAnchor,
122
+ note: `Modify on ${recordVarDisplay} — consider ModifyAll`,
123
+ });
124
+
125
+ const finding: Finding = {
126
+ id: `d5/${routine.id}/${loop.id}`,
127
+ rootCauseKey: `d5/${routine.id}/${loop.id}`,
128
+ detector: "d5-set-based-opportunity",
129
+ title: "Loop-and-Modify could be ModifyAll",
130
+ rootCause: `${routine.name} loops over ${recordVarDisplay} and Modifies each row with no conditional branches or inter-record DB calls — ModifyAll on the same filter would issue one SQL statement.`,
131
+ severity: "info",
132
+ confidence: toConfidence([], "possible"),
133
+ primaryLocation: modify.sourceAnchor,
134
+ evidencePath: path,
135
+ affectedObjects: [routine.objectId],
136
+ affectedTables: modify.tableId !== undefined ? [modify.tableId] : [],
137
+ fixOptions: [
138
+ {
139
+ description:
140
+ "Replace the FindSet+repeat+Modify pattern with ModifyAll on the same filter.",
141
+ safety: "medium",
142
+ },
143
+ ],
144
+ provenance: [{ source: "tree-sitter" }],
145
+ };
146
+ finding.fingerprint = fingerprintOf(finding, model);
147
+ findings.push(finding);
148
+ emittedForRoutine++;
149
+ }
150
+ if (emittedForRoutine === 0) skippedOther++;
151
+ }
152
+
153
+ return {
154
+ findings: findings.sort((a, b) => compareStrings(a.id, b.id)),
155
+ stats: {
156
+ detector: "d5-set-based-opportunity",
157
+ candidatesConsidered,
158
+ findingsEmitted: findings.length,
159
+ skipped: { other: skippedOther > 0 ? skippedOther : undefined },
160
+ },
161
+ };
162
+ }
@@ -0,0 +1,151 @@
1
+ import type { CombinedEdge, 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 { RoutineId } from "../model/ids.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
+ function tarjanSCC(
12
+ nodes: RoutineId[],
13
+ edgesFrom: (id: RoutineId) => CombinedEdge[],
14
+ ): RoutineId[][] {
15
+ let index = 0;
16
+ const indices = new Map<RoutineId, number>();
17
+ const lowlink = new Map<RoutineId, number>();
18
+ const onStack = new Set<RoutineId>();
19
+ const stack: RoutineId[] = [];
20
+ const sccs: RoutineId[][] = [];
21
+
22
+ function strongconnect(v: RoutineId): void {
23
+ indices.set(v, index);
24
+ lowlink.set(v, index);
25
+ index++;
26
+ stack.push(v);
27
+ onStack.add(v);
28
+ for (const e of edgesFrom(v)) {
29
+ const w = e.to;
30
+ if (!indices.has(w)) {
31
+ strongconnect(w);
32
+ const lvl = lowlink.get(v) ?? 0;
33
+ const lwl = lowlink.get(w) ?? 0;
34
+ lowlink.set(v, Math.min(lvl, lwl));
35
+ } else if (onStack.has(w)) {
36
+ const lvl = lowlink.get(v) ?? 0;
37
+ const iwl = indices.get(w) ?? 0;
38
+ lowlink.set(v, Math.min(lvl, iwl));
39
+ }
40
+ }
41
+ if (lowlink.get(v) === indices.get(v)) {
42
+ const scc: RoutineId[] = [];
43
+ let w: RoutineId | undefined;
44
+ do {
45
+ w = stack.pop();
46
+ if (w === undefined) break;
47
+ onStack.delete(w);
48
+ scc.push(w);
49
+ } while (w !== v);
50
+ sccs.push(scc);
51
+ }
52
+ }
53
+ for (const v of nodes) if (!indices.has(v)) strongconnect(v);
54
+ return sccs;
55
+ }
56
+
57
+ /**
58
+ * D7 — Recursive event expansion: an event-subscriber cycle where a subscriber
59
+ * transitively publishes an event whose subscriber eventually publishes the original
60
+ * event again (or any other event in the same SCC). Any such cycle risks unbounded
61
+ * recursion at runtime.
62
+ *
63
+ * Algorithm: Tarjan SCC over the combined graph. Report any SCC of size >= 2 that
64
+ * contains at least one event-dispatch edge (within the SCC) AND at least one primary
65
+ * routine.
66
+ */
67
+ export function detectD7(
68
+ model: SemanticModel,
69
+ graph: CombinedGraph,
70
+ ctx: DetectorContext,
71
+ ): { findings: Finding[]; stats: DetectorStats } {
72
+ const edgesFrom = (id: RoutineId) => graph.edgesByFrom.get(id) ?? [];
73
+ const sccs = tarjanSCC(graph.nodes, edgesFrom).filter((scc) => scc.length >= 2);
74
+
75
+ const findings: Finding[] = [];
76
+ const { routineById } = ctx;
77
+ const candidatesConsidered = sccs.length;
78
+
79
+ for (const scc of sccs) {
80
+ const inSet = new Set(scc);
81
+ const hasEventEdge = scc.some((from) =>
82
+ (graph.edgesByFrom.get(from) ?? []).some(
83
+ (e) => e.kind === "event-dispatch" && inSet.has(e.to),
84
+ ),
85
+ );
86
+ if (!hasEventEdge) continue;
87
+ const hasPrimary = scc.some((id) => {
88
+ const r = routineById.get(id);
89
+ return r !== undefined && roleOf(r) === "primary";
90
+ });
91
+ if (!hasPrimary) continue;
92
+
93
+ const sortedScc = [...scc].sort();
94
+ const path: EvidenceStep[] = sortedScc.map((id) => {
95
+ const r = routineById.get(id);
96
+ return {
97
+ routineId: id,
98
+ sourceAnchor: r?.sourceAnchor ?? {
99
+ sourceUnitId: "",
100
+ range: { startLine: 0, startColumn: 0, endLine: 0, endColumn: 0 },
101
+ enclosingRoutineId: id,
102
+ syntaxKind: "routine",
103
+ },
104
+ note: r ? `participant: ${r.name}` : `participant: ${id}`,
105
+ };
106
+ });
107
+ const firstId = sortedScc[0];
108
+ if (firstId === undefined) continue;
109
+ const anchorRoutine = routineById.get(firstId);
110
+ if (!anchorRoutine) continue;
111
+
112
+ const finding: Finding = {
113
+ id: `d7/${sortedScc.join(",")}`,
114
+ rootCauseKey: `d7/${sortedScc.join(",")}`,
115
+ detector: "d7-recursive-event-expansion",
116
+ title: "Event subscriber chain forms a cycle",
117
+ rootCause: `Routines ${sortedScc.map((id) => routineById.get(id)?.name ?? id).join(" → ")} → ${anchorRoutine.name} form an event-dispatch cycle — invoking any of them at runtime can trigger unbounded recursion.`,
118
+ severity: "high",
119
+ confidence: toConfidence([], "likely"),
120
+ primaryLocation: anchorRoutine.sourceAnchor,
121
+ evidencePath: path,
122
+ affectedObjects: [
123
+ ...new Set(
124
+ sortedScc
125
+ .map((id) => routineById.get(id)?.objectId)
126
+ .filter((x): x is string => x !== undefined),
127
+ ),
128
+ ].sort(),
129
+ affectedTables: [],
130
+ fixOptions: [
131
+ {
132
+ description:
133
+ "Break the cycle: either remove one of the event publishes from a subscriber, or gate the publish on a 'currently-processing' flag.",
134
+ safety: "low",
135
+ },
136
+ ],
137
+ provenance: [{ source: "tree-sitter" }],
138
+ };
139
+ finding.fingerprint = fingerprintOf(finding, model);
140
+ findings.push(finding);
141
+ }
142
+ return {
143
+ findings: findings.sort((a, b) => compareStrings(a.id, b.id)),
144
+ stats: {
145
+ detector: "d7-recursive-event-expansion",
146
+ candidatesConsidered,
147
+ findingsEmitted: findings.length,
148
+ skipped: {},
149
+ },
150
+ };
151
+ }