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,566 @@
1
+ #!/usr/bin/env bun
2
+ import { Command } from "commander";
3
+ import { pruneCache } from "../deps/dependency-cache.ts";
4
+ import { ALL_DETECTORS, DEFAULT_DETECTORS } from "../detectors/registry.ts";
5
+ import { analyzeWorkspace } from "../index.ts";
6
+ import type { Scope } from "../model/entities.ts";
7
+ import { filterFindings } from "../projection/finding-filters.ts";
8
+ import { groupFindings } from "../projection/finding-groups.ts";
9
+ import type { FindingSummary } from "../projection/finding-summary.ts";
10
+ import { projectFinding } from "../projection/finding-summary.ts";
11
+ import { applyBaseline, loadBaseline, saveBaseline } from "./baseline.ts";
12
+ import { runDiff } from "./diff.ts";
13
+ import { runEventsChains } from "./events-chains.ts";
14
+ import { runEventsFanout } from "./events-fanout.ts";
15
+ import { computeExitCode, parseFailOn } from "./exit-code.ts";
16
+ import { runFingerprint } from "./fingerprint.ts";
17
+ import { formatCompactJson } from "./format-compact-json.ts";
18
+ import { formatHtml } from "./format-html.ts";
19
+ import { formatFullModelJson } from "./format-json.ts";
20
+ import { formatSarif } from "./format-sarif.ts";
21
+ import { formatTerminal } from "./format-terminal.ts";
22
+ import { runPolicyCheck, runPolicyExplain } from "./policy.ts";
23
+
24
+ const SCOPE_VALUES = ["primary", "all"] as const;
25
+
26
+ const program = new Command();
27
+
28
+ program
29
+ .name("al-sem")
30
+ .description("Static semantic analysis engine for Microsoft Business Central AL code");
31
+
32
+ program
33
+ .command("analyze")
34
+ .argument("<workspace>", "path to the AL workspace root")
35
+ .option("--alpackages <dir>", "path to the .alpackages directory")
36
+ .option("--format <format>", "output format: auto | terminal | json | sarif | html", "auto")
37
+ .option("--deterministic", "pin timestamps for byte-stable output", false)
38
+ .option("--no-dep-summaries", "skip the behavioral dependency cold run (structural ABI only)")
39
+ .option("--no-roots-config", "ignore roots.config.json overlay even if present")
40
+ .option(
41
+ "--dep-cache-dir <dir>",
42
+ "override the dependency cache directory (default ~/.al-sem/cache/)",
43
+ )
44
+ .option("--dump-model", "emit the full SemanticModel (debug-only, can be >500 MB)")
45
+ .option(
46
+ "--min-severity <sev>",
47
+ "drop findings below this severity: critical|high|medium|low|info",
48
+ )
49
+ .option(
50
+ "--detector <ids>",
51
+ "comma-separated allow-list of detector ids (e.g. d1-db-op-in-loop,d3-missing-setloadfields)",
52
+ )
53
+ .option(
54
+ "--scope <scope>",
55
+ "primary (default) drops findings anchored in a dependency; all keeps them",
56
+ "primary",
57
+ )
58
+ .option("--limit <n>", "cap output at first N findings (after filtering)", (v: string) =>
59
+ Number.parseInt(v, 10),
60
+ )
61
+ .option("--group-by <by>", "group terminal output by: object | routine | table | detector | file")
62
+ .option("--baseline <file>", "baseline file (fingerprints to suppress on this run)")
63
+ .option("--update-baseline", "rewrite the baseline file from this run's findings", false)
64
+ .option(
65
+ "--fail-on <sev>",
66
+ "exit 1 if any finding at this severity or above (after baseline / filters)",
67
+ )
68
+ .action(
69
+ async (
70
+ workspace: string,
71
+ opts: {
72
+ alpackages?: string;
73
+ format: string;
74
+ deterministic: boolean;
75
+ depSummaries: boolean;
76
+ rootsConfig: boolean;
77
+ depCacheDir?: string;
78
+ dumpModel?: boolean;
79
+ minSeverity?: string;
80
+ detector?: string;
81
+ scope: string;
82
+ limit?: number;
83
+ groupBy?: string;
84
+ baseline?: string;
85
+ updateBaseline: boolean;
86
+ failOn?: string;
87
+ },
88
+ ) => {
89
+ // --- enum flag validation ---
90
+ const SEVERITY_VALUES = ["critical", "high", "medium", "low", "info"] as const;
91
+ if (opts.minSeverity !== undefined && !SEVERITY_VALUES.includes(opts.minSeverity as never)) {
92
+ process.stderr.write(
93
+ `al-sem: invalid --min-severity '${opts.minSeverity}'. Expected one of: ${SEVERITY_VALUES.join(", ")}\n`,
94
+ );
95
+ process.exitCode = 1;
96
+ return;
97
+ }
98
+
99
+ if (!SCOPE_VALUES.includes(opts.scope as never)) {
100
+ process.stderr.write(
101
+ `al-sem: invalid --scope '${opts.scope}'. Expected one of: ${SCOPE_VALUES.join(", ")}\n`,
102
+ );
103
+ process.exitCode = 1;
104
+ return;
105
+ }
106
+
107
+ const GROUP_BY_VALUES = ["object", "routine", "table", "detector", "file"] as const;
108
+ if (opts.groupBy !== undefined && !GROUP_BY_VALUES.includes(opts.groupBy as never)) {
109
+ process.stderr.write(
110
+ `al-sem: invalid --group-by '${opts.groupBy}'. Expected one of: ${GROUP_BY_VALUES.join(", ")}\n`,
111
+ );
112
+ process.exitCode = 1;
113
+ return;
114
+ }
115
+
116
+ let failOn: FindingSummary["severity"] | undefined;
117
+ if (opts.failOn !== undefined) {
118
+ try {
119
+ failOn = parseFailOn(opts.failOn);
120
+ } catch (err) {
121
+ process.stderr.write(`al-sem: ${(err as Error).message}\n`);
122
+ process.exitCode = 1;
123
+ return;
124
+ }
125
+ }
126
+
127
+ if (opts.updateBaseline === true && opts.baseline === undefined) {
128
+ process.stderr.write("al-sem: --update-baseline has no effect without --baseline\n");
129
+ }
130
+
131
+ // Safe casts — values validated above
132
+ const minSeverity = opts.minSeverity as FindingSummary["severity"] | undefined;
133
+ const scope = opts.scope as "primary" | "all";
134
+ const groupBy = opts.groupBy as
135
+ | "object"
136
+ | "routine"
137
+ | "table"
138
+ | "detector"
139
+ | "file"
140
+ | undefined;
141
+
142
+ // If the user explicitly opted into a detector via `--detector` that isn't in
143
+ // DEFAULT_DETECTORS (e.g. d40-transitive-load-missing), run the union — otherwise
144
+ // the registry would silently never produce that detector's findings even though
145
+ // the user asked for them. Default registry remains in effect when --detector
146
+ // is absent or only names default-registry detectors.
147
+ const requestedDetectors = opts.detector
148
+ ? opts.detector.split(",").map((s: string) => s.trim())
149
+ : undefined;
150
+ const defaultNames = new Set(DEFAULT_DETECTORS.map((d) => d.name));
151
+ const needsOptIn = requestedDetectors?.some((n) => !defaultNames.has(n)) ?? false;
152
+ const detectorList = needsOptIn ? ALL_DETECTORS : undefined;
153
+
154
+ const result = await analyzeWorkspace({
155
+ workspaceRoot: workspace,
156
+ alpackagesDir: opts.alpackages,
157
+ deterministic: opts.deterministic,
158
+ noDepSummaries: opts.depSummaries === false,
159
+ noRootsConfig: opts.rootsConfig === false,
160
+ dependencyCacheDir: opts.depCacheDir,
161
+ detectors: detectorList,
162
+ });
163
+ if (opts.dumpModel === true) {
164
+ process.stdout.write(`${formatFullModelJson(result)}\n`);
165
+ return;
166
+ }
167
+
168
+ // --- filter pipeline ---
169
+ // First pass: severity + detector (no limit yet)
170
+ const projected = result.findings.map((f) => projectFinding(f, result.model));
171
+ const filtered = filterFindings(projected, {
172
+ minSeverity,
173
+ detectors: opts.detector
174
+ ? opts.detector.split(",").map((s: string) => s.trim())
175
+ : undefined,
176
+ });
177
+
178
+ // scope filter: primary (default) drops findings whose primaryLocation object is in a dep
179
+ const objectsById = new Map(result.model.objects.map((o) => [o.id, o]));
180
+ const scoped =
181
+ scope === "all"
182
+ ? filtered
183
+ : filtered.filter((f) => {
184
+ const objId = f.primaryLocation.objectId;
185
+ if (!objId) return true; // unknown object — keep (don't silently drop)
186
+ return objectsById.get(objId)?.analysisRole !== "dependency";
187
+ });
188
+
189
+ // Second pass: limit only (applied after scope drop so users get expected count)
190
+ const limited = opts.limit !== undefined ? scoped.slice(0, opts.limit) : scoped;
191
+
192
+ // --- baseline suppression ---
193
+ const baseline = opts.baseline ? loadBaseline(opts.baseline) : new Set<string>();
194
+ const newFindings = applyBaseline(limited, baseline);
195
+ if (opts.updateBaseline && opts.baseline) {
196
+ // Save the current scoped+filtered set (not just new ones) — this becomes the new floor.
197
+ saveBaseline(opts.baseline, limited);
198
+ }
199
+
200
+ const filteredIds = new Set(newFindings.map((f) => f.id));
201
+ const filteredResult = {
202
+ ...result,
203
+ findings: result.findings.filter((f) => filteredIds.has(f.id)),
204
+ };
205
+
206
+ const format =
207
+ opts.format === "auto" ? (process.stdout.isTTY ? "terminal" : "json") : opts.format;
208
+
209
+ // group-by terminal rendering
210
+ if (groupBy && format === "terminal") {
211
+ const groups = groupFindings(newFindings, groupBy);
212
+ const cov = result.model.coverage;
213
+ const lines: string[] = [];
214
+ lines.push(
215
+ `Analysed ${cov.routinesTotal} routines (${cov.routinesBodyAvailable} with bodies, ${cov.routinesParseIncomplete.length} parse-incomplete); ${cov.sourceUnitsParsed}/${cov.sourceUnitsTotal} source units parsed; ${cov.opaqueApps.length} opaque app(s).`,
216
+ );
217
+ lines.push("");
218
+ lines.push(`Grouped by ${groupBy} (top ${groups.length}):`);
219
+ for (const g of groups) {
220
+ lines.push(` ${g.key}: ${g.findings.length}`);
221
+ }
222
+ process.stdout.write(`${lines.join("\n")}\n`);
223
+ // still apply exit-code for group-by path
224
+ if (failOn) {
225
+ process.exitCode = computeExitCode(newFindings, failOn);
226
+ }
227
+ return;
228
+ }
229
+
230
+ if (format === "json") {
231
+ process.stdout.write(`${formatCompactJson(filteredResult)}\n`);
232
+ } else if (format === "terminal") {
233
+ process.stdout.write(`${formatTerminal(filteredResult)}\n`);
234
+ } else if (format === "sarif") {
235
+ process.stdout.write(`${formatSarif(filteredResult)}\n`);
236
+ } else if (format === "html") {
237
+ process.stdout.write(`${formatHtml(filteredResult)}\n`);
238
+ } else {
239
+ process.stderr.write(
240
+ `al-sem: unknown format '${format}'. Expected: auto, terminal, json, sarif, html\n`,
241
+ );
242
+ process.exitCode = 1;
243
+ return;
244
+ }
245
+
246
+ // --- exit-code gate ---
247
+ if (failOn) {
248
+ process.exitCode = computeExitCode(newFindings, failOn);
249
+ }
250
+ },
251
+ );
252
+
253
+ program
254
+ .command("fingerprint")
255
+ .description("Show the per-root capability fingerprint for a workspace (Phase 1 §4.4 surface)")
256
+ .argument("<workspace>", "path to the AL workspace root")
257
+ .option("--format <fmt>", "output format: human | json | cbor | cbor.gz")
258
+ .option("--out <path>", "output file or shard directory")
259
+ .option("--shard <mode>", "primary-only | all")
260
+ .option("--deterministic", "pin generatedAt for byte-stable output", false)
261
+ .option("--alsem-version <v>", "version string in output", "0.0.0")
262
+ .option("--no-roots-config", "ignore roots.config.json overlay")
263
+ .option("--roots <kinds>", "comma-separated RootKind list (human only)")
264
+ .option(
265
+ "--routine <selector>",
266
+ "routine selector (display or StableRoutineId); repeatable",
267
+ collectRoutine,
268
+ [],
269
+ )
270
+ .option("--include-inherited", "include inherited facts (default)", true)
271
+ .option("--no-include-inherited", "direct facts only")
272
+ .option("--witness <mode>", "false | 0 | <1..256> | all", "3")
273
+ .option("--strict", "exit non-zero on any analyzer error-severity diagnostic", false)
274
+ .option("--debug", "print stack on internal error", false)
275
+ .option("--verbosity <v>", "compact | full (human only)", "compact")
276
+ .option("--color", "force color output", false)
277
+ .action(
278
+ async (
279
+ workspace: string,
280
+ cmdOpts: {
281
+ format?: string;
282
+ out?: string;
283
+ shard?: string;
284
+ deterministic?: boolean;
285
+ alsemVersion?: string;
286
+ rootsConfig: boolean;
287
+ roots?: string;
288
+ routine?: string[];
289
+ includeInherited?: boolean;
290
+ witness?: string;
291
+ strict?: boolean;
292
+ debug?: boolean;
293
+ verbosity?: string;
294
+ color?: boolean;
295
+ },
296
+ ) => {
297
+ const specified = new Set<string>();
298
+ if (cmdOpts.roots !== undefined) specified.add("roots");
299
+ if (Array.isArray(cmdOpts.routine) && cmdOpts.routine.length > 0)
300
+ specified.add("routineSelectors");
301
+ if (cmdOpts.includeInherited === false) specified.add("includeInherited");
302
+ if (cmdOpts.witness !== undefined && cmdOpts.witness !== "3") specified.add("witness");
303
+ const witness =
304
+ cmdOpts.witness === undefined
305
+ ? undefined
306
+ : cmdOpts.witness === "false"
307
+ ? false
308
+ : cmdOpts.witness === "all"
309
+ ? ("all" as const)
310
+ : Number.parseInt(cmdOpts.witness, 10);
311
+ const exitCode = await runFingerprint({
312
+ workspace,
313
+ format: cmdOpts.format as never,
314
+ out: cmdOpts.out,
315
+ shard: cmdOpts.shard as "primary-only" | "all" | undefined,
316
+ deterministic: cmdOpts.deterministic === true,
317
+ alsemVersion: cmdOpts.alsemVersion,
318
+ noRootsConfig: cmdOpts.rootsConfig === false,
319
+ roots:
320
+ cmdOpts.roots !== undefined
321
+ ? String(cmdOpts.roots)
322
+ .split(",")
323
+ .map((s) => s.trim())
324
+ .filter((s) => s.length > 0)
325
+ : undefined,
326
+ routineSelectors:
327
+ Array.isArray(cmdOpts.routine) && cmdOpts.routine.length > 0
328
+ ? cmdOpts.routine
329
+ : undefined,
330
+ includeInherited: cmdOpts.includeInherited !== false,
331
+ witness,
332
+ strict: cmdOpts.strict === true,
333
+ debug: cmdOpts.debug === true,
334
+ verbosity: cmdOpts.verbosity as "compact" | "full" | undefined,
335
+ color: cmdOpts.color === true,
336
+ _specifiedFlags: specified,
337
+ });
338
+ process.exit(exitCode);
339
+ },
340
+ );
341
+
342
+ program
343
+ .command("diff")
344
+ .argument("<old>", "snapshot file or workspace directory")
345
+ .argument("<new>", "snapshot file or workspace directory")
346
+ .option("--format <fmt>", "output format: human | json | sarif", "human")
347
+ .option("--out <path>", "write output to file instead of stdout")
348
+ .option("--coverage-policy <policy>", "loose | strict", "loose")
349
+ .option("--renames <path>", "rename overlay JSON file")
350
+ .option("--fail-on <sev>", "exit 1 if any finding ≥ severity")
351
+ .option("--strict", "exit 1 on analyzer error-severity diagnostic", false)
352
+ .option("--deterministic", "pin generatedAt for byte-stable output", false)
353
+ .option("--alsem-version <v>", "version string in output", "0.0.0")
354
+ .option("--debug", "print stack on internal error", false)
355
+ .action(
356
+ async (
357
+ oldArg: string,
358
+ newArg: string,
359
+ cmdOpts: {
360
+ format?: string;
361
+ out?: string;
362
+ coveragePolicy?: string;
363
+ renames?: string;
364
+ failOn?: string;
365
+ strict?: boolean;
366
+ deterministic?: boolean;
367
+ alsemVersion?: string;
368
+ debug?: boolean;
369
+ },
370
+ ) => {
371
+ const exitCode = await runDiff({
372
+ oldArg,
373
+ newArg,
374
+ format: cmdOpts.format as never,
375
+ out: cmdOpts.out,
376
+ coveragePolicy: cmdOpts.coveragePolicy as never,
377
+ renamesPath: cmdOpts.renames,
378
+ failOn: cmdOpts.failOn as never,
379
+ strict: cmdOpts.strict === true,
380
+ deterministic: cmdOpts.deterministic === true,
381
+ alsemVersion: cmdOpts.alsemVersion,
382
+ debug: cmdOpts.debug === true,
383
+ });
384
+ process.exit(exitCode);
385
+ },
386
+ );
387
+
388
+ const events = program.command("events").description("Event blast-radius reports");
389
+
390
+ events
391
+ .command("fanout")
392
+ .description("Per-publisher fanout report (direct subscribers + coverage)")
393
+ .argument("<workspace>", "path to the AL workspace root")
394
+ .option("--format <fmt>", "human | json", "human")
395
+ .option("--out <path>", "output file")
396
+ .option("--coverage-policy <p>", "warn | strict | ignore", "warn")
397
+ .option("--deterministic", "pin generated_at for byte-stable output", false)
398
+ .option("--alsem-version <v>", "version string in output", "0.0.0")
399
+ .option("--strict", "exit 1 on analyzer error-severity diagnostic", false)
400
+ .option(
401
+ "--scope <scope>",
402
+ "primary (default) drops dependency-only events; all keeps them",
403
+ "primary",
404
+ )
405
+ .action(async (workspace: string, cmdOpts: Record<string, unknown>) => {
406
+ if (!SCOPE_VALUES.includes(cmdOpts.scope as never)) {
407
+ process.stderr.write(
408
+ `al-sem: invalid --scope '${cmdOpts.scope}'. Expected one of: ${SCOPE_VALUES.join(", ")}\n`,
409
+ );
410
+ process.exit(1);
411
+ }
412
+ const exitCode = await runEventsFanout({
413
+ workspace,
414
+ format: cmdOpts.format as never,
415
+ out: cmdOpts.out as string | undefined,
416
+ coveragePolicy: cmdOpts.coveragePolicy as never,
417
+ deterministic: cmdOpts.deterministic === true,
418
+ alsemVersion: cmdOpts.alsemVersion as string | undefined,
419
+ strict: cmdOpts.strict === true,
420
+ scope: cmdOpts.scope as Scope,
421
+ });
422
+ process.exit(exitCode);
423
+ });
424
+
425
+ events
426
+ .command("chains")
427
+ .description("Per-publisher chain tree (event → subscriber → ...)")
428
+ .argument("<workspace>", "path to the AL workspace root")
429
+ .option("--format <fmt>", "human | json", "human")
430
+ .option("--out <path>", "output file")
431
+ .option("--coverage-policy <p>", "warn | strict | ignore", "warn")
432
+ .option("--max-depth <n>", "maximum tree depth (0..256)", (v: string) => Number.parseInt(v, 10))
433
+ .option("--deterministic", "pin generated_at for byte-stable output", false)
434
+ .option("--alsem-version <v>", "version string in output", "0.0.0")
435
+ .option("--strict", "exit 1 on analyzer error-severity diagnostic", false)
436
+ .option(
437
+ "--scope <scope>",
438
+ "primary (default) drops dependency-only events; all keeps them",
439
+ "primary",
440
+ )
441
+ .action(async (workspace: string, cmdOpts: Record<string, unknown>) => {
442
+ if (!SCOPE_VALUES.includes(cmdOpts.scope as never)) {
443
+ process.stderr.write(
444
+ `al-sem: invalid --scope '${cmdOpts.scope}'. Expected one of: ${SCOPE_VALUES.join(", ")}\n`,
445
+ );
446
+ process.exit(1);
447
+ }
448
+ const exitCode = await runEventsChains({
449
+ workspace,
450
+ format: cmdOpts.format as never,
451
+ out: cmdOpts.out as string | undefined,
452
+ coveragePolicy: cmdOpts.coveragePolicy as never,
453
+ maxDepth: cmdOpts.maxDepth as number | undefined,
454
+ deterministic: cmdOpts.deterministic === true,
455
+ alsemVersion: cmdOpts.alsemVersion as string | undefined,
456
+ strict: cmdOpts.strict === true,
457
+ scope: cmdOpts.scope as Scope,
458
+ });
459
+ process.exit(exitCode);
460
+ });
461
+
462
+ const policy = program
463
+ .command("policy")
464
+ .description("Declarative policy rules over capability facts");
465
+
466
+ policy
467
+ .command("check")
468
+ .description("Check workspace against effective policy")
469
+ .argument("<workspace>", "path to the AL workspace root")
470
+ .option("--policy <path>", "explicit policy file")
471
+ .option("--no-policy", "disable all policy evaluation")
472
+ .option("--format <fmt>", "human | json | sarif", "human")
473
+ .option("--out <path>", "output file")
474
+ .option("--deterministic", "pin generated_at", false)
475
+ .option("--alsem-version <v>", "version string in output", "0.0.0")
476
+ .option("--strict", "exit 1 on analyzer error-severity diagnostic", false)
477
+ .option(
478
+ "--scope <scope>",
479
+ "primary (default) drops dependency-anchored findings; all keeps them",
480
+ "primary",
481
+ )
482
+ .action(async (workspace: string, cmdOpts: Record<string, unknown>) => {
483
+ if (!SCOPE_VALUES.includes(cmdOpts.scope as never)) {
484
+ process.stderr.write(
485
+ `al-sem: invalid --scope '${cmdOpts.scope}'. Expected one of: ${SCOPE_VALUES.join(", ")}\n`,
486
+ );
487
+ process.exit(1);
488
+ }
489
+ // commander's --no-X flips the value to false. Detect that as "disable".
490
+ const policyArg = cmdOpts.policy;
491
+ const noPolicy = policyArg === false;
492
+ const policyPath = typeof policyArg === "string" ? policyArg : undefined;
493
+ const exitCode = await runPolicyCheck({
494
+ workspace,
495
+ policyPath,
496
+ noPolicy,
497
+ format: cmdOpts.format as never,
498
+ out: cmdOpts.out as string | undefined,
499
+ deterministic: cmdOpts.deterministic === true,
500
+ alsemVersion: cmdOpts.alsemVersion as string | undefined,
501
+ strict: cmdOpts.strict === true,
502
+ scope: cmdOpts.scope as Scope,
503
+ });
504
+ process.exit(exitCode);
505
+ });
506
+
507
+ policy
508
+ .command("explain")
509
+ .description("Explain a policy rule's semantics + matches")
510
+ .argument("<rule-id>", "rule id to explain")
511
+ .argument("<workspace>", "path to the AL workspace root")
512
+ .option("--policy <path>", "explicit policy file")
513
+ .option("--routine <selector>", "narrow trace to one routine")
514
+ .option("--finding <id>", "narrow trace to one finding id")
515
+ .option("--format <fmt>", "human | json", "human")
516
+ .action(async (ruleId: string, workspace: string, cmdOpts: Record<string, unknown>) => {
517
+ const exitCode = await runPolicyExplain({
518
+ workspace,
519
+ ruleId,
520
+ policyPath: typeof cmdOpts.policy === "string" ? cmdOpts.policy : undefined,
521
+ routine: cmdOpts.routine as string | undefined,
522
+ findingId: cmdOpts.finding as string | undefined,
523
+ format: cmdOpts.format as never,
524
+ });
525
+ process.exit(exitCode);
526
+ });
527
+
528
+ const cache = program.command("cache").description("Inspect and maintain the dependency cache");
529
+
530
+ cache
531
+ .command("prune")
532
+ .description(
533
+ "Remove dependency cache entries this build can no longer use (version mismatch, corrupt files, tampered content hash). Kept entries are not touched.",
534
+ )
535
+ .option(
536
+ "--dep-cache-dir <dir>",
537
+ "override the dependency cache directory (default ~/.al-sem/cache/)",
538
+ )
539
+ .option("--dry-run", "report what would be removed without deleting anything", false)
540
+ .action((opts: { depCacheDir?: string; dryRun: boolean }) => {
541
+ const result = pruneCache(opts.depCacheDir, { dryRun: opts.dryRun });
542
+ const kb = (n: number): string => `${(n / 1024).toFixed(1)} KB`;
543
+ const verb = opts.dryRun ? "would remove" : "removed";
544
+ process.stdout.write(`al-sem cache: ${result.cacheDir}\n`);
545
+ if (result.entries.length === 0) {
546
+ process.stdout.write(" (empty)\n");
547
+ return;
548
+ }
549
+ const removed = result.entries.filter((e) => e.status !== "kept");
550
+ const kept = result.entries.length - removed.length;
551
+ process.stdout.write(
552
+ ` ${verb} ${result.filesRemoved} file(s) totalling ${kb(result.bytesFreed)}; kept ${kept}.\n`,
553
+ );
554
+ for (const e of removed) {
555
+ process.stdout.write(` - ${e.file} (${kb(e.bytes)}) ${e.status}\n`);
556
+ }
557
+ });
558
+
559
+ function collectRoutine(value: string, prev: string[]): string[] {
560
+ return [...prev, value];
561
+ }
562
+
563
+ program.parseAsync(process.argv).catch((err: unknown) => {
564
+ process.stderr.write(`al-sem: ${err instanceof Error ? err.message : String(err)}\n`);
565
+ process.exitCode = 1;
566
+ });