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 { RecordOpType } from "../model/entities.ts";
2
+
3
+ /**
4
+ * The effect class of a record operation. `touchesDb` is driven only by db-read /
5
+ * db-write / db-lock; state-only ops feed D3's load-field analysis and parameterRoles;
6
+ * `trigger` (Validate) has no direct DB effect — its effects arrive via the Phase 2a
7
+ * implicit-trigger edge.
8
+ */
9
+ export type OpEffectClass = "db-read" | "db-write" | "db-lock" | "state-only" | "trigger";
10
+
11
+ const CLASS_BY_OP: Record<RecordOpType, OpEffectClass> = {
12
+ FindSet: "db-read",
13
+ FindFirst: "db-read",
14
+ FindLast: "db-read",
15
+ Find: "db-read",
16
+ Get: "db-read",
17
+ Next: "db-read",
18
+ Count: "db-read",
19
+ CountApprox: "db-read",
20
+ IsEmpty: "db-read",
21
+ CalcFields: "db-read",
22
+ CalcSums: "db-read",
23
+ TestField: "state-only",
24
+ Modify: "db-write",
25
+ ModifyAll: "db-write",
26
+ Insert: "db-write",
27
+ Delete: "db-write",
28
+ DeleteAll: "db-write",
29
+ LockTable: "db-lock",
30
+ SetLoadFields: "state-only",
31
+ AddLoadFields: "state-only",
32
+ SetRange: "state-only",
33
+ SetFilter: "state-only",
34
+ SetCurrentKey: "state-only",
35
+ Reset: "state-only",
36
+ Copy: "state-only",
37
+ TransferFields: "state-only",
38
+ Init: "state-only",
39
+ Validate: "trigger",
40
+ };
41
+
42
+ /** Classify a record operation by its database effect. Pure, total over RecordOpType. */
43
+ export function classifyOp(op: RecordOpType): OpEffectClass {
44
+ return CLASS_BY_OP[op];
45
+ }
46
+
47
+ /** True when this op class contributes to `touchesDb`. */
48
+ export function isDbTouchingClass(cls: OpEffectClass): boolean {
49
+ return cls === "db-read" || cls === "db-write" || cls === "db-lock";
50
+ }
51
+
52
+ /**
53
+ * Per-op record-flow role used by the record-flow framework's may-fact
54
+ * bootstrap (spec §(a)). Each op classifies into at most one of these
55
+ * categories for the purposes of state-flow tracking. Field-level facts
56
+ * (readsFields/writesFields) are computed independently by D3 already.
57
+ */
58
+ export type RecordFlowOpRole =
59
+ | "loadsFromDb" // Get / FindFirst / FindLast / FindSet / Find / Next
60
+ | "initialises" // Init
61
+ | "persistsCurrent" // Modify / Insert / Rename
62
+ | "setBasedWrite" // ModifyAll / DeleteAll
63
+ | "validates" // Validate
64
+ | "copiesInto" // Copy / TransferFields (target side)
65
+ | "resetsFilter" // Reset
66
+ | "neutral"; // SetRange / SetFilter / SetLoadFields / AddLoadFields / TestField / etc.
67
+
68
+ // Partial so most ops fall through to "neutral"; tightening the key to RecordOpType
69
+ // gives exhaustiveness — adding a new op-type will surface here at the compiler if
70
+ // the new op should map to a non-neutral role. (Note: "Rename" is not yet in
71
+ // RecordOpType — when it lands, decide whether to add it here as "persistsCurrent".)
72
+ const ROLE_BY_OP: Partial<Record<RecordOpType, RecordFlowOpRole>> = {
73
+ Get: "loadsFromDb",
74
+ FindFirst: "loadsFromDb",
75
+ FindLast: "loadsFromDb",
76
+ FindSet: "loadsFromDb",
77
+ Find: "loadsFromDb",
78
+ Next: "loadsFromDb",
79
+ Init: "initialises",
80
+ Modify: "persistsCurrent",
81
+ Insert: "persistsCurrent",
82
+ ModifyAll: "setBasedWrite",
83
+ DeleteAll: "setBasedWrite",
84
+ Validate: "validates",
85
+ Copy: "copiesInto",
86
+ TransferFields: "copiesInto",
87
+ Reset: "resetsFilter",
88
+ };
89
+
90
+ export function recordFlowRoleOf(op: RecordOpType): RecordFlowOpRole {
91
+ return ROLE_BY_OP[op] ?? "neutral";
92
+ }
@@ -0,0 +1,189 @@
1
+ import type { CallSite, Routine } from "../model/entities.ts";
2
+ import type { EvidenceStep } from "../model/finding.ts";
3
+ import type { CallsiteId, RoutineId } from "../model/ids.ts";
4
+ import type { SemanticModel } from "../model/model.ts";
5
+ import type { Uncertainty } from "../model/summary.ts";
6
+ import type { CombinedEdge, CombinedGraph } from "./combined-graph.ts";
7
+ import { dedupeUncertainties } from "./uncertainty-util.ts";
8
+
9
+ /** A real op site the walk can terminate at. Policies may return a richer subtype. */
10
+ export interface Terminal {
11
+ routineId: RoutineId;
12
+ /** Loop nesting depth of the op site within its OWN routine. */
13
+ localLoopDepth: number;
14
+ }
15
+
16
+ /** Why a walk branch stopped. Detectors emit findings only from `complete` results. */
17
+ export type WalkStop = "complete" | "cycle-cut" | "depth-cut" | "node-budget-cut" | "dead-end";
18
+
19
+ export interface WalkResult {
20
+ path: EvidenceStep[];
21
+ effectiveLoopDepth: number;
22
+ uncertainties: Uncertainty[];
23
+ stop: WalkStop;
24
+ }
25
+
26
+ /** The mutable context threaded through one walk branch. */
27
+ export interface PathCtx {
28
+ routinePath: RoutineId[];
29
+ inheritedLoopDepth: number;
30
+ steps: EvidenceStep[];
31
+ uncertainties: Uncertainty[];
32
+ }
33
+
34
+ export interface WalkBounds {
35
+ maxDepth: number; // max routine-path length
36
+ maxNodes: number; // max nodes visited across the whole walk
37
+ }
38
+
39
+ /** Detector-supplied policy: which edges to follow, what counts as a terminal, how to build steps. */
40
+ export interface WalkPolicy<T extends Terminal = Terminal> {
41
+ terminalsAt(node: RoutineId, ctx: PathCtx): T[];
42
+ expand(node: RoutineId, ctx: PathCtx): CombinedEdge[];
43
+ buildHopStep(edge: CombinedEdge, ctx: PathCtx): EvidenceStep;
44
+ buildTerminalStep(terminal: T, ctx: PathCtx): EvidenceStep;
45
+ }
46
+
47
+ export interface WalkOpts {
48
+ /** Loop depth already established by the detector (e.g. the loop D1 started from). */
49
+ initialLoopDepth?: number;
50
+ /** Evidence steps the detector wants prepended (e.g. the loop step). */
51
+ initialSteps?: EvidenceStep[];
52
+ /**
53
+ * Prebuilt indexes. `walkEvidence` is called once per in-loop call site by D1/D2, so
54
+ * rebuilding these from `model`/`graph` on every call is O(routines + edges) per call —
55
+ * the dominant cost on large workspaces. Callers that hold the shared DetectorContext
56
+ * pass its maps; when omitted, the walker builds them itself (unchanged behaviour for
57
+ * one-off callers / tests). All three are read-only here.
58
+ */
59
+ routineById?: Map<RoutineId, Routine>;
60
+ uncertaintyEdgesByFrom?: Map<RoutineId, Uncertainty[]>;
61
+ callSiteById?: Map<CallsiteId, CallSite>;
62
+ }
63
+
64
+ /**
65
+ * Bounded depth-first evidence walk. Returns one WalkResult per branch that reached a
66
+ * terminal (`complete`) or stopped (`cycle-cut` / `depth-cut` / `node-budget-cut` /
67
+ * `dead-end`). Pure — no I/O. Cycle detection is per-path; bounds cap depth and total nodes.
68
+ */
69
+ export function walkEvidence<T extends Terminal>(
70
+ start: RoutineId,
71
+ policy: WalkPolicy<T>,
72
+ bounds: WalkBounds,
73
+ graph: CombinedGraph,
74
+ model: SemanticModel,
75
+ opts: WalkOpts = {},
76
+ ): WalkResult[] {
77
+ const results: WalkResult[] = [];
78
+ let nodesVisited = 0;
79
+ const routineById = opts.routineById ?? new Map(model.routines.map((r) => [r.id, r]));
80
+
81
+ const uncertaintyEdgesByFrom =
82
+ opts.uncertaintyEdgesByFrom ??
83
+ (() => {
84
+ const m = new Map<RoutineId, Uncertainty[]>();
85
+ for (const ue of graph.uncertaintyEdges) {
86
+ const list = m.get(ue.from);
87
+ if (list) list.push(ue.uncertainty);
88
+ else m.set(ue.from, [ue.uncertainty]);
89
+ }
90
+ return m;
91
+ })();
92
+
93
+ const callSiteById =
94
+ opts.callSiteById ??
95
+ (() => {
96
+ const m = new Map<CallsiteId, CallSite>();
97
+ for (const r of model.routines) {
98
+ for (const cs of r.features.callSites) m.set(cs.id, cs);
99
+ }
100
+ return m;
101
+ })();
102
+
103
+ const uncertaintiesAt = (node: RoutineId): Uncertainty[] => {
104
+ const fromSummary = routineById.get(node)?.summary?.uncertainties ?? [];
105
+ const fromEdges = uncertaintyEdgesByFrom.get(node) ?? [];
106
+ return [...fromSummary, ...fromEdges];
107
+ };
108
+
109
+ const loopDepthOfEdge = (edge: CombinedEdge): number => {
110
+ if (edge.callsiteId === undefined) return 0;
111
+ const cs = callSiteById.get(edge.callsiteId);
112
+ return cs?.loopStack.length ?? 0;
113
+ };
114
+
115
+ const visit = (node: RoutineId, ctx: PathCtx): void => {
116
+ nodesVisited++;
117
+ const ctxHere: PathCtx = {
118
+ ...ctx,
119
+ uncertainties: dedupeUncertainties([...ctx.uncertainties, ...uncertaintiesAt(node)]),
120
+ };
121
+
122
+ const terminals = policy.terminalsAt(node, ctxHere);
123
+ for (const t of terminals) {
124
+ results.push({
125
+ path: [...ctxHere.steps, policy.buildTerminalStep(t, ctxHere)],
126
+ effectiveLoopDepth: ctxHere.inheritedLoopDepth + t.localLoopDepth,
127
+ uncertainties: ctxHere.uncertainties,
128
+ stop: "complete",
129
+ });
130
+ }
131
+
132
+ const edges = policy.expand(node, ctxHere);
133
+ if (edges.length === 0 && terminals.length === 0) {
134
+ results.push({
135
+ path: ctxHere.steps,
136
+ effectiveLoopDepth: ctxHere.inheritedLoopDepth,
137
+ uncertainties: ctxHere.uncertainties,
138
+ stop: "dead-end",
139
+ });
140
+ return;
141
+ }
142
+
143
+ for (const edge of edges) {
144
+ if (nodesVisited >= bounds.maxNodes) {
145
+ results.push({
146
+ path: ctxHere.steps,
147
+ effectiveLoopDepth: ctxHere.inheritedLoopDepth,
148
+ uncertainties: ctxHere.uncertainties,
149
+ stop: "node-budget-cut",
150
+ });
151
+ continue;
152
+ }
153
+ if (ctxHere.routinePath.includes(edge.to)) {
154
+ results.push({
155
+ path: ctxHere.steps,
156
+ effectiveLoopDepth: ctxHere.inheritedLoopDepth,
157
+ uncertainties: ctxHere.uncertainties,
158
+ stop: "cycle-cut",
159
+ });
160
+ continue;
161
+ }
162
+ if (ctxHere.routinePath.length >= bounds.maxDepth) {
163
+ results.push({
164
+ path: ctxHere.steps,
165
+ effectiveLoopDepth: ctxHere.inheritedLoopDepth,
166
+ uncertainties: ctxHere.uncertainties,
167
+ stop: "depth-cut",
168
+ });
169
+ continue;
170
+ }
171
+ const childCtx: PathCtx = {
172
+ routinePath: [...ctxHere.routinePath, edge.to],
173
+ inheritedLoopDepth: ctxHere.inheritedLoopDepth + loopDepthOfEdge(edge),
174
+ steps: [...ctxHere.steps, policy.buildHopStep(edge, ctxHere)],
175
+ uncertainties: ctxHere.uncertainties,
176
+ };
177
+ visit(edge.to, childCtx);
178
+ }
179
+ };
180
+
181
+ visit(start, {
182
+ routinePath: [start],
183
+ inheritedLoopDepth: opts.initialLoopDepth ?? 0,
184
+ steps: opts.initialSteps ?? [],
185
+ uncertainties: [],
186
+ });
187
+
188
+ return results;
189
+ }
@@ -0,0 +1,23 @@
1
+ import type { RoutineId } from "../model/ids.ts";
2
+ import type { CombinedEdge, CombinedGraph } from "./combined-graph.ts";
3
+
4
+ /** Map of routineId → edges where that routine is the callee. */
5
+ export type ReverseCallGraph = Map<RoutineId, CombinedEdge[]>;
6
+
7
+ /** Invert `graph.edgesByFrom` so each routine knows who calls it. */
8
+ export function buildReverseCallGraph(graph: CombinedGraph): ReverseCallGraph {
9
+ const reverse: ReverseCallGraph = new Map();
10
+ for (const edges of graph.edgesByFrom.values()) {
11
+ for (const e of edges) {
12
+ const list = reverse.get(e.to);
13
+ if (list) list.push(e);
14
+ else reverse.set(e.to, [e]);
15
+ }
16
+ }
17
+ return reverse;
18
+ }
19
+
20
+ /** Return the resolved callers of a routine; empty list when none. */
21
+ export function callersOf(reverse: ReverseCallGraph, routineId: RoutineId): CombinedEdge[] {
22
+ return reverse.get(routineId) ?? [];
23
+ }
@@ -0,0 +1,194 @@
1
+ import type { RootsConfig, RootsConfigTarget } from "../config/roots-config.ts";
2
+ import type { Routine } from "../model/entities.ts";
3
+ import type { Diagnostic } from "../model/finding.ts";
4
+ import type { RoutineId } from "../model/ids.ts";
5
+ import type { SemanticModel } from "../model/model.ts";
6
+ import {
7
+ type RootClassification,
8
+ type RootKind,
9
+ isExternallyReachableKind,
10
+ } from "../model/root-classification.ts";
11
+ import { ROOT_KIND_ORDER } from "./root-classifier.ts";
12
+
13
+ /**
14
+ * Merge a `RootsConfig` overlay on top of the AST classification result
15
+ * (Phase 1 §4.3 Task 6).
16
+ *
17
+ * Provenance discipline:
18
+ * - AST classifications are the base layer (no config = output equals input).
19
+ * - Each config entry: resolve target → routine. On success, merge into
20
+ * an existing AST classification OR create a new one.
21
+ * - When AST + config agree on kinds → `source: "ast+config"`,
22
+ * `confidence: "static"`.
23
+ * - When AST + config disagree → emit `kinds-mismatch` diagnostic; union the
24
+ * kinds, still `source: "ast+config"`, `confidence: "static"` (AST
25
+ * corroboration upgrades user-asserted → static).
26
+ * - Config-only routines (no AST signal) → `source: "config"`,
27
+ * `confidence: "user-asserted"`.
28
+ * - Two config entries on the same routine (no AST signal) →
29
+ * `[roots-config/duplicate-target]` diagnostic; last-write-wins on the
30
+ * stored entry, both ids named in the diagnostic.
31
+ * - Unresolved targets → `[roots-config/unresolved]` diagnostic; entry
32
+ * dropped.
33
+ * - Ambiguous targets (multiple matches) → `[roots-config/ambiguous]`
34
+ * diagnostic; classification recorded on the FIRST routine by canonical
35
+ * id sort, with `resolutionStatus: "ambiguous"`.
36
+ *
37
+ * Pure: never throws, never does I/O. Output is sorted by `routineId` for
38
+ * determinism, matching the AST classifier's invariant.
39
+ */
40
+ export function overlayConfigRoots(
41
+ astRoots: RootClassification[],
42
+ config: RootsConfig | undefined,
43
+ model: SemanticModel,
44
+ ): { roots: RootClassification[]; diagnostics: Diagnostic[] } {
45
+ if (config === undefined) {
46
+ return { roots: astRoots, diagnostics: [] };
47
+ }
48
+
49
+ const diagnostics: Diagnostic[] = [];
50
+ // `astRoots` already has at most one entry per RoutineId (Task 3 invariant).
51
+ // `astByRoutine` is a FROZEN snapshot of the AST baseline — read-only.
52
+ // `byRoutine` is the accumulator (starts from AST, then overlay writes win).
53
+ // Keeping them separate guarantees a second config entry on the same routine
54
+ // sees the ORIGINAL AST kind set, not the first entry's merged result.
55
+ const astByRoutine = new Map<RoutineId, RootClassification>(
56
+ astRoots.map((r) => [r.routineId, r]),
57
+ );
58
+ const byRoutine = new Map<RoutineId, RootClassification>(astByRoutine);
59
+ // Tracks which routines have already been written by a config entry in
60
+ // this pass, so we can emit `[roots-config/duplicate-target]` when two
61
+ // entries target the same routine.
62
+ const configWriters = new Map<RoutineId, string>();
63
+
64
+ for (const entry of config.roots) {
65
+ const matches = resolveTarget(entry.target, model).sort((a, b) =>
66
+ a.id < b.id ? -1 : a.id > b.id ? 1 : 0,
67
+ );
68
+
69
+ if (matches.length === 0) {
70
+ diagnostics.push(
71
+ diag(
72
+ "warning",
73
+ `[roots-config/unresolved] roots.config.json entry "${entry.id}" did not match any routine; skipping.`,
74
+ entry.id,
75
+ ),
76
+ );
77
+ continue;
78
+ }
79
+
80
+ const ambiguous = matches.length > 1;
81
+ if (ambiguous) {
82
+ diagnostics.push(
83
+ diag(
84
+ "warning",
85
+ `[roots-config/ambiguous] roots.config.json entry "${entry.id}" matched ${matches.length} routines; using first by id sort.`,
86
+ entry.id,
87
+ ),
88
+ );
89
+ }
90
+
91
+ // matches.length >= 1 — guarded above.
92
+ // biome-ignore lint/style/noNonNullAssertion: length checked above.
93
+ const winner = matches[0]!;
94
+ const existingAst = astByRoutine.get(winner.id);
95
+ const hasAst = existingAst !== undefined;
96
+ // Loader already canonicalized the kind set (deduped + sorted in
97
+ // ROOT_KIND_ORDER); Set for O(k) lookup.
98
+ const cfgKinds = new Set<RootKind>(entry.kinds);
99
+ const cfgExternally = entry.externallyReachable;
100
+
101
+ // Duplicate-target check fires independently of AST status — two
102
+ // config entries pointing at the same routine is a config-author
103
+ // mistake either way.
104
+ const priorWriter = configWriters.get(winner.id);
105
+ if (priorWriter !== undefined) {
106
+ diagnostics.push(
107
+ diag(
108
+ "warning",
109
+ `[roots-config/duplicate-target] roots.config.json entries "${priorWriter}" and "${entry.id}" both target the same routine; last entry wins.`,
110
+ entry.id,
111
+ ),
112
+ );
113
+ }
114
+
115
+ if (!hasAst) {
116
+ // Config-only root: no AST signal, "user-asserted" confidence.
117
+ // Last-write-wins when multiple config entries target the same
118
+ // routine — the duplicate-target diagnostic was already emitted
119
+ // above naming both ids.
120
+ const kinds: RootKind[] = ROOT_KIND_ORDER.filter((k) => cfgKinds.has(k));
121
+ // Defensive: loader rejects entries with kinds.length === 0,
122
+ // so this guard should be unreachable. Keep it so a future
123
+ // loader change can't silently emit empty-kinds classifications.
124
+ if (kinds.length === 0) continue;
125
+ byRoutine.set(winner.id, {
126
+ routineId: winner.id,
127
+ kinds,
128
+ externallyReachable: cfgExternally ?? kinds.some(isExternallyReachableKind),
129
+ source: "config",
130
+ confidence: "user-asserted",
131
+ sourceAnchor: winner.sourceAnchor,
132
+ configEntryId: entry.id,
133
+ resolutionStatus: ambiguous ? "ambiguous" : "resolved",
134
+ });
135
+ configWriters.set(winner.id, entry.id);
136
+ } else {
137
+ // AST + config corroboration: union kinds, upgrade to "static".
138
+ // `existingAst` is the ORIGINAL AST entry from the frozen snapshot,
139
+ // so a second config entry on the same routine still unions
140
+ // against AST's kinds (not entry 1's merged result).
141
+ //
142
+ // biome-ignore lint/style/noNonNullAssertion: hasAst === true implies astByRoutine has the entry.
143
+ const existing = existingAst!;
144
+ const astKindSet = new Set<RootKind>(existing.kinds);
145
+ const onlyAstSet = new Set<RootKind>([...astKindSet].filter((k) => !cfgKinds.has(k)));
146
+ const onlyCfgSet = new Set<RootKind>([...cfgKinds].filter((k) => !astKindSet.has(k)));
147
+ if (onlyAstSet.size > 0 || onlyCfgSet.size > 0) {
148
+ // Order diff kind lists by ROOT_KIND_ORDER for stable
149
+ // reading and to match the union-output ordering.
150
+ const onlyAst = ROOT_KIND_ORDER.filter((k) => onlyAstSet.has(k));
151
+ const onlyCfg = ROOT_KIND_ORDER.filter((k) => onlyCfgSet.has(k));
152
+ diagnostics.push(
153
+ diag(
154
+ "warning",
155
+ `[roots-config/kinds-mismatch] roots.config.json entry "${entry.id}" disagrees with AST: ast-only=${JSON.stringify(onlyAst)}, config-only=${JSON.stringify(onlyCfg)}.`,
156
+ entry.id,
157
+ ),
158
+ );
159
+ }
160
+ const unionedSet = new Set<RootKind>([...existing.kinds, ...entry.kinds]);
161
+ const unionedKinds: RootKind[] = ROOT_KIND_ORDER.filter((k) => unionedSet.has(k));
162
+ byRoutine.set(winner.id, {
163
+ ...existing,
164
+ kinds: unionedKinds,
165
+ externallyReachable: cfgExternally ?? unionedKinds.some(isExternallyReachableKind),
166
+ source: "ast+config",
167
+ confidence: "static",
168
+ configEntryId: entry.id,
169
+ resolutionStatus: ambiguous ? "ambiguous" : "resolved",
170
+ });
171
+ configWriters.set(winner.id, entry.id);
172
+ }
173
+ }
174
+
175
+ const roots = [...byRoutine.values()].sort((a, b) =>
176
+ a.routineId < b.routineId ? -1 : a.routineId > b.routineId ? 1 : 0,
177
+ );
178
+ return { roots, diagnostics };
179
+ }
180
+
181
+ function resolveTarget(target: RootsConfigTarget, model: SemanticModel): Routine[] {
182
+ if ("routineId" in target) {
183
+ const r = model.routines.find((rr) => rr.id === target.routineId);
184
+ return r === undefined ? [] : [r];
185
+ }
186
+ const lcName = target.routineName.toLowerCase();
187
+ return model.routines.filter(
188
+ (rr) => rr.objectId === target.objectId && rr.name.toLowerCase() === lcName,
189
+ );
190
+ }
191
+
192
+ function diag(severity: Diagnostic["severity"], message: string, sourceRef: string): Diagnostic {
193
+ return { severity, stage: "discover", message, sourceRef };
194
+ }
@@ -0,0 +1,135 @@
1
+ import type { ObjectDecl, Routine } from "../model/entities.ts";
2
+ import type { ObjectId } from "../model/ids.ts";
3
+ import type { SemanticModel } from "../model/model.ts";
4
+ import {
5
+ ROOT_KIND_VALUES,
6
+ type RootClassification,
7
+ type RootKind,
8
+ isExternallyReachableKind,
9
+ } from "../model/root-classification.ts";
10
+
11
+ /**
12
+ * Canonical RootKind declaration order. Re-exported from the model layer
13
+ * (`ROOT_KIND_VALUES`) so tests and future formatters can reference the
14
+ * source-of-truth rather than redeclaring. Typed as `readonly RootKind[]`
15
+ * (widening away the tuple's literal `.length`) to preserve the original
16
+ * signature for existing consumers.
17
+ */
18
+ export const ROOT_KIND_ORDER: readonly RootKind[] = ROOT_KIND_VALUES;
19
+
20
+ /**
21
+ * Phase 1 §4.3 AST-only root-classifier. Produces `RootClassification[]` for every
22
+ * routine that qualifies as one or more `RootKind`. Routines with no qualifying kind
23
+ * are not in the result.
24
+ *
25
+ * Pure transform over the `SemanticModel`; never throws. Routines whose declaring
26
+ * object is missing from `model.objects` (should never happen, but `objectId` is a
27
+ * string alias) are silently skipped rather than crashing — the engine never throws.
28
+ *
29
+ * Deferred kinds (not produced by this implementation):
30
+ * - `page-action`: needs Page action AST indexing (not yet in routine-indexer).
31
+ * - `web-service-exposed`: needs a cross-object WebService scan.
32
+ * - `job-queue-entrypoint`: no static signal — depends on runtime registration.
33
+ *
34
+ * Output is sorted by `routineId` (canonical lexicographic) for determinism.
35
+ */
36
+ export function classifyRoots(model: SemanticModel): RootClassification[] {
37
+ const objectsById = new Map<ObjectId, ObjectDecl>(model.objects.map((o) => [o.id, o]));
38
+ const result: RootClassification[] = [];
39
+
40
+ for (const routine of model.routines) {
41
+ const object = objectsById.get(routine.objectId);
42
+ if (object === undefined) continue;
43
+ const kinds = kindsFor(routine, object);
44
+ if (kinds.length === 0) continue;
45
+ const externallyReachable = kinds.some(isExternallyReachableKind);
46
+ result.push({
47
+ routineId: routine.id,
48
+ kinds,
49
+ externallyReachable,
50
+ source: "ast",
51
+ confidence: "static",
52
+ sourceAnchor: routine.sourceAnchor,
53
+ });
54
+ }
55
+
56
+ // Canonical sort for determinism — RoutineId is a string.
57
+ result.sort((a, b) => (a.routineId < b.routineId ? -1 : a.routineId > b.routineId ? 1 : 0));
58
+ return result;
59
+ }
60
+
61
+ /**
62
+ * Compute the set of `RootKind`s a routine qualifies for, based purely on its
63
+ * structural shape + the host object's declared metadata. Returns the empty
64
+ * array when no kind applies.
65
+ *
66
+ * `public-procedure` is a catch-all and is only added when no more specific
67
+ * kind applied — otherwise routines on an Install/Upgrade/API host would be
68
+ * double-classified.
69
+ */
70
+ function kindsFor(routine: Routine, object: ObjectDecl): RootKind[] {
71
+ const kinds: RootKind[] = [];
72
+
73
+ // Trigger kinds — gated on routine.kind === "trigger". Codeunit OnRun is not
74
+ // a separate kind here; it falls through to the Subtype-based classification.
75
+ if (routine.kind === "trigger") {
76
+ switch (object.objectType) {
77
+ case "Table":
78
+ case "TableExtension":
79
+ kinds.push("trigger-table");
80
+ break;
81
+ case "Page":
82
+ case "PageExtension":
83
+ kinds.push("trigger-page");
84
+ break;
85
+ case "Report":
86
+ kinds.push("report-trigger");
87
+ break;
88
+ // Other object types: codeunit triggers (OnRun) get classified via
89
+ // Subtype below; the trigger alone is not an entry-point kind today.
90
+ }
91
+ }
92
+
93
+ // Event-subscriber — direct from routine.kind (set by routine-indexer when
94
+ // the [EventSubscriber(...)] attribute is present).
95
+ if (routine.kind === "event-subscriber") {
96
+ kinds.push("event-subscriber");
97
+ }
98
+
99
+ // Codeunit Subtype-based kinds. Applies to ALL routines on a Codeunit with
100
+ // the matching Subtype — Install/Upgrade codeunits run their OnRun (and any
101
+ // helper procedures invoked from it) as part of app install/upgrade flow.
102
+ if (object.objectType === "Codeunit") {
103
+ const subtype = object.objectSubtype?.toLowerCase();
104
+ if (subtype === "install") kinds.push("install-codeunit");
105
+ if (subtype === "upgrade") kinds.push("upgrade-codeunit");
106
+ }
107
+
108
+ // Page with PageType=API — every routine on the page is HTTP-exposed.
109
+ if (
110
+ (object.objectType === "Page" || object.objectType === "PageExtension") &&
111
+ object.pageType?.toLowerCase() === "api"
112
+ ) {
113
+ kinds.push("api-page");
114
+ }
115
+
116
+ // Test procedures — via [Test] attribute on the routine itself.
117
+ if (routine.attributesParsed.some((a) => a.name.toLowerCase() === "test")) {
118
+ kinds.push("test-procedure");
119
+ }
120
+
121
+ // Public procedures — non-trigger, non-event-subscriber procedures with
122
+ // default access (undefined accessModifier = AL's "public" default). This
123
+ // is the catch-all callable-surface kind: only added when nothing more
124
+ // specific applied, so a default-access procedure on an Install codeunit
125
+ // stays `["install-codeunit"]`, not `["install-codeunit","public-procedure"]`.
126
+ if (routine.kind === "procedure" && routine.accessModifier === undefined && kinds.length === 0) {
127
+ kinds.push("public-procedure");
128
+ }
129
+
130
+ // Normalize to the documented invariant: deduplicated, sorted in RootKind
131
+ // declaration order. Insertion order above happens to match — this pass
132
+ // makes it defensive against future reorderings.
133
+ const seen = new Set<RootKind>(kinds);
134
+ return ROOT_KIND_ORDER.filter((k) => seen.has(k));
135
+ }