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,204 @@
1
+ import { existsSync, writeFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { analyzeWorkspace } from "../index.ts";
5
+ import type { Scope } from "../model/entities.ts";
6
+ import { runPolicy } from "../policy/policy-engine.ts";
7
+ import { loadPolicyFromFile } from "../policy/policy-loader.ts";
8
+ import type { PolicyDoc, PolicyRunResult } from "../policy/policy-types.ts";
9
+ import { formatPolicy } from "./format-policy.ts";
10
+
11
+ const VALID_FORMATS = new Set(["human", "json", "sarif"]);
12
+
13
+ export interface PolicyCheckOptions {
14
+ workspace: string;
15
+ policyPath?: string;
16
+ noPolicy?: boolean;
17
+ format?: "human" | "json" | "sarif";
18
+ out?: string;
19
+ deterministic?: boolean;
20
+ alsemVersion?: string;
21
+ strict?: boolean;
22
+ scope?: Scope;
23
+ }
24
+
25
+ export async function runPolicyCheck(opts: PolicyCheckOptions): Promise<number> {
26
+ const format = opts.format ?? "human";
27
+ if (!VALID_FORMATS.has(format)) {
28
+ process.stderr.write(`al-sem policy check: invalid --format '${format}'\n`);
29
+ return 1;
30
+ }
31
+
32
+ const { model, diagnostics } = await analyzeWorkspace({ workspaceRoot: opts.workspace });
33
+ if (opts.strict === true && diagnostics.some((d) => d.severity === "error")) {
34
+ for (const d of diagnostics) process.stderr.write(`${d.severity}: ${d.message}\n`);
35
+ return 1;
36
+ }
37
+
38
+ // Resolve effective policy.
39
+ let policy: PolicyDoc | undefined;
40
+ let source: string;
41
+ if (opts.noPolicy === true) {
42
+ policy = undefined;
43
+ source = "disabled";
44
+ } else if (opts.policyPath !== undefined) {
45
+ const abs = resolve(opts.policyPath);
46
+ const loaded = loadPolicyFromFile(abs);
47
+ if (!loaded.ok) {
48
+ for (const e of loaded.errors) process.stderr.write(`policy load error: ${e}\n`);
49
+ return 1;
50
+ }
51
+ policy = loaded.policy;
52
+ source = `explicit:${abs}`;
53
+ } else {
54
+ // Auto-detect workspace policy.
55
+ const candidates = ["al-sem.policy.yaml", "al-sem.policy.yml"];
56
+ let foundPath: string | undefined;
57
+ for (const name of candidates) {
58
+ const candidate = resolve(opts.workspace, name);
59
+ if (existsSync(candidate)) {
60
+ foundPath = candidate;
61
+ break;
62
+ }
63
+ }
64
+ if (foundPath !== undefined) {
65
+ const loaded = loadPolicyFromFile(foundPath);
66
+ if (!loaded.ok) {
67
+ for (const e of loaded.errors) process.stderr.write(`policy load error: ${e}\n`);
68
+ return 1;
69
+ }
70
+ policy = loaded.policy;
71
+ source = `auto:${foundPath}`;
72
+ } else {
73
+ // Use bundled default.
74
+ const defaultPath = fileURLToPath(new URL("../policy/policy-default.yaml", import.meta.url));
75
+ if (!existsSync(defaultPath)) {
76
+ process.stderr.write(
77
+ `al-sem policy check: bundled default policy not found at ${defaultPath} (use --policy or --no-policy)\n`,
78
+ );
79
+ return 1;
80
+ }
81
+ const loaded = loadPolicyFromFile(defaultPath);
82
+ if (!loaded.ok) {
83
+ for (const e of loaded.errors)
84
+ process.stderr.write(`bundled default policy load error: ${e}\n`);
85
+ return 1;
86
+ }
87
+ policy = loaded.policy;
88
+ source = "default";
89
+ }
90
+ }
91
+
92
+ const result: PolicyRunResult = runPolicy(model, policy, source, {
93
+ scope: opts.scope ?? "primary",
94
+ });
95
+ const text = formatPolicy(result, {
96
+ format,
97
+ deterministic: opts.deterministic,
98
+ alsemVersion: opts.alsemVersion,
99
+ });
100
+ try {
101
+ if (opts.out !== undefined) writeFileSync(opts.out, text);
102
+ else process.stdout.write(text);
103
+ } catch (err) {
104
+ process.stderr.write(`failed to write: ${(err as Error).message}\n`);
105
+ return 1;
106
+ }
107
+ for (const d of diagnostics) process.stderr.write(`${d.severity}: ${d.message}\n`);
108
+ return 0;
109
+ }
110
+
111
+ export interface PolicyExplainOptions {
112
+ workspace: string;
113
+ ruleId: string;
114
+ policyPath?: string;
115
+ routine?: string;
116
+ findingId?: string;
117
+ format?: "human" | "json";
118
+ }
119
+
120
+ export async function runPolicyExplain(opts: PolicyExplainOptions): Promise<number> {
121
+ const format = opts.format ?? "human";
122
+ if (!new Set(["human", "json"]).has(format)) {
123
+ process.stderr.write(`al-sem policy explain: invalid --format '${format}'\n`);
124
+ return 1;
125
+ }
126
+
127
+ // Resolve effective policy (mirrors runPolicyCheck's resolution).
128
+ let policy: PolicyDoc;
129
+ let source: string;
130
+ if (opts.policyPath !== undefined) {
131
+ const abs = resolve(opts.policyPath);
132
+ const loaded = loadPolicyFromFile(abs);
133
+ if (!loaded.ok) {
134
+ for (const e of loaded.errors) process.stderr.write(`policy load error: ${e}\n`);
135
+ return 1;
136
+ }
137
+ policy = loaded.policy;
138
+ source = `explicit:${abs}`;
139
+ } else {
140
+ // Auto-detect workspace policy.
141
+ const candidates = ["al-sem.policy.yaml", "al-sem.policy.yml"];
142
+ let foundPath: string | undefined;
143
+ for (const name of candidates) {
144
+ const candidate = resolve(opts.workspace, name);
145
+ if (existsSync(candidate)) {
146
+ foundPath = candidate;
147
+ break;
148
+ }
149
+ }
150
+ if (foundPath !== undefined) {
151
+ const loaded = loadPolicyFromFile(foundPath);
152
+ if (!loaded.ok) {
153
+ for (const e of loaded.errors) process.stderr.write(`policy load error: ${e}\n`);
154
+ return 1;
155
+ }
156
+ policy = loaded.policy;
157
+ source = `auto:${foundPath}`;
158
+ } else {
159
+ // Use bundled default.
160
+ const defaultPath = fileURLToPath(new URL("../policy/policy-default.yaml", import.meta.url));
161
+ if (!existsSync(defaultPath)) {
162
+ process.stderr.write(
163
+ "al-sem policy explain: bundled default policy not found (use --policy)\n",
164
+ );
165
+ return 1;
166
+ }
167
+ const loaded = loadPolicyFromFile(defaultPath);
168
+ if (!loaded.ok) {
169
+ for (const e of loaded.errors)
170
+ process.stderr.write(`bundled default policy load error: ${e}\n`);
171
+ return 1;
172
+ }
173
+ policy = loaded.policy;
174
+ source = "default";
175
+ }
176
+ }
177
+
178
+ const rule = policy.rules.find((r) => r.id === opts.ruleId);
179
+ if (rule === undefined) {
180
+ process.stderr.write(
181
+ `al-sem policy explain: rule '${opts.ruleId}' not found in effective policy (${source})\n`,
182
+ );
183
+ return 1;
184
+ }
185
+
186
+ // Rule-level summary output.
187
+ // Targeted --routine / --finding deep-trace rendering is deferred to Phase 4 follow-ups.
188
+ const lines: string[] = [];
189
+ lines.push(`Rule: ${rule.id}`);
190
+ if (rule.title !== undefined) lines.push(`Title: ${rule.title}`);
191
+ lines.push(`Severity: ${rule.severity}`);
192
+ lines.push(`Coverage gate: ${rule.requireCoverage ?? policy.defaults?.requireCoverage ?? "any"}`);
193
+ lines.push(`On unknown: ${rule.onUnknown ?? policy.defaults?.onUnknown ?? "fail-closed"}`);
194
+ lines.push(`Effective policy: ${source}`);
195
+ lines.push("");
196
+ lines.push("Normalized AST:");
197
+ lines.push(JSON.stringify(rule.when, undefined, 2));
198
+ if (rule.except !== undefined) {
199
+ lines.push("Except:");
200
+ lines.push(JSON.stringify(rule.except, undefined, 2));
201
+ }
202
+ process.stdout.write(`${lines.join("\n")}\n`);
203
+ return 0;
204
+ }
@@ -0,0 +1,302 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { resolve } from "node:path";
4
+ import type { Diagnostic } from "../model/finding.ts";
5
+ import type { ObjectId, RoutineId } from "../model/ids.ts";
6
+ import { ROOT_KIND_VALUES, type RootKind } from "../model/root-classification.ts";
7
+
8
+ /**
9
+ * Top-level shape of `roots.config.json` — workspace-rooted file that lets a
10
+ * developer assert routines as entry-point roots beyond what AST classification
11
+ * (`src/engine/root-classifier.ts`) can derive on its own. Loaded at workspace
12
+ * discovery time alongside `app.json`; resolution (target → routine match) and
13
+ * overlay merge with AST classifications happens downstream.
14
+ */
15
+ export interface RootsConfig {
16
+ version: 1;
17
+ roots: RootsConfigEntry[];
18
+ }
19
+
20
+ export interface RootsConfigEntry {
21
+ /** Stable identifier — surfaces in diagnostics and `RootClassification.configEntryId`. */
22
+ id: string;
23
+ target: RootsConfigTarget;
24
+ kinds: RootKind[];
25
+ /** Defaults to true downstream (config entries are typically external assertions). */
26
+ externallyReachable?: boolean;
27
+ note?: string;
28
+ }
29
+
30
+ export type RootsConfigTarget =
31
+ | { routineId: RoutineId }
32
+ | { objectId: ObjectId; routineName: string };
33
+
34
+ export interface LoadedRootsConfig {
35
+ /** `undefined` if the file is missing or malformed at the top level. */
36
+ config: RootsConfig | undefined;
37
+ /** sha256 of the raw file bytes; `undefined` if the file is missing. */
38
+ contentHash: string | undefined;
39
+ /** Absolute path to the loaded file; `undefined` if the file is missing. */
40
+ path: string | undefined;
41
+ /**
42
+ * Per-entry validation diagnostics. NOT resolution errors — those belong to
43
+ * the overlay (Task 6).
44
+ */
45
+ diagnostics: Diagnostic[];
46
+ }
47
+
48
+ /**
49
+ * Set of accepted kind strings, derived from `ROOT_KIND_VALUES` so the
50
+ * validator's accepted set is exactly the `RootKind` union — no drift.
51
+ */
52
+ const VALID_KINDS = new Set<string>(ROOT_KIND_VALUES);
53
+
54
+ /**
55
+ * Loads and validates `roots.config.json` from `<workspaceRoot>/roots.config.json`.
56
+ *
57
+ * Pure: never throws. All file-I/O and parse failures surface as `Diagnostic[]`
58
+ * (matching the engine-never-throws contract).
59
+ *
60
+ * Missing file is the common case and not an error: returns a clean empty
61
+ * result with no diagnostics. The returned `contentHash` is the sha256 of the
62
+ * raw file bytes (no whitespace normalization) so it is byte-stable for the
63
+ * workspaceFingerprint pipeline (Task 7).
64
+ */
65
+ export function loadRootsConfig(workspaceRoot: string): LoadedRootsConfig {
66
+ const path = resolve(workspaceRoot, "roots.config.json");
67
+ if (!existsSync(path)) {
68
+ return { config: undefined, contentHash: undefined, path: undefined, diagnostics: [] };
69
+ }
70
+
71
+ let bytes: Buffer;
72
+ try {
73
+ bytes = readFileSync(path);
74
+ } catch (err) {
75
+ return {
76
+ config: undefined,
77
+ contentHash: undefined,
78
+ path,
79
+ diagnostics: [
80
+ diag(
81
+ "error",
82
+ `[roots-config/read-error] Cannot read roots.config.json: ${(err as Error).message}`,
83
+ path,
84
+ ),
85
+ ],
86
+ };
87
+ }
88
+
89
+ const contentHash = createHash("sha256").update(bytes).digest("hex");
90
+ // `contentHash` is over the RAW bytes (BOM included) so the workspaceFingerprint
91
+ // pipeline sees every byte-level change. The parser input strips a leading BOM
92
+ // so `JSON.parse` doesn't choke on `` at position 0 — mirrors `decodeText`
93
+ // in `src/symbols/symbol-reference-reader.ts`.
94
+ const rawText = bytes.toString("utf8");
95
+ const text = rawText.charCodeAt(0) === 0xfeff ? rawText.slice(1) : rawText;
96
+ const diagnostics: Diagnostic[] = [];
97
+
98
+ let parsed: unknown;
99
+ try {
100
+ parsed = JSON.parse(text);
101
+ } catch (err) {
102
+ diagnostics.push(diag("error", `[roots-config/parse-error] ${(err as Error).message}`, path));
103
+ return { config: undefined, contentHash, path, diagnostics };
104
+ }
105
+
106
+ const validated = validateRootsConfig(parsed, path);
107
+ diagnostics.push(...validated.diagnostics);
108
+ return { config: validated.config, contentHash, path, diagnostics };
109
+ }
110
+
111
+ function diag(severity: Diagnostic["severity"], message: string, sourceRef: string): Diagnostic {
112
+ return { severity, stage: "discover", message, sourceRef };
113
+ }
114
+
115
+ function validateRootsConfig(
116
+ parsed: unknown,
117
+ path: string,
118
+ ): { config: RootsConfig | undefined; diagnostics: Diagnostic[] } {
119
+ const diagnostics: Diagnostic[] = [];
120
+
121
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
122
+ diagnostics.push(
123
+ diag(
124
+ "error",
125
+ "[roots-config/invalid-top-level] roots.config.json top level must be an object.",
126
+ path,
127
+ ),
128
+ );
129
+ return { config: undefined, diagnostics };
130
+ }
131
+
132
+ const obj = parsed as Record<string, unknown>;
133
+ if (obj.version !== 1) {
134
+ diagnostics.push(
135
+ diag(
136
+ "error",
137
+ `[roots-config/unsupported-version] roots.config.json version must be 1 (got ${JSON.stringify(obj.version)}).`,
138
+ path,
139
+ ),
140
+ );
141
+ return { config: undefined, diagnostics };
142
+ }
143
+
144
+ if (!Array.isArray(obj.roots)) {
145
+ diagnostics.push(
146
+ diag(
147
+ "error",
148
+ '[roots-config/invalid-roots] roots.config.json "roots" field must be an array.',
149
+ path,
150
+ ),
151
+ );
152
+ return { config: undefined, diagnostics };
153
+ }
154
+
155
+ const entries: RootsConfigEntry[] = [];
156
+ const seenIds = new Set<string>();
157
+
158
+ for (let i = 0; i < obj.roots.length; i++) {
159
+ const entryUnknown = obj.roots[i];
160
+ if (entryUnknown === null || typeof entryUnknown !== "object" || Array.isArray(entryUnknown)) {
161
+ diagnostics.push(
162
+ diag(
163
+ "warning",
164
+ `[roots-config/invalid-root-shape] roots[${i}] is not an object; skipping.`,
165
+ path,
166
+ ),
167
+ );
168
+ continue;
169
+ }
170
+ const entry = entryUnknown as Record<string, unknown>;
171
+
172
+ if (typeof entry.id !== "string") {
173
+ diagnostics.push(
174
+ diag(
175
+ "warning",
176
+ `[roots-config/invalid-root-shape] roots[${i}] missing string "id"; skipping.`,
177
+ path,
178
+ ),
179
+ );
180
+ continue;
181
+ }
182
+
183
+ if (seenIds.has(entry.id)) {
184
+ diagnostics.push(
185
+ diag(
186
+ "warning",
187
+ `[roots-config/duplicate-entry-id] roots[${i}] duplicates id "${entry.id}"; skipping duplicate.`,
188
+ path,
189
+ ),
190
+ );
191
+ continue;
192
+ }
193
+
194
+ const target = parseTarget(entry.target);
195
+ if (target === undefined) {
196
+ diagnostics.push(
197
+ diag(
198
+ "warning",
199
+ `[roots-config/invalid-target] roots[${i}] ("${entry.id}") has invalid target; skipping.`,
200
+ path,
201
+ ),
202
+ );
203
+ continue;
204
+ }
205
+
206
+ if (!Array.isArray(entry.kinds)) {
207
+ diagnostics.push(
208
+ diag(
209
+ "warning",
210
+ `[roots-config/invalid-root-shape] roots[${i}] ("${entry.id}") "kinds" must be an array; skipping.`,
211
+ path,
212
+ ),
213
+ );
214
+ continue;
215
+ }
216
+
217
+ // Canonicalize `kinds`: dedup (silent — mirrors AST classifier behaviour) and
218
+ // sort in `ROOT_KIND_VALUES` declaration order. This matches the invariant
219
+ // documented on `RootClassification.kinds` so AST and config paths produce
220
+ // byte-identical kind arrays for equivalent inputs.
221
+ const seenKinds = new Set<RootKind>();
222
+ for (const k of entry.kinds) {
223
+ if (typeof k === "string" && VALID_KINDS.has(k)) {
224
+ seenKinds.add(k as RootKind);
225
+ } else {
226
+ diagnostics.push(
227
+ diag(
228
+ "warning",
229
+ `[roots-config/unknown-root-kind] roots[${i}] ("${entry.id}") kind ${JSON.stringify(k)} is not a known RootKind; dropping.`,
230
+ path,
231
+ ),
232
+ );
233
+ }
234
+ }
235
+ const kinds: RootKind[] = ROOT_KIND_VALUES.filter((k) => seenKinds.has(k));
236
+
237
+ if (kinds.length === 0) {
238
+ diagnostics.push(
239
+ diag(
240
+ "warning",
241
+ `[roots-config/invalid-root-shape] roots[${i}] ("${entry.id}") has no valid kinds; skipping entry.`,
242
+ path,
243
+ ),
244
+ );
245
+ continue;
246
+ }
247
+
248
+ const validEntry: RootsConfigEntry = {
249
+ id: entry.id,
250
+ target,
251
+ kinds,
252
+ };
253
+ // Optional fields: present-and-correctly-typed → carry through;
254
+ // absent → omit; present-but-wrong-typed → drop the field AND emit a
255
+ // warning so authors notice typos like `externallyReachable: "true"`.
256
+ if (entry.externallyReachable !== undefined) {
257
+ if (typeof entry.externallyReachable === "boolean") {
258
+ validEntry.externallyReachable = entry.externallyReachable;
259
+ } else {
260
+ diagnostics.push(
261
+ diag(
262
+ "warning",
263
+ `[roots-config/invalid-root-shape] roots[${i}] ("${entry.id}") "externallyReachable" must be a boolean (got ${JSON.stringify(entry.externallyReachable)}); dropping field.`,
264
+ path,
265
+ ),
266
+ );
267
+ }
268
+ }
269
+ if (entry.note !== undefined) {
270
+ if (typeof entry.note === "string") {
271
+ validEntry.note = entry.note;
272
+ } else {
273
+ diagnostics.push(
274
+ diag(
275
+ "warning",
276
+ `[roots-config/invalid-root-shape] roots[${i}] ("${entry.id}") "note" must be a string (got ${JSON.stringify(entry.note)}); dropping field.`,
277
+ path,
278
+ ),
279
+ );
280
+ }
281
+ }
282
+ entries.push(validEntry);
283
+ seenIds.add(entry.id);
284
+ }
285
+
286
+ return { config: { version: 1, roots: entries }, diagnostics };
287
+ }
288
+
289
+ /**
290
+ * Parse a target object into one of two accepted shapes. When both `routineId`
291
+ * and `objectId + routineName` are present, `routineId` takes precedence
292
+ * (the stable id is more specific than the name-based lookup).
293
+ */
294
+ function parseTarget(t: unknown): RootsConfigTarget | undefined {
295
+ if (t === null || typeof t !== "object" || Array.isArray(t)) return undefined;
296
+ const obj = t as Record<string, unknown>;
297
+ if (typeof obj.routineId === "string") return { routineId: obj.routineId };
298
+ if (typeof obj.objectId === "string" && typeof obj.routineName === "string") {
299
+ return { objectId: obj.objectId, routineName: obj.routineName };
300
+ }
301
+ return undefined;
302
+ }
@@ -0,0 +1,74 @@
1
+ import { ANALYZER_VERSION, GRAMMAR_VERSION } from "../providers/discover.ts";
2
+
3
+ /**
4
+ * Cache-affecting version stamps. Every stamp here is folded into a DependencyArtifact's
5
+ * cache key. Ownership (bump when…):
6
+ * - analyzer: release identity (already bumped per release).
7
+ * - grammar: tree-sitter-al grammar behaviour changes.
8
+ * - symbolReader: .app extraction / manifest / SymbolReference projection changes.
9
+ * - summarySchema: RoutineSummary shape OR semantics — lattice, residuals, transfer
10
+ * functions, fixed-point — changes.
11
+ * - depCache: artifact serialization format, canonicalization rules, or key structure
12
+ * changes.
13
+ * - resourcePolicy: the deterministic preflight-limit constants in dependency-pipeline.ts
14
+ * change.
15
+ *
16
+ * `symbolReader` is bumped from "1" to "2" in this phase because the .app reader was
17
+ * replaced (app-package-zip + the new readers).
18
+ */
19
+ export const CACHE_VERSIONS = {
20
+ analyzer: ANALYZER_VERSION,
21
+ grammar: GRAMMAR_VERSION,
22
+ // symbolReader "6": Phase 0b-β — dep extraction now emits CapabilityFact[]
23
+ // via extractCapabilities on dep routines with embedded source. Extends
24
+ // RoutineSummary with capabilityFactsDirect / capabilityFactsInherited /
25
+ // coverage fields populated by per-family extractors + SCC composers.
26
+ // symbolReader "7": Phase 1 §4.3 — ObjectDecl gains objectSubtype (Codeunit
27
+ // Subtype property) and pageType (Page PageType property), extracted from
28
+ // AL source and from .app SymbolReference JSON. Required for root-classifier
29
+ // to identify install-codeunit / upgrade-codeunit / api-page kinds.
30
+ symbolReader: "7",
31
+ // summarySchema "6": Phase 1c — RoutineSummary's legacy boolean lattice
32
+ // (touchesDb, commits, writesTables, publishesEvents) deleted. The fields
33
+ // have been replaced by Phase 1a capability-query helpers (touchesDbOf,
34
+ // mayCommit, writesTablesOf, publishesEventsOf, reachableCoverage). Producer
35
+ // code in summary-engine.ts + summary-runner.ts simplified accordingly;
36
+ // fingerprint hash format changes — fixed-point convergence count may
37
+ // shift but final summaries are functionally equivalent.
38
+ // summarySchema "7": Phase 3 — IntraproceduralFeatures gained varAssignments
39
+ // (drives D43 IsHandled-skip detection).
40
+ // summarySchema "8": Phase 3.1 — IntraproceduralFeatures gained conditionReferences
41
+ // (drives D43 dispatch-site guard detection).
42
+ // summarySchema "9": capability-cone propagation — capabilityFactsInherited is now
43
+ // computed by bottom-up SCC-condensation cone propagation (shortest-path-wins
44
+ // attribution) and canonically sorted; coverage cones share the same pass. Witness/via
45
+ // on equal-distance ties and array order change vs the legacy per-routine BFS.
46
+ summarySchema: "9",
47
+ // depCache "2": canonical-json now omits undefined-valued object keys (was: rendered as
48
+ // `null` + key kept). Dependency-artifact bytes change; absent optional fields (e.g. fact
49
+ // resourceId) round-trip as undefined, restoring the model invariant.
50
+ depCache: "2",
51
+ resourcePolicy: "1",
52
+ } as const;
53
+
54
+ /** A stable, human-readable rendering of the version tuple — used in keys and the snapshot test. */
55
+ export function cacheVersionTuple(): string {
56
+ return [
57
+ `analyzer=${CACHE_VERSIONS.analyzer}`,
58
+ `grammar=${CACHE_VERSIONS.grammar}`,
59
+ `symbolReader=${CACHE_VERSIONS.symbolReader}`,
60
+ `summarySchema=${CACHE_VERSIONS.summarySchema}`,
61
+ `depCache=${CACHE_VERSIONS.depCache}`,
62
+ `resourcePolicy=${CACHE_VERSIONS.resourcePolicy}`,
63
+ ].join(";");
64
+ }
65
+
66
+ /**
67
+ * A per-build fingerprint folded into the cache key for non-release (dev) builds, so local
68
+ * logic changes never silently reuse a stale artifact. Release builds (where
69
+ * `process.env.AL_SEM_RELEASE === "1"`) return "" — they are protected by `analyzer`.
70
+ */
71
+ export function devFingerprint(): string {
72
+ if (process.env.AL_SEM_RELEASE === "1") return "";
73
+ return process.env.AL_SEM_DEV_FINGERPRINT ?? "dev";
74
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Deterministic JSON: object keys sorted recursively, arrays kept in order, object keys
3
+ * with an `undefined` value are omitted (standard JSON); `undefined` array elements render
4
+ * as `null`. The basis of artifact content-addressing.
5
+ */
6
+ export function canonicalStringify(value: unknown): string {
7
+ if (value === undefined || value === null) return "null";
8
+ if (typeof value === "number") {
9
+ if (!Number.isFinite(value)) return "null";
10
+ return Object.is(value, -0) ? "0" : String(value);
11
+ }
12
+ if (typeof value === "boolean") return value ? "true" : "false";
13
+ if (typeof value === "string") return JSON.stringify(value);
14
+ if (Array.isArray(value)) return `[${value.map(canonicalStringify).join(",")}]`;
15
+ if (typeof value === "object") {
16
+ const obj = value as Record<string, unknown>;
17
+ // Omit undefined-valued keys (standard JSON.stringify semantics). The previous behavior
18
+ // kept the key and rendered the value as `null`, which round-tripped absent optional
19
+ // fields (e.g. CapabilityFact.resourceId) back as `null` and violated the
20
+ // "absent ⇒ undefined" model invariant. Array undefined elements still render as `null`.
21
+ const keys = Object.keys(obj)
22
+ .filter((k) => obj[k] !== undefined)
23
+ .sort();
24
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalStringify(obj[k])}`).join(",")}}`;
25
+ }
26
+ return "null";
27
+ }