@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.
- package/LICENSE +202 -0
- package/README.md +262 -0
- package/cli.mjs +2792 -0
- package/index.mjs +85 -0
- package/lsp.mjs +81 -0
- package/nx.mjs +24 -0
- package/package.json +81 -0
- package/presets/clean-architecture.json +78 -0
- package/presets/ddd-bounded-contexts.json +88 -0
- package/presets/hexagonal.json +68 -0
- package/presets/layered.json +92 -0
- package/presets/modular-monolith.json +85 -0
- package/presets/vertical-slice.json +68 -0
- package/src/analysis/analyze.mjs +218 -0
- package/src/analysis/contract.md +259 -0
- package/src/analysis/go.mjs +414 -0
- package/src/analysis/manifest-util.mjs +68 -0
- package/src/analysis/python.mjs +1266 -0
- package/src/analysis/registry.mjs +74 -0
- package/src/analysis/rust.mjs +674 -0
- package/src/analysis/source-util.mjs +230 -0
- package/src/analysis/typescript.mjs +1034 -0
- package/src/analysis/vue.mjs +156 -0
- package/src/architecture-intent/intent-fingerprint.mjs +29 -0
- package/src/architecture-intent/judge.mjs +539 -0
- package/src/architecture-intent/model.mjs +703 -0
- package/src/architecture-intent/selectors.mjs +170 -0
- package/src/canonical.mjs +48 -0
- package/src/commands/README.md +266 -0
- package/src/commands/adr.mjs +248 -0
- package/src/commands/check.mjs +989 -0
- package/src/commands/context-command.mjs +212 -0
- package/src/commands/context.mjs +790 -0
- package/src/commands/custom-rules.mjs +428 -0
- package/src/commands/debt.mjs +218 -0
- package/src/commands/diff.mjs +523 -0
- package/src/commands/discover.mjs +159 -0
- package/src/commands/drift.mjs +473 -0
- package/src/commands/edge-constraints.mjs +355 -0
- package/src/commands/explain.mjs +359 -0
- package/src/commands/fitness.mjs +226 -0
- package/src/commands/graph.mjs +297 -0
- package/src/commands/health.mjs +213 -0
- package/src/commands/history.mjs +614 -0
- package/src/commands/impact.mjs +226 -0
- package/src/commands/plan-context-command.mjs +496 -0
- package/src/commands/policy.mjs +138 -0
- package/src/commands/provenance-command.mjs +352 -0
- package/src/commands/provenance.mjs +159 -0
- package/src/commands/reconcile.mjs +219 -0
- package/src/commands/report.mjs +553 -0
- package/src/commands/snapshot-meta.mjs +107 -0
- package/src/commands/waivers.mjs +240 -0
- package/src/config.mjs +1308 -0
- package/src/containment.mjs +234 -0
- package/src/custom-rules/evidence.mjs +340 -0
- package/src/custom-rules/host.mjs +1023 -0
- package/src/custom-rules/values.mjs +43 -0
- package/src/entry-point.mjs +55 -0
- package/src/errors.mjs +36 -0
- package/src/eslint-config.mjs +542 -0
- package/src/go-work.mjs +394 -0
- package/src/governance/adr-registry.mjs +539 -0
- package/src/governance/clock.mjs +69 -0
- package/src/governance/debt-ledger.mjs +274 -0
- package/src/governance/discovery-proposal.mjs +423 -0
- package/src/governance/fitness-registry.mjs +504 -0
- package/src/governance/fitness-rules.mjs +668 -0
- package/src/governance/metrics.mjs +392 -0
- package/src/governance/preset-fingerprints.json +16 -0
- package/src/governance/profile-registry.mjs +366 -0
- package/src/governance/provenance-record.mjs +177 -0
- package/src/governance/reconcile-candidates.mjs +301 -0
- package/src/governance/reconcile-score.mjs +503 -0
- package/src/governance/row-schema.mjs +208 -0
- package/src/governance/verdict.mjs +127 -0
- package/src/governance/waiver.mjs +105 -0
- package/src/graph/create-dependencies.mjs +96 -0
- package/src/intent/intent-manifest.json +347 -0
- package/src/intent/mask-non-code.mjs +640 -0
- package/src/lsp/boundary-config.mjs +225 -0
- package/src/lsp/diagnose.mjs +202 -0
- package/src/lsp/diagnostics.mjs +241 -0
- package/src/lsp/protocol.mjs +215 -0
- package/src/lsp/server.mjs +922 -0
- package/src/lsp/workspace-index.mjs +891 -0
- package/src/nx-json.mjs +95 -0
- package/src/options.mjs +611 -0
- package/src/process.mjs +91 -0
- package/src/providers/moon.mjs +733 -0
- package/src/providers/native/README.md +204 -0
- package/src/providers/native/coverage.mjs +74 -0
- package/src/providers/native/differential.fixtures.mjs +1277 -0
- package/src/providers/native/discover.mjs +431 -0
- package/src/providers/native/graph.mjs +234 -0
- package/src/providers/native/index.mjs +152 -0
- package/src/providers/native/model.mjs +755 -0
- package/src/providers/nx.mjs +178 -0
- package/src/report/README.md +89 -0
- package/src/report/adr-text.mjs +129 -0
- package/src/report/context-text.mjs +109 -0
- package/src/report/debt-text.mjs +105 -0
- package/src/report/diff-text.mjs +219 -0
- package/src/report/discover-text.mjs +186 -0
- package/src/report/drift-text.mjs +194 -0
- package/src/report/envelope-shape.mjs +161 -0
- package/src/report/evidence.mjs +157 -0
- package/src/report/explain-text.mjs +159 -0
- package/src/report/graph-text.mjs +116 -0
- package/src/report/health-text.mjs +123 -0
- package/src/report/history-text.mjs +204 -0
- package/src/report/impact-text.mjs +128 -0
- package/src/report/json.mjs +173 -0
- package/src/report/plan-context-text.mjs +159 -0
- package/src/report/provenance-text.mjs +78 -0
- package/src/report/reconcile-text.mjs +159 -0
- package/src/report/report-text.mjs +264 -0
- package/src/report/sarif.mjs +953 -0
- package/src/report/text.mjs +823 -0
- package/src/report/waivers-text.mjs +100 -0
- package/src/rules/README.md +123 -0
- package/src/rules/index.mjs +962 -0
- package/src/rules/match.mjs +1708 -0
- package/src/rules/messages.mjs +73 -0
- package/src/rules/reachability.mjs +224 -0
- package/src/rules/specifiers.mjs +300 -0
- package/src/rules/tags.mjs +238 -0
- package/src/rules/topology.mjs +333 -0
- package/src/tsconfig-paths.mjs +237 -0
- package/src/verdict.mjs +145 -0
- 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
|
+
}
|