@ecoma-io/archkeep 0.13.0

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 (131) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +262 -0
  3. package/cli.mjs +2792 -0
  4. package/index.mjs +85 -0
  5. package/lsp.mjs +81 -0
  6. package/nx.mjs +24 -0
  7. package/package.json +81 -0
  8. package/presets/clean-architecture.json +78 -0
  9. package/presets/ddd-bounded-contexts.json +88 -0
  10. package/presets/hexagonal.json +68 -0
  11. package/presets/layered.json +92 -0
  12. package/presets/modular-monolith.json +85 -0
  13. package/presets/vertical-slice.json +68 -0
  14. package/src/analysis/analyze.mjs +218 -0
  15. package/src/analysis/contract.md +259 -0
  16. package/src/analysis/go.mjs +414 -0
  17. package/src/analysis/manifest-util.mjs +68 -0
  18. package/src/analysis/python.mjs +1266 -0
  19. package/src/analysis/registry.mjs +74 -0
  20. package/src/analysis/rust.mjs +674 -0
  21. package/src/analysis/source-util.mjs +230 -0
  22. package/src/analysis/typescript.mjs +1034 -0
  23. package/src/analysis/vue.mjs +156 -0
  24. package/src/architecture-intent/intent-fingerprint.mjs +29 -0
  25. package/src/architecture-intent/judge.mjs +539 -0
  26. package/src/architecture-intent/model.mjs +703 -0
  27. package/src/architecture-intent/selectors.mjs +170 -0
  28. package/src/canonical.mjs +48 -0
  29. package/src/commands/README.md +266 -0
  30. package/src/commands/adr.mjs +248 -0
  31. package/src/commands/check.mjs +989 -0
  32. package/src/commands/context-command.mjs +212 -0
  33. package/src/commands/context.mjs +790 -0
  34. package/src/commands/custom-rules.mjs +428 -0
  35. package/src/commands/debt.mjs +218 -0
  36. package/src/commands/diff.mjs +523 -0
  37. package/src/commands/discover.mjs +159 -0
  38. package/src/commands/drift.mjs +473 -0
  39. package/src/commands/edge-constraints.mjs +355 -0
  40. package/src/commands/explain.mjs +359 -0
  41. package/src/commands/fitness.mjs +226 -0
  42. package/src/commands/graph.mjs +297 -0
  43. package/src/commands/health.mjs +213 -0
  44. package/src/commands/history.mjs +614 -0
  45. package/src/commands/impact.mjs +226 -0
  46. package/src/commands/plan-context-command.mjs +496 -0
  47. package/src/commands/policy.mjs +138 -0
  48. package/src/commands/provenance-command.mjs +352 -0
  49. package/src/commands/provenance.mjs +159 -0
  50. package/src/commands/reconcile.mjs +219 -0
  51. package/src/commands/report.mjs +553 -0
  52. package/src/commands/snapshot-meta.mjs +107 -0
  53. package/src/commands/waivers.mjs +240 -0
  54. package/src/config.mjs +1308 -0
  55. package/src/containment.mjs +234 -0
  56. package/src/custom-rules/evidence.mjs +340 -0
  57. package/src/custom-rules/host.mjs +1023 -0
  58. package/src/custom-rules/values.mjs +43 -0
  59. package/src/entry-point.mjs +55 -0
  60. package/src/errors.mjs +36 -0
  61. package/src/eslint-config.mjs +542 -0
  62. package/src/go-work.mjs +394 -0
  63. package/src/governance/adr-registry.mjs +539 -0
  64. package/src/governance/clock.mjs +69 -0
  65. package/src/governance/debt-ledger.mjs +274 -0
  66. package/src/governance/discovery-proposal.mjs +423 -0
  67. package/src/governance/fitness-registry.mjs +504 -0
  68. package/src/governance/fitness-rules.mjs +668 -0
  69. package/src/governance/metrics.mjs +392 -0
  70. package/src/governance/preset-fingerprints.json +16 -0
  71. package/src/governance/profile-registry.mjs +366 -0
  72. package/src/governance/provenance-record.mjs +177 -0
  73. package/src/governance/reconcile-candidates.mjs +301 -0
  74. package/src/governance/reconcile-score.mjs +503 -0
  75. package/src/governance/row-schema.mjs +208 -0
  76. package/src/governance/verdict.mjs +127 -0
  77. package/src/governance/waiver.mjs +105 -0
  78. package/src/graph/create-dependencies.mjs +96 -0
  79. package/src/intent/intent-manifest.json +347 -0
  80. package/src/intent/mask-non-code.mjs +640 -0
  81. package/src/lsp/boundary-config.mjs +225 -0
  82. package/src/lsp/diagnose.mjs +202 -0
  83. package/src/lsp/diagnostics.mjs +241 -0
  84. package/src/lsp/protocol.mjs +215 -0
  85. package/src/lsp/server.mjs +922 -0
  86. package/src/lsp/workspace-index.mjs +891 -0
  87. package/src/nx-json.mjs +95 -0
  88. package/src/options.mjs +611 -0
  89. package/src/process.mjs +91 -0
  90. package/src/providers/moon.mjs +733 -0
  91. package/src/providers/native/README.md +204 -0
  92. package/src/providers/native/coverage.mjs +74 -0
  93. package/src/providers/native/differential.fixtures.mjs +1277 -0
  94. package/src/providers/native/discover.mjs +431 -0
  95. package/src/providers/native/graph.mjs +234 -0
  96. package/src/providers/native/index.mjs +152 -0
  97. package/src/providers/native/model.mjs +755 -0
  98. package/src/providers/nx.mjs +178 -0
  99. package/src/report/README.md +89 -0
  100. package/src/report/adr-text.mjs +129 -0
  101. package/src/report/context-text.mjs +109 -0
  102. package/src/report/debt-text.mjs +105 -0
  103. package/src/report/diff-text.mjs +219 -0
  104. package/src/report/discover-text.mjs +186 -0
  105. package/src/report/drift-text.mjs +194 -0
  106. package/src/report/envelope-shape.mjs +161 -0
  107. package/src/report/evidence.mjs +157 -0
  108. package/src/report/explain-text.mjs +159 -0
  109. package/src/report/graph-text.mjs +116 -0
  110. package/src/report/health-text.mjs +123 -0
  111. package/src/report/history-text.mjs +204 -0
  112. package/src/report/impact-text.mjs +128 -0
  113. package/src/report/json.mjs +173 -0
  114. package/src/report/plan-context-text.mjs +159 -0
  115. package/src/report/provenance-text.mjs +78 -0
  116. package/src/report/reconcile-text.mjs +159 -0
  117. package/src/report/report-text.mjs +264 -0
  118. package/src/report/sarif.mjs +953 -0
  119. package/src/report/text.mjs +823 -0
  120. package/src/report/waivers-text.mjs +100 -0
  121. package/src/rules/README.md +123 -0
  122. package/src/rules/index.mjs +962 -0
  123. package/src/rules/match.mjs +1708 -0
  124. package/src/rules/messages.mjs +73 -0
  125. package/src/rules/reachability.mjs +224 -0
  126. package/src/rules/specifiers.mjs +300 -0
  127. package/src/rules/tags.mjs +238 -0
  128. package/src/rules/topology.mjs +333 -0
  129. package/src/tsconfig-paths.mjs +237 -0
  130. package/src/verdict.mjs +145 -0
  131. package/src/workspace.mjs +580 -0
