@ecoma-io/archkeep 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +262 -0
  3. package/cli.mjs +2792 -0
  4. package/index.mjs +85 -0
  5. package/lsp.mjs +81 -0
  6. package/nx.mjs +24 -0
  7. package/package.json +81 -0
  8. package/presets/clean-architecture.json +78 -0
  9. package/presets/ddd-bounded-contexts.json +88 -0
  10. package/presets/hexagonal.json +68 -0
  11. package/presets/layered.json +92 -0
  12. package/presets/modular-monolith.json +85 -0
  13. package/presets/vertical-slice.json +68 -0
  14. package/src/analysis/analyze.mjs +218 -0
  15. package/src/analysis/contract.md +259 -0
  16. package/src/analysis/go.mjs +414 -0
  17. package/src/analysis/manifest-util.mjs +68 -0
  18. package/src/analysis/python.mjs +1266 -0
  19. package/src/analysis/registry.mjs +74 -0
  20. package/src/analysis/rust.mjs +674 -0
  21. package/src/analysis/source-util.mjs +230 -0
  22. package/src/analysis/typescript.mjs +1034 -0
  23. package/src/analysis/vue.mjs +156 -0
  24. package/src/architecture-intent/intent-fingerprint.mjs +29 -0
  25. package/src/architecture-intent/judge.mjs +539 -0
  26. package/src/architecture-intent/model.mjs +703 -0
  27. package/src/architecture-intent/selectors.mjs +170 -0
  28. package/src/canonical.mjs +48 -0
  29. package/src/commands/README.md +266 -0
  30. package/src/commands/adr.mjs +248 -0
  31. package/src/commands/check.mjs +989 -0
  32. package/src/commands/context-command.mjs +212 -0
  33. package/src/commands/context.mjs +790 -0
  34. package/src/commands/custom-rules.mjs +428 -0
  35. package/src/commands/debt.mjs +218 -0
  36. package/src/commands/diff.mjs +523 -0
  37. package/src/commands/discover.mjs +159 -0
  38. package/src/commands/drift.mjs +473 -0
  39. package/src/commands/edge-constraints.mjs +355 -0
  40. package/src/commands/explain.mjs +359 -0
  41. package/src/commands/fitness.mjs +226 -0
  42. package/src/commands/graph.mjs +297 -0
  43. package/src/commands/health.mjs +213 -0
  44. package/src/commands/history.mjs +614 -0
  45. package/src/commands/impact.mjs +226 -0
  46. package/src/commands/plan-context-command.mjs +496 -0
  47. package/src/commands/policy.mjs +138 -0
  48. package/src/commands/provenance-command.mjs +352 -0
  49. package/src/commands/provenance.mjs +159 -0
  50. package/src/commands/reconcile.mjs +219 -0
  51. package/src/commands/report.mjs +553 -0
  52. package/src/commands/snapshot-meta.mjs +107 -0
  53. package/src/commands/waivers.mjs +240 -0
  54. package/src/config.mjs +1308 -0
  55. package/src/containment.mjs +234 -0
  56. package/src/custom-rules/evidence.mjs +340 -0
  57. package/src/custom-rules/host.mjs +1023 -0
  58. package/src/custom-rules/values.mjs +43 -0
  59. package/src/entry-point.mjs +55 -0
  60. package/src/errors.mjs +36 -0
  61. package/src/eslint-config.mjs +542 -0
  62. package/src/go-work.mjs +394 -0
  63. package/src/governance/adr-registry.mjs +539 -0
  64. package/src/governance/clock.mjs +69 -0
  65. package/src/governance/debt-ledger.mjs +274 -0
  66. package/src/governance/discovery-proposal.mjs +423 -0
  67. package/src/governance/fitness-registry.mjs +504 -0
  68. package/src/governance/fitness-rules.mjs +668 -0
  69. package/src/governance/metrics.mjs +392 -0
  70. package/src/governance/preset-fingerprints.json +16 -0
  71. package/src/governance/profile-registry.mjs +366 -0
  72. package/src/governance/provenance-record.mjs +177 -0
  73. package/src/governance/reconcile-candidates.mjs +301 -0
  74. package/src/governance/reconcile-score.mjs +503 -0
  75. package/src/governance/row-schema.mjs +208 -0
  76. package/src/governance/verdict.mjs +127 -0
  77. package/src/governance/waiver.mjs +105 -0
  78. package/src/graph/create-dependencies.mjs +96 -0
  79. package/src/intent/intent-manifest.json +347 -0
  80. package/src/intent/mask-non-code.mjs +640 -0
  81. package/src/lsp/boundary-config.mjs +225 -0
  82. package/src/lsp/diagnose.mjs +202 -0
  83. package/src/lsp/diagnostics.mjs +241 -0
  84. package/src/lsp/protocol.mjs +215 -0
  85. package/src/lsp/server.mjs +922 -0
  86. package/src/lsp/workspace-index.mjs +891 -0
  87. package/src/nx-json.mjs +95 -0
  88. package/src/options.mjs +611 -0
  89. package/src/process.mjs +91 -0
  90. package/src/providers/moon.mjs +733 -0
  91. package/src/providers/native/README.md +204 -0
  92. package/src/providers/native/coverage.mjs +74 -0
  93. package/src/providers/native/differential.fixtures.mjs +1277 -0
  94. package/src/providers/native/discover.mjs +431 -0
  95. package/src/providers/native/graph.mjs +234 -0
  96. package/src/providers/native/index.mjs +152 -0
  97. package/src/providers/native/model.mjs +755 -0
  98. package/src/providers/nx.mjs +178 -0
  99. package/src/report/README.md +89 -0
  100. package/src/report/adr-text.mjs +129 -0
  101. package/src/report/context-text.mjs +109 -0
  102. package/src/report/debt-text.mjs +105 -0
  103. package/src/report/diff-text.mjs +219 -0
  104. package/src/report/discover-text.mjs +186 -0
  105. package/src/report/drift-text.mjs +194 -0
  106. package/src/report/envelope-shape.mjs +161 -0
  107. package/src/report/evidence.mjs +157 -0
  108. package/src/report/explain-text.mjs +159 -0
  109. package/src/report/graph-text.mjs +116 -0
  110. package/src/report/health-text.mjs +123 -0
  111. package/src/report/history-text.mjs +204 -0
  112. package/src/report/impact-text.mjs +128 -0
  113. package/src/report/json.mjs +173 -0
  114. package/src/report/plan-context-text.mjs +159 -0
  115. package/src/report/provenance-text.mjs +78 -0
  116. package/src/report/reconcile-text.mjs +159 -0
  117. package/src/report/report-text.mjs +264 -0
  118. package/src/report/sarif.mjs +953 -0
  119. package/src/report/text.mjs +823 -0
  120. package/src/report/waivers-text.mjs +100 -0
  121. package/src/rules/README.md +123 -0
  122. package/src/rules/index.mjs +962 -0
  123. package/src/rules/match.mjs +1708 -0
  124. package/src/rules/messages.mjs +73 -0
  125. package/src/rules/reachability.mjs +224 -0
  126. package/src/rules/specifiers.mjs +300 -0
  127. package/src/rules/tags.mjs +238 -0
  128. package/src/rules/topology.mjs +333 -0
  129. package/src/tsconfig-paths.mjs +237 -0
  130. package/src/verdict.mjs +145 -0
  131. package/src/workspace.mjs +580 -0
@@ -0,0 +1,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
+ }