@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,428 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The custom-rules fold: every rule the workspace's policy declares, loaded
|
|
3
|
+
* from its artifact and judged over the evidence this run already computed.
|
|
4
|
+
*
|
|
5
|
+
* A custom rule is a pure function from evidence to verdict
|
|
6
|
+
* (`../../../../docs/adr/0002-custom-rules-one-contract.md`). Two modules
|
|
7
|
+
* already own the halves of that sentence — `../custom-rules/evidence.mjs`
|
|
8
|
+
* builds the "in" side and `../custom-rules/host.mjs` runs the artifact and
|
|
9
|
+
* holds it to the contract — and neither reads a file, because the commands
|
|
10
|
+
* layer owns files (`./README.md`, and the same split `../rules/README.md`
|
|
11
|
+
* states: "reads records, never files"). This module is the third piece: it
|
|
12
|
+
* turns a declared row into bytes, drives those two, and hands back the same
|
|
13
|
+
* verdict records `check` already folds into its exit machinery for fitness.
|
|
14
|
+
*
|
|
15
|
+
* ## By presence, and once
|
|
16
|
+
*
|
|
17
|
+
* There is no `--custom-rules` flag, for the reason there is no `--fitness`
|
|
18
|
+
* one (`./fitness.mjs`'s header): an opt-in flag makes a forgotten flag
|
|
19
|
+
* byte-identical to "no custom rules checked", which is the silent direction
|
|
20
|
+
* this tool exists to end. A policy that declares `customRules` gets them
|
|
21
|
+
* judged, once per run, over the facts the run already holds.
|
|
22
|
+
*
|
|
23
|
+
* ## The two failure classes are not interchangeable
|
|
24
|
+
*
|
|
25
|
+
* `../custom-rules/host.mjs` separates them and this module routes them:
|
|
26
|
+
*
|
|
27
|
+
* - **LOAD** — the declared law could not be loaded (unreadable artifact,
|
|
28
|
+
* hash mismatch, bytes that are not wasm, a module that asks for an import,
|
|
29
|
+
* a missing export, a self-description that will not read). This module
|
|
30
|
+
* THROWS, which reaches a reader the way a malformed boundary config does:
|
|
31
|
+
* `../../cli.mjs`'s `runCheck` prints the message and exits 3. Judging a
|
|
32
|
+
* tree against a law that was never read would be a verdict about nothing —
|
|
33
|
+
* and reporting the rule as `unknown` instead would let a workspace ship a
|
|
34
|
+
* permanently unloadable rule and still see a green-ish table row for it.
|
|
35
|
+
* Every rule is loaded BEFORE any is evaluated, so a run either judges the
|
|
36
|
+
* whole declared law or refuses it; half a law, half-applied, is a verdict
|
|
37
|
+
* nobody declared.
|
|
38
|
+
* - **EVALUATE** — the law loaded and this rule could not reach a verdict (a
|
|
39
|
+
* trap, a timeout, an unreadable verdict, an evidence kind this contract
|
|
40
|
+
* does not carry). The RULE's verdict becomes `unknown` with the host's own
|
|
41
|
+
* self-standing reason carried through unedited, and the run's exit follows
|
|
42
|
+
* the same lane a `unknown` fitness function takes (exit 3).
|
|
43
|
+
*
|
|
44
|
+
* ## A path-scoped run answers `not_applicable`, before anything is read
|
|
45
|
+
*
|
|
46
|
+
* `check <path>` analyzes a subset of the tree, so the `imports` kind is a
|
|
47
|
+
* subset and the evidence bundle would describe a workspace that does not
|
|
48
|
+
* exist. A rule judged over it would answer about a tree nobody has —
|
|
49
|
+
* `not_applicable` is the posture `coverage-minimum` already takes for the
|
|
50
|
+
* identical reason (`../governance/fitness-rules.mjs`'s `coverageMinimum`):
|
|
51
|
+
* loud, reason named, and — unlike `unknown` — not by itself a failed run, so
|
|
52
|
+
* a scoped run over a clean subtree does not exit 3 for a question it was
|
|
53
|
+
* never in a position to ask.
|
|
54
|
+
*
|
|
55
|
+
* The decision is taken BEFORE any artifact is read, which is the one place
|
|
56
|
+
* this module deliberately differs from a full run: a scoped run reads no
|
|
57
|
+
* artifact, hashes nothing, and starts no worker. Loading is not free — it
|
|
58
|
+
* runs `archkeep_describe` inside a worker — and paying for it to reach a
|
|
59
|
+
* verdict already known to be `not_applicable` would be work with no judgment
|
|
60
|
+
* behind it. Nothing is hidden by that: every declared rule still gets its own
|
|
61
|
+
* row, naming itself and why it did not apply, in all three faces.
|
|
62
|
+
*
|
|
63
|
+
* ## Determinism
|
|
64
|
+
*
|
|
65
|
+
* Rules are judged in declaration order (row order is semantic for a policy,
|
|
66
|
+
* the same reason `../canonical.mjs` leaves `depConstraints` order alone), and
|
|
67
|
+
* a rule's own findings are left in the order it emitted them: a rule holds no
|
|
68
|
+
* ambient capability, so its output is a function of the evidence bytes, and
|
|
69
|
+
* re-sorting would destroy an order the rule chose rather than normalize an
|
|
70
|
+
* accident. The evidence itself is byte-deterministic by construction
|
|
71
|
+
* (`../custom-rules/evidence.mjs`).
|
|
72
|
+
*/
|
|
73
|
+
import { readFileSync } from "node:fs";
|
|
74
|
+
import { join } from "node:path";
|
|
75
|
+
|
|
76
|
+
import { containmentViolation } from "../containment.mjs";
|
|
77
|
+
import { buildEvidenceBundle, serializeEvidenceBundle } from "../custom-rules/evidence.mjs";
|
|
78
|
+
import {
|
|
79
|
+
CUSTOM_RULE_TIMEOUT_MS,
|
|
80
|
+
evaluateCustomRule,
|
|
81
|
+
loadCustomRule,
|
|
82
|
+
} from "../custom-rules/host.mjs";
|
|
83
|
+
// The overall-verdict precedence — any `fail`, then any `unknown`, then
|
|
84
|
+
// all-`not_applicable`, else `pass` — is stated once, by the module that
|
|
85
|
+
// already owned it for fitness. A second copy here would be the one that
|
|
86
|
+
// drifted (`../../../../AGENTS.md`, "Never state a rule twice").
|
|
87
|
+
import { fitnessVerdictFor } from "../governance/fitness-registry.mjs";
|
|
88
|
+
import { fitnessVerdict } from "../governance/verdict.mjs";
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The namespace every custom finding is reported under, in all three faces:
|
|
92
|
+
* `custom/<ruleName>/<findingId>`. Built here, once, and carried on the
|
|
93
|
+
* records this module hands back — so `../report/text.mjs`, `../report/sarif.mjs`
|
|
94
|
+
* and `../../cli.mjs`'s JSON envelope render an id rather than compose one,
|
|
95
|
+
* and three faces cannot come to spell the same finding three ways.
|
|
96
|
+
*
|
|
97
|
+
* @param {string} ruleName
|
|
98
|
+
* @param {string} findingId
|
|
99
|
+
* @returns {string}
|
|
100
|
+
*/
|
|
101
|
+
function namespacedId(ruleName, findingId) {
|
|
102
|
+
return `custom/${ruleName}/${findingId}`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Whether a policy declares custom rules at all — the one condition that
|
|
107
|
+
* separates "this workspace declared no rules of its own" from "the rules
|
|
108
|
+
* could not be judged". Exported so `../../cli.mjs` and this module cannot
|
|
109
|
+
* come to disagree about what "declares custom rules" means, the same bargain
|
|
110
|
+
* `./fitness.mjs`'s `declaresFitness` states.
|
|
111
|
+
*
|
|
112
|
+
* @param {{customRules?: unknown}|null|undefined} config The loaded policy.
|
|
113
|
+
* @returns {boolean}
|
|
114
|
+
*/
|
|
115
|
+
export function declaresCustomRules(config) {
|
|
116
|
+
return config !== null && config !== undefined && config.customRules !== undefined;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The default artifact reader: a rule's bytes, read from the workspace root.
|
|
121
|
+
*
|
|
122
|
+
* Binary, and therefore not `Workspace.readFile` — that reader decodes UTF-8
|
|
123
|
+
* (`../workspace.mjs`), which would replace every byte a wasm module is made
|
|
124
|
+
* of. It carries the same containment rule that reader does: a path that
|
|
125
|
+
* resolves through a symlink out of the tree hands back bytes the workspace
|
|
126
|
+
* never committed, and running them as the declared law is the whole verdict
|
|
127
|
+
* built on outside input (`../containment.mjs`, the G-10 closure).
|
|
128
|
+
*
|
|
129
|
+
* `null` covers every way the bytes did not arrive — absent, unreadable,
|
|
130
|
+
* escaping the tree — because the caller reports all three the same way: a
|
|
131
|
+
* declared law with no bytes behind it is a run that refuses. The artifact is
|
|
132
|
+
* NOT required to be a tracked file: the `sha256` the row pins is what makes
|
|
133
|
+
* "the law CI ran is the law review saw" checkable (`../config.mjs`), and a
|
|
134
|
+
* tracked-ness requirement would refuse a legitimately generated artifact
|
|
135
|
+
* whose hash a reviewer approved in the policy anyway.
|
|
136
|
+
*
|
|
137
|
+
* @param {string} root Absolute workspace root.
|
|
138
|
+
* @returns {(artifact: string) => Uint8Array|null}
|
|
139
|
+
*/
|
|
140
|
+
export function readArtifactBytes(root) {
|
|
141
|
+
return (artifact) => {
|
|
142
|
+
const abs = join(root, artifact);
|
|
143
|
+
if (containmentViolation(root, abs) !== null) return null;
|
|
144
|
+
try {
|
|
145
|
+
return readFileSync(abs);
|
|
146
|
+
} catch {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* @param {string} name
|
|
154
|
+
* @param {string} reason
|
|
155
|
+
* @returns {never}
|
|
156
|
+
*/
|
|
157
|
+
function refuseLoad(name, reason) {
|
|
158
|
+
// The `custom rule "<name>": ` prefix is the host's own spelling for a
|
|
159
|
+
// failure (`../custom-rules/host.mjs`'s `failed`), matched here so a
|
|
160
|
+
// caller-side load failure and a host-side one read as one class.
|
|
161
|
+
throw new Error(`archkeep: custom rule "${name}": ${reason}`);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* One rule's verdict record — the same envelope every governance consumer
|
|
166
|
+
* reads (`../governance/verdict.mjs`), plus the two facts a custom rule adds.
|
|
167
|
+
*
|
|
168
|
+
* `reason` is the policy row's own declared reason (why the workspace has this
|
|
169
|
+
* rule at all), which is a different question from a verdict's `message` (what
|
|
170
|
+
* this run decided) and from `notApplicableReason` (why it did not apply). All
|
|
171
|
+
* three can be true at once, so all three are carried rather than collapsed.
|
|
172
|
+
*
|
|
173
|
+
* @param {{name: string, reason: string}} row The declared policy row.
|
|
174
|
+
* @param {{verdict: "pass"|"fail"|"unknown"|"not_applicable", evidence: object,
|
|
175
|
+
* message: string, findings?: object[], notApplicableReason?: string}} decided
|
|
176
|
+
* @returns {object}
|
|
177
|
+
*/
|
|
178
|
+
function decisionFor(row, decided) {
|
|
179
|
+
return {
|
|
180
|
+
...fitnessVerdict({
|
|
181
|
+
verdict: decided.verdict,
|
|
182
|
+
name: row.name,
|
|
183
|
+
evidence: decided.evidence,
|
|
184
|
+
message: decided.message,
|
|
185
|
+
notApplicableReason: decided.notApplicableReason,
|
|
186
|
+
}),
|
|
187
|
+
reason: row.reason,
|
|
188
|
+
findings: decided.findings ?? [],
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* A declared rule's row on a path-scoped run.
|
|
194
|
+
*
|
|
195
|
+
* `evidence` names the artifact but NOT its `sha256`: on this path nothing was
|
|
196
|
+
* read and nothing was hashed, and printing the declared digest beside a rule
|
|
197
|
+
* that never ran would present an unverified claim in the slot every other row
|
|
198
|
+
* uses for a verified one.
|
|
199
|
+
*
|
|
200
|
+
* @param {{name: string, artifact: string, reason: string}} row
|
|
201
|
+
* @returns {object}
|
|
202
|
+
*/
|
|
203
|
+
function scopedDecision(row) {
|
|
204
|
+
return decisionFor(row, {
|
|
205
|
+
verdict: "not_applicable",
|
|
206
|
+
evidence: { artifact: row.artifact, scoped: true },
|
|
207
|
+
notApplicableReason:
|
|
208
|
+
"this run was scoped to specific paths — a custom rule is judged over whole-tree evidence, " +
|
|
209
|
+
"and it needs a full, unscoped run",
|
|
210
|
+
message:
|
|
211
|
+
"does not apply to a path-scoped run — a custom rule sees the whole tree's evidence or " +
|
|
212
|
+
"none of it, so it needs a full `check` with no paths",
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* The evidence facts every rule in this run is judged over, assembled once.
|
|
218
|
+
*
|
|
219
|
+
* Only the `rule` block differs between rules (its `params` ride inside the
|
|
220
|
+
* bundle), so everything else is collected here and handed to
|
|
221
|
+
* `buildEvidenceBundle` once per rule. The bundle's own sorting and validation
|
|
222
|
+
* run per call — that is its contract, and reaching around it to sort here
|
|
223
|
+
* would put a second opinion about "what the evidence is" beside the one
|
|
224
|
+
* module that owns the question.
|
|
225
|
+
*
|
|
226
|
+
* Attribution for `imports` comes from `commandContext.owned` — the ownership
|
|
227
|
+
* map `../workspace.mjs` already built — because the workspace layer is the
|
|
228
|
+
* only one allowed to say which project owns a file, and re-deriving it from a
|
|
229
|
+
* root prefix here would be a second answer to a question already answered.
|
|
230
|
+
*
|
|
231
|
+
* @param {object} commandContext From `./context.mjs`'s `resolveCommandContext`.
|
|
232
|
+
* @param {object} policy The loaded boundary policy.
|
|
233
|
+
* @returns {{projects: object[], edges: object[], imports: object[], policy: object}}
|
|
234
|
+
*/
|
|
235
|
+
function observedFacts(commandContext, policy) {
|
|
236
|
+
const { graph, owned, analysis } = commandContext;
|
|
237
|
+
const projectOfFile = new Map(owned.map(({ file, project }) => [file, project]));
|
|
238
|
+
return {
|
|
239
|
+
// Tags are read off the graph node, which is where a provider puts them —
|
|
240
|
+
// and a node with no `tags` key IS an untagged project, the same reading
|
|
241
|
+
// `../rules/` already gives it (`projectWithoutTagsCannotHaveDependencies`
|
|
242
|
+
// exists precisely because that state is real), never "tags were not read".
|
|
243
|
+
// `root` is passed through verbatim: a graph that carries none refuses in
|
|
244
|
+
// `buildEvidenceBundle` by name rather than being defaulted to a path.
|
|
245
|
+
projects: Object.entries(graph.nodes).map(([key, node]) => ({
|
|
246
|
+
name: node.name ?? key,
|
|
247
|
+
root: node.data?.root,
|
|
248
|
+
tags: Array.isArray(node.data?.tags) ? node.data.tags : [],
|
|
249
|
+
})),
|
|
250
|
+
edges: Object.values(graph.dependencies ?? {}).flat(),
|
|
251
|
+
imports: analysis.imports.map((site) => ({
|
|
252
|
+
site,
|
|
253
|
+
// `undefined` here is not papered over: `buildEvidenceBundle` refuses an
|
|
254
|
+
// unattributed site by name, which is the loud direction for a state
|
|
255
|
+
// that means the ownership map and the analysis disagree.
|
|
256
|
+
sourceProject: projectOfFile.get(site.sourceFile),
|
|
257
|
+
})),
|
|
258
|
+
policy,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* One rule's verdict, from the verdict document it returned.
|
|
264
|
+
*
|
|
265
|
+
* @param {{name: string, artifact: string, sha256: string, reason: string}} row
|
|
266
|
+
* @param {Record<string, any>} verdict The validated verdict document.
|
|
267
|
+
* @returns {object}
|
|
268
|
+
*/
|
|
269
|
+
function judgedDecision(row, verdict) {
|
|
270
|
+
const findings = verdict.findings.map((finding) => ({
|
|
271
|
+
id: namespacedId(row.name, finding.id),
|
|
272
|
+
message: finding.message,
|
|
273
|
+
// Every position field is stated only when the rule stated it: a finding
|
|
274
|
+
// about a whole workspace has no file, and writing one in would send a
|
|
275
|
+
// reader to a line the rule never claimed (`../report/sarif.mjs` drops the
|
|
276
|
+
// location block for the same reason).
|
|
277
|
+
...(finding.sourceFile === undefined ? {} : { sourceFile: finding.sourceFile }),
|
|
278
|
+
...(finding.line === undefined ? {} : { line: finding.line }),
|
|
279
|
+
...(finding.column === undefined ? {} : { column: finding.column }),
|
|
280
|
+
...(finding.project === undefined ? {} : { project: finding.project }),
|
|
281
|
+
}));
|
|
282
|
+
const evidence = { artifact: row.artifact, sha256: row.sha256, findings: findings.length };
|
|
283
|
+
if (verdict.verdict === "fail") {
|
|
284
|
+
return decisionFor(row, {
|
|
285
|
+
verdict: "fail",
|
|
286
|
+
evidence,
|
|
287
|
+
findings,
|
|
288
|
+
message: `reported ${findings.length} finding${findings.length === 1 ? "" : "s"}`,
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
if (verdict.verdict === "unknown") {
|
|
292
|
+
return decisionFor(row, {
|
|
293
|
+
verdict: "unknown",
|
|
294
|
+
evidence,
|
|
295
|
+
findings,
|
|
296
|
+
message: `could not judge this workspace — ${verdict.reason}`,
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
if (verdict.verdict === "not_applicable") {
|
|
300
|
+
return decisionFor(row, {
|
|
301
|
+
verdict: "not_applicable",
|
|
302
|
+
evidence,
|
|
303
|
+
findings,
|
|
304
|
+
notApplicableReason: verdict.notApplicableReason,
|
|
305
|
+
message: `did not apply to this workspace — ${verdict.notApplicableReason}`,
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
return decisionFor(row, {
|
|
309
|
+
verdict: "pass",
|
|
310
|
+
evidence,
|
|
311
|
+
findings,
|
|
312
|
+
message: "judged this workspace and reported no finding",
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Every declared custom rule, loaded and judged against this run's facts.
|
|
318
|
+
*
|
|
319
|
+
* @param {object} commandContext From `./context.mjs`'s `resolveCommandContext`.
|
|
320
|
+
* @param {{rows: object[], policy: object, scoped?: boolean,
|
|
321
|
+
* readArtifact?: (artifact: string) => Uint8Array|null, collectEvidence?: boolean,
|
|
322
|
+
* timeoutMs?: number}} run
|
|
323
|
+
* `rows` is the validated `customRules` list, `policy` the loaded boundary
|
|
324
|
+
* policy the `policy` evidence kind is read from, `scoped` set when `paths`
|
|
325
|
+
* narrowed the run. `readArtifact` is the one seam reaching outside this
|
|
326
|
+
* process, injectable for the reason every reader in this package is; a
|
|
327
|
+
* smaller `timeoutMs` lets a test drive the budget without waiting out the
|
|
328
|
+
* real one. `collectEvidence` keeps each rule's serialized bundle for the
|
|
329
|
+
* caller — what `../../cli.mjs`'s `--evidence-out` writes out, and off by
|
|
330
|
+
* default because the bundle is the largest document a run builds.
|
|
331
|
+
* @returns {Promise<{decisions: object[], overall: {verdict: string},
|
|
332
|
+
* catalogue: {ruleId: string, rule: string, findingId: string, message: string}[],
|
|
333
|
+
* evidence: {rule: string, bytes: Uint8Array}[]}>}
|
|
334
|
+
* `catalogue` is every finding each loaded rule DECLARES it can report — the
|
|
335
|
+
* reportingDescriptor set a SARIF result resolves against — so a rule that
|
|
336
|
+
* fired nothing is still described rather than nameless. It is empty on a
|
|
337
|
+
* scoped run, where nothing was loaded. `evidence` is empty unless
|
|
338
|
+
* `collectEvidence` asked for it, and always empty on a scoped run — the
|
|
339
|
+
* two states are told apart by the caller, which knows which it asked for.
|
|
340
|
+
* @throws {Error} on any load-class failure, naming the rule and the reason.
|
|
341
|
+
*/
|
|
342
|
+
export async function customRulesForCheck(
|
|
343
|
+
commandContext,
|
|
344
|
+
{
|
|
345
|
+
rows,
|
|
346
|
+
policy,
|
|
347
|
+
scoped = false,
|
|
348
|
+
readArtifact,
|
|
349
|
+
collectEvidence = false,
|
|
350
|
+
timeoutMs = CUSTOM_RULE_TIMEOUT_MS,
|
|
351
|
+
},
|
|
352
|
+
) {
|
|
353
|
+
if (scoped) {
|
|
354
|
+
const decisions = rows.map(scopedDecision);
|
|
355
|
+
// No bundle is built on this path and none is returned: a scoped run's
|
|
356
|
+
// evidence would describe a workspace that does not exist, and handing an
|
|
357
|
+
// author bytes their rule was never judged over is worse than handing
|
|
358
|
+
// them none. `../../cli.mjs`'s `--evidence-out` says so out loud rather
|
|
359
|
+
// than writing an empty directory.
|
|
360
|
+
return { decisions, overall: fitnessVerdictFor(decisions), catalogue: [], evidence: [] };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const read = readArtifact ?? readArtifactBytes(commandContext.root);
|
|
364
|
+
|
|
365
|
+
/** @type {{row: object, module: WebAssembly.Module, describe: Record<string, any>}[]} */
|
|
366
|
+
const loaded = [];
|
|
367
|
+
for (const row of rows) {
|
|
368
|
+
const artifactBytes = read(row.artifact);
|
|
369
|
+
if (artifactBytes === null || artifactBytes === undefined) {
|
|
370
|
+
refuseLoad(
|
|
371
|
+
row.name,
|
|
372
|
+
`the artifact "${row.artifact}" could not be read — a path that does not exist, cannot ` +
|
|
373
|
+
`be opened, or resolves through a symlink out of the workspace all reach this run as ` +
|
|
374
|
+
`no bytes at all, and a declared law with no bytes behind it is a run that refuses ` +
|
|
375
|
+
`rather than a rule that quietly judges nothing`,
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
const outcome = await loadCustomRule({
|
|
379
|
+
name: row.name,
|
|
380
|
+
artifactBytes,
|
|
381
|
+
declaredSha256: row.sha256,
|
|
382
|
+
timeoutMs,
|
|
383
|
+
});
|
|
384
|
+
if (!outcome.ok) throw new Error(`archkeep: ${outcome.failure.reason}`);
|
|
385
|
+
loaded.push({ row, module: outcome.module, describe: outcome.describe });
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const catalogue = loaded.flatMap(({ row, describe }) =>
|
|
389
|
+
describe.findings.map((entry) => ({
|
|
390
|
+
ruleId: namespacedId(row.name, entry.id),
|
|
391
|
+
rule: row.name,
|
|
392
|
+
findingId: entry.id,
|
|
393
|
+
message: entry.message,
|
|
394
|
+
})),
|
|
395
|
+
);
|
|
396
|
+
|
|
397
|
+
const observed = observedFacts(commandContext, policy);
|
|
398
|
+
/** @type {{rule: string, bytes: Uint8Array}[]} */
|
|
399
|
+
const evidence = [];
|
|
400
|
+
const decisions = [];
|
|
401
|
+
for (const { row, module, describe } of loaded) {
|
|
402
|
+
// `row` is handed to the bundle as the declared row itself — read, never
|
|
403
|
+
// written (`../custom-rules/evidence.mjs`'s `buildEvidenceBundle`), which
|
|
404
|
+
// is what keeps an absent `params` absent on the policy object every later
|
|
405
|
+
// reader sees.
|
|
406
|
+
const evidenceBytes = serializeEvidenceBundle(buildEvidenceBundle({ ...observed, rule: row }));
|
|
407
|
+
// Collected only when a caller asked for it. The bundle is the largest
|
|
408
|
+
// document this run builds — every import site in the tree — and keeping
|
|
409
|
+
// one per rule alive for a run nobody asked to inspect would be memory
|
|
410
|
+
// spent on a debugging aid that was never requested.
|
|
411
|
+
if (collectEvidence) evidence.push({ rule: row.name, bytes: evidenceBytes });
|
|
412
|
+
const outcome = await evaluateCustomRule({ module, describe, evidenceBytes, timeoutMs });
|
|
413
|
+
decisions.push(
|
|
414
|
+
outcome.ok
|
|
415
|
+
? judgedDecision(row, outcome.verdict)
|
|
416
|
+
: decisionFor(row, {
|
|
417
|
+
verdict: "unknown",
|
|
418
|
+
evidence: { artifact: row.artifact, sha256: row.sha256, findings: 0 },
|
|
419
|
+
// The host's own words, unedited: the reason is self-standing by
|
|
420
|
+
// its contract, and a paraphrase here would be a second account of
|
|
421
|
+
// a failure only the host saw.
|
|
422
|
+
message: outcome.failure.reason,
|
|
423
|
+
}),
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
return { decisions, overall: fitnessVerdictFor(decisions), catalogue, evidence };
|
|
428
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `debt` command: the architecture-debt ledger — the exemptions, gaps and
|
|
3
|
+
* violations a workspace is carrying, each aged across the snapshot history.
|
|
4
|
+
*
|
|
5
|
+
* Descriptive, read-only: `debt` never changes a verdict and never exits 1.
|
|
6
|
+
* It computes today's candid facts the same way `check`/`drift` do — the
|
|
7
|
+
* boundary config's `boundarySuppressions`, and `judgeIntent`'s findings,
|
|
8
|
+
* notes and unresolved — and ages them across the history directory `graph
|
|
9
|
+
* --format json` snapshots live in (`../commands/history.mjs`'s
|
|
10
|
+
* `readSnapshots`).
|
|
11
|
+
*
|
|
12
|
+
* It is a report, not a gate: its exit code is 0 on a completed ledger (even a
|
|
13
|
+
* long one) and 3 when the run cannot complete — no snapshots, a malformed or
|
|
14
|
+
* inconsistent snapshot directory, incomplete graph coverage, or intent that
|
|
15
|
+
* cannot be verified.
|
|
16
|
+
*
|
|
17
|
+
* ## The snapshot directory
|
|
18
|
+
*
|
|
19
|
+
* The ledger ages across the same directory the `history` command reads — a
|
|
20
|
+
* consumer-managed directory of graph envelopes, the sole source of truth
|
|
21
|
+
* with no index file (that is `history`'s choice, inherited here). When the
|
|
22
|
+
* directory holds fewer than two snapshots, `agings: false` states that every
|
|
23
|
+
* entry is really age-0 because the record cannot establish age — never a
|
|
24
|
+
* guessed age. A missing directory is a no-verdict (exit 3), never an empty
|
|
25
|
+
* ledger.
|
|
26
|
+
*
|
|
27
|
+
* ## Fail-closed
|
|
28
|
+
*
|
|
29
|
+
* Three conditions refuse loudly (exit 3) instead of degrading:
|
|
30
|
+
*
|
|
31
|
+
* - the graph coverage is incomplete — every "project missing" would be
|
|
32
|
+
* ambiguous between "gone" and "never seen";
|
|
33
|
+
* - the intent cannot be verified (`judgeIntent`'s `unresolved` non-empty) —
|
|
34
|
+
* an intent that cannot be verified is not a clean ledger ("cannot verify"
|
|
35
|
+
* must never read as "no debt");
|
|
36
|
+
* - the snapshot directory cannot be read or parsed — the ledger would either
|
|
37
|
+
* be empty (a shrug) or age against a record it could not read.
|
|
38
|
+
*
|
|
39
|
+
* An empty entry list must mean exactly "no exemptions, gaps or findings".
|
|
40
|
+
*
|
|
41
|
+
* ## Determinism
|
|
42
|
+
*
|
|
43
|
+
* The ledger sorts by the evaluator's total key — plain `<` comparison, never
|
|
44
|
+
* `localeCompare` — and `sampleTime` rides in the result so a reader can see
|
|
45
|
+
* when the ledger was taken; the aging itself is snapshot-relative
|
|
46
|
+
* (`../governance/debt-ledger.mjs`).
|
|
47
|
+
*/
|
|
48
|
+
import { isWholeFileFailure } from "../analysis/source-util.mjs";
|
|
49
|
+
import { judgeIntent } from "../architecture-intent/judge.mjs";
|
|
50
|
+
import { INTENT_FILE, loadIntent } from "../architecture-intent/model.mjs";
|
|
51
|
+
import { computeDebtLedger } from "../governance/debt-ledger.mjs";
|
|
52
|
+
import { formatDebtReport } from "../report/debt-text.mjs";
|
|
53
|
+
import { jsonEnvelope, renderJson } from "../report/json.mjs";
|
|
54
|
+
import { resolveProvenance } from "./provenance.mjs";
|
|
55
|
+
import { buildObserved, refuseIncompleteGraph } from "./drift.mjs";
|
|
56
|
+
import { readSnapshots } from "./history.mjs";
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Runs the `debt` command: computes the current candid facts, ages them across
|
|
60
|
+
* the snapshot directory, and returns the ledger report.
|
|
61
|
+
*
|
|
62
|
+
* The `config` is resolved by the caller (`cli.mjs`) exactly the way `diff`'s
|
|
63
|
+
* is — `--config`, the workspace's declared `boundaryConfig`, or the inline
|
|
64
|
+
* `archkeep.json` policy — so a debt run and a `check` run never disagree about
|
|
65
|
+
* the current boundary law.
|
|
66
|
+
*
|
|
67
|
+
* @param {string} dir Absolute path to the history directory.
|
|
68
|
+
* @param {object} commandContext From `resolveCommandContext`.
|
|
69
|
+
* @param {{config?: {depConstraints: object[], options: object,
|
|
70
|
+
* suppressions: object[]}|null, referenceTime?: number|string,
|
|
71
|
+
* io?: {readSnapshots?: Function, loadIntentOverride?: Function,
|
|
72
|
+
* resolveProvenance?: Function}}} [options] Injectable seams for tests,
|
|
73
|
+
* mirroring the `history`/`drift` pattern.
|
|
74
|
+
* @returns {Promise<{status: "ok", ledger: object, coverage: object,
|
|
75
|
+
* report: {text: string, json: string}}>}
|
|
76
|
+
* @throws {Error} on every condition the header lists, all exit-3 class.
|
|
77
|
+
*/
|
|
78
|
+
export async function debtCommand(dir, commandContext, options = {}) {
|
|
79
|
+
const { root, provider, marker, analysis } = commandContext;
|
|
80
|
+
const io = options.io ?? {};
|
|
81
|
+
const config = options.config ?? { depConstraints: [], options: {}, suppressions: [] };
|
|
82
|
+
|
|
83
|
+
refuseIncompleteGraph(commandContext);
|
|
84
|
+
|
|
85
|
+
const notAnalyzed = analysis.failures
|
|
86
|
+
.filter(isWholeFileFailure)
|
|
87
|
+
.map(({ sourceFile, reason }) => ({ file: sourceFile, reason }));
|
|
88
|
+
if (notAnalyzed.length > 0) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
`archkeep: debt has incomplete coverage — ${notAnalyzed.length} file` +
|
|
91
|
+
`${notAnalyzed.length === 1 ? "" : "s"} could not be analyzed, so every "project missing" ` +
|
|
92
|
+
`would be ambiguous between "gone" and "never seen". Fix the unanalyzed files and re-run.`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// The snapshot directory is the sole source of truth for aging; a directory
|
|
97
|
+
// that cannot be read or parsed throws here (exit 3), never an empty ledger.
|
|
98
|
+
const read = (io.readSnapshots ?? readSnapshots)(dir, root);
|
|
99
|
+
|
|
100
|
+
// A directory that EXISTS but holds no snapshots is the same state
|
|
101
|
+
// `historyCommand` refuses in `./history.mjs`, and for the same reason: an
|
|
102
|
+
// empty directory is not an empty history, it is no record at all. The
|
|
103
|
+
// ledger ages every entry against that record, so with nothing to age
|
|
104
|
+
// against a "✔ no architecture debt" line would be a claim this run cannot
|
|
105
|
+
// make — a fresh `.archkeep/history/`, or a CI cache whose capture step never
|
|
106
|
+
// ran, would silently turn a `archkeep debt` gate into a no-op. This
|
|
107
|
+
// command's own header says so ("A missing directory is a no-verdict
|
|
108
|
+
// (exit 3), never an empty ledger"); an unpopulated one is the same
|
|
109
|
+
// no-verdict wearing a different errno.
|
|
110
|
+
if (read.files.length === 0) {
|
|
111
|
+
throw new Error(
|
|
112
|
+
`archkeep: the history directory '${dir}' contains no snapshots — there is no record to ` +
|
|
113
|
+
`age the ledger against, so "no architecture debt" would be a claim this run cannot ` +
|
|
114
|
+
`make. Capture one first with 'archkeep history <dir> --capture' (or point the command ` +
|
|
115
|
+
`at the directory where you keep graph snapshots).`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const intent = await (io.loadIntentOverride ?? loadIntent)(root, {
|
|
120
|
+
tracked: commandContext.tracked,
|
|
121
|
+
});
|
|
122
|
+
if (intent === undefined) {
|
|
123
|
+
throw new Error(
|
|
124
|
+
`archkeep: debt requires a tracked ${INTENT_FILE} at the workspace root, but none ` +
|
|
125
|
+
`is present — a ledger of architecture debt needs the declared intent to compare against`,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const verdict = judgeIntent(intent, {
|
|
130
|
+
nodes: commandContext.graph.nodes,
|
|
131
|
+
dependencies: commandContext.graph.dependencies,
|
|
132
|
+
});
|
|
133
|
+
if (verdict.unresolved.length > 0) {
|
|
134
|
+
throw new Error(
|
|
135
|
+
`archkeep: cannot build a debt ledger — ${INTENT_FILE} cannot be verified: ` +
|
|
136
|
+
verdict.unresolved
|
|
137
|
+
.map(({ boundary, issue }) => `boundary/row ${boundary}: ${issue}`)
|
|
138
|
+
.join("; ") +
|
|
139
|
+
`. An intent that cannot be verified is not a clean ledger; fix the intent or the graph and re-run.`,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const ledger = computeDebtLedger(
|
|
144
|
+
{
|
|
145
|
+
suppressions: config.suppressions,
|
|
146
|
+
intentNotes: verdict.notes,
|
|
147
|
+
findings: verdict.findings,
|
|
148
|
+
},
|
|
149
|
+
read,
|
|
150
|
+
{ referenceTime: options.referenceTime },
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
const observed = buildObserved(commandContext);
|
|
154
|
+
const coverage = {
|
|
155
|
+
complete: true,
|
|
156
|
+
projects: observed.projects.length,
|
|
157
|
+
analyzedFiles: analysis.analyzed,
|
|
158
|
+
imports: analysis.imports.length,
|
|
159
|
+
notAnalyzed: [],
|
|
160
|
+
blindSpots: analysis.failures
|
|
161
|
+
.filter((failure) => !isWholeFileFailure(failure))
|
|
162
|
+
.map(({ sourceFile, line, column, reason }) => ({ file: sourceFile, line, column, reason })),
|
|
163
|
+
// The ledger names what it could not age — a reader can tell "aged" from
|
|
164
|
+
// "observed, not yet aged". The second note discloses `sampleTime`'s own
|
|
165
|
+
// nature: it reflects the wall clock at the moment of THIS run, not the
|
|
166
|
+
// workspace, so it is expected to differ between two runs of an unchanged
|
|
167
|
+
// tree, by design (`../governance/clock.mjs`) — disclosed here, in-band,
|
|
168
|
+
// so a consumer diffing or hashing two envelopes to detect real drift
|
|
169
|
+
// knows to exclude it rather than read clock drift as architectural
|
|
170
|
+
// change; every other field is deterministic given the same law, history
|
|
171
|
+
// directory and tree.
|
|
172
|
+
notes: [
|
|
173
|
+
`ledger ages are snapshot-relative; ${ledger.entries.length} entr` +
|
|
174
|
+
`${ledger.entries.length === 1 ? "y" : "ies"} derived across ${read.files.length} snapshot` +
|
|
175
|
+
`${read.files.length === 1 ? "" : "s"}`,
|
|
176
|
+
"sampleTime is the wall clock at the moment of this run, not a fact about the " +
|
|
177
|
+
"workspace — it is expected to differ between two runs of an unchanged tree and " +
|
|
178
|
+
"should be excluded from any diff or hash meant to detect real change. Every other " +
|
|
179
|
+
"field here is deterministic given the same law, history directory and tree.",
|
|
180
|
+
],
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const context = {
|
|
184
|
+
root,
|
|
185
|
+
provider,
|
|
186
|
+
marker,
|
|
187
|
+
provenance: (io.resolveProvenance ?? resolveProvenance)(root),
|
|
188
|
+
};
|
|
189
|
+
const result = {
|
|
190
|
+
dir,
|
|
191
|
+
snapshots: read.files.length,
|
|
192
|
+
agings: ledger.agings,
|
|
193
|
+
sampleTime: ledger.sampleTime,
|
|
194
|
+
entries: ledger.entries,
|
|
195
|
+
total: ledger.total,
|
|
196
|
+
byKind: ledger.byKind,
|
|
197
|
+
bySeverity: ledger.bySeverity,
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const envelope = jsonEnvelope({
|
|
201
|
+
command: "debt",
|
|
202
|
+
context,
|
|
203
|
+
status: "ok",
|
|
204
|
+
exitCode: 0,
|
|
205
|
+
coverage,
|
|
206
|
+
result,
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
status: "ok",
|
|
211
|
+
ledger: result,
|
|
212
|
+
coverage,
|
|
213
|
+
report: {
|
|
214
|
+
text: formatDebtReport({ ledger: result, coverage }),
|
|
215
|
+
json: renderJson(envelope),
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
}
|