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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Torben Leth
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,361 @@
1
+ # al-sem — Static semantic analyzer for AL (Microsoft Business Central)
2
+
3
+ [![TypeScript](https://img.shields.io/badge/typescript-5.6-blue)](https://typescriptlang.org)
4
+ [![Bun](https://img.shields.io/badge/runtime-bun-f9f1e1)](https://bun.sh)
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
6
+
7
+ Find cross-file performance, correctness, and compatibility bugs in an AL workspace —
8
+ the kind per-file linters can't catch because they require the whole call graph:
9
+ DB-ops-inside-loops walked across procedures, Commit inside a posting transaction span,
10
+ event-subscriber cycles, integration events with no subscribers anywhere, MinVersion
11
+ drift against actual call sites, and ten more. Pure static analysis (no profile, no
12
+ runtime), but accurate enough to be actionable in CI and in an editor.
13
+
14
+ 34 default detectors plus 1 opt-in (D40), 35 total. Tuned on a real Continia BC extension — see
15
+ [precision study](docs/superpowers/precision-study-do-cloud.md).
16
+
17
+ Beyond `analyze`, al-sem also ships four standalone surfaces over the same `SemanticModel`:
18
+ declarative **`policy`** rules over capability facts, event blast-radius reports (**`events
19
+ fanout`** / **`events chains`**), a cross-version snapshot **`diff`**, and **`fingerprint`** (emit a
20
+ `CapabilitySnapshot`). `diff` and `fingerprint` accept either a workspace directory or a raw `.app`
21
+ symbol package, so two `.app` versions can be compared with no source checkout.
22
+
23
+ **Status (2026-06-02):** code-complete through Phase 4 (record-flow framework) plus the L6 policy
24
+ layer, event blast-radius, snapshot diff/fingerprint, and HTML report surfaces. 1828 tests pass;
25
+ `tsc` and Biome clean. See [STATUS](docs/superpowers/STATUS.md).
26
+
27
+ ## Install
28
+
29
+ ```bash
30
+ bun add al-sem
31
+ ```
32
+
33
+ al-sem ships a native tree-sitter parser per platform that downloads via a postinstall
34
+ hook. Bun requires you to opt in:
35
+
36
+ ```jsonc
37
+ // package.json
38
+ { "trustedDependencies": ["al-sem"] }
39
+ ```
40
+
41
+ **Supported platforms:** `win32-x64`, `linux-x64`, `darwin-arm64` shipped today;
42
+ `darwin-x64` (Intel macOS) is pending the next `tree-sitter-al` release. Other
43
+ platforms fail at first parse with a clear `NativeParserUnavailableError`.
44
+
45
+ **Install-time environment overrides (for air-gapped / mirrored environments):**
46
+
47
+ - `AL_SEM_NATIVE_PARSER_PATH=/abs/path/to/lib` — use a preseeded artifact, skip download.
48
+ - `AL_SEM_NATIVE_PARSER_OFFLINE=1` — require the canonical artifact to already exist; never download.
49
+ - `AL_SEM_NATIVE_PARSER_BASE_URL=https://internal-mirror/...` — fetch from an internal mirror instead of GitHub.
50
+
51
+ If `trustedDependencies` is not configured, preseed the artifact via `AL_SEM_NATIVE_PARSER_PATH`.
52
+
53
+ ## Quick start
54
+
55
+ ```bash
56
+ bunx al-sem analyze . --min-severity high --format terminal
57
+ ```
58
+
59
+ Or run the bundled demo against a small intentionally-buggy workspace:
60
+ `bash demo/run-demos.sh all` — walks the cross-file detectors the standard
61
+ AL cops can't replicate and writes a sample HTML report to `demo/report.html`.
62
+
63
+ Sample output:
64
+
65
+ ```text
66
+ Analysed 1234 routines (1230 with bodies, 4 parse-incomplete); 251/251 source units parsed; 0 opaque app(s).
67
+
68
+ HIGH (12):
69
+ [d1-db-op-in-loop] Database operation inside a loop — A loop in PostSalesDoc reaches FindSet on Sales Line.
70
+ ws:src/Codeunit/SalesPostHelper.Codeunit.al:204:13 in Sales-Post Helper :: PostSalesDoc
71
+ confidence: likely
72
+ fix (medium): Move the database operation outside the loop, or batch it into a set-based operation.
73
+ [d3-missing-setloadfields] Missing SetLoadFields ...
74
+ ```
75
+
76
+ By default `--format auto` emits terminal on a TTY and compact JSON on a pipe.
77
+ `--format json` always emits the compact summary; `--format sarif` emits SARIF
78
+ 2.1.0 for GitHub code-scanning; `--format html` emits a self-contained visual
79
+ report (per-finding interprocedural evidence-path flows + a publisher→subscriber
80
+ event graph, no external assets) for sharing or blog embedding; `--dump-model`
81
+ opts into the legacy full-model dump (debug-only, can exceed 500 MB).
82
+
83
+ ## CI integration
84
+
85
+ ```yaml
86
+ - name: al-sem
87
+ run: |
88
+ bunx al-sem analyze . \
89
+ --baseline .al-sem-baseline.json \
90
+ --fail-on high \
91
+ --format sarif > al-sem.sarif
92
+
93
+ - uses: github/codeql-action/upload-sarif@v3
94
+ with: { sarif_file: al-sem.sarif }
95
+ ```
96
+
97
+ The first run is noisy by design — generate a baseline once and commit it:
98
+
99
+ ```bash
100
+ bunx al-sem analyze . --baseline .al-sem-baseline.json --update-baseline
101
+ ```
102
+
103
+ Subsequent runs report only NEW findings; the baseline survives nearby edits
104
+ because fingerprints exclude line numbers. `--update-baseline` without
105
+ `--baseline` is a no-op and writes a warning to stderr.
106
+
107
+ ## CLI options
108
+
109
+ | Flag | Default | Description |
110
+ |------|---------|-------------|
111
+ | `--alpackages <dir>` | `<ws>/.alpackages` if present | Explicit path to the dependency `.alpackages` directory. |
112
+ | `--format <fmt>` | `auto` | `auto` \| `terminal` \| `json` \| `sarif` \| `html`. `html` emits a self-contained visual report (evidence-path flows + event graph). |
113
+ | `--deterministic` | off | Pin timestamps for byte-stable output. |
114
+ | `--no-dep-summaries` | off | Skip behavioral dependency cold run (structural ABI only). Cached separately from the full-mode cache, so the second run with this flag is warm. |
115
+ | `--dep-cache-dir <dir>` | `~/.al-sem/cache/` | Override the dependency cache directory. |
116
+ | `--dump-model` | off | Emit the full SemanticModel (debug-only, can be >500 MB). |
117
+ | `--min-severity <sev>` | none | Drop findings below `critical \| high \| medium \| low \| info`. |
118
+ | `--detector <ids>` | all | Comma-separated allow-list of detector ids. |
119
+ | `--scope <scope>` | `primary` | `primary` drops findings whose actionable anchor is in a dependency. |
120
+ | `--limit <n>` | unlimited | Cap output at the first N findings (after filtering and scope). |
121
+ | `--group-by <by>` | off | Terminal-only grouped output: `object \| routine \| table \| detector \| file`. |
122
+ | `--baseline <file>` | none | Suppress fingerprints present in the baseline file. |
123
+ | `--update-baseline` | off | Rewrite the baseline file from this run's findings. |
124
+ | `--fail-on <sev>` | none | Exit 1 if any finding at this severity or above (after baseline / filters). |
125
+
126
+ ## Cache maintenance
127
+
128
+ ```bash
129
+ bunx al-sem cache prune # remove stale dep-cache entries
130
+ bunx al-sem cache prune --dry-run # classify without deleting
131
+ ```
132
+
133
+ Stale = version-stamp mismatch with this build, corrupt file, mis-named file, or
134
+ tampered content hash. Valid current-version artifacts are kept untouched.
135
+ `--dep-cache-dir <dir>` overrides the cache location for both `analyze` and
136
+ `cache prune`.
137
+
138
+ ## Other commands
139
+
140
+ All of these run the same pipeline as `analyze` and reuse the dependency cache.
141
+
142
+ ### `policy` — declarative rules over capability facts
143
+
144
+ ```bash
145
+ bunx al-sem policy check . # workspace's own app(s), bundled default rules
146
+ bunx al-sem policy check . --scope all # include dependency-anchored findings
147
+ bunx al-sem policy explain no-commit-in-event-subscribers
148
+ ```
149
+
150
+ A policy is a YAML file of rules whose `when`/`except` predicates match capability facts
151
+ (op, resource, root kind, confidence, …) under Kleene tri-state semantics. al-sem auto-detects
152
+ `al-sem.policy.yaml` in the workspace, else applies the 8 bundled defaults (no Commit in event
153
+ subscribers / triggers, no interactive UI or ledger writes from API roots, etc.). `--format
154
+ human | json | sarif`. Like `analyze`, `policy check` defaults to `--scope primary` (the
155
+ workspace's own app); `--scope all` reports model-wide.
156
+
157
+ ### `events` — event blast-radius reports
158
+
159
+ ```bash
160
+ bunx al-sem events fanout . # per-event publisher → subscriber counts + coverage
161
+ bunx al-sem events chains . # publisher → subscriber relay trees (cycle/depth-bounded)
162
+ ```
163
+
164
+ Both default to `--scope primary` ("primary participates": the publisher or any subscriber is in
165
+ the workspace's own app); `--scope all` enumerates the entire merged event graph. `--format
166
+ human | json`.
167
+
168
+ ### `diff` — compare two snapshots / workspaces / `.app` files
169
+
170
+ ```bash
171
+ bunx al-sem diff old.app new.app # cross-version .app diff (no checkout needed)
172
+ bunx al-sem diff ./baseline.cbor.gz . # persisted snapshot vs live workspace
173
+ ```
174
+
175
+ Each side may be a workspace directory, a persisted snapshot artifact, or a raw `.app`. Reports
176
+ deltas across five axes — ABI/contract, schema, events, capabilities, permissions — with
177
+ `--format human | json | sarif` and `--fail-on <sev>`.
178
+
179
+ ### `fingerprint` — emit a CapabilitySnapshot
180
+
181
+ ```bash
182
+ bunx al-sem fingerprint . --format cbor.gz --out snapshot.cbor.gz
183
+ bunx al-sem fingerprint some.app --format json # snapshot a raw .app directly
184
+ ```
185
+
186
+ Persist a snapshot for later `diff` (the CI-friendly path: snapshot each release, diff the
187
+ artifacts) or inspect per-root capability fingerprints (`--format human`). Accepts a workspace
188
+ directory or a `.app` file.
189
+
190
+ ## Library usage
191
+
192
+ ```ts
193
+ import {
194
+ analyzeWorkspace,
195
+ projectFinding,
196
+ filterFindings,
197
+ applyBaseline,
198
+ loadBaseline,
199
+ computeExitCode,
200
+ } from "al-sem";
201
+
202
+ const result = await analyzeWorkspace({ workspaceRoot: "./", deterministic: true });
203
+ const compact = result.findings.map((f) => projectFinding(f, result.model));
204
+ const high = filterFindings(compact, { minSeverity: "high" });
205
+
206
+ // CI gate: load a baseline, drop known findings, fail on remaining "high" or worse.
207
+ const baseline = loadBaseline(".al-sem-baseline.json");
208
+ const newOnly = applyBaseline(high, baseline);
209
+ process.exitCode = computeExitCode(newOnly, "high");
210
+ ```
211
+
212
+ Re-exports from the package root, by area:
213
+
214
+ | Area | Exports |
215
+ |------|---------|
216
+ | Pipeline | `analyzeWorkspace`, `indexWorkspace`, `AnalyzeWorkspaceOptions`, `AnalyzeWorkspaceResult`, `IndexWorkspaceResult` |
217
+ | Model types | `Finding`, `FindingSummary`, `FindingLocation`, `Diagnostic`, `DetectorStats`, `SemanticModel`, `Routine`, `ObjectDecl`, `Table`, `SourceAnchor`, … (everything from `./model/index.ts`) |
218
+ | Projection | `projectFinding`, `filterFindings`, `FilterOptions`, `groupFindings`, `FindingGroup`, `GroupBy`, `fingerprintOf` |
219
+ | Output | `buildCompactReport`, `CompactReport`, `formatCompactJson`, `formatSarif` |
220
+ | Baseline / CI | `loadBaseline`, `saveBaseline`, `applyBaseline`, `BaselineFile`, `computeExitCode`, `parseFailOn` |
221
+ | Sources | `SourceUnit`, `SourceProvider`, `ExternalSourceProvider` |
222
+
223
+ `indexWorkspace(options)` stops after L2 (discovery + indexing only), for callers
224
+ that drive `resolveModel` themselves. `analyzeWorkspace` runs the full pipeline.
225
+
226
+ ## MCP server
227
+
228
+ al-sem also ships an MCP server (`bunx al-sem-mcp` or `bun run src/mcp/server.ts`)
229
+ exposing seven progressive-disclosure tools — `list_findings`, `list_rollups`
230
+ (multi-detector view), `get_finding`, `list_hotspots`, `get_routine_summary`,
231
+ `explain_path`, `get_analysis_health`. See [docs/MCP.md](docs/MCP.md) for wiring
232
+ instructions.
233
+
234
+ ## Detectors
235
+
236
+ | Detector | Category | Flags |
237
+ |----------|----------|-------|
238
+ | `d1-db-op-in-loop` | Performance | Database operation reachable inside a loop, interprocedurally; severity by op class. |
239
+ | `d2-event-fanout-in-loop` | Performance | Event raised inside a loop whose subscribers touch the database. |
240
+ | `d3-missing-setloadfields` | Performance | Record retrieval whose loaded field set doesn't cover the fields accessed (same routine + directly-resolved callees). |
241
+ | `d4-repeated-lookup-in-loop` | Performance | Identical Get/FindFirst/FindLast called repeatedly in a loop with a literal key. |
242
+ | `d5-set-based-opportunity` | Performance | Loop body is a single Modify on the iterating record — ModifyAll candidate. |
243
+ | `d7-recursive-event-expansion` | Correctness | Event subscriber chain forms a cycle (runtime infinite recursion). |
244
+ | `d8-commit-in-transaction` | Correctness | Commit inside a posting transaction span — breaks atomicity. |
245
+ | `d9-transaction-span-summary` | Info | Transaction span describes its routine / table / event reach. |
246
+ | `d10-self-modifying-loop` | Correctness | Modify/Validate/Delete on the loop-iterating record. |
247
+ | `d11-modify-without-get` | Correctness | Modify/Validate on a record that was never loaded (no Get/Find/Init/Insert) in this routine. |
248
+ | `d12-dead-integration-event` | Hygiene | Published IntegrationEvent has no subscribers anywhere. |
249
+ | `d13-cross-app-internal-call` | Hygiene | Calls a routine marked Access=Internal in another app. |
250
+ | `d14-dead-routine` | Hygiene | `local procedure` unreachable from any entry-point or non-local procedure. |
251
+ | `d16-obsolete-routine-call` | Compatibility | Calls a routine marked [Obsolete(...)] (info Pending, high Removed). |
252
+ | `d17-min-version-drift` | Compatibility | Calls into a dependency whose installed version exceeds the declared MinVersion (app-level precision; per-routine pending upstream metadata). |
253
+ | `d18-constant-filter-in-loop` | Performance | `SetRange`/`SetFilter` with literal-only arguments inside a loop — the same filter is applied every iteration; hoist it out. |
254
+ | `d19-unused-parameter` | Hygiene | Procedure parameter declared but never referenced in the body. Skips triggers and event-subscribers (signatures dictated by the publisher). |
255
+ | `d20-unreachable-after-exit` | Correctness | Statement that follows `Exit;`, `Error(...)`, or `CurrReport.Quit` at the same nesting level — control leaves the routine before it can run. |
256
+ | `d21-read-without-load` | Correctness | `TestField` / `CalcFields` / `CalcSums` on a record never loaded earlier in the routine — read returns the AL default. D11's read-side sibling. |
257
+ | `d22-flowfield-without-calcfields` | Correctness | Reads a FlowField with no prior `CalcFields(<that field>)` on the same record-var — silent zero/empty result. |
258
+ | `d29-subscriber-modify-on-event-record` | Correctness | Subscriber to an `OnAfter*Modify` / `OnBefore*Delete` event mutates the inbound record parameter — re-fires the same event, recursive-trigger risk. |
259
+ | `d32-constant-boolean-parameter` | Hygiene | `local procedure` Boolean parameter where every resolved primary-app caller passes the same literal — dead parameter, candidate for flattening. |
260
+ | `d33-unfiltered-bulk-write` | Correctness | `DeleteAll` (critical) or `ModifyAll` (high) on a local non-temp record with no prior SetRange/SetFilter since the last Reset — whole-table impact. |
261
+ | `d34-commit-in-loop` | Correctness | `Commit` inside a loop, direct or transitive via callee summary. Per-iteration commits break atomicity; nested-loop case escalates to critical. |
262
+ | `d35-commit-in-event-subscriber` | Correctness | `Commit` reachable from an `[EventSubscriber]` routine. Publisher cannot roll back what the subscriber committed. |
263
+ | `d36-late-setloadfields` | Performance | `SetLoadFields` / `AddLoadFields` placed AFTER a Get/Find, with no later load — the partial-record optimisation cannot apply. |
264
+ | `d37-validate-without-persist` | Correctness | `Validate` on a record with no subsequent Modify/Insert before the record is reloaded or the routine exits — the field write is silently discarded. |
265
+ | `d38-subscriber-to-obsolete-event` | Upgrade | `[EventSubscriber]` bound to a publisher routine carrying `[Obsolete(...)]`. Pending → info (plan migration); Removed → high (subscriber will stop firing). |
266
+ | `d39-record-left-dirty-across-chain` | Correctness | Caller forwards a record to a helper that exits dirty (path-proven `Validate` with no subsequent `Modify`/`Insert` on at least one exit path), and the caller never persists after the call — the field write is silently discarded across the chain. Strictly interprocedural; only fires on path-proven `dirtyAtExit === "yes"` from the P6.T2 walker. |
267
+ | `d40-transitive-load-missing` *(opt-in)* | Correctness | Caller forwards a record to a helper that reads or mutates without loading. Strictly interprocedural — closes D11/D21's by-var-parameter precision gap. Currently opt-in (Phase 4 straight-line walker; Phase 6's full walker re-enables by default after the loop-loaded false-positive class is closed). Enable via `--detector d40-transitive-load-missing`. |
268
+ | `d41-transitive-filter-loss` | Correctness | Caller sets filters on a record, forwards it by-var to a helper that calls Reset, and then performs a filter-sensitive op (FindFirst/FindLast/FindSet/Find/Next/CalcSums/DeleteAll/ModifyAll/Count/IsEmpty) on the record without re-filtering — the filters are silently lost and the subsequent op runs on the unfiltered set. Strictly interprocedural; the post-call-use requirement prevents flagging intentional reset helpers. |
269
+ | `d42-cross-call-wrong-setloadfields` | Performance | Caller narrowed a record's load via SetLoadFields/AddLoadFields then forwards it to a helper that reads a field outside the narrow — the runtime issues an extra SQL round-trip to fetch the missing field, defeating the partial-load optimisation. Strictly interprocedural; only fires when both sides are concrete (caller narrow and callee `requiredLoadedFieldsAtEntry` from the Phase 6 walker). |
270
+ | `d43-event-ishandled-skip` | Correctness | Invoker raises an `IsHandled`-guarded integration event whose subscriber set may set the guard, skipping the invoker's own guarded table writes — the writes are silently bypassed. Dispatch-site (invoker-centric) analysis. |
271
+ | `d44-event-multi-subscriber-overlap` | Correctness | Multiple subscribers to one event write the same table (execution-order-dependent outcome), plus a read-after-write hazard class across subscribers. |
272
+ | `d45-event-transitive-table-exposure` | Correctness | A primary publisher's event reaches, via an N-hop subscriber→publisher relay chain, a subscriber that writes a sensitive table — transitive table exposure the publisher doesn't see locally. |
273
+
274
+ ---
275
+
276
+ ## Architecture (advanced)
277
+
278
+ A layered pipeline, each layer a pure transform over the previous:
279
+
280
+ ```
281
+ L0 parser / symbols parse AL + read .app symbol packages
282
+ L1 providers discover workspace + external sources
283
+ L1.5 deps cached dependency artifacts merged into the index
284
+ L2 index → SemanticIndex (objects, routines, tables, features)
285
+ L3 resolve → SemanticModel (call graph, event graph, coverage)
286
+ L4 engine combined graph → Tarjan SCC → fixed-point RoutineSummary
287
+ L5 detectors walk the model + summaries → Finding[] (scoped to primary)
288
+ L6 projection compact FindingSummary + filter + group + fingerprint
289
+ ```
290
+
291
+ `analyzeWorkspace` runs the whole pipeline:
292
+
293
+ ```
294
+ discoverSources → buildSemanticIndex → resolveModel
295
+ → buildCombinedGraph → computeSummaries → runDetectors
296
+ → { model, findings, diagnostics, detectorStats }
297
+ ```
298
+
299
+ ### Key design principles
300
+
301
+ - **The engine never throws.** Failures — unparseable files, missing symbols, resolution
302
+ gaps — surface as `Diagnostic[]`, never exceptions. There is no "silent clean".
303
+ - **Determinism is a contract.** With `deterministic: true`, output is byte-stable:
304
+ timestamps are pinned, every derived collection has a canonical sort, Map/Set iteration
305
+ never leaks into output unsorted. `test/e2e.test.ts` guards this.
306
+ - **Detectors are pure queries** over the `SemanticModel` + summaries. They prune via
307
+ `RoutineSummary`, then use the shared path-walker with a detector-specific policy to
308
+ build evidence-backed `Finding`s. Each detector dedupes findings by `id` before sorting.
309
+ - **L4 summaries** compose per-routine effects bottom-up over the call graph's SCC
310
+ condensation, using a finite monotone fixed-point so recursive cycles converge.
311
+ - **Dependency direction is one-way**: al-sem knows nothing of al-perf. al-perf
312
+ consumes al-sem as a library.
313
+
314
+ ### Source layout
315
+
316
+ ```
317
+ src/
318
+ parser/ AL parsing (native bun:ffi tree-sitter) + AST helpers
319
+ symbols/ .app symbol-package reader
320
+ providers/ workspace + external source discovery
321
+ deps/ L1.5 — dependency artifact types, cache, pipeline orchestration
322
+ index/ SemanticIndex construction (objects, routines, intraprocedural features)
323
+ resolve/ call resolution, event graph, record types, coverage → SemanticModel
324
+ engine/ L4 — combined graph, SCC, effect lattice, summary engine, path-walker,
325
+ reverse call graph, entry points, transaction spans, attribute parser
326
+ detectors/ L5 — 34 default detectors plus 1 opt-in (D40), 35 total + shared
327
+ DetectorContext, confidence mapping, registry
328
+ policy/ L6 — declarative capability-fact rules (Kleene tri-state evaluator)
329
+ snapshot/ CapabilitySnapshot compose + serialize (json/cbor/cbor.gz)
330
+ diff/ cross-snapshot delta engine (ABI, schema, events, capabilities, permissions)
331
+ model/ shared types — entities, graph, summary, finding, identity, ids, analysisRole
332
+ cli/ commander CLI + terminal / JSON / SARIF formatters
333
+ mcp/ MCP server (seven tools, progressive-disclosure)
334
+ index.ts public library entry point
335
+ ```
336
+
337
+ ## Development
338
+
339
+ ```bash
340
+ bun install
341
+ bun test # run all tests
342
+ bun run typecheck # bunx tsc --noEmit
343
+ bun run lint # bunx biome check src test
344
+ bun run format # bunx biome format --write src test
345
+ ```
346
+
347
+ **Tech stack:** Bun · TypeScript · `bun:ffi` + native `tree-sitter-al` shared library ·
348
+ `commander` · `fflate` (`.app` package extraction) · `bun:test` · Biome.
349
+
350
+ Design specs and implementation plans live under `docs/superpowers/` —
351
+ `specs/` for designs, `plans/` for the phased TDD implementation plans.
352
+
353
+ ## Status
354
+
355
+ See [docs/superpowers/STATUS.md](docs/superpowers/STATUS.md) for the current phase
356
+ status and roadmap.
357
+
358
+ ---
359
+
360
+ **Author**: Torben Leth
361
+ **License**: MIT (see [LICENSE](LICENSE))
package/package.json ADDED
@@ -0,0 +1,64 @@
1
+ {
2
+ "name": "al-sem",
3
+ "version": "0.0.1",
4
+ "description": "Static semantic analysis engine for Microsoft Business Central AL code",
5
+ "license": "MIT",
6
+ "author": "Torben Leth",
7
+ "keywords": [
8
+ "business-central",
9
+ "al",
10
+ "static-analysis",
11
+ "semantic-analysis",
12
+ "call-graph",
13
+ "performance",
14
+ "linter",
15
+ "dynamics-365",
16
+ "bun"
17
+ ],
18
+ "files": [
19
+ "src",
20
+ "!src/parser/native/lib-*",
21
+ "!src/parser/native/lib.*",
22
+ "scripts",
23
+ "tsconfig.json"
24
+ ],
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/SShadowS/al-sem.git"
28
+ },
29
+ "homepage": "https://github.com/SShadowS/al-sem#readme",
30
+ "bugs": {
31
+ "url": "https://github.com/SShadowS/al-sem/issues"
32
+ },
33
+ "type": "module",
34
+ "module": "src/index.ts",
35
+ "exports": {
36
+ ".": "./src/index.ts"
37
+ },
38
+ "bin": {
39
+ "al-sem": "src/cli/index.ts",
40
+ "al-sem-mcp": "src/mcp/server.ts"
41
+ },
42
+ "engines": {
43
+ "bun": ">=1.0.0"
44
+ },
45
+ "scripts": {
46
+ "test": "bun test",
47
+ "typecheck": "bunx tsc --noEmit",
48
+ "lint": "bunx biome check src test",
49
+ "format": "bunx biome format --write src test",
50
+ "postinstall": "bun run scripts/fetch-native-parser.ts"
51
+ },
52
+ "dependencies": {
53
+ "@modelcontextprotocol/sdk": "^1.29.0",
54
+ "cbor-x": "^1.6.4",
55
+ "commander": "^14.0.3",
56
+ "fflate": "^0.8.2",
57
+ "yaml": "^2.9.0"
58
+ },
59
+ "devDependencies": {
60
+ "@biomejs/biome": "^1.9.0",
61
+ "@types/bun": "latest",
62
+ "typescript": "^5.6.0"
63
+ }
64
+ }
@@ -0,0 +1,44 @@
1
+ // scripts/d40-diff.ts
2
+ // One-shot diff between two precision runs (Round N vs Round N+1).
3
+ // Prints the count of added/removed findings + a sample of removed rootCauses
4
+ // so the reviewer can confirm a fix targeted the intended FP class.
5
+
6
+ import { readFileSync } from "node:fs";
7
+
8
+ interface FindingLike {
9
+ id: string;
10
+ severity: string;
11
+ rootCause: string;
12
+ }
13
+ interface RunJson {
14
+ findings: FindingLike[];
15
+ }
16
+
17
+ const [beforePath, afterPath] = process.argv.slice(2);
18
+ if (!beforePath || !afterPath) {
19
+ console.error("usage: d40-diff.ts <before.json> <after.json>");
20
+ process.exit(2);
21
+ }
22
+
23
+ const before = JSON.parse(readFileSync(beforePath, "utf8")) as RunJson;
24
+ const after = JSON.parse(readFileSync(afterPath, "utf8")) as RunJson;
25
+
26
+ const idsBefore = new Set(before.findings.map((f) => f.id));
27
+ const idsAfter = new Set(after.findings.map((f) => f.id));
28
+ const removed = before.findings.filter((f) => !idsAfter.has(f.id));
29
+ const added = after.findings.filter((f) => !idsBefore.has(f.id));
30
+
31
+ console.log(`before: ${before.findings.length}, after: ${after.findings.length}`);
32
+ console.log(`removed: ${removed.length}, added: ${added.length}`);
33
+ console.log("");
34
+ console.log("=== Sample of removed findings (first 10) ===");
35
+ for (const f of removed.slice(0, 10)) {
36
+ console.log(`- [${f.severity}] ${f.rootCause}`);
37
+ }
38
+ if (added.length > 0) {
39
+ console.log("");
40
+ console.log("=== Sample of added findings (first 10) ===");
41
+ for (const f of added.slice(0, 10)) {
42
+ console.log(`- [${f.severity}] ${f.rootCause}`);
43
+ }
44
+ }
@@ -0,0 +1,179 @@
1
+ // scripts/fetch-native-parser.ts
2
+ // Postinstall: idempotent provisioning of the fat shim for the current platform.
3
+ // Resolution order:
4
+ // 1. AL_SEM_NATIVE_PARSER_PATH — copy from absolute path, write meta.
5
+ // 2. AL_SEM_NATIVE_PARSER_OFFLINE=1 — require canonical lib; normalize meta if absent.
6
+ // 3. AL_SEM_NATIVE_PARSER_BASE_URL — override download base URL.
7
+ // 4. Default: download from GH releases.
8
+ // Failures other than offline-missing-lib soft-fail (warn + exit 0). Runtime
9
+ // surfaces the missing parser as a single NativeParserUnavailableError.
10
+
11
+ import {
12
+ copyFileSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync,
13
+ } from "node:fs";
14
+ import { createHash } from "node:crypto";
15
+ import { dirname, join } from "node:path";
16
+ import { fileURLToPath } from "node:url";
17
+ import { platform as nodePlatform, arch as nodeArch } from "node:process";
18
+
19
+ export const TREE_SITTER_AL_RELEASE_TAG = "v2.5.2-shim";
20
+
21
+ const SUPPORTED: Record<string, string> = {
22
+ "win32-x64": "tree-sitter-al-win32-x64.dll",
23
+ "linux-x64": "tree-sitter-al-linux-x64.so",
24
+ "darwin-x64": "tree-sitter-al-darwin-x64.dylib",
25
+ "darwin-arm64": "tree-sitter-al-darwin-arm64.dylib",
26
+ };
27
+
28
+ export function resolveAssetName(platform: string, arch: string): string {
29
+ const key = `${platform}-${arch}`;
30
+ const name = SUPPORTED[key];
31
+ if (!name) {
32
+ throw new Error(
33
+ `al-sem: native parser unavailable for ${key}. Supported: ${Object.keys(SUPPORTED).join(", ")}. ` +
34
+ `File an issue at https://github.com/SShadowS/tree-sitter-al/issues to add this platform.`,
35
+ );
36
+ }
37
+ return name;
38
+ }
39
+
40
+ export interface LibMeta {
41
+ tag: string;
42
+ platform: string;
43
+ arch: string;
44
+ asset: string;
45
+ sha256: string;
46
+ }
47
+
48
+ function sha256Hex(buf: Buffer): string {
49
+ return createHash("sha256").update(buf).digest("hex");
50
+ }
51
+
52
+ export function normalizeMeta(
53
+ libPath: string,
54
+ ref: { tag: string; platform: string; arch: string },
55
+ ): LibMeta {
56
+ const bytes = readFileSync(libPath);
57
+ return {
58
+ tag: ref.tag,
59
+ platform: ref.platform,
60
+ arch: ref.arch,
61
+ asset: resolveAssetName(ref.platform, ref.arch),
62
+ sha256: sha256Hex(bytes),
63
+ };
64
+ }
65
+
66
+ export function writeMeta(nativeDir: string, meta: LibMeta): void {
67
+ writeFileSync(join(nativeDir, "lib.meta.json"), JSON.stringify(meta, null, 2));
68
+ }
69
+
70
+ export function readMeta(nativeDir: string): LibMeta | null {
71
+ const p = join(nativeDir, "lib.meta.json");
72
+ if (!existsSync(p)) return null;
73
+ try {
74
+ return JSON.parse(readFileSync(p, "utf8")) as LibMeta;
75
+ } catch {
76
+ return null;
77
+ }
78
+ }
79
+
80
+ export function libFilename(tag: string, platform: string, arch: string): string {
81
+ const asset = SUPPORTED[`${platform}-${arch}`] ?? "";
82
+ const ext = asset.split(".").pop() ?? "so";
83
+ return `lib-${tag}-${platform}-${arch}.${ext}`;
84
+ }
85
+
86
+ async function downloadTo(url: string, destPath: string): Promise<void> {
87
+ const res = await fetch(url);
88
+ if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${url}`);
89
+ const buf = Buffer.from(await res.arrayBuffer());
90
+ mkdirSync(dirname(destPath), { recursive: true });
91
+ writeFileSync(destPath, buf);
92
+ }
93
+
94
+ async function main(): Promise<void> {
95
+ const here = dirname(fileURLToPath(import.meta.url));
96
+ const nativeDir = join(here, "..", "src", "parser", "native");
97
+ mkdirSync(nativeDir, { recursive: true });
98
+ const platform = nodePlatform;
99
+ const arch = nodeArch;
100
+ const tag = TREE_SITTER_AL_RELEASE_TAG;
101
+
102
+ let asset: string;
103
+ try {
104
+ asset = resolveAssetName(platform, arch);
105
+ } catch (err) {
106
+ process.stderr.write(`${(err as Error).message}\n`);
107
+ process.exit(0); // soft-fail; runtime surfaces it
108
+ }
109
+
110
+ const targetFile = libFilename(tag, platform, arch);
111
+ const targetPath = join(nativeDir, targetFile);
112
+
113
+ // 1. AL_SEM_NATIVE_PARSER_PATH override
114
+ const explicit = process.env.AL_SEM_NATIVE_PARSER_PATH;
115
+ if (explicit) {
116
+ if (!existsSync(explicit)) {
117
+ process.stderr.write(`al-sem: AL_SEM_NATIVE_PARSER_PATH=${explicit} does not exist\n`);
118
+ process.exit(1);
119
+ }
120
+ copyFileSync(explicit, targetPath);
121
+ writeMeta(nativeDir, normalizeMeta(targetPath, { tag, platform, arch }));
122
+ return;
123
+ }
124
+
125
+ // 2. Offline mode
126
+ if (process.env.AL_SEM_NATIVE_PARSER_OFFLINE === "1") {
127
+ if (!existsSync(targetPath)) {
128
+ process.stderr.write(
129
+ `al-sem: AL_SEM_NATIVE_PARSER_OFFLINE=1 but ${targetPath} is missing. ` +
130
+ `Preseed the artifact or unset the env var.\n`,
131
+ );
132
+ process.exit(1);
133
+ }
134
+ if (!readMeta(nativeDir)) {
135
+ writeMeta(nativeDir, normalizeMeta(targetPath, { tag, platform, arch }));
136
+ }
137
+ return;
138
+ }
139
+
140
+ // 3. Idempotent check
141
+ const meta = readMeta(nativeDir);
142
+ if (
143
+ meta &&
144
+ meta.tag === tag &&
145
+ meta.platform === platform &&
146
+ meta.arch === arch &&
147
+ existsSync(targetPath)
148
+ ) {
149
+ const actual = sha256Hex(readFileSync(targetPath));
150
+ if (actual === meta.sha256) return;
151
+ process.stderr.write(`al-sem: cache mismatch — re-downloading ${targetFile}\n`);
152
+ try { unlinkSync(targetPath); } catch {}
153
+ }
154
+
155
+ // 4. Download
156
+ const baseUrl =
157
+ process.env.AL_SEM_NATIVE_PARSER_BASE_URL ??
158
+ "https://github.com/SShadowS/tree-sitter-al/releases/download/";
159
+ const url = `${baseUrl}${tag}/${asset}`;
160
+ try {
161
+ await downloadTo(url, targetPath);
162
+ writeMeta(nativeDir, normalizeMeta(targetPath, { tag, platform, arch }));
163
+ } catch (err) {
164
+ process.stderr.write(
165
+ `al-sem: could not fetch ${url}: ${(err as Error).message}. ` +
166
+ `Re-run bun install with network, or set AL_SEM_NATIVE_PARSER_BASE_URL ` +
167
+ `(mirror) or AL_SEM_NATIVE_PARSER_PATH (preseeded artifact).\n`,
168
+ );
169
+ process.exit(0); // soft-fail; defer to runtime
170
+ }
171
+ }
172
+
173
+ // Allow `bun run scripts/fetch-native-parser.ts` AND `import` (for tests).
174
+ if (import.meta.main) {
175
+ main().catch((err) => {
176
+ process.stderr.write(`al-sem postinstall: ${err}\n`);
177
+ process.exit(0);
178
+ });
179
+ }