@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,553 @@
1
+ /**
2
+ * The `report` command: one architecture governance document — how healthy the
3
+ * architecture is, and **why** — assembled from the surfaces the other
4
+ * commands already own.
5
+ *
6
+ * `health` answers the first half with numbers (`./health.mjs`). The second
7
+ * half is spread across four other commands: which suppressions the boundary
8
+ * law is carrying (`./waivers.mjs`), which declared quality gates hold
9
+ * (`./fitness.mjs`), which recorded decision authorizes each governed row
10
+ * (`./adr.mjs` and each row's `decisionRef`), and where this run's facts came
11
+ * from (`./provenance.mjs`). A maintainer answering "is our architecture in
12
+ * good shape, and on whose authority" had to run five commands and hold the
13
+ * answer in their head. This command is that answer as one document.
14
+ *
15
+ * ## It composes; it never re-decides
16
+ *
17
+ * Every number and every verdict here is produced by the SAME function the
18
+ * owning command calls — `healthCommand`, `waiversCommand`, `fitnessCommand`,
19
+ * `readAdrContext`, `resolveDecisionRef`, `hasOrigin`, `resolveProvenance`.
20
+ * There is no new scan, no second traversal, and no second copy of any
21
+ * judgment, which is what makes it impossible for this document to disagree
22
+ * with `health`, `waivers`, `fitness`, `adr` or `provenance` about the same
23
+ * tree under the same law. (Under a DIFFERENT law it answers a different
24
+ * question, which is what `--config` asks for: a `report --config other.mjs`
25
+ * describes `other.mjs`'s world, exactly as `check --config other.mjs` judges
26
+ * it.) The cost of composing whole commands is real and accepted: each one
27
+ * resolves provenance for its own envelope, and `fitnessCommand` re-reads the
28
+ * workspace's intent through `driftForCheck` rather than taking the copy this
29
+ * command already holds — in a real run both come from the one tracked
30
+ * `architecture-intent.json`, so they agree. The alternative — reaching past
31
+ * these commands into their internals, or caching a result one of them
32
+ * computed — is the disagreement this design exists to refuse.
33
+ *
34
+ * One law governs the whole document. `../../cli.mjs` resolves the boundary
35
+ * policy once (`resolvePolicy`, so `--config` and a `profiles` registry work
36
+ * exactly as they do for `check`) and hands the same object to every surface
37
+ * below, so the report can never cite two different laws in one page.
38
+ *
39
+ * ## Descriptive, and what its status means
40
+ *
41
+ * `report` is descriptive: it never exits 1, because a description of how
42
+ * healthy an architecture is is never itself a finding — `check` and `fitness`
43
+ * are the only two verbs whose verdict carries that lane (`../../AGENTS.md`,
44
+ * "What is a stub, and how each one says so"). A failing fitness gate or a
45
+ * live boundary violation is therefore RENDERED, loudly and by name, over
46
+ * exit 0; the commands that own those verdicts own their exit codes.
47
+ *
48
+ * What the status does mean is whether the document could be established:
49
+ *
50
+ * - `ok` (exit 0) — every surface reached a verdict.
51
+ * - `no-verdict` (exit 3) — at least one did not, and `result.uninspectable`
52
+ * names every one of them with the reason. That list is the whole point:
53
+ * an empty result is a claim, not a shrug (`../../../../AGENTS.md`), so a
54
+ * metric the run could not measure, a waiver surface it could not read, a
55
+ * gate it could not determine, and a `decisionRef` that resolves to nothing
56
+ * all appear as named `unknown`s that hold the whole document back from
57
+ * claiming a verdict. **Nothing here is ever rendered as a clean zero over
58
+ * evidence the run could not inspect.**
59
+ *
60
+ * Three things deliberately do NOT hold the document back, because each is a
61
+ * stated fact rather than an uninspected one:
62
+ *
63
+ * - a **`not_applicable`** surface (no boundary law, no declared fitness, no
64
+ * ADR registry) — the workspace declared none, and saying "could not look"
65
+ * about a thing that does not exist would be a false claim
66
+ * (`../governance/metrics.mjs` fixes that distinction for metrics; it holds
67
+ * the same way for a whole surface);
68
+ * - **repo provenance that git cannot establish** — `resolveProvenance`
69
+ * returns `null` for "git absent, or not a repository", which is a
70
+ * legitimate no-origin claim the report prints as such, and which `adr` and
71
+ * `provenance` both already exit 0 over;
72
+ * - an **unattested row** (no `origin` record) — a documentation finding
73
+ * `provenance` reports over exit 0, rendered here the same way.
74
+ *
75
+ * An unresolved `decisionRef` is the opposite case and is a `no-verdict` here:
76
+ * a citation that names nothing is a governance claim nobody can check, and
77
+ * reading it as a pass is exactly the silent direction.
78
+ *
79
+ * **This is stricter than `check`, deliberately, and the difference is worth
80
+ * knowing.** `check` resolves the citations on BOTH tables — the intent rows
81
+ * and the `depConstraints` rows — but folds only the intent half into its
82
+ * no-verdict lane (`../../cli.mjs` skips a `depConstraints`-shaped row before
83
+ * counting `intentUnresolvedDecisionRefs`), so a dangling citation on a
84
+ * constraint row is rendered by the gate without failing the build. That is a
85
+ * gate's call to make: it fails builds, and a documentation citation is not
86
+ * a broken boundary. This document is the opposite instrument — its entire
87
+ * subject is on whose authority each governed row stands — so it holds both
88
+ * tables to the same standard and cannot claim a verdict over a citation it
89
+ * could not resolve, wherever the row sits. It changes no exit code of
90
+ * `check`'s, and the two never disagree about the FACT: both name the same
91
+ * unresolved citation, through the same `resolveDecisionRef`.
92
+ *
93
+ * ## Determinism
94
+ *
95
+ * Text and JSON are byte-stable over an unchanged tree, an unchanged law and
96
+ * the same reference instant. Rows keep their declaration order (the order the
97
+ * workspace wrote them, which is semantic for a constraint table) or a
98
+ * plain-`<` sort — never `localeCompare`. The one clock-dependent fact this
99
+ * document carries is a waiver's `active`/`expired` status, which is a fact
100
+ * about the deadline the workspace itself wrote and the same judgment `check`
101
+ * makes; `remainingMs` — the millisecond countdown that genuinely differs
102
+ * between two runs a second apart — is deliberately NOT carried here, and
103
+ * `waivers` remains the surface that reports it (with its own disclosure).
104
+ *
105
+ * It does not print, and it does not decide the process's exit code —
106
+ * `../../cli.mjs` owns those (`./README.md`).
107
+ */
108
+ import { formatGovernanceReport } from "../report/report-text.mjs";
109
+ import { jsonEnvelope, renderJson } from "../report/json.mjs";
110
+ import { healthCommand } from "./health.mjs";
111
+ import { declaresFitness, fitnessCommand } from "./fitness.mjs";
112
+ import { waiversCommand } from "./waivers.mjs";
113
+ import { readAdrContext } from "./adr.mjs";
114
+ import { resolveProvenance } from "./provenance.mjs";
115
+ import {
116
+ configRows,
117
+ hasOrigin,
118
+ intentRows,
119
+ rowLabel,
120
+ unresolvedDecisionRefNote,
121
+ } from "./provenance-command.mjs";
122
+ import {
123
+ ADR_DIR,
124
+ adrsBinding,
125
+ declaredFitnessNames,
126
+ resolveDecisionRef,
127
+ stripAdrPrefix,
128
+ } from "../governance/adr-registry.mjs";
129
+
130
+ /**
131
+ * The message a thrown refusal carries, as the report's reason for a surface
132
+ * it could not establish.
133
+ *
134
+ * The catches that call this are deliberately broad, and that is the one
135
+ * place a reader should push back, so: a composed command throws for exactly
136
+ * two reasons — a workspace condition it refuses over (incomplete coverage, a
137
+ * graph that cannot see the tree's edges, an unreadable registry), or a bug in
138
+ * this package. Turning the first into a named `unknown` is the whole design;
139
+ * the second lands there too, and still surfaces as a NON-ZERO exit with the
140
+ * error's own message printed under `could not inspect`. Neither can produce
141
+ * a clean-looking run, which is the property that matters — a `catch` that
142
+ * swallows is the defect (`../../../../AGENTS.md`), and one that converts to
143
+ * a named no-verdict is not one.
144
+ *
145
+ * Every refusal in this package throws an `Error`
146
+ * whose message is already the sentence a user reads (`archkeep: …`), so the
147
+ * report quotes it rather than inventing a second wording that could drift
148
+ * from the command's own.
149
+ *
150
+ * @param {unknown} error
151
+ * @returns {string}
152
+ */
153
+ function refusalReason(error) {
154
+ const message = /** @type {{message?: unknown}} */ (error)?.message;
155
+ return typeof message === "string" && message.length > 0 ? message : String(error);
156
+ }
157
+
158
+ /**
159
+ * Plain string comparison — never `localeCompare`, which depends on the locale
160
+ * and the Node build's ICU data and would let two machines order the same rows
161
+ * differently (the determinism rule every snapshot-state command shares).
162
+ *
163
+ * @param {string} a
164
+ * @param {string} b
165
+ * @returns {number}
166
+ */
167
+ function byBytes(a, b) {
168
+ return a < b ? -1 : a > b ? 1 : 0;
169
+ }
170
+
171
+ /**
172
+ * The waiver surface, as the document carries it: the counts, and one row per
173
+ * suppression with what it currently covers.
174
+ *
175
+ * `remainingMs` is dropped on purpose (see this module's header). `status` is
176
+ * kept: it is the same active/expired judgment `check` makes about a deadline
177
+ * the workspace wrote down, and an expired waiver silently re-asserting is
178
+ * exactly what a maintainer reads this document for.
179
+ *
180
+ * @param {object} surface `waiversCommand`'s `waivers` result.
181
+ * @returns {{waivers: number, covered: number, expired: number, stale: number,
182
+ * suppressions: number, suppressed: number}}
183
+ */
184
+ function waiverCounts(surface) {
185
+ return {
186
+ waivers: surface.waivers.length,
187
+ covered: surface.covered,
188
+ expired: surface.expired,
189
+ stale: surface.stale,
190
+ suppressions: surface.suppressions.length,
191
+ suppressed: surface.suppressed,
192
+ };
193
+ }
194
+
195
+ /**
196
+ * One rendered suppression row. Both halves of the table are carried — a
197
+ * temporary waiver and a permanent suppression — because a permanent row
198
+ * appears in no other report at all (`./waivers.mjs`'s header owns why).
199
+ *
200
+ * No `decisionRef` here, and that is a decision rather than an omission: a
201
+ * suppression row has no such field — `../config.mjs`'s `SUPPRESSION_KEYS`
202
+ * admits `path`, `messageId`, `reason`, `expiresAt` and `origin`, and a row
203
+ * carrying anything else is refused at load. Rendering one anyway would print
204
+ * a citation this document never resolved, next to rows whose citations it
205
+ * did, and a reader would reasonably take the two for the same claim.
206
+ *
207
+ * @param {object} row
208
+ * @param {"waiver"|"suppression"} kind
209
+ * @returns {{kind: string, path: string, status: string, covered: number,
210
+ * reason: string|null, expiresAt: string|null}}
211
+ */
212
+ function suppressionRow(row, kind) {
213
+ return {
214
+ kind,
215
+ path: row.path,
216
+ status: kind === "waiver" ? row.status : "permanent",
217
+ covered: row.covered,
218
+ reason: typeof row.reason === "string" ? row.reason : null,
219
+ expiresAt: typeof row.expiresAt === "string" ? row.expiresAt : null,
220
+ };
221
+ }
222
+
223
+ /**
224
+ * Runs the `report` command: composes health, waivers, fitness, the decision
225
+ * registry and provenance into one document, and reports which evidence — if
226
+ * any — it could not inspect.
227
+ *
228
+ * @param {object} commandContext From `resolveCommandContext`.
229
+ * @param {{config?: object|null, intent?: object|null, trendDir?: string|null,
230
+ * policySource?: string|null, now?: string, readSnapshots?: Function}} [io]
231
+ * `config` is the ONE boundary law the whole document is written against
232
+ * (`../../cli.mjs`'s `resolvePolicy` resolved it, so `--config` and profiles
233
+ * apply), `intent` the loaded intent model or `null`, `trendDir` the
234
+ * snapshot directory for trends (the same directory `history` reads),
235
+ * `policySource` the workspace-relative name of the law that governed the
236
+ * run, and `now` the injectable clock the waiver judgment reads.
237
+ * @returns {Promise<{status: "ok"|"no-verdict", result: object, coverage: object,
238
+ * report: {text: string, json: string}}>}
239
+ * @throws {Error} on the conditions every command refuses out of — no
240
+ * workspace, a graph the provider could not build, a commitless repository —
241
+ * all exit-3 class, raised by the composed commands themselves.
242
+ */
243
+ export async function reportCommand(commandContext, io = {}) {
244
+ const { root, provider, marker, tracked } = commandContext;
245
+ const config = io.config === undefined ? null : io.config;
246
+ const intent = io.intent === undefined ? null : io.intent;
247
+
248
+ /**
249
+ * Every piece of evidence this run could not inspect, named. This list is
250
+ * what decides the document's status, and a surface that lands here can
251
+ * never also be rendered as a clean zero.
252
+ *
253
+ * @type {{surface: string, reason: string}[]}
254
+ */
255
+ const uninspectable = [];
256
+
257
+ // ── Health ────────────────────────────────────────────────────────────
258
+ // The metrics, verbatim from the command that owns them. Each `unknown`
259
+ // metric is carried into `uninspectable` with the note the metric itself
260
+ // wrote, so the document says WHICH number it could not establish rather
261
+ // than only that one is missing.
262
+ const health = healthCommand(commandContext, {
263
+ config,
264
+ intent,
265
+ trendDir: io.trendDir ?? null,
266
+ ...(io.readSnapshots ? { readSnapshots: io.readSnapshots } : {}),
267
+ });
268
+ for (const [key, metric] of Object.entries(health.metrics).sort(([a], [b]) => byBytes(a, b))) {
269
+ if (metric.verdict === "unknown") {
270
+ uninspectable.push({
271
+ surface: `metric:${key}`,
272
+ reason: metric.note ?? "the metric could not be measured over this run's evidence",
273
+ });
274
+ }
275
+ }
276
+ // A guard, not a check on the workspace: `health` decides its own status
277
+ // from the same metrics walked above, so a `no-verdict` health with nothing
278
+ // in `uninspectable` would mean this loop stopped seeing unknowns — and the
279
+ // document would claim a verdict `health` refused. That is a bug in this
280
+ // file, so it throws rather than degrades, the same posture `jsonEnvelope`
281
+ // takes for the invariants it asserts (`../report/json.mjs`).
282
+ if (health.status === "no-verdict" && uninspectable.length === 0) {
283
+ throw new Error(
284
+ "archkeep: report could not account for health's no-verdict — no metric was carried into " +
285
+ "the uninspectable list. This is a bug in the report command, not a fact about the " +
286
+ "workspace being judged.",
287
+ );
288
+ }
289
+
290
+ // ── Waivers ───────────────────────────────────────────────────────────
291
+ // The suppression table the law is carrying. No law, no table: that is
292
+ // `not_applicable`, never an empty list that would read as "nothing is
293
+ // waived". A refusal (incomplete coverage, a graph that cannot see the
294
+ // workspace's edges) becomes a named unknown instead of a silent zero.
295
+ /** @type {{verdict: string, note: string|null, counts: object|null, rows: object[]}} */
296
+ let waivers = {
297
+ verdict: "not_applicable",
298
+ note: "no boundary law is declared, so there is no suppression table to read",
299
+ counts: null,
300
+ rows: [],
301
+ };
302
+ if (config !== null) {
303
+ try {
304
+ const surface = await waiversCommand(commandContext, config, {
305
+ ...(io.now ? { now: io.now } : {}),
306
+ });
307
+ waivers = {
308
+ verdict: "ok",
309
+ note: null,
310
+ counts: waiverCounts(surface.waivers),
311
+ rows: [
312
+ ...surface.waivers.waivers.map((row) => suppressionRow(row, "waiver")),
313
+ ...surface.waivers.suppressions.map((row) => suppressionRow(row, "suppression")),
314
+ ],
315
+ };
316
+ } catch (error) {
317
+ const reason = refusalReason(error);
318
+ waivers = { verdict: "unknown", note: reason, counts: null, rows: [] };
319
+ uninspectable.push({ surface: "waivers", reason });
320
+ }
321
+ }
322
+
323
+ // ── The decision registry ─────────────────────────────────────────────
324
+ // Read first, because both the fitness section and the citation section
325
+ // link into it. An unreadable registry is a named unknown for both — "could
326
+ // not read the registry" must never read as "no ADRs" (`./adr.mjs`).
327
+ /** @type {{records: object[], byId: Map<string, object>}|null} */
328
+ let registry = null;
329
+ /** @type {string|null} */
330
+ let registryRefusal = null;
331
+ try {
332
+ registry = readAdrContext(root, { tracked });
333
+ } catch (error) {
334
+ registryRefusal = refusalReason(error);
335
+ uninspectable.push({ surface: "decisions", reason: registryRefusal });
336
+ }
337
+
338
+ // ── Fitness gates ─────────────────────────────────────────────────────
339
+ // Every declared quality gate, with its verdict and the ADR(s) that bind it
340
+ // — `adrsBinding` is `adr`'s own reverse lookup, so the link this document
341
+ // draws is the one that command would answer. A policy declaring no gates
342
+ // is `not_applicable` (`declaresFitness` is the one predicate both this and
343
+ // `fitnessCommand`'s own refusal read); an undetermined gate is an unknown
344
+ // that holds the document back, the same lane `fitness` and `check` put it
345
+ // in.
346
+ /** @type {{verdict: string, note: string|null, functions: object[]}} */
347
+ let fitness = {
348
+ verdict: "not_applicable",
349
+ note: "the boundary law declares no fitness functions",
350
+ functions: [],
351
+ };
352
+ if (declaresFitness(config)) {
353
+ try {
354
+ const judged = await fitnessCommand(commandContext, { config });
355
+ fitness = {
356
+ verdict: judged.fitness.verdict,
357
+ note: null,
358
+ functions: judged.fitness.functions.map((decision) => ({
359
+ name: decision.name,
360
+ verdict: decision.verdict,
361
+ message: typeof decision.message === "string" ? decision.message : null,
362
+ // An unreadable registry cannot answer "which ADR binds this gate",
363
+ // so the link is `null` — distinct from `[]`, which is the real
364
+ // answer "no recorded decision binds it".
365
+ adrs: registry === null ? null : adrsBinding(registry.records, decision.name),
366
+ })),
367
+ };
368
+ for (const decision of judged.fitness.functions) {
369
+ if (decision.verdict === "unknown") {
370
+ uninspectable.push({
371
+ surface: `fitness:${decision.name}`,
372
+ reason:
373
+ typeof decision.message === "string" && decision.message.length > 0
374
+ ? decision.message
375
+ : "the fitness function could not be determined",
376
+ });
377
+ }
378
+ }
379
+ } catch (error) {
380
+ const reason = refusalReason(error);
381
+ fitness = { verdict: "unknown", note: reason, functions: [] };
382
+ uninspectable.push({ surface: "fitness", reason });
383
+ }
384
+ }
385
+
386
+ // ── The governed rows: attestation and citation ───────────────────────
387
+ // The same two row walks `provenance` makes, through the same two exported
388
+ // helpers, so the two commands cannot come to disagree about which rows
389
+ // exist. `intentRows` reads a normalized intent model; a workspace with no
390
+ // intent file contributes none.
391
+ const governanceRows = [...(intent === null ? [] : intentRows(intent)), ...configRows(config)];
392
+ const unattested = governanceRows
393
+ .filter(({ row }) => !hasOrigin(row))
394
+ .map(({ kind, row }) => ({ kind, label: rowLabel(kind, row) }));
395
+
396
+ // Each governed row that CITES a decision, and whether the citation
397
+ // resolves. `resolveDecisionRef` is the one function that answers it, over
398
+ // the registry index and — F04 — the fitness ids the executed policy
399
+ // DECLARES, never the ADRs' own bindings, which would let a citation
400
+ // resolve itself.
401
+ const knownFitness = declaredFitnessNames(config);
402
+ const citations = governanceRows
403
+ .filter(({ row }) => typeof row?.decisionRef === "string" && row.decisionRef.trim() !== "")
404
+ .map(({ kind, row }) => {
405
+ const decisionRef = row.decisionRef;
406
+ // A registry that could not be read cannot resolve anything: every
407
+ // citation is `unknown` (already carried into `uninspectable` once, by
408
+ // the registry refusal itself — naming each row again would report the
409
+ // one failure N times).
410
+ const resolution =
411
+ registry === null
412
+ ? "unknown"
413
+ : resolveDecisionRef(registry.byId, knownFitness, decisionRef);
414
+ const record =
415
+ resolution === "adr" ? registry?.byId.get(stripAdrPrefix(decisionRef)) : undefined;
416
+ return {
417
+ kind,
418
+ label: rowLabel(kind, row),
419
+ decisionRef,
420
+ resolution,
421
+ adr: record === undefined ? null : { id: record.id, status: record.status },
422
+ };
423
+ });
424
+ if (registry !== null) {
425
+ for (const citation of citations) {
426
+ if (citation.resolution === "unknown") {
427
+ uninspectable.push({
428
+ surface: `decisionRef:${citation.label}`,
429
+ reason: unresolvedDecisionRefNote(citation.decisionRef),
430
+ });
431
+ }
432
+ }
433
+ }
434
+
435
+ // The order of these three tests is load-bearing, and it is NOT the order
436
+ // they were first written in. An unresolved citation is checked BEFORE the
437
+ // empty-registry case: a workspace with no ADRs at all whose constraint row
438
+ // cites `0009-missing` has a citation that resolved to nothing, and reading
439
+ // that surface as `not_applicable` — "the workspace records no decisions
440
+ // there" — would state a true sentence in place of the one that matters,
441
+ // collapsing "nothing to measure" into "measured and could not resolve".
442
+ // Those two must never read alike (`../report/report-text.mjs` renders both
443
+ // and says the same).
444
+ const unresolvedCitation = citations.some((citation) => citation.resolution === "unknown");
445
+ const decisions = {
446
+ verdict:
447
+ registry === null
448
+ ? "unknown"
449
+ : unresolvedCitation
450
+ ? "unknown"
451
+ : registry.records.length === 0
452
+ ? "not_applicable"
453
+ : "ok",
454
+ note:
455
+ registryRefusal ??
456
+ (!unresolvedCitation && registry !== null && registry.records.length === 0
457
+ ? `no ADRs in ${ADR_DIR}/ — the workspace records no decisions there`
458
+ : null),
459
+ registry: { dir: ADR_DIR, count: registry === null ? null : registry.records.length },
460
+ records:
461
+ registry === null
462
+ ? []
463
+ : registry.records.map((record) => ({
464
+ id: record.id,
465
+ status: record.status,
466
+ bindings: [...record.bindings],
467
+ })),
468
+ citations,
469
+ };
470
+
471
+ // ── Provenance ────────────────────────────────────────────────────────
472
+ // Where this run's facts came from. `null` is git's honest "no origin
473
+ // claim", printed as such and never folded into a commit this run cannot
474
+ // name (`./provenance.mjs`).
475
+ const repo = resolveProvenance(root);
476
+ const provenance = {
477
+ repo: repo ?? { commit: null, remote: null, dirty: null },
478
+ established: repo !== null,
479
+ policySource: io.policySource ?? null,
480
+ rows: { total: governanceRows.length, unattested },
481
+ };
482
+
483
+ const status = uninspectable.length > 0 ? "no-verdict" : "ok";
484
+
485
+ // The health coverage facts, plus this document's own disclosures. The two
486
+ // `complete`/`notAnalyzed` fields ride through unchanged — `jsonEnvelope`
487
+ // asserts they agree with each other and with an `ok` status, and this
488
+ // document must never widen either claim.
489
+ const coverage = {
490
+ ...health.coverage,
491
+ notes: [
492
+ ...health.coverage.notes,
493
+ "report composes health, waivers, fitness, the ADR registry and provenance through the " +
494
+ "same functions those commands run; it performs no scan of its own and cannot disagree " +
495
+ "with them about the same tree.",
496
+ "a waiver's active/expired status is judged against this run's reference instant, the " +
497
+ "same judgement `check` makes; remainingMs is not carried here — run `waivers` for it.",
498
+ "the debt surface here is the per-run one (the boundary law's own deferred rows). The " +
499
+ "ledger aged across snapshots is `debt`'s, which needs a snapshot directory.",
500
+ ],
501
+ };
502
+
503
+ const result = {
504
+ trendDir: io.trendDir ?? null,
505
+ metrics: health.metrics,
506
+ trends: health.trends,
507
+ waivers,
508
+ fitness,
509
+ decisions,
510
+ provenance,
511
+ // Unconditional, like `provenance`'s own `unattested`: an empty array is
512
+ // itself the claim "every surface was inspectable", never an omitted key
513
+ // that would leave a reader unable to tell "checked, clean" from "never
514
+ // checked" (`../../../../AGENTS.md`).
515
+ uninspectable,
516
+ };
517
+
518
+ const envelope = jsonEnvelope({
519
+ command: "report",
520
+ context: { root, provider, marker, provenance: repo },
521
+ status,
522
+ exitCode: status === "ok" ? 0 : 3,
523
+ coverage,
524
+ result,
525
+ // No `decision` field, deliberately. The envelope's decision must agree
526
+ // with its status (`../report/json.mjs`), and this status is about whether
527
+ // the document could be ESTABLISHED, not about whether the architecture is
528
+ // healthy — so a report over a tree with live violations would carry
529
+ // `verdict: "pass"`. That is the disagreement the envelope refuses,
530
+ // arriving through the one field that is optional. `health` omits it for
531
+ // the same reason.
532
+ });
533
+
534
+ return {
535
+ status,
536
+ result,
537
+ coverage,
538
+ report: {
539
+ text: formatGovernanceReport({
540
+ coverage,
541
+ metrics: health.metrics,
542
+ trends: health.trends,
543
+ waivers,
544
+ fitness,
545
+ decisions,
546
+ provenance,
547
+ uninspectable,
548
+ context: { root, provider, marker },
549
+ }),
550
+ json: renderJson(envelope),
551
+ },
552
+ };
553
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Shared comparison of the snapshot metadata two graph envelopes carry beyond
3
+ * the graph itself — provider, repository provenance, and policy fingerprint.
4
+ * `diff` and `history` both need to know whether these changed between two
5
+ * states, and they must agree about it, so the comparison lives here once
6
+ * rather than in each (`../README.md`'s "single home" rule applied to a fact
7
+ * two commands share).
8
+ *
9
+ * This is a pure function of its arguments and decides nothing about exit
10
+ * codes or renderings: it reports raw facts — changed / unchanged /
11
+ * unverifiable — and each consumer decides what a fact means. `diff` renders
12
+ * them as `coverage.notes` and `result.policyMismatch`; `history` renders them
13
+ * as a transition's change classification. The two could drift only if one
14
+ * stopped calling this, which is the point of sharing it.
15
+ *
16
+ * "Unverifiable" is a deliberate third state, never collapsed into
17
+ * "unchanged". When a side is missing a field, this tool cannot assert the
18
+ * metadata is the same — asserting so would be the silent direction
19
+ * (`../../../../AGENTS.md`): reporting "no change" where it cannot look.
20
+ *
21
+ * `provenanceChanged`/`policyChanged` are `null` exactly when the comparison
22
+ * could not be made (one side missing), which is why a consumer must check for
23
+ * `null` before treating either as a boolean.
24
+ */
25
+
26
+ /**
27
+ * Compares the provider, provenance, and policy fingerprint of two graph
28
+ * envelopes.
29
+ *
30
+ * @param {{baselineProvider: string|null, headProvider: string|null,
31
+ * baselineProvenance: {commit: string, remote: string|null, dirty: boolean}|null,
32
+ * headProvenance: {commit: string, remote: string|null, dirty: boolean}|null,
33
+ * baselineFingerprint: string|null, headFingerprint: string|null}} input
34
+ * @returns {{providerChanged: boolean,
35
+ * provenanceChanged: boolean|null, provenanceOneSided: boolean, crossRepo: boolean,
36
+ * dirtyBaseline: boolean, dirtyHead: boolean,
37
+ * policyChanged: boolean|null, policyOneSided: boolean,
38
+ * policyMismatch: {baseline: {fingerprint: string}, head: {fingerprint: string}}|null}}
39
+ */
40
+ export function compareSnapshotMetadata({
41
+ baselineProvider,
42
+ headProvider,
43
+ baselineProvenance,
44
+ headProvenance,
45
+ baselineFingerprint,
46
+ headFingerprint,
47
+ }) {
48
+ // Provider differs only when the baseline actually names one — a snapshot
49
+ // that predates the workspace header cannot be asserted different (diff's
50
+ // original condition, kept exact so its notes are byte-identical).
51
+ const providerChanged = !!baselineProvider && baselineProvider !== headProvider;
52
+
53
+ let provenanceChanged = null;
54
+ let provenanceOneSided = false;
55
+ let crossRepo = false;
56
+ if (baselineProvenance && headProvenance) {
57
+ provenanceChanged = baselineProvenance.commit !== headProvenance.commit;
58
+ if (
59
+ baselineProvenance.remote &&
60
+ headProvenance.remote &&
61
+ baselineProvenance.remote !== headProvenance.remote
62
+ ) {
63
+ crossRepo = true;
64
+ }
65
+ } else if ((baselineProvenance && !headProvenance) || (!baselineProvenance && headProvenance)) {
66
+ // Exactly one side carries provenance — the comparison is unverifiable.
67
+ provenanceOneSided = true;
68
+ }
69
+ // Both sides missing provenance: provenanceChanged stays null (we cannot
70
+ // assert anything) but not "one-sided", so no note about an uneven pair.
71
+
72
+ let policyChanged = null;
73
+ let policyOneSided = false;
74
+ if (baselineFingerprint && headFingerprint) {
75
+ policyChanged = baselineFingerprint !== headFingerprint;
76
+ } else if (
77
+ (baselineFingerprint && !headFingerprint) ||
78
+ (!baselineFingerprint && headFingerprint)
79
+ ) {
80
+ policyOneSided = true;
81
+ }
82
+ // Both sides carrying no fingerprint: policyChanged stays null — no config
83
+ // was ever provided, so neither "changed" nor a one-sided warning applies.
84
+
85
+ // A snapshot taken from a dirty (uncommitted) tree is not a reproducible
86
+ // claim about the commit it names (`../../commands/provenance.mjs`'s header),
87
+ // so each consumer is told when either side came from one. Consumers decide
88
+ // what the fact means — `history` discloses it, `diff` leaves it to its own
89
+ // notes.
90
+ const dirtyBaseline = baselineProvenance?.dirty === true;
91
+ const dirtyHead = headProvenance?.dirty === true;
92
+
93
+ return {
94
+ providerChanged,
95
+ provenanceChanged,
96
+ provenanceOneSided,
97
+ crossRepo,
98
+ dirtyBaseline,
99
+ dirtyHead,
100
+ policyChanged,
101
+ policyOneSided,
102
+ policyMismatch:
103
+ policyChanged === true
104
+ ? { baseline: { fingerprint: baselineFingerprint }, head: { fingerprint: headFingerprint } }
105
+ : null,
106
+ };
107
+ }