@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,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
|
+
}
|