@@ -0,0 +1,1023 @@
1
+ /**
2
+ * The core-WebAssembly host: the one place a rule this engine did not write is
3
+ * loaded, executed, and held to the contract
4
+ * (`../../../../docs/adr/0002-custom-rules-one-contract.md`).
5
+ *
6
+ * Two functions, because the two halves fail for unrelated reasons and the
7
+ * failure classes are not interchangeable:
8
+ *
9
+ * - `loadCustomRule` answers "is this the law the policy declared?" — bytes
10
+ * that hash to what the row pinned, a module that compiles, exports the four
11
+ * symbols the ABI names, asks for nothing, and can describe itself. Every
12
+ * failure here is **load class**: the declared law could not be loaded, so
13
+ * the run refuses the way it refuses a malformed config rather than judging
14
+ * a tree against a law it never read.
15
+ * - `evaluateCustomRule` answers "what does this rule say about this
16
+ * evidence?" Every failure here is **evaluate class**: the law loaded, this
17
+ * rule could not reach a verdict, and the rule's verdict becomes `unknown`
18
+ * with the cause named — never an approximation, and never a `pass` by
19
+ * omission (`../governance/verdict.mjs`, invariant I5).
20
+ *
21
+ * Neither ever returns an empty result standing in for "could not look". A
22
+ * failure carries a `reason` naming what went wrong, because the reason is the
23
+ * only thing that separates a rule that judged and found nothing from a rule
24
+ * that never ran (`../../../../AGENTS.md`).
25
+ *
26
+ * ## No imports is the mechanism, not the promise
27
+ *
28
+ * The ADR's "a rule holds no ambient capability" is a property of the module,
29
+ * not of this host's restraint: a core-wasm module can reach nothing it did
30
+ * not import, so a module that imports nothing has no clock, no filesystem, no
31
+ * network and no randomness to reach for. `WebAssembly.Module.imports(module)`
32
+ * is where that is checked, and it is checked before anything is instantiated
33
+ * — a module with an import cannot even be instantiated without the host
34
+ * supplying it, and supplying a stub would be the quiet way to grant the
35
+ * capability the contract refuses.
36
+ *
37
+ * ## Every call into a rule runs in a worker, under a budget
38
+ *
39
+ * `archkeep_describe` and `archkeep_evaluate` both run inside a
40
+ * `node:worker_threads` Worker, through the one `runInWorker` below, because a
41
+ * rule that never returns has to be stoppable: `worker.terminate()` interrupts
42
+ * a wasm loop, and nothing else in Node can — a wasm loop on the main thread
43
+ * starves the event loop, so a timer set beside it never fires. Reading the
44
+ * self-description is not the cheap, safe half it looks like: it is the first
45
+ * call into code the workspace declared and this engine did not write, and a
46
+ * describe that spins would hang `check` forever, which is worse than any
47
+ * wrong verdict. So the budget covers both, `CUSTOM_RULE_TIMEOUT_MS` by
48
+ * default and injectable per call, and a describe that exceeds it is a LOAD
49
+ * failure naming the budget — the law could not be loaded.
50
+ *
51
+ * The worker's source is a string in this file rather than a second module,
52
+ * for a deployment reason: this package is installed into workspaces it is not
53
+ * part of, often through a symlinked store layout, and a `new Worker(new
54
+ * URL("./worker.mjs", import.meta.url))` would make execution depend on that
55
+ * path resolving in a consumer's tree — the exact class of failure
56
+ * `../../../../scripts/verify-package.mjs` exists to catch. A string has no
57
+ * path to resolve. The cost is that the string is not linted or type-checked,
58
+ * which is paid for by `./host.test.mjs` driving every outcome it can post.
59
+ *
60
+ * ## What crosses back is the claimed range, never the memory
61
+ *
62
+ * The worker unpacks the returned i64, bounds-checks it, and posts back only
63
+ * the bytes the rule actually pointed at. Posting the whole linear memory
64
+ * would have put the packing rule in one place — but the size of that copy is
65
+ * the RULE's decision, not this host's: a rule that grows its memory to a
66
+ * gigabyte would make every call a gigabyte structured clone, and growth is
67
+ * exactly the lever a hostile artifact has. So the claim is capped at
68
+ * `CUSTOM_RULE_MAX_VERDICT_BYTES` before anything is copied, and a claim past
69
+ * the cap is a named failure rather than an allocation.
70
+ *
71
+ * ## Memory has a stated bound, and a declared limit
72
+ *
73
+ * `CUSTOM_RULE_MEMORY_LIMIT_BYTES` is the ADR's "bounded memory", implemented
74
+ * as the honest v1: the worker measures `memory.buffer.byteLength` twice — once
75
+ * after instantiation, once after the call returns — and a measurement past
76
+ * the limit is a named failure. **The declared limit is that those are the
77
+ * only two boundaries at which growth is observed**: a rule that grows to a
78
+ * gigabyte and shrinks back inside one call is not caught, and wasm32 caps how
79
+ * far that can go at 4 GiB regardless. What the two checks do buy is that no
80
+ * rule can END a call holding more than the limit, and no rule can be
81
+ * instantiated holding more.
82
+ *
83
+ * A Worker's `resourceLimits` is deliberately NOT set, and the reason is
84
+ * measured rather than assumed: a worker created with
85
+ * `maxOldGenerationSizeMb: 32` allocated a 64 MiB `WebAssembly.Memory` and
86
+ * touched its last byte without complaint, because a wasm memory's backing
87
+ * store lives outside the V8 heap those options bound. Setting them would
88
+ * bound the wrong thing — the rule's memory would still be unbounded while
89
+ * ordinary runs gained a new way to die — so the byteLength check is the
90
+ * contract.
91
+ *
92
+ * ## One instance per call, never reused
93
+ *
94
+ * Each call builds a fresh `WebAssembly.Instance` inside its own worker, and
95
+ * the instance that read the description is gone before evaluation starts. A
96
+ * reused instance would carry one call's leftover heap into the next one's
97
+ * judgment, which is a rule whose verdict depends on what ran before it — the
98
+ * opposite of the determinism the carrier was chosen for.
99
+ */
100
+
101
+ import { createHash } from "node:crypto";
102
+ import { posix, win32 } from "node:path";
103
+ import { Worker } from "node:worker_threads";
104
+
105
+ // A finding id is spelled the way a rule name is, by the contract's decision
106
+ // (`custom/<rule>/<finding>` has one alphabet end to end), so the grammar is
107
+ // read from the module that owns it rather than restated here.
108
+ import { CUSTOM_RULE_NAME_PATTERN } from "../config.mjs";
109
+ import { VERDICTS, isVerdict } from "../governance/verdict.mjs";
110
+ import { EVIDENCE_CONTRACT, EVIDENCE_KINDS } from "./evidence.mjs";
111
+ import { describeValue, isNonEmptyString, isPlainObject } from "./values.mjs";
112
+
113
+ /**
114
+ * How long one call into a rule gets to answer, in milliseconds. Ten seconds
115
+ * is far past any real rule over a real workspace and far short of a CI job's
116
+ * patience: the number exists to turn "hung forever" into "named failure", not
117
+ * to price a rule's work.
118
+ */
119
+ export const CUSTOM_RULE_TIMEOUT_MS = 10_000;
120
+
121
+ /**
122
+ * The largest range this host will copy out of a rule's memory. Eight
123
+ * mebibytes is orders of magnitude past any real verdict — the whole evidence
124
+ * bundle for a large workspace is smaller — and it is what stops a claimed
125
+ * length from being an allocation instruction.
126
+ */
127
+ export const CUSTOM_RULE_MAX_VERDICT_BYTES = 8 * 1024 * 1024;
128
+
129
+ /**
130
+ * The most linear memory a rule may hold at the two boundaries the worker
131
+ * measures. 256 MiB is far past what reading an evidence bundle needs and far
132
+ * short of what a workspace's CI runner can absorb without noticing.
133
+ */
134
+ export const CUSTOM_RULE_MEMORY_LIMIT_BYTES = 256 * 1024 * 1024;
135
+
136
+ /** The four ABI symbols a rule module must export, and the kind each must be. */
137
+ export const REQUIRED_EXPORTS = Object.freeze({
138
+ memory: "memory",
139
+ archkeep_alloc: "function",
140
+ archkeep_describe: "function",
141
+ archkeep_evaluate: "function",
142
+ });
143
+
144
+ /** Every key `archkeep_describe` may state. Anything else is refused by name. */
145
+ const DESCRIBE_KEYS = Object.freeze(["contract", "name", "needs", "findings"]);
146
+
147
+ /** Every key a catalogue entry may state. */
148
+ const CATALOGUE_KEYS = Object.freeze(["id", "message"]);
149
+
150
+ /** Every key a verdict may state. */
151
+ const VERDICT_KEYS = Object.freeze([
152
+ "contract",
153
+ "verdict",
154
+ "findings",
155
+ "reason",
156
+ "notApplicableReason",
157
+ ]);
158
+
159
+ /** Every key a verdict's finding may state. */
160
+ const FINDING_KEYS = Object.freeze(["id", "message", "sourceFile", "line", "column", "project"]);
161
+
162
+ /**
163
+ * The contract version this host speaks, on both sides of the ABI. It is the
164
+ * bundle's own version rather than a second number beside it: the evidence a
165
+ * rule receives, the description it answers with, and the verdict it returns
166
+ * version together, so a rule built for one is built for all three, and two
167
+ * constants would let them drift into a state no rule could be built against.
168
+ */
169
+ const CONTRACT = EVIDENCE_CONTRACT;
170
+
171
+ /**
172
+ * The worker body, driving one call — `describe` or `evaluate` — against a
173
+ * fresh instance.
174
+ *
175
+ * It performs the four steps that must happen next to the instance and cannot
176
+ * be done from anywhere else: instantiate, measure memory, make the call, and
177
+ * read the range the call named. Everything it decides is arithmetic on
178
+ * numbers the instance owns; every WORD a reader ends up seeing is chosen on
179
+ * the main thread from the outcome it posts, so the message table stays where
180
+ * lint, `tsc` and coverage can read it.
181
+ *
182
+ * The evidence write is not guarded by a bounds check of its own — the engine
183
+ * already has one. `Uint8Array.prototype.set` refuses an offset that does not
184
+ * fit, and refuses a pointer that is not an index at all, so the `catch` names
185
+ * the pointer the rule handed back instead of re-deriving a predicate the
186
+ * platform already owns.
187
+ *
188
+ * `api.memory.buffer` is read outside any `try` on purpose. A module with no
189
+ * memory export cannot reach here through `loadCustomRule`, which refuses it
190
+ * by name; one that reaches here anyway throws where nothing catches, the
191
+ * worker posts nothing, and the main thread's "died without answering" case
192
+ * takes it. That is the loud direction — the alternative is a guard that
193
+ * invents a verdict for a module the ABI has no way to drive.
194
+ */
195
+ const WORKER_SOURCE = `
196
+ "use strict";
197
+ const { parentPort, workerData } = require("node:worker_threads");
198
+
199
+ function run() {
200
+ const memoryLimit = workerData.memoryLimit;
201
+ const maxBytes = workerData.maxBytes;
202
+
203
+ let instance;
204
+ try {
205
+ instance = new WebAssembly.Instance(workerData.module, {});
206
+ } catch (error) {
207
+ return { outcome: "instantiate", detail: String(error && error.message) };
208
+ }
209
+ const api = instance.exports;
210
+
211
+ if (api.memory.buffer.byteLength > memoryLimit) {
212
+ return { outcome: "memory", stage: "instantiation", byteLength: api.memory.buffer.byteLength };
213
+ }
214
+
215
+ let packed;
216
+ if (workerData.call === "describe") {
217
+ try {
218
+ packed = api.archkeep_describe();
219
+ } catch (error) {
220
+ return { outcome: "trap", stage: "archkeep_describe", detail: String(error && error.message) };
221
+ }
222
+ } else {
223
+ const evidence = workerData.evidence;
224
+ let pointer;
225
+ try {
226
+ pointer = api.archkeep_alloc(evidence.byteLength);
227
+ } catch (error) {
228
+ return { outcome: "trap", stage: "archkeep_alloc", detail: String(error && error.message) };
229
+ }
230
+ const view = new Uint8Array(api.memory.buffer);
231
+ try {
232
+ view.set(evidence, pointer);
233
+ } catch (error) {
234
+ return {
235
+ outcome: "unwritable",
236
+ pointer: String(pointer),
237
+ length: evidence.byteLength,
238
+ memoryBytes: view.byteLength,
239
+ detail: String(error && error.message),
240
+ };
241
+ }
242
+ try {
243
+ packed = api.archkeep_evaluate(pointer, evidence.byteLength);
244
+ } catch (error) {
245
+ return { outcome: "trap", stage: "archkeep_evaluate", detail: String(error && error.message) };
246
+ }
247
+ }
248
+
249
+ // Re-read the buffer rather than reusing a view: a call that grew memory
250
+ // detached every view taken before it.
251
+ const byteLength = api.memory.buffer.byteLength;
252
+ if (byteLength > memoryLimit) {
253
+ return { outcome: "memory", stage: "the call", byteLength: byteLength };
254
+ }
255
+
256
+ if (typeof packed !== "bigint") {
257
+ return { outcome: "packing", was: typeof packed, value: String(packed) };
258
+ }
259
+ const unsigned = BigInt.asUintN(64, packed);
260
+ const ptr = Number(unsigned >> 32n);
261
+ const len = Number(unsigned & 0xffffffffn);
262
+ if (ptr + len > byteLength) {
263
+ return { outcome: "range", ptr: ptr, len: len, byteLength: byteLength };
264
+ }
265
+ if (len > maxBytes) {
266
+ return { outcome: "oversize", ptr: ptr, len: len, byteLength: byteLength };
267
+ }
268
+ return {
269
+ outcome: "answered",
270
+ ptr: ptr,
271
+ len: len,
272
+ byteLength: byteLength,
273
+ slice: new Uint8Array(api.memory.buffer).slice(ptr, ptr + len),
274
+ };
275
+ }
276
+
277
+ parentPort.postMessage(run());
278
+ `;
279
+
280
+ /** What an unknown thrown value has to say for itself. */
281
+ const messageOf = (error) => (error instanceof Error ? error.message : String(error));
282
+
283
+ /**
284
+ * @typedef {object} CustomRuleFailure
285
+ * @property {"load"|"evaluate"} class Which half could not be reached: the law
286
+ * itself (`"load"` — the run refuses like a malformed config) or this rule's
287
+ * judgment over it (`"evaluate"` — the rule's verdict becomes `unknown`).
288
+ * @property {string} reason What went wrong, named. Never empty, because an
289
+ * unnamed failure is indistinguishable from no failure at all.
290
+ */
291
+
292
+ /**
293
+ * @typedef {object} LoadedCustomRule
294
+ * @property {boolean} ok
295
+ * @property {WebAssembly.Module} [module] The compiled rule, present when `ok`.
296
+ * @property {Record<string, any>} [describe] The validated self-description,
297
+ * present when `ok`.
298
+ * @property {CustomRuleFailure} [failure] Present when not `ok`.
299
+ */
300
+
301
+ /**
302
+ * @typedef {object} CustomRuleOutcome
303
+ * @property {boolean} ok
304
+ * @property {Record<string, any>} [verdict] The validated verdict, present
305
+ * when `ok`.
306
+ * @property {CustomRuleFailure} [failure] Present when not `ok`.
307
+ */
308
+
309
+ /**
310
+ * @param {"load"|"evaluate"} failureClass
311
+ * @param {string} name
312
+ * @param {string} reason
313
+ * @returns {{ok: false, failure: CustomRuleFailure}}
314
+ */
315
+ function failed(failureClass, name, reason) {
316
+ return {
317
+ ok: false,
318
+ failure: { class: failureClass, reason: `custom rule "${name}": ${reason}` },
319
+ };
320
+ }
321
+
322
+ /**
323
+ * A budget both entry points take, refused the same way.
324
+ *
325
+ * A wrong budget is a bug in the pipeline that composed the call, not a fact
326
+ * about the rule — reporting it as the rule's own failure would blame the
327
+ * artifact for the caller's mistake.
328
+ *
329
+ * @param {unknown} timeoutMs
330
+ * @param {string} entryPoint
331
+ * @throws {Error}
332
+ */
333
+ function requireBudget(timeoutMs, entryPoint) {
334
+ if (typeof timeoutMs !== "number" || !Number.isFinite(timeoutMs) || timeoutMs <= 0) {
335
+ throw new Error(
336
+ `archkeep: ${entryPoint} needs a positive timeout in milliseconds, got ` +
337
+ `${describeValue(timeoutMs)}`,
338
+ );
339
+ }
340
+ }
341
+
342
+ /**
343
+ * A range's bytes as a parsed JSON document. Decoding is fatal on purpose: a
344
+ * lossy decode would turn malformed bytes into a document full of replacement
345
+ * characters, which parses or fails for the wrong reason either way.
346
+ *
347
+ * `detail` is `null` on success and a sentence on failure, rather than a
348
+ * discriminated `ok` union: this package's `typecheck` target runs without
349
+ * `strict` (`../../tsconfig.json` argues why), and without `strictNullChecks`
350
+ * a `true`/`false` discriminant widens to `boolean`, so narrowing a union on
351
+ * it silently stops working and every success-branch field read becomes an
352
+ * error. One shape with a nullable `detail` needs no narrowing at all.
353
+ *
354
+ * @param {Uint8Array} bytes
355
+ * @returns {{value: any, detail: string|null}}
356
+ */
357
+ function decodeJson(bytes) {
358
+ let text;
359
+ try {
360
+ text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
361
+ } catch (error) {
362
+ return { value: null, detail: `returned bytes that are not UTF-8 (${messageOf(error)})` };
363
+ }
364
+ try {
365
+ return { value: JSON.parse(text), detail: null };
366
+ } catch (error) {
367
+ return { value: null, detail: `returned text that is not JSON (${messageOf(error)})` };
368
+ }
369
+ }
370
+
371
+ /**
372
+ * Keys an object states that the contract has no place for.
373
+ *
374
+ * @param {Record<string, any>} document
375
+ * @param {readonly string[]} allowed
376
+ * @returns {string[]}
377
+ */
378
+ const unknownKeys = (document, allowed) =>
379
+ Object.keys(document).filter((key) => !allowed.includes(key));
380
+
381
+ /**
382
+ * Why a worker run carries no answer, or `null` when it carries one.
383
+ *
384
+ * One table for both entry points, because the ways a call into a rule can go
385
+ * wrong do not depend on which call it was — only the CLASS does, and that is
386
+ * the caller's to decide. A second copy of these sentences under `load` would
387
+ * be the one that drifted.
388
+ *
389
+ * @param {WorkerOutcome} outcome
390
+ * @param {{call: string, timeoutMs: number}} run The exported symbol that was
391
+ * called, and the budget it was given.
392
+ * @returns {string|null}
393
+ */
394
+ function outcomeReason(outcome, { call, timeoutMs }) {
395
+ switch (outcome.outcome) {
396
+ case "timeout":
397
+ return (
398
+ `${call} did not return within its ${timeoutMs}ms budget and was terminated — a rule ` +
399
+ `that never answers is a run that never ends`
400
+ );
401
+ case "instantiate":
402
+ return `the module could not be instantiated (${outcome.detail})`;
403
+ case "trap":
404
+ return `${outcome.stage} trapped (${outcome.detail})`;
405
+ case "unwritable":
406
+ return (
407
+ `archkeep_alloc answered ${outcome.pointer} for ${outcome.length} evidence bytes, which ` +
408
+ `does not fit in its ${outcome.memoryBytes} bytes of linear memory (${outcome.detail})`
409
+ );
410
+ case "memory":
411
+ return (
412
+ `it held ${outcome.byteLength} bytes of linear memory after ${outcome.stage}, past the ` +
413
+ `${CUSTOM_RULE_MEMORY_LIMIT_BYTES}-byte limit — a rule's memory is bounded so a hostile ` +
414
+ `artifact cannot make the machine that enforces the law the thing it takes down`
415
+ );
416
+ case "packing":
417
+ return (
418
+ `${call} returned ${outcome.was} ${outcome.value}, not the packed i64 the ABI fixes — a ` +
419
+ `function declared with any other result type reaches this host as something that is ` +
420
+ `not a BigInt`
421
+ );
422
+ case "range":
423
+ return (
424
+ `${call} returned the range ${outcome.ptr}..${outcome.ptr + outcome.len}, which is ` +
425
+ `outside its own ${outcome.byteLength} bytes of linear memory — bytes this host cannot ` +
426
+ `read are bytes nothing can be concluded from`
427
+ );
428
+ case "oversize":
429
+ return (
430
+ `${call} claimed ${outcome.len} bytes, past the ${CUSTOM_RULE_MAX_VERDICT_BYTES}-byte ` +
431
+ `cap this host will copy out of a rule — a claimed length is a number the rule chose, ` +
432
+ `and honouring it unchecked would make it an allocation instruction`
433
+ );
434
+ case "died":
435
+ return (
436
+ `the worker running it ${outcome.detail} before it answered — no answer was produced, ` +
437
+ `and an absent answer is never read as a passing one`
438
+ );
439
+ default:
440
+ return null;
441
+ }
442
+ }
443
+
444
+ /**
445
+ * Loads one declared rule from its artifact bytes.
446
+ *
447
+ * Reading the artifact is the caller's, not this module's: the commands layer
448
+ * owns files and this host owns bytes, the same split `../rules/README.md`
449
+ * states for the boundary rules. So an unreadable artifact is a load failure
450
+ * the caller reports; everything from the hash onward is decided here.
451
+ *
452
+ * It is asynchronous because reading the self-description means calling into
453
+ * the artifact, and every call into an artifact is budgeted — see this
454
+ * module's header.
455
+ *
456
+ * @param {{name: string, artifactBytes: Uint8Array, declaredSha256: string,
457
+ * timeoutMs?: number}} declared
458
+ * @returns {Promise<LoadedCustomRule>}
459
+ * @throws {Error} when the budget is not a positive number of milliseconds.
460
+ */
461
+ export async function loadCustomRule({
462
+ name,
463
+ artifactBytes,
464
+ declaredSha256,
465
+ timeoutMs = CUSTOM_RULE_TIMEOUT_MS,
466
+ }) {
467
+ requireBudget(timeoutMs, "loadCustomRule");
468
+ const refuse = (reason) => failed("load", name, reason);
469
+
470
+ if (!ArrayBuffer.isView(artifactBytes)) {
471
+ return refuse(
472
+ `the artifact was handed over as ${describeValue(artifactBytes)} rather than a view of its ` +
473
+ `bytes — this host takes the artifact's bytes, never a path`,
474
+ );
475
+ }
476
+
477
+ const computed = createHash("sha256").update(artifactBytes).digest("hex");
478
+ if (computed !== declaredSha256) {
479
+ return refuse(
480
+ `the artifact hashes to ${computed}, and the policy pinned ${describeValue(declaredSha256)} ` +
481
+ `— the hash is what makes "the law CI ran is the law review saw" checkable, so a ` +
482
+ `mismatch is a refusal, never a quieter law`,
483
+ );
484
+ }
485
+
486
+ /** @type {WebAssembly.Module} */
487
+ let module;
488
+ try {
489
+ // The cast is a type-checker fact, not a runtime one: TypeScript 5.7
490
+ // narrowed `BufferSource` to `ArrayBufferView<ArrayBuffer>`, which the
491
+ // `Buffer` a caller gets back from `readFile` (an `ArrayBufferLike` view,
492
+ // often pooled at a non-zero offset) does not satisfy — while the runtime
493
+ // accepts it and has since wasm shipped. Reading `.buffer` instead would
494
+ // be the wrong fix twice over: it would type-check and it would hash a
495
+ // pooled buffer's other tenants.
496
+ module = new WebAssembly.Module(/** @type {BufferSource} */ (artifactBytes));
497
+ } catch (error) {
498
+ return refuse(`the artifact is not a valid WebAssembly module (${messageOf(error)})`);
499
+ }
500
+
501
+ const imports = WebAssembly.Module.imports(module);
502
+ if (imports.length > 0) {
503
+ const named = imports.map((entry) => `${entry.module}.${entry.name}`).join(", ");
504
+ return refuse(
505
+ `the module declares ${imports.length} import(s) (${named}) and the contract grants no ` +
506
+ `imports — a rule reaches nothing it did not import, which is what makes "no ambient ` +
507
+ `capability" a property of the module rather than a promise about this host`,
508
+ );
509
+ }
510
+
511
+ const declaredExports = new Map(
512
+ WebAssembly.Module.exports(module).map((entry) => [entry.name, entry.kind]),
513
+ );
514
+ for (const [required, kind] of Object.entries(REQUIRED_EXPORTS)) {
515
+ const found = declaredExports.get(required);
516
+ if (found === undefined) {
517
+ return refuse(
518
+ `the module exports no ${kind} named "${required}" — the ABI requires ` +
519
+ `${Object.keys(REQUIRED_EXPORTS).join(", ")}, and a rule missing one cannot be driven ` +
520
+ `at all`,
521
+ );
522
+ }
523
+ if (found !== kind) {
524
+ return refuse(`the module exports "${required}" as a ${found}, and the ABI needs a ${kind}`);
525
+ }
526
+ }
527
+
528
+ const answered = await runInWorker({ module, call: "describe", timeoutMs });
529
+ const failure = outcomeReason(answered, { call: "archkeep_describe", timeoutMs });
530
+ if (failure !== null) return refuse(failure);
531
+
532
+ const document = decodeJson(answered.slice);
533
+ if (document.detail !== null) return refuse(`archkeep_describe ${document.detail}`);
534
+
535
+ const violation = describeViolation(document.value, name);
536
+ if (violation !== null) return refuse(violation);
537
+
538
+ return { ok: true, module, describe: document.value };
539
+ }
540
+
541
+ /**
542
+ * What is wrong with a rule's self-description, or `null`.
543
+ *
544
+ * `needs` is deliberately NOT judged here — see `needsViolation`.
545
+ *
546
+ * @param {unknown} document
547
+ * @param {string} declaredName
548
+ * @returns {string|null}
549
+ */
550
+ function describeViolation(document, declaredName) {
551
+ if (!isPlainObject(document)) {
552
+ return `archkeep_describe returned ${describeValue(document)}, not a self-description object`;
553
+ }
554
+
555
+ const unknown = unknownKeys(document, DESCRIBE_KEYS);
556
+ if (unknown.length > 0) {
557
+ return (
558
+ `archkeep_describe states ${unknown.map((key) => `"${key}"`).join(", ")}, which contract ` +
559
+ `${CONTRACT} has no place for — a key this host does not read is a claim the rule's author ` +
560
+ `believes is being honoured`
561
+ );
562
+ }
563
+
564
+ if (document.contract !== CONTRACT) {
565
+ return (
566
+ `archkeep_describe states contract ${describeValue(document.contract)}, and this engine ` +
567
+ `speaks contract ${CONTRACT} — a rule built against another contract is not approximated`
568
+ );
569
+ }
570
+
571
+ if (document.name !== declaredName) {
572
+ return (
573
+ `archkeep_describe calls itself ${describeValue(document.name)}, and the policy declared it ` +
574
+ `"${declaredName}" — the declared name is what every finding is namespaced under, so the ` +
575
+ `two must be one name`
576
+ );
577
+ }
578
+
579
+ return catalogueViolation(document.findings);
580
+ }
581
+
582
+ /**
583
+ * What is wrong with a rule's findings catalogue, or `null`.
584
+ *
585
+ * The catalogue is the reason this is checked at load rather than when a
586
+ * finding arrives: it is the reportingDescriptor set a SARIF result resolves
587
+ * against, so a rule whose catalogue is malformed produces findings that
588
+ * cannot be rendered — discovered on the run that finally fails, long after
589
+ * the rule was reviewed.
590
+ *
591
+ * @param {unknown} findings
592
+ * @returns {string|null}
593
+ */
594
+ function catalogueViolation(findings) {
595
+ if (!Array.isArray(findings)) {
596
+ return `archkeep_describe states findings ${describeValue(findings)}, not a catalogue array`;
597
+ }
598
+ if (findings.length === 0) {
599
+ return (
600
+ `archkeep_describe states an empty findings catalogue — a rule that can name nothing it ` +
601
+ `might find can never report one, and would read as permanently clean`
602
+ );
603
+ }
604
+
605
+ const seen = new Set();
606
+ for (const [index, entry] of findings.entries()) {
607
+ if (!isPlainObject(entry)) {
608
+ return `archkeep_describe findings[${index}] is ${describeValue(entry)}, not an object`;
609
+ }
610
+ const unknown = unknownKeys(entry, CATALOGUE_KEYS);
611
+ if (unknown.length > 0) {
612
+ return (
613
+ `archkeep_describe findings[${index}] states ` +
614
+ `${unknown.map((key) => `"${key}"`).join(", ")}, and a catalogue entry carries ` +
615
+ `${CATALOGUE_KEYS.join(" and ")} only`
616
+ );
617
+ }
618
+ if (typeof entry.id !== "string" || !CUSTOM_RULE_NAME_PATTERN.test(entry.id)) {
619
+ return (
620
+ `archkeep_describe findings[${index}].id is ${describeValue(entry.id)}, and a finding id ` +
621
+ `is lowercase words joined by single dashes (${CUSTOM_RULE_NAME_PATTERN.source})`
622
+ );
623
+ }
624
+ if (seen.has(entry.id)) {
625
+ return (
626
+ `archkeep_describe declares the finding id "${entry.id}" twice — two entries under one id ` +
627
+ `make the namespaced id ambiguous, and a report would resolve findings to whichever ` +
628
+ `entry it happened to read last`
629
+ );
630
+ }
631
+ seen.add(entry.id);
632
+ if (!isNonEmptyString(entry.message)) {
633
+ return (
634
+ `archkeep_describe findings[${index}].message is ${describeValue(entry.message)} — a ` +
635
+ `catalogue entry with no message renders as a finding that says nothing`
636
+ );
637
+ }
638
+ }
639
+ return null;
640
+ }
641
+
642
+ /**
643
+ * What is wrong with a rule's declared evidence needs, or `null`.
644
+ *
645
+ * This is evaluate class rather than load class, and the split is the ADR's:
646
+ * a rule that asks for a kind this engine cannot supply has loaded perfectly
647
+ * well, and what it cannot do is judge — so it becomes an `unknown` verdict
648
+ * with the gap named, never an approximation over the kinds that were
649
+ * available. The roster it is checked against is `EVIDENCE_KINDS`, which lives
650
+ * beside the code that produces the kinds (`./evidence.mjs`).
651
+ *
652
+ * @param {unknown} describe
653
+ * @returns {string|null}
654
+ */
655
+ function needsViolation(describe) {
656
+ const needs = isPlainObject(describe) ? describe.needs : undefined;
657
+ if (!Array.isArray(needs)) {
658
+ return (
659
+ `it declares needs ${describeValue(needs)}, not an array of evidence kinds — a rule that ` +
660
+ `cannot state what it needs cannot be handed it`
661
+ );
662
+ }
663
+ for (const kind of needs) {
664
+ if (!EVIDENCE_KINDS.includes(kind)) {
665
+ return (
666
+ `it needs the evidence kind ${describeValue(kind)}, which contract ${CONTRACT} does not ` +
667
+ `carry — this engine supplies ${EVIDENCE_KINDS.join(", ")}, and judging over the kinds it ` +
668
+ `does hold would be a verdict computed from evidence the rule said was not enough`
669
+ );
670
+ }
671
+ }
672
+ return null;
673
+ }
674
+
675
+ /**
676
+ * Runs one rule over one evidence bundle, in its own worker, under a budget.
677
+ *
678
+ * @param {{module: WebAssembly.Module, describe: Record<string, any>,
679
+ * evidenceBytes: Uint8Array, timeoutMs?: number}} run
680
+ * @returns {Promise<CustomRuleOutcome>}
681
+ * @throws {Error} when the caller's own arguments are wrong — bytes that are
682
+ * not bytes, a budget that is not a positive number. Those are bugs in the
683
+ * pipeline that composed the call, not facts about the rule, and reporting
684
+ * them as the rule's `unknown` would blame the wrong thing.
685
+ */
686
+ export async function evaluateCustomRule({
687
+ module,
688
+ describe,
689
+ evidenceBytes,
690
+ timeoutMs = CUSTOM_RULE_TIMEOUT_MS,
691
+ }) {
692
+ if (!(evidenceBytes instanceof Uint8Array)) {
693
+ throw new Error(
694
+ `archkeep: evaluateCustomRule needs the evidence bundle's bytes, got ` +
695
+ `${describeValue(evidenceBytes)} — serializeEvidenceBundle (./evidence.mjs) produces them`,
696
+ );
697
+ }
698
+ requireBudget(timeoutMs, "evaluateCustomRule");
699
+
700
+ const name = isPlainObject(describe) ? describe.name : undefined;
701
+ const refuse = (reason) => failed("evaluate", String(name), reason);
702
+
703
+ const needs = needsViolation(describe);
704
+ if (needs !== null) return refuse(needs);
705
+
706
+ const answered = await runInWorker({
707
+ module,
708
+ call: "evaluate",
709
+ evidence: evidenceBytes,
710
+ timeoutMs,
711
+ });
712
+ const failure = outcomeReason(answered, { call: "archkeep_evaluate", timeoutMs });
713
+ if (failure !== null) return refuse(failure);
714
+
715
+ const document = decodeJson(answered.slice);
716
+ if (document.detail !== null) return refuse(`archkeep_evaluate ${document.detail}`);
717
+
718
+ const violation = verdictViolation(document.value, describe);
719
+ if (violation !== null) return refuse(`it returned a hollow verdict — ${violation}`);
720
+
721
+ return { ok: true, verdict: document.value };
722
+ }
723
+
724
+ /**
725
+ * What is wrong with a verdict, or `null`.
726
+ *
727
+ * The obligations are the four-state vocabulary's, not this host's invention
728
+ * (`../governance/verdict.mjs` states I1–I5 and `../report/evidence.mjs`
729
+ * enforces the same ones for a command's own decision): `fail` names what
730
+ * failed, `pass` names nothing, `unknown` names why it could not tell,
731
+ * `not_applicable` names why it did not apply. A rule that breaks one of them
732
+ * has returned a shape, not a judgment — hence "hollow", and hence a refusal
733
+ * rather than a best-effort read.
734
+ *
735
+ * The `reason` / `notApplicableReason` checks run in BOTH directions: a
736
+ * `reason` on a passing verdict is a rule that meant to say `unknown` and
737
+ * said `pass`, which is precisely the failure the vocabulary exists to
738
+ * refuse, so it is named rather than ignored.
739
+ *
740
+ * @param {unknown} verdict
741
+ * @param {Record<string, any>} describe
742
+ * @returns {string|null}
743
+ */
744
+ function verdictViolation(verdict, describe) {
745
+ if (!isPlainObject(verdict)) {
746
+ return `it is ${describeValue(verdict)}, not a verdict object`;
747
+ }
748
+
749
+ const unknown = unknownKeys(verdict, VERDICT_KEYS);
750
+ if (unknown.length > 0) {
751
+ return (
752
+ `it states ${unknown.map((key) => `"${key}"`).join(", ")}, which contract ${CONTRACT} has ` +
753
+ `no place for`
754
+ );
755
+ }
756
+
757
+ if (verdict.contract !== CONTRACT) {
758
+ return (
759
+ `it states contract ${describeValue(verdict.contract)}, and this engine speaks contract ` +
760
+ `${CONTRACT}`
761
+ );
762
+ }
763
+
764
+ if (!isVerdict(verdict.verdict)) {
765
+ return (
766
+ `it states the verdict ${describeValue(verdict.verdict)}, and the vocabulary is ` +
767
+ `${VERDICTS.join(", ")}`
768
+ );
769
+ }
770
+
771
+ if (!Array.isArray(verdict.findings)) {
772
+ return (
773
+ `it states findings ${describeValue(verdict.findings)} — every verdict states its findings, ` +
774
+ `and a clean rule states the empty list rather than leaving the field out`
775
+ );
776
+ }
777
+
778
+ const catalogue = new Set(
779
+ (Array.isArray(describe.findings) ? describe.findings : []).map((entry) => entry?.id),
780
+ );
781
+ for (const [index, finding] of verdict.findings.entries()) {
782
+ const violation = findingViolation(finding, index, catalogue);
783
+ if (violation !== null) return violation;
784
+ }
785
+
786
+ if (verdict.verdict === "fail" && verdict.findings.length === 0) {
787
+ return `it fails and names no finding, so nothing says what failed`;
788
+ }
789
+ if (verdict.verdict === "pass" && verdict.findings.length > 0) {
790
+ return (
791
+ `it passes and carries ${verdict.findings.length} finding(s) — "pass" and "fail" cannot ` +
792
+ `both be true of the same rule, and the finding is the half that is evidenced`
793
+ );
794
+ }
795
+
796
+ return (
797
+ reasonViolation(verdict, "reason", "unknown") ??
798
+ reasonViolation(verdict, "notApplicableReason", "not_applicable")
799
+ );
800
+ }
801
+
802
+ /**
803
+ * Whether a verdict's reason field and its verdict agree, in both directions.
804
+ *
805
+ * @param {Record<string, any>} verdict
806
+ * @param {"reason"|"notApplicableReason"} field
807
+ * @param {"unknown"|"not_applicable"} requiredFor
808
+ * @returns {string|null}
809
+ */
810
+ function reasonViolation(verdict, field, requiredFor) {
811
+ const present = isNonEmptyString(verdict[field]);
812
+ if (verdict.verdict === requiredFor && !present) {
813
+ return (
814
+ `it is "${requiredFor}" and its ${field} is ${describeValue(verdict[field])} — ` +
815
+ `"${requiredFor}" is a claim about what could not be established, and a reader has to be ` +
816
+ `told which`
817
+ );
818
+ }
819
+ if (verdict.verdict !== requiredFor && verdict[field] !== undefined) {
820
+ return (
821
+ `it is "${verdict.verdict}" and carries a ${field}, which only the "${requiredFor}" ` +
822
+ `verdict carries — a rule that meant "${requiredFor}" and said "${verdict.verdict}" would ` +
823
+ `be read as having decided`
824
+ );
825
+ }
826
+ return null;
827
+ }
828
+
829
+ /**
830
+ * What is wrong with one finding, or `null`.
831
+ *
832
+ * @param {unknown} finding
833
+ * @param {number} index
834
+ * @param {Set<unknown>} catalogue Ids `archkeep_describe` declared.
835
+ * @returns {string|null}
836
+ */
837
+ function findingViolation(finding, index, catalogue) {
838
+ if (!isPlainObject(finding)) {
839
+ return `findings[${index}] is ${describeValue(finding)}, not a finding object`;
840
+ }
841
+
842
+ const unknown = unknownKeys(finding, FINDING_KEYS);
843
+ if (unknown.length > 0) {
844
+ return (
845
+ `findings[${index}] states ${unknown.map((key) => `"${key}"`).join(", ")}, and a finding ` +
846
+ `carries ${FINDING_KEYS.join(", ")} only`
847
+ );
848
+ }
849
+
850
+ if (!catalogue.has(finding.id)) {
851
+ return (
852
+ `findings[${index}].id is ${describeValue(finding.id)}, which the rule's own catalogue ` +
853
+ `does not declare — a finding a report cannot resolve to a catalogue entry is a finding ` +
854
+ `SARIF drops`
855
+ );
856
+ }
857
+ if (!isNonEmptyString(finding.message)) {
858
+ return `findings[${index}].message is ${describeValue(finding.message)}`;
859
+ }
860
+ if (finding.sourceFile !== undefined && !isNonEmptyString(finding.sourceFile)) {
861
+ return `findings[${index}].sourceFile is ${describeValue(finding.sourceFile)}`;
862
+ }
863
+ if (finding.sourceFile !== undefined && !isWorkspaceRelative(finding.sourceFile)) {
864
+ return (
865
+ `findings[${index}].sourceFile is ${describeValue(finding.sourceFile)}, which does not name ` +
866
+ `a file inside the workspace — every path this tool reports is workspace-relative ` +
867
+ `(../analysis/contract.md), and an absolute or ".."-carrying one is a location no reader's ` +
868
+ `checkout resolves: GitHub's code scanning drops such a result without saying so, so the ` +
869
+ `run would fail on a finding whose annotation never appears`
870
+ );
871
+ }
872
+ if (finding.project !== undefined && !isNonEmptyString(finding.project)) {
873
+ return `findings[${index}].project is ${describeValue(finding.project)}`;
874
+ }
875
+
876
+ for (const axis of ["line", "column"]) {
877
+ if (finding[axis] === undefined) continue;
878
+ if (finding.sourceFile === undefined) {
879
+ return (
880
+ `findings[${index}] states a ${axis} and no sourceFile — a position with no file sends a ` +
881
+ `reader to a line in nothing`
882
+ );
883
+ }
884
+ if (!Number.isInteger(finding[axis]) || finding[axis] < 1) {
885
+ return (
886
+ `findings[${index}].${axis} is ${describeValue(finding[axis])}, and positions are 1-based ` +
887
+ `integers (../analysis/contract.md fixes that for every position this tool reports)`
888
+ );
889
+ }
890
+ }
891
+
892
+ return null;
893
+ }
894
+
895
+ /**
896
+ * Whether a path a rule named is one this workspace can resolve: relative, and
897
+ * spelled without a `..` segment.
898
+ *
899
+ * **The refusal lives in `findingViolation` above, beside its refusal of a
900
+ * sub-1 `line`, because that is where a rule's verdict is judged.** The
901
+ * position was already held to the contract there and the path was not, which
902
+ * is the whole of the defect. A rule that names a file outside the workspace
903
+ * has not produced a verdict about this workspace, and that is true of every
904
+ * face the verdict reaches — the text report, the JSON envelope and SARIF
905
+ * alike — not only of the one whose consumer happens to notice.
906
+ * `../report/` renders and decides nothing (`../../AGENTS.md`), so a formatter
907
+ * that dropped such a location would be a rule wearing a formatter's name —
908
+ * and dropping it is the silent direction anyway: the run still fails while
909
+ * the annotation a developer would act on never appears.
910
+ *
911
+ * The shapes refused are the ones `../report/sarif.integration.test.mjs`
912
+ * already states a SARIF `uri` must never have, rather than a second opinion
913
+ * about paths: absolute, and carrying a `..` segment. That test names a third,
914
+ * a `file:` URI, which needs no refusal here — `toUriReference` encodes each
915
+ * segment, so a scheme's `:` leaves as `%3A` and no path can arrive at GitHub
916
+ * still spelled as one. A `..` is refused even where it collapses back inside
917
+ * the tree (`a/../b.go`): the URI reference is emitted as written, and
918
+ * normalizing it here would answer about a file the rule did not name.
919
+ *
920
+ * Absoluteness is asked of BOTH rooting rules, because the string arrives from
921
+ * an artifact this engine did not write and is judged on whichever platform CI
922
+ * happens to run: `win32.isAbsolute` is what makes `C:\x` and `\\server\share`
923
+ * absolute on a POSIX runner, where `posix.isAbsolute` reads them as ordinary
924
+ * relative names. Both separator families are split for the `..` test for the
925
+ * same reason, the way `../containment.mjs` splits one.
926
+ *
927
+ * @param {string} path
928
+ * @returns {boolean}
929
+ */
930
+ function isWorkspaceRelative(path) {
931
+ if (posix.isAbsolute(path) || win32.isAbsolute(path)) return false;
932
+ return !path.split(/[\\/]/u).includes("..");
933
+ }
934
+
935
+ /**
936
+ * One worker run's raw result. It is a single flat shape rather than a
937
+ * discriminated union for the reason `decodeJson` states: without
938
+ * `strictNullChecks` the literal discriminants widen and narrowing stops
939
+ * working, so a union here would type-check as if every field were always
940
+ * present — which is worse than not modelling the variants at all.
941
+ *
942
+ * @typedef {object} WorkerOutcome
943
+ * @property {"answered"|"instantiate"|"trap"|"unwritable"|"memory"|"packing"|"range"|"oversize"|"died"|"timeout"} outcome
944
+ * @property {Uint8Array} [slice] `"answered"`: exactly the bytes the rule pointed at.
945
+ * @property {number} [ptr] The pointer half of the packed range.
946
+ * @property {number} [len] The length half of the packed range.
947
+ * @property {number} [byteLength] The linear memory's size when it was measured.
948
+ * @property {string} [stage] `"trap"`: which export trapped. `"memory"`: which
949
+ * of the two boundaries the measurement was taken at.
950
+ * @property {string} [was] `"packing"`: the type that arrived instead of a BigInt.
951
+ * @property {string} [value] `"packing"`: what that value said for itself.
952
+ * @property {string} [pointer] `"unwritable"`: what `archkeep_alloc` answered.
953
+ * @property {number} [length] `"unwritable"`: how many evidence bytes there were.
954
+ * @property {number} [memoryBytes] `"unwritable"`: how much memory there was.
955
+ * @property {string} [detail] The engine's own words, where it had any.
956
+ */
957
+
958
+ /**
959
+ * One call into a rule, in its own worker, settled exactly once.
960
+ *
961
+ * Every listener routes through `settle`, and the guard is what makes the
962
+ * budget mean something: `terminate()` makes the worker exit, so without it
963
+ * the `exit` listener would answer the timeout race with "exited with code 1"
964
+ * and the reason a reader saw would name the symptom instead of the budget.
965
+ * `exit` still has a case of its own, because a worker that dies without
966
+ * posting anything is the silent shape this whole module is built against.
967
+ *
968
+ * @param {{module: WebAssembly.Module, call: "describe"|"evaluate",
969
+ * evidence?: Uint8Array, timeoutMs: number}} run
970
+ * @returns {Promise<WorkerOutcome>}
971
+ */
972
+ function runInWorker({ module, call, evidence, timeoutMs }) {
973
+ return new Promise((resolve) => {
974
+ /** @type {Worker} */
975
+ let worker;
976
+ try {
977
+ worker = new Worker(WORKER_SOURCE, {
978
+ eval: true,
979
+ workerData: {
980
+ module,
981
+ call,
982
+ evidence,
983
+ maxBytes: CUSTOM_RULE_MAX_VERDICT_BYTES,
984
+ memoryLimit: CUSTOM_RULE_MEMORY_LIMIT_BYTES,
985
+ },
986
+ });
987
+ } catch (error) {
988
+ // Defense in depth: every value in `workerData` is cloneable today, so
989
+ // nothing reaches this — and a constructor that threw past the promise
990
+ // would reject `evaluateCustomRule` instead of answering it, turning
991
+ // the one failure mode this module refuses to have into an exception a
992
+ // caller has to already be catching.
993
+ resolve({ outcome: "died", detail: `could not be started (${messageOf(error)})` });
994
+ return;
995
+ }
996
+
997
+ let settled = false;
998
+ /** @param {WorkerOutcome} outcome */
999
+ const settle = (outcome) => {
1000
+ if (settled) return;
1001
+ settled = true;
1002
+ clearTimeout(budget);
1003
+ // Resolve only once the thread is actually gone, rather than firing
1004
+ // `terminate()` off and answering immediately. A call that returned
1005
+ // while its worker was still winding down would leave a thread running
1006
+ // past the run that owns it — measured here as a vitest child exiting
1007
+ // before it flushed its coverage file, and in a consumer's tree it
1008
+ // would be a `check` that had answered but not yet ended. The same
1009
+ // callback takes both settlements: `terminate()` resolves with the exit
1010
+ // code and does not reject, and answering the caller is the right move
1011
+ // either way.
1012
+ const answer = () => resolve(outcome);
1013
+ worker.terminate().then(answer, answer);
1014
+ };
1015
+
1016
+ const budget = setTimeout(() => settle({ outcome: "timeout" }), timeoutMs);
1017
+ worker.on("message", settle);
1018
+ worker.on("error", (error) =>
1019
+ settle({ outcome: "died", detail: `failed (${messageOf(error)})` }),
1020
+ );
1021
+ worker.on("exit", (code) => settle({ outcome: "died", detail: `exited with code ${code}` }));
1022
+ });
1023
+ }