@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,614 @@
1
+ /**
2
+ * The `history` command: the architecture's evolution across time, read from
3
+ * a consumer-managed directory of graph snapshots.
4
+ *
5
+ * `history <dir>` reads every `graph --format json` envelope in a directory
6
+ * (the directory is the sole source of truth — no index file, no database)
7
+ * and produces one evolution record: the snapshots in history order and the
8
+ * transitions between consecutive ones, each transition classified by the
9
+ * verifiable signals it carries.
10
+ *
11
+ * `--capture` appends a snapshot of the current workspace first — writing
12
+ * `<seq>-<sha8>.json` (a zero-padded monotonic sequence and the snapshot's
13
+ * architecture identity, so filename byte-sort IS history order) — then
14
+ * produces the record that includes it. Capture deduplicates: when the
15
+ * current architecture identity matches the last snapshot, no new file is
16
+ * written and no empty transition is manufactured. The capture answer — was
17
+ * this a new snapshot (`duplicate: false`) or a dedup against the last one
18
+ * (`duplicate: true`)? — is an always-present field, so the envelope's shape
19
+ * is not a function of prior directory state (E-F05).
20
+ *
21
+ * The snapshots are full graph envelopes, not deltas. Each is
22
+ * content-addressable (its identity derives from its own bytes) and
23
+ * self-validating (`parseBaseline` refuses a malformed or incomplete one),
24
+ * so a corrupted or truncated file stops the record loudly instead of
25
+ * degrading it.
26
+ *
27
+ * It is descriptive: it never exits 1. A description of how the architecture
28
+ * evolved is never a finding. An empty directory
29
+ * or an unreadable snapshot is a no-verdict run (exit 3), never a record of
30
+ * nothing.
31
+ *
32
+ * ## Why a directory, not an index
33
+ *
34
+ * An index file would be a second copy of facts the snapshot files already
35
+ * hold, and two copies drift — the same reason `scripts/check-packages.mjs`
36
+ * derives its target list from `ci.yml` rather than holding a copy
37
+ * (`../../../../AGENTS.md`). The directory is ordered by filename byte-sort;
38
+ * a snapshot is replaced or deleted by moving its file; a `history` that
39
+ * cannot make sense of the directory says so instead of guessing.
40
+ *
41
+ * ## What a transition can and cannot assert
42
+ *
43
+ * A transition between two complete snapshots is classified by signals the
44
+ * snapshots actually carry, never by inference:
45
+ *
46
+ * - **architecture** — the graph diff (`computeDiff`). Added/removed/changed
47
+ * projects and edges are a change to the architecture itself.
48
+ * - **policy / intent** — the `policy.fingerprint` carried in each snapshot,
49
+ * compared by `./snapshot-meta.mjs`. In 1.x the boundary law a workspace
50
+ * declares IS its stated architectural intent; the fingerprint is that law
51
+ * content-addressed. A fingerprint change is one signal with two names:
52
+ * the policy changed, and the intent it encodes changed. There is no
53
+ * separate, unverifiable "intent" field (a snapshot carries no record of a
54
+ * team's reasons), so this command never invents one.
55
+ * - **provider-configuration** — the `workspace.provider` header. A native →
56
+ * nx migration is a change to how the architecture is read, and structural
57
+ * differences on either side of it are provider-artefacts, not
58
+ * architecture.
59
+ * - **code drift** — provenance (git commit) advancing while the architecture
60
+ * and policy are unchanged is a disclosure, not a change classification:
61
+ * the code moved without the architecture moving, which is exactly the
62
+ * state an "architecture evolution" lens must name rather than bury.
63
+ * - **coverage / intent caveats** — everything a snapshot does not carry is
64
+ * disclosed, never asserted: rule-impact cannot be recomputed from stored
65
+ * snapshots (there is no constraint table in them), so each record names
66
+ * that limit.
67
+ *
68
+ * What it needs from its caller is a `CommandContext` for the head (used
69
+ * only for `--capture`), the directory path, and an IO seam. It does not
70
+ * print, and it does not decide the process's exit code — `../../cli.mjs`
71
+ * owns those (`./README.md`).
72
+ */
73
+ import { createHash } from "node:crypto";
74
+ import { existsSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
75
+ import { basename, join, resolve } from "node:path";
76
+
77
+ import { isWholeFileFailure } from "../analysis/source-util.mjs";
78
+ import { containmentViolation } from "../containment.mjs";
79
+ import { jsonEnvelope, renderJson } from "../report/json.mjs";
80
+ import { formatHistoryReport } from "../report/history-text.mjs";
81
+ import { computeDiff, parseBaseline } from "./diff.mjs";
82
+ import { buildDependencies, buildProjects } from "./graph.mjs";
83
+ import { resolveProvenance } from "./provenance.mjs";
84
+ import { compareSnapshotMetadata } from "./snapshot-meta.mjs";
85
+
86
+ /**
87
+ * A graph envelope's architecture identity: the part that determines whether
88
+ * the architecture itself changed, independent of which machine, which
89
+ * workspace root, or which provider revision read it.
90
+ *
91
+ * The identity is a SHA-256 of the canonicalized JSON of the fields
92
+ * `computeDiff` actually compares — projects and dependencies — plus the
93
+ * policy fingerprint when the snapshot carries one. The policy fingerprint is
94
+ * included because a policy is architectural intent: `--capture` must write a
95
+ * new snapshot when the law changes even if the graph did not move.
96
+ *
97
+ * The project serialization deliberately drops every field beyond the graph
98
+ * diff's four (`name`, `root`, `type`, `tags`), so "identity moved" always
99
+ * coincides with "the diff sees an architectural change" — and a build-target
100
+ * change (which `buildProjects` emits but `computeDiff` deliberately ignores)
101
+ * neither moves the identity nor manufacturers an empty transition. The same
102
+ * lossiness is what keeps future graph fields from fabricating a false
103
+ * architecture change. The workspace header (`root`, `provider`, `marker`,
104
+ * `provenance`) is excluded — the root is a local path that varies by machine,
105
+ * and provider/provenance are facts about the reading, not the architecture.
106
+ * Provider and provenance changes surface through the transition
107
+ * classification instead.
108
+ *
109
+ * @param {{projects: object[], dependencies: object[], policy?: {fingerprint: string}|null}} snapshot
110
+ * @returns {string} A hex-encoded SHA-256.
111
+ */
112
+ export function snapshotIdentity({ projects, dependencies, policy }) {
113
+ const identityProjects = projects.map(({ name, root, type, tags }) => ({
114
+ name,
115
+ root,
116
+ type,
117
+ tags,
118
+ }));
119
+ const canonical = JSON.stringify(
120
+ { projects: identityProjects, dependencies, policy: policy?.fingerprint ?? null },
121
+ (_, value) =>
122
+ value !== null && typeof value === "object" && !Array.isArray(value)
123
+ ? Object.fromEntries(
124
+ Object.keys(value)
125
+ .sort()
126
+ .map((key) => [key, value[key]]),
127
+ )
128
+ : value,
129
+ );
130
+ return createHash("sha256").update(canonical).digest("hex");
131
+ }
132
+
133
+ /**
134
+ * Reads and validates every snapshot in the history directory.
135
+ *
136
+ * The directory is the sole source of truth, so this refuses loudly on any
137
+ * condition that would make the record dishonest: an unreadable snapshot, a
138
+ * file that is not a complete graph envelope, or a directory that does not
139
+ * exist. It deliberately ignores `.tmp` files — an interrupted `--capture`
140
+ * leaves one behind, and that must never count as a snapshot. Two snapshots
141
+ * may share an architecture identity at non-adjacent positions (an A → B → A
142
+ * evolution is real history); only capture dedups, against the last file.
143
+ *
144
+ * @param {string} dir Absolute path to the history directory.
145
+ * @param {string} [root] The workspace root, when the caller has one. A
146
+ * directory whose STRING lies inside the workspace but whose realpath
147
+ * escapes it — a workspace-controlled symlink in an intermediate component,
148
+ * the `.archkeep/history -> /tmp/out` case — is refused loudly here, the
149
+ * read-side half of the write guard `writeSnapshotFile` below enforces on
150
+ * capture (G-10). A directory the caller names OUTSIDE the workspace is the
151
+ * caller's explicit choice and is left untouched, exactly like `--output
152
+ * /tmp` (`../containment.mjs`).
153
+ * @returns {{files: {name: string, path: string, envelope: object, id: string}[]}}
154
+ * @throws {Error} when the directory cannot be read, when its realpath leaves
155
+ * the workspace, or on the first unreadable or malformed snapshot.
156
+ */
157
+ export function readSnapshots(dir, root) {
158
+ // The dir's realpath is the whole containment question: `readdirSync`
159
+ // succeeded on it below, so the directory (or its deepest existing ancestor)
160
+ // exists, and `containmentViolation` resolves that through every intermediate
161
+ // component. The `forWrite` policy is applied deliberately — the WRITE side
162
+ // of this same command family refuses a workspace-inside history dir whose
163
+ // intermediate components are symlinks, and a read of the same dir must not
164
+ // accept what its own capture would refuse (G-10). The `existsSync(root)`
165
+ // gate keeps the probe off in-memory fixture roots.
166
+ if (root !== undefined && existsSync(root)) {
167
+ const violation = containmentViolation(root, resolve(dir), { forWrite: true });
168
+ if (violation !== null) {
169
+ throw new Error(`archkeep: cannot read the history directory '${dir}': ${violation}`);
170
+ }
171
+ }
172
+ let names;
173
+ try {
174
+ names = readdirSync(dir);
175
+ } catch (cause) {
176
+ throw new Error(
177
+ `archkeep: cannot read the history directory '${dir}': ${cause?.message ?? cause}`,
178
+ { cause },
179
+ );
180
+ }
181
+ names = names.filter((name) => name.endsWith(".json") && !name.endsWith(".json.tmp"));
182
+ names.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
183
+
184
+ const files = [];
185
+ for (const name of names) {
186
+ const path = join(dir, name);
187
+ let text;
188
+ try {
189
+ text = readFileSync(path, "utf8");
190
+ } catch (cause) {
191
+ throw new Error(
192
+ `archkeep: cannot read the history snapshot '${path}': ${cause?.message ?? cause}`,
193
+ { cause },
194
+ );
195
+ }
196
+ const parsed = parseBaseline(text, path);
197
+ files.push({
198
+ name,
199
+ path,
200
+ envelope: {
201
+ coverage: parsed.coverage,
202
+ result: {
203
+ projects: parsed.projects,
204
+ dependencies: parsed.dependencies,
205
+ policy: parsed.policy ?? undefined,
206
+ },
207
+ workspace: { provider: parsed.provider, provenance: parsed.provenance },
208
+ },
209
+ id: snapshotIdentity({
210
+ projects: parsed.projects,
211
+ dependencies: parsed.dependencies,
212
+ policy: parsed.policy,
213
+ }),
214
+ });
215
+ }
216
+ return { files };
217
+ }
218
+
219
+ /**
220
+ * The short filename suffix for a snapshot identity.
221
+ *
222
+ * @param {string} id Full hex SHA-256 from `snapshotIdentity`.
223
+ * @returns {string} First 8 hex characters.
224
+ */
225
+ export function shortId(id) {
226
+ return id.slice(0, 8);
227
+ }
228
+
229
+ /**
230
+ * The zero-padded sequence number for `--capture`, taken from the highest
231
+ * existing snapshot filename. `0001` for a fresh directory.
232
+ *
233
+ * The width widens from a four-digit minimum rather than overflowing: a
234
+ * `10000` that padded to four digits would byte-sort *before* `9999-…` and
235
+ * silently rewind history order, and the sequence regex would stop seeing the
236
+ * 5-digit name so repeated captures would clobber the same file. Fresh
237
+ * directories start at `0001`; each subsequent capture pads to at least the
238
+ * width the next number needs, so the sequence always advances and no two
239
+ * captures ever target the same file.
240
+ *
241
+ * @param {{files: {name: string}[]}} read From `readSnapshots`.
242
+ * @returns {string} Zero-padded sequence, at least four digits.
243
+ */
244
+ export function nextSequence(read) {
245
+ let max = 0;
246
+ for (const file of read.files) {
247
+ const match = /^(\d+)-/.exec(file.name);
248
+ if (match) max = Math.max(max, Number.parseInt(match[1], 10));
249
+ }
250
+ const width = Math.max(4, String(max + 1).length);
251
+ return String(max + 1).padStart(width, "0");
252
+ }
253
+
254
+ /**
255
+ * Computes the evolution record from a list of snapshots: history order,
256
+ * each snapshot's identity, and the classified transition from each to the
257
+ * next.
258
+ *
259
+ * A transition's `architectureChanged` is the graph diff alone — a pure
260
+ * policy change (same graph, different fingerprint) is classified as a policy
261
+ * change, not as an architecture change. A provenance advance on a transition
262
+ * where neither the architecture nor the policy changed is disclosed as code
263
+ * drift: the code moved without the architecture moving, which an
264
+ * "architecture evolution" lens must name rather than bury.
265
+ *
266
+ * @param {{name: string, path: string, envelope: object, id: string}[]} files
267
+ * @returns {{snapshots: {name: string, id: string}[],
268
+ * transitions: {from: string, to: string, architectureChanged: boolean,
269
+ * changes: object|null, policyChanged: boolean|null, providerChanged: boolean,
270
+ * codeDrift: boolean, notes: string[]}[]}}
271
+ */
272
+ export function computeEvolution(files) {
273
+ const snapshots = files.map((file) => ({ name: file.name, id: file.id }));
274
+ const transitions = [];
275
+
276
+ for (let i = 0; i + 1 < files.length; i++) {
277
+ const from = files[i];
278
+ const to = files[i + 1];
279
+ const meta = compareSnapshotMetadata({
280
+ baselineProvider: from.envelope.workspace.provider,
281
+ headProvider: to.envelope.workspace.provider,
282
+ baselineProvenance: from.envelope.workspace.provenance,
283
+ headProvenance: to.envelope.workspace.provenance,
284
+ baselineFingerprint: from.envelope.result.policy?.fingerprint ?? null,
285
+ headFingerprint: to.envelope.result.policy?.fingerprint ?? null,
286
+ });
287
+
288
+ const notes = [];
289
+ if (meta.policyChanged === true) {
290
+ // A policy change is disclosed the way `diff` discloses it — a fact
291
+ // about how the transition must be interpreted, not a structural
292
+ // change and not a refusal.
293
+ notes.push(
294
+ "policy (the declared architectural intent) changed between these snapshots — " +
295
+ "the boundary law differs even though the graph may not",
296
+ );
297
+ }
298
+ if (meta.providerChanged) {
299
+ notes.push(
300
+ `provider changed (${from.envelope.workspace.provider} → ${to.envelope.workspace.provider}) — ` +
301
+ "structural differences may be provider-artefacts rather than real architectural changes",
302
+ );
303
+ }
304
+ if (meta.crossRepo) {
305
+ notes.push("provenance remotes differ — these snapshots may be from unrelated repositories");
306
+ }
307
+ // The one-sided cases are the silent direction: a fingerprint or
308
+ // provenance on one snapshot and not the other cannot be asserted "the
309
+ // same", so it is disclosed rather than read as unchanged.
310
+ if (meta.policyOneSided) {
311
+ notes.push(
312
+ "policy (the declared architectural intent) could not be compared — one snapshot " +
313
+ "records the boundary law and the other does not",
314
+ );
315
+ }
316
+ if (meta.provenanceOneSided) {
317
+ notes.push(
318
+ "repository provenance could not be compared — one snapshot records its origin and the other does not",
319
+ );
320
+ }
321
+ // A snapshot taken from a dirty tree is not a reproducible claim about the
322
+ // commit it names, so the transition says which side came from one rather
323
+ // than reading it as a claim about committed history.
324
+ if (meta.dirtyBaseline) {
325
+ const commit = from.envelope.workspace.provenance?.commit;
326
+ notes.push(
327
+ "the baseline snapshot was captured from an uncommitted (dirty) tree — its architecture " +
328
+ `is a claim about uncommitted state${typeof commit === "string" ? `, not about commit '${commit}'` : ""}`,
329
+ );
330
+ }
331
+ if (meta.dirtyHead) {
332
+ const commit = to.envelope.workspace.provenance?.commit;
333
+ notes.push(
334
+ "the head snapshot was captured from an uncommitted (dirty) tree — its architecture " +
335
+ `is a claim about uncommitted state${typeof commit === "string" ? `, not about commit '${commit}'` : ""}`,
336
+ );
337
+ }
338
+
339
+ const diff = computeDiff(
340
+ {
341
+ projects: from.envelope.result.projects,
342
+ dependencies: from.envelope.result.dependencies,
343
+ },
344
+ {
345
+ projects: to.envelope.result.projects,
346
+ dependencies: to.envelope.result.dependencies,
347
+ },
348
+ );
349
+ const architectureChanged =
350
+ diff.addedProjects.length > 0 ||
351
+ diff.removedProjects.length > 0 ||
352
+ diff.changedProjects.length > 0 ||
353
+ diff.addedEdges.length > 0 ||
354
+ diff.removedEdges.length > 0;
355
+
356
+ // Code drift is a disclosure, so it is only asserted when every signal
357
+ // that could refute it is verifiable and unchanged: the architecture did
358
+ // not move, the policy was actually compared and did not change, and
359
+ // provenance advanced. A `null` policyChanged (one-sided, or neither
360
+ // snapshot carries a fingerprint) is "could not be compared", not "the
361
+ // same" — asserting code drift on an unverifiable policy would report a
362
+ // clean transition where the tool cannot look.
363
+ const codeDrift =
364
+ !architectureChanged && meta.policyChanged === false && meta.provenanceChanged === true;
365
+
366
+ transitions.push({
367
+ from: from.name,
368
+ to: to.name,
369
+ architectureChanged,
370
+ // A provider change is rendered with an empty diff (no graph change on
371
+ // top of a carrier change), a policy-only transition with null — so a
372
+ // consumer can tell "the carrier changed" from "only the record's
373
+ // interpretation changed".
374
+ changes: architectureChanged || meta.providerChanged ? diff : null,
375
+ policyChanged: meta.policyChanged,
376
+ providerChanged: meta.providerChanged,
377
+ codeDrift,
378
+ notes,
379
+ });
380
+ }
381
+
382
+ return { snapshots, transitions };
383
+ }
384
+
385
+ /**
386
+ * Runs the `history` command.
387
+ *
388
+ * `--capture` writes a snapshot of the current workspace before reading the
389
+ * directory, deduplicating on the architecture identity. When the capture's
390
+ * identity matches the last snapshot, no file is written — the record is a
391
+ * claim over the snapshots that exist, and manufacturing a new file for an
392
+ * unchanged architecture would make history lie about the space between
393
+ * snapshots.
394
+ *
395
+ * @param {string} dir Absolute path to the history directory.
396
+ * @param {object} commandContext From `resolveCommandContext` — used only for
397
+ * `--capture`.
398
+ * @param {{capture?: boolean, policyFingerprint?: string|null, io?: {readSnapshots?: Function,
399
+ * writeFile?: Function, resolveProvenance?: Function}}} [options] `policyFingerprint`
400
+ * is the boundary law's fingerprint when one is available, so a captured
401
+ * snapshot records the policy it was taken under. Injectable IO lets a test
402
+ * drive capture without the filesystem; `writeFile` receives the absolute
403
+ * target path and the rendered JSON string.
404
+ * @returns {{status: "ok"|"no-verdict", evolution: object, coverage: object,
405
+ * report: {text: string, json: string}}}
406
+ * @throws {Error} when the directory contains no snapshots, when a snapshot
407
+ * cannot be read or validated, or when the head graph has incomplete
408
+ * coverage under `--capture`.
409
+ */
410
+ export function historyCommand(
411
+ dir,
412
+ commandContext,
413
+ { capture = false, policyFingerprint = null, io = {} } = {},
414
+ ) {
415
+ const readSnapshotsFromDisk = (dir) =>
416
+ (io.readSnapshots ?? readSnapshots)(dir, commandContext.root);
417
+ // Default write is atomic: write the full snapshot to `<name>.json.tmp`,
418
+ // then rename over the final name. `readSnapshots` filters `.json.tmp` out,
419
+ // so an interrupted capture leaves a partial file the record will never read.
420
+ // `{flag: "wx"}` refuses rather than follows a symlink already sitting at
421
+ // the `.tmp` path — `../../cli.mjs`'s `writeOutputReport` docstring owns the
422
+ // full mechanism and the threat it closes; `--capture`'s snapshot name is
423
+ // predictable from the observed graph, which is exactly what makes a
424
+ // planted `.tmp` symlink here practical rather than theoretical.
425
+ //
426
+ // Containment is checked against the WORKSPACE root, not the named
427
+ // directory — the same policy as `--output` (`../containment.mjs`): a
428
+ // history dir the user names OUTSIDE the workspace (`/var/archkeep/history`
429
+ // on a symlinked mount) is the caller's explicit choice and proceeds, while
430
+ // a history dir INSIDE the workspace whose intermediate components are
431
+ // workspace-controlled symlinks (`archkeep history .archkeep/history
432
+ // --capture` on a PR that committed `.archkeep/history -> /tmp/out`) is the
433
+ // same runner-write escape as G-02 and is refused loudly. A refusal throws —
434
+ // capture failing loudly beats a snapshot landing outside the tree.
435
+ const writeSnapshotFile =
436
+ io.writeFile ??
437
+ ((path, text) => {
438
+ const violation = containmentViolation(commandContext.root, path, { forWrite: true });
439
+ if (violation !== null) {
440
+ throw new Error(`archkeep: refusing to write the history snapshot '${path}': ${violation}`);
441
+ }
442
+ const tmp = `${path}.tmp`;
443
+ writeFileSync(tmp, text, { flag: "wx" });
444
+ renameSync(tmp, path);
445
+ });
446
+ const provenanceResolver = io.resolveProvenance ?? resolveProvenance;
447
+
448
+ let read;
449
+ let captured = null;
450
+ if (capture) {
451
+ const notAnalyzed = commandContext.analysis.failures
452
+ .filter(isWholeFileFailure)
453
+ .map(({ sourceFile, reason }) => ({ file: sourceFile, reason }));
454
+ if (notAnalyzed.length > 0) {
455
+ throw new Error(
456
+ `archkeep: the head graph has incomplete coverage — ${notAnalyzed.length} file` +
457
+ `${notAnalyzed.length === 1 ? "" : "s"} could not be analyzed, so a captured snapshot ` +
458
+ `would under-represent the real architecture. Fix the unanalyzed files and re-run.`,
459
+ );
460
+ }
461
+
462
+ read = readSnapshotsFromDisk(dir);
463
+
464
+ const head = {
465
+ projects: buildProjects(commandContext.graph.nodes),
466
+ dependencies: buildDependencies(commandContext.graph.dependencies),
467
+ };
468
+ const headPolicy = policyFingerprint ? { fingerprint: policyFingerprint } : null;
469
+ const id = snapshotIdentity({ ...head, policy: headPolicy });
470
+
471
+ const last = read.files[read.files.length - 1];
472
+ if (last && last.id === id && last.envelope.workspace.provider === commandContext.provider) {
473
+ // Same architecture identity AND the same provider as the last snapshot —
474
+ // writing a new file would manufacture a transition that does not exist.
475
+ // A changed provider is not deduplicated: a pure provider migration
476
+ // (nx → moon with an identical graph and policy) changes how the
477
+ // architecture is read, so it must surface as a transition rather than be
478
+ // swallowed by the identity match.
479
+ //
480
+ // `duplicate` is an ALWAYS-PRESENT boolean sibling, never an
481
+ // only-when-true appended key: the capture envelope's shape must not be
482
+ // a function of history-directory state (E-F05). Two captures over an
483
+ // unchanged tree differ in exactly this one readable field, never in
484
+ // which keys exist — a consumer diffing run#1 vs run#2 can see it is
485
+ // the "was this a new snapshot" answer, not a structural change.
486
+ captured = { name: last.name, id, duplicate: true };
487
+ } else {
488
+ const sequence = nextSequence(read);
489
+ const name = `${sequence}-${shortId(id)}.json`;
490
+ const path = join(dir, name);
491
+ const provenance = provenanceResolver(commandContext.root);
492
+ const envelope = jsonEnvelope({
493
+ command: "graph",
494
+ context: {
495
+ root: commandContext.root,
496
+ provider: commandContext.provider,
497
+ marker: commandContext.marker,
498
+ provenance,
499
+ },
500
+ status: "ok",
501
+ exitCode: 0,
502
+ coverage: {
503
+ complete: true,
504
+ projects: head.projects.length,
505
+ analyzedFiles: commandContext.analysis.analyzed,
506
+ imports: commandContext.analysis.imports.length,
507
+ notAnalyzed: [],
508
+ blindSpots: commandContext.analysis.failures
509
+ .filter((f) => !isWholeFileFailure(f))
510
+ .map(({ sourceFile, line, column, reason }) => ({
511
+ file: sourceFile,
512
+ line,
513
+ column,
514
+ reason,
515
+ })),
516
+ notes: [],
517
+ },
518
+ result: { ...head, policy: headPolicy ?? undefined },
519
+ });
520
+ writeSnapshotFile(path, renderJson(envelope));
521
+ captured = { name, id, duplicate: false };
522
+ read.files.push(envelopeToSnapshot(envelope, path, id));
523
+ }
524
+ } else {
525
+ read = readSnapshotsFromDisk(dir);
526
+ }
527
+
528
+ if (read.files.length === 0) {
529
+ // An empty directory is not an empty history — it is no record at all.
530
+ // "0 snapshots" would read as a claim about a history that never existed.
531
+ throw new Error(
532
+ `archkeep: the history directory '${dir}' contains no snapshots — there is no history ` +
533
+ `to describe. Capture one first with 'archkeep history <dir> --capture' (or point the ` +
534
+ `command at the directory where you keep graph snapshots).`,
535
+ );
536
+ }
537
+
538
+ const evolution = computeEvolution(read.files);
539
+
540
+ const lastSnapshot = read.files[read.files.length - 1].envelope;
541
+ const coverage = {
542
+ complete: true,
543
+ projects: lastSnapshot.result.projects.length,
544
+ analyzedFiles: lastSnapshot.coverage.analyzedFiles,
545
+ imports: lastSnapshot.coverage.imports,
546
+ notAnalyzed: [],
547
+ blindSpots: [],
548
+ notes: [
549
+ "rule-impact cannot be recomputed from stored snapshots — snapshots carry the graph and " +
550
+ "the policy fingerprint, not the constraint table or import sites. Run `check` at any " +
551
+ "commit for the boundary verdict; run `diff` for one transition's rule-impact.",
552
+ ],
553
+ };
554
+
555
+ const context = {
556
+ root: commandContext.root,
557
+ provider: commandContext.provider,
558
+ marker: commandContext.marker,
559
+ provenance: provenanceResolver(commandContext.root),
560
+ };
561
+ const result = {
562
+ dir,
563
+ captured,
564
+ snapshots: evolution.snapshots,
565
+ transitions: evolution.transitions,
566
+ };
567
+
568
+ const envelope = jsonEnvelope({
569
+ command: "history",
570
+ context,
571
+ status: "ok",
572
+ exitCode: 0,
573
+ coverage,
574
+ result,
575
+ });
576
+
577
+ return {
578
+ status: "ok",
579
+ evolution: result,
580
+ coverage,
581
+ report: {
582
+ text: formatHistoryReport({ evolution: result, coverage }),
583
+ json: renderJson(envelope),
584
+ },
585
+ };
586
+ }
587
+
588
+ /**
589
+ * Rebuilds the shape `readSnapshots` produces from an envelope this command
590
+ * just wrote, so a capture need not round-trip through the filesystem. Kept
591
+ * private: `readSnapshots` is the only reader a caller sees.
592
+ *
593
+ * @param {object} envelope The envelope `jsonEnvelope` built for the capture.
594
+ * @param {string} path The absolute path it was written to.
595
+ * @param {string} id The identity already computed for this snapshot — passed
596
+ * through rather than recomputed, so a capture hashes the architecture once
597
+ * and the record and the filename share that one hash.
598
+ * @returns {{name: string, path: string, envelope: object, id: string}}
599
+ */
600
+ function envelopeToSnapshot(envelope, path, id) {
601
+ return {
602
+ name: basename(path),
603
+ path,
604
+ envelope: {
605
+ coverage: envelope.coverage,
606
+ result: envelope.result,
607
+ workspace: {
608
+ provider: envelope.workspace.provider,
609
+ provenance: envelope.workspace.provenance,
610
+ },
611
+ },
612
+ id,
613
+ };
614
+ }