@ecoma-io/archkeep 0.24.1 → 0.26.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 (125) hide show
  1. package/cli.mjs +166 -506
  2. package/commands.mjs +7 -1
  3. package/package.json +1 -1
  4. package/src/architecture-intent/judge.mjs +1 -1
  5. package/src/architecture-intent/model.mjs +34 -0
  6. package/src/canonical.mjs +2 -1
  7. package/src/commands/adr-for-workspace.mjs +63 -0
  8. package/src/commands/analyze-capability.mjs +13 -0
  9. package/src/commands/change-intent.mjs +11 -0
  10. package/src/commands/change.mjs +315 -55
  11. package/src/commands/check-capability.mjs +13 -0
  12. package/src/commands/check.mjs +13 -3
  13. package/src/commands/compare-capability.mjs +20 -0
  14. package/src/commands/completeness.mjs +12 -0
  15. package/src/commands/context-command.mjs +31 -0
  16. package/src/commands/coverage-verdict.mjs +15 -2
  17. package/src/commands/debt.mjs +30 -0
  18. package/src/commands/decisions.mjs +59 -10
  19. package/src/commands/delta-snapshot.mjs +25 -4
  20. package/src/commands/delta.mjs +301 -67
  21. package/src/commands/diff.mjs +48 -9
  22. package/src/commands/discover.mjs +37 -0
  23. package/src/commands/drift.mjs +45 -0
  24. package/src/commands/evolution.mjs +22 -2
  25. package/src/commands/explain-capability.mjs +11 -0
  26. package/src/commands/explain.mjs +24 -0
  27. package/src/commands/fitness.mjs +119 -10
  28. package/src/commands/govern-capability.mjs +16 -0
  29. package/src/commands/graph.mjs +39 -0
  30. package/src/commands/health.mjs +34 -0
  31. package/src/commands/history.mjs +119 -2
  32. package/src/commands/impact.mjs +23 -0
  33. package/src/commands/inspect-capability.mjs +16 -0
  34. package/src/commands/plan-context-command.mjs +35 -0
  35. package/src/commands/policy.mjs +42 -4
  36. package/src/commands/provenance-command.mjs +15 -24
  37. package/src/commands/report.mjs +38 -0
  38. package/src/commands/rules-capability.mjs +16 -0
  39. package/src/commands/rules.mjs +3 -1
  40. package/src/commands/scenario.mjs +23 -0
  41. package/src/commands/snapshot-meta.mjs +33 -0
  42. package/src/commands/trajectory.mjs +35 -0
  43. package/src/commands/waivers.mjs +30 -0
  44. package/src/corpus/goldens/.gitkeep +0 -0
  45. package/src/corpus/goldens/adr.json +62 -0
  46. package/src/corpus/goldens/adr.text +6 -0
  47. package/src/corpus/goldens/change.json +98 -0
  48. package/src/corpus/goldens/change.text +6 -0
  49. package/src/corpus/goldens/check.json +90 -0
  50. package/src/corpus/goldens/check.sarif +479 -0
  51. package/src/corpus/goldens/check.text +9 -0
  52. package/src/corpus/goldens/context.json +47 -0
  53. package/src/corpus/goldens/context.text +6 -0
  54. package/src/corpus/goldens/debt.json +59 -0
  55. package/src/corpus/goldens/debt.text +7 -0
  56. package/src/corpus/goldens/decisions.json +74 -0
  57. package/src/corpus/goldens/decisions.text +7 -0
  58. package/src/corpus/goldens/delta.json +94 -0
  59. package/src/corpus/goldens/delta.sarif +472 -0
  60. package/src/corpus/goldens/delta.text +5 -0
  61. package/src/corpus/goldens/diff.json +58 -0
  62. package/src/corpus/goldens/diff.text +4 -0
  63. package/src/corpus/goldens/discover.json +77 -0
  64. package/src/corpus/goldens/discover.text +10 -0
  65. package/src/corpus/goldens/drift.json +43 -0
  66. package/src/corpus/goldens/drift.text +3 -0
  67. package/src/corpus/goldens/evolution.json +328 -0
  68. package/src/corpus/goldens/evolution.text +32 -0
  69. package/src/corpus/goldens/explain.json +60 -0
  70. package/src/corpus/goldens/explain.text +7 -0
  71. package/src/corpus/goldens/fitness.json +45 -0
  72. package/src/corpus/goldens/fitness.text +3 -0
  73. package/src/corpus/goldens/graph.json +78 -0
  74. package/src/corpus/goldens/graph.text +10 -0
  75. package/src/corpus/goldens/health.json +72 -0
  76. package/src/corpus/goldens/health.text +10 -0
  77. package/src/corpus/goldens/help.text +104 -0
  78. package/src/corpus/goldens/history.json +43 -0
  79. package/src/corpus/goldens/history.text +4 -0
  80. package/src/corpus/goldens/impact.json +327 -0
  81. package/src/corpus/goldens/impact.text +10 -0
  82. package/src/corpus/goldens/provenance.json +453 -0
  83. package/src/corpus/goldens/provenance.text +14 -0
  84. package/src/corpus/goldens/reconcile.json +168 -0
  85. package/src/corpus/goldens/reconcile.text +3 -0
  86. package/src/corpus/goldens/report.json +168 -0
  87. package/src/corpus/goldens/report.text +42 -0
  88. package/src/corpus/goldens/rules verify.json +0 -0
  89. package/src/corpus/goldens/rules verify.text +0 -0
  90. package/src/corpus/goldens/scenario.json +441 -0
  91. package/src/corpus/goldens/scenario.text +28 -0
  92. package/src/corpus/goldens/trajectory.json +83 -0
  93. package/src/corpus/goldens/trajectory.text +10 -0
  94. package/src/corpus/goldens/usage-error.text +105 -0
  95. package/src/corpus/goldens/waivers.json +40 -0
  96. package/src/corpus/goldens/waivers.text +1 -0
  97. package/src/corpus/goldens-lsp/initialize.result.json +15 -0
  98. package/src/corpus/goldens-lsp/publish-cleared-empty.json +4 -0
  99. package/src/corpus/goldens-lsp/publish-fixed-empty.json +5 -0
  100. package/src/corpus/goldens-lsp/publish-unparseable.json +54 -0
  101. package/src/corpus/goldens-lsp/publish-violation.json +22 -0
  102. package/src/corpus/goldens-lsp/register-watched-files.json +72 -0
  103. package/src/custom-rules/values.mjs +1 -4
  104. package/src/go-work.mjs +8 -23
  105. package/src/governance/evolution-event.mjs +15 -0
  106. package/src/governance/evolution-store.mjs +59 -31
  107. package/src/governance/fitness-registry.mjs +14 -1
  108. package/src/governance/provenance-record.mjs +0 -152
  109. package/src/governance/verdict.mjs +62 -7
  110. package/src/intent/intent-manifest.json +22 -16
  111. package/src/lsp/server.mjs +5 -8
  112. package/src/lsp/workspace-index.mjs +55 -220
  113. package/src/options.mjs +10 -0
  114. package/src/providers/native/discover.mjs +13 -12
  115. package/src/providers/native/model.mjs +8 -3
  116. package/src/providers/nx-static.mjs +231 -0
  117. package/src/report/json.mjs +7 -4
  118. package/src/report/sarif.mjs +8 -3
  119. package/src/rules/edge-constraints.mjs +9 -0
  120. package/src/rules/index.mjs +26 -10
  121. package/src/rules/messages.mjs +64 -14
  122. package/src/rules/specifiers.mjs +21 -20
  123. package/src/tsconfig-paths.mjs +8 -14
  124. package/src/values.mjs +11 -0
  125. package/src/verdict.mjs +127 -16
package/commands.mjs CHANGED
@@ -25,7 +25,12 @@
25
25
  * one. What is deliberately absent: the `run*` drivers and argv parsing
26
26
  * (`cli.mjs` alone owns what a process's stdout and exit code mean), and the
27
27
  * renderers (`src/report/` shapes bytes for humans; an importer reads the
28
- * envelopes the commands already return).
28
+ * envelopes the commands already return). `adrForWorkspace`
29
+ * (`./src/commands/adr-for-workspace.mjs`) is on the roster without bending
30
+ * that: it composes the workspace preamble two faces (`cli.mjs`'s `runAdr`
31
+ * and the MCP history adapter) used to duplicate — one of them past this
32
+ * subpath — and returns exactly what `adrCommand` returns, printing no byte
33
+ * and deciding no exit code of its own.
29
34
  *
30
35
  * The seams the CLI threads are threaded the same way here: `readGraph` and
31
36
  * `listFiles` are injectable on every entry point that reaches outside the
@@ -45,6 +50,7 @@ export { driftCommand } from "./src/commands/drift.mjs";
45
50
  export { planContextCommand } from "./src/commands/plan-context-command.mjs";
46
51
  export { historyCommand } from "./src/commands/history.mjs";
47
52
  export { adrCommand } from "./src/commands/adr.mjs";
53
+ export { adrForWorkspace } from "./src/commands/adr-for-workspace.mjs";
48
54
  export { discoverCommand } from "./src/commands/discover.mjs";
49
55
  export { reconcileCommand } from "./src/commands/reconcile.mjs";
50
56
  export {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ecoma-io/archkeep",
3
- "version": "0.24.1",
3
+ "version": "0.26.0",
4
4
  "description": "Architecture authority for human and agentic software development — deterministic, evidence-backed enforcement of declared architecture.",
5
5
  "keywords": [
6
6
  "architecture",
@@ -34,7 +34,7 @@ import { buildReachability, getPath, pathExists } from "../../src/rules/reachabi
34
34
  /**
35
35
  * What a finding means — one entry per `messageId`, the arrangement
36
36
  * `../../src/report/sarif.mjs` derives its rule descriptors from, the same as
37
- * `../../src/go-work.mjs` and `../../src/tsconfig-paths.mjs`.
37
+ * the per-domain tables in `../rules/messages.mjs`.
38
38
  */
39
39
  export const INTENT_MESSAGES = Object.freeze({
40
40
  intentForbiddenEdge:
@@ -11,6 +11,17 @@
11
11
  * observed architecture stays the graph Archkeep derives from source; governance
12
12
  * is a deterministic comparison (`./judge.mjs`). NO LLM/AI anywhere in the core.
13
13
  *
14
+ * Which "intent" this is: one of four unrelated surfaces sharing the word —
15
+ * PD-13 outcome (c), four surfaces, never one type
16
+ * (`../../../../docs/architecture/refactor/DECISIONS.md`). This module is
17
+ * the workspace-declared law: the grammar, validation, and loading of
18
+ * `architecture-intent.json`, judged against the observed graph by `check`
19
+ * and `drift`. The other three: the v1.0 evidence-manifest registry
20
+ * (`../intent/` — `intent-manifest.json` and its executable gate), the
21
+ * declared-change grammar (`../commands/change-intent.mjs`, one change's
22
+ * expected consequences, verified by the `change` command), and the fileless
23
+ * run-envelope intent shape (`result.intent` in a `--format json` envelope).
24
+ *
14
25
  * This module mirrors `../../config.mjs`'s and
15
26
  * `../../providers/native/model.mjs`'s split: a pure `(raw) -> string[]`
16
27
  * validator and a thin loader that reads, parses, validates and throws — one
@@ -696,3 +707,26 @@ export async function loadIntent(root, { read = readFileFromDisk, tracked } = {}
696
707
  }
697
708
  return normalizeIntent(raw);
698
709
  }
710
+
711
+ /**
712
+ * The driver gate over `loadIntent`: load the workspace's declared law only
713
+ * when the tracked-file list names it, `null` otherwise.
714
+ *
715
+ * Three `cli.mjs` drivers (decisions, health, report) need the same answer:
716
+ * an intent file `git ls-files` does not list is absence — the callers render
717
+ * their no-intent face — while a tracked-but-unreadable file is a no-verdict
718
+ * throw that the shared catch folds to exit 3. The membership pre-test is a
719
+ * pure equivalence, not a containment guard: `loadIntent` early-returns for
720
+ * untracked input before any containment check, so this helper changes no
721
+ * verdict on any input.
722
+ *
723
+ * @param {string} root Absolute workspace root.
724
+ * @param {string[]} tracked The `git ls-files` list.
725
+ * @returns {Promise<object|null>} The normalized, validated model, or `null`
726
+ * when the file is not tracked.
727
+ * @throws {Error} exactly what `loadIntent` throws — tracked-but-unreadable
728
+ * stays a no-verdict, never absence.
729
+ */
730
+ export async function loadIntentIfTracked(root, tracked) {
731
+ return tracked.includes(INTENT_FILE) ? loadIntent(root, { tracked }) : null;
732
+ }
package/src/canonical.mjs CHANGED
@@ -11,7 +11,8 @@
11
11
  * correct.
12
12
  *
13
13
  * Used by `computePolicyFingerprint` (`../commands/graph.mjs`), the intent
14
- * fingerprint (`./intent-fingerprint.mjs`), the evidence-snapshot serializer
14
+ * fingerprint (`./architecture-intent/intent-fingerprint.mjs`), the
15
+ * evidence-snapshot serializer
15
16
  * (`./commands/delta-snapshot.mjs`, through the exported replacer below), and
16
17
  * anything else a fingerprint or a byte-deterministic file is produced from —
17
18
  * one canonicalizer, in one place, so two serializations cannot drift.
@@ -0,0 +1,63 @@
1
+ /**
2
+ * The `adr` preamble, composed once: the workspace root walked up from a
3
+ * working directory, the tracked file list, then `./adr.mjs`'s `adrCommand`.
4
+ * `../../cli.mjs`'s `runAdr` and the MCP history adapter
5
+ * (`../../../archkeep-mcp/src/engine.mjs`) each inlined these three steps,
6
+ * and the adapter's copy reached past the `./commands` subpath into the
7
+ * engine's root entry for `findWorkspaceRoot` and `listTrackedFiles` — the
8
+ * one import in that package that made it something other than a client of
9
+ * the command layer. The composition lives here, beside the command it
10
+ * feeds, so both faces run one path and neither needs the root entry for it.
11
+ *
12
+ * It holds no policy of its own. The two preamble decisions move verbatim,
13
+ * because inventing either here would be a second opinion about what a
14
+ * workspace is:
15
+ *
16
+ * - the root is `findWorkspaceRoot(cwd, WORKSPACE_MARKERS)` — the same
17
+ * marker list `./context.mjs`'s `resolveCommandContext` walks, whose own
18
+ * header owns why callers of a workspace root may not differ in what a
19
+ * workspace root IS;
20
+ * - the files are `(io.listFiles ?? listTrackedFiles)(root)` — the injectable
21
+ * default every command reads through, so a test drives the read over a
22
+ * fixture tree with no git.
23
+ *
24
+ * Everything a caller might decide stays with the caller. The `null` — no
25
+ * ancestor of `cwd` is a workspace root — is the caller's refusal to render,
26
+ * never an answer: the CLI words its own refusal and picks its own exit
27
+ * code, the adapter throws its own, and a caller that ignored the null
28
+ * crashes one line later instead of reading a clean result. The throws
29
+ * (`adrCommand`'s unreadable registry, `listTrackedFiles`' failed
30
+ * `git ls-files`) propagate unchanged for the same reason. And it prints no
31
+ * byte and decides no exit code, so it is not one of the `run*` drivers
32
+ * `../../commands.mjs` leaves to `../../cli.mjs` — it returns exactly what
33
+ * the command returns.
34
+ */
35
+ import { findWorkspaceRoot, listTrackedFiles } from "../workspace.mjs";
36
+
37
+ import { adrCommand } from "./adr.mjs";
38
+ import { WORKSPACE_MARKERS } from "./context.mjs";
39
+
40
+ /**
41
+ * Runs the `adr` command for the workspace `cwd` falls in.
42
+ *
43
+ * @param {{cwd: string}} request The working directory the root is walked up
44
+ * from — the same walk `resolveCommandContext` makes, without the project
45
+ * graph it builds.
46
+ * @param {{id?: string}} [options] Forwarded to `adrCommand` unchanged.
47
+ * @param {{listFiles?: Function}} [io] The tracked-file seam, typed like
48
+ * every command's (`Function`, the shape `./check.mjs` and
49
+ * `./context.mjs` declare), defaulted to `git ls-files`
50
+ * (`../workspace.mjs`) — injected where a test answers for git.
51
+ * @returns {object|null} What `adrCommand` returns, verbatim; `null` when no
52
+ * ancestor of `cwd` is a workspace root — the caller's refusal to render,
53
+ * never a clean answer (the module header owns why the message is not
54
+ * worded here).
55
+ * @throws {Error} on an unreadable registry or a failed `git ls-files`,
56
+ * exactly as `adrCommand` and `listTrackedFiles` throw — each caller maps
57
+ * that the way it already did.
58
+ */
59
+ export function adrForWorkspace({ cwd }, options = {}, io = {}) {
60
+ const root = findWorkspaceRoot(cwd, WORKSPACE_MARKERS);
61
+ if (root === null) return null;
62
+ return adrCommand(root, options, { tracked: (io.listFiles ?? listTrackedFiles)(root) });
63
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * `analyze` capability facade — the word's verb roster as explicit named
3
+ * re-exports: `discover`, with the two proposal→intent steps the
4
+ * `--write-intent` lane drives — the serialized bytes and the target
5
+ * refusal.
6
+ *
7
+ * Pure surface module: no judgment, no defaults, no logic — every export is
8
+ * a re-export of a command module. `cli.mjs` routes its verb imports through
9
+ * the facade so the vocabulary's code referent is visible to the import
10
+ * graph, not just to a comment (PD-18,
11
+ * ../../../../docs/architecture/refactor/DECISIONS.md).
12
+ */
13
+ export { discoverCommand, intentJsonFromProposal, intentWriteRefusal } from "./discover.mjs";
@@ -9,6 +9,17 @@
9
9
  * architectural delta (`./change.mjs`), and then belongs to the pull request
10
10
  * as the reviewable answer to "what did this change do to the architecture?".
11
11
  *
12
+ * Which "intent" this is: one of four unrelated surfaces sharing the word —
13
+ * PD-13 outcome (c), four surfaces, never one type
14
+ * (`../../../../docs/architecture/refactor/DECISIONS.md`). This module is
15
+ * the declared-change grammar: one change's expected architectural
16
+ * consequences, verified by the `change` command against the actual delta.
17
+ * The other three: the workspace-declared law (`../architecture-intent/` —
18
+ * the `architecture-intent.json` file this module deliberately does not
19
+ * extend, per the paragraph below), the v1.0 evidence-manifest registry
20
+ * (`../intent/`), and the fileless run-envelope intent shape
21
+ * (`result.intent` in a `--format json` envelope).
22
+ *
12
23
  * This file mirrors `../architecture-intent/model.mjs`'s split — a pure
13
24
  * `(raw) -> string[]` validator, a thin loader that reads, parses, validates
14
25
  * and throws one Error naming every violation at once — and deliberately does
@@ -101,14 +101,17 @@ import {
101
101
  } from "./delta.mjs";
102
102
  import { providerMismatch, readEvidenceSnapshot } from "./delta-snapshot.mjs";
103
103
  import { coverageRefusal, coverageVerdict } from "./coverage-verdict.mjs";
104
+ import { isAbsolute, resolve } from "node:path";
104
105
  import { blindSpotRows } from "../analysis/source-util.mjs";
105
106
  import { cyclicProjects } from "../governance/fitness-rules.mjs";
106
- import { fitnessVerdict } from "../governance/verdict.mjs";
107
+ import { eventSnapshotSide } from "./history.mjs";
108
+ import { VERDICTS, fitnessVerdict, isVerdict } from "../governance/verdict.mjs";
109
+ import { describe, isStringArray } from "../values.mjs";
107
110
  import { buildDecision } from "../report/evidence.mjs";
108
111
  import { jsonEnvelope, renderJson } from "../report/json.mjs";
109
112
  import { formatChangeReport } from "../report/change-text.mjs";
110
113
  import { evaluateRun } from "../rules/index.mjs";
111
- import { compareSnapshotMetadata } from "./snapshot-meta.mjs";
114
+ import { compareSnapshotMetadata, dirtyBaselineNote, dirtyHeadNote } from "./snapshot-meta.mjs";
112
115
  import { resolveProvenance } from "./provenance.mjs";
113
116
  import { referenceTime } from "../governance/clock.mjs";
114
117
  import {
@@ -124,6 +127,8 @@ import { writeEvent } from "../governance/evolution-store.mjs";
124
127
  import { judgeIntent } from "../architecture-intent/judge.mjs";
125
128
  import { INTENT_FILE, loadIntent } from "../architecture-intent/model.mjs";
126
129
  import { debtChangeDiff } from "../governance/debt-ledger.mjs";
130
+ import { resolveCommandContext } from "./context.mjs";
131
+ import { resolvePolicy } from "./policy.mjs";
127
132
 
128
133
  /**
129
134
  * One expected-fact row as the report and JSON carry it. Kept in one builder
@@ -368,6 +373,9 @@ function reconciliationVerdict(lists, unprovenReasons) {
368
373
  return "matched";
369
374
  }
370
375
 
376
+ /** The verdict axis `reconcileDisposition` maps — the four `reconciliationVerdict` answers. */
377
+ const RECONCILE_VERDICTS = Object.freeze(["matched", "undeclared", "unfulfilled", "unproven"]);
378
+
371
379
  /**
372
380
  * The reconcile DISPOSITION mapping (wave 3, design §5) — the verdict axis
373
381
  * first, then the declared constraints' verdicts. Pure and exported so the
@@ -390,8 +398,33 @@ function reconciliationVerdict(lists, unprovenReasons) {
390
398
  * this run — empty when none were declared or when the base identity was
391
399
  * unproven (constraints are left unevaluated then).
392
400
  * @returns {"accepted"|"rejected"|"no-verdict"}
401
+ * @throws {Error} on an axis value outside the four, or a constraint row
402
+ * whose verdict is outside the canonical vocabulary — a stranger would fold
403
+ * to `accepted`, and a fabricated acceptance is the one answer this mapping
404
+ * never hands out.
393
405
  */
394
406
  export function reconcileDisposition(verdict, constraints = []) {
407
+ // The mapping owns its input latch — it is exported and pure, the one place
408
+ // the precedence is stated, and a stranger axis value would match no named
409
+ // arm and land on `accepted`: a fabricated acceptance, the one answer this
410
+ // function must never hand out over input it did not understand.
411
+ if (!RECONCILE_VERDICTS.includes(verdict)) {
412
+ throw new Error(
413
+ `archkeep: refusing to map a reconcile verdict ${describe(verdict)} — ` +
414
+ `expected one of ${RECONCILE_VERDICTS.join(", ")}. This is a bug in the ` +
415
+ `reconciliation that produced it.`,
416
+ );
417
+ }
418
+ // Same latch one axis down: a constraint row verdict outside the four states
419
+ // matches neither `unknown` nor `fail`, and the mapping reads it as consent.
420
+ for (const row of constraints) {
421
+ if (!isVerdict(row.verdict)) {
422
+ throw new Error(
423
+ `archkeep: refusing to map a constraint row whose verdict is ${describe(row.verdict)} — ` +
424
+ `expected one of ${VERDICTS.join(", ")}. This is a bug in the judge that built the row.`,
425
+ );
426
+ }
427
+ }
395
428
  // used by its own test
396
429
  if (verdict === "unproven") return "no-verdict";
397
430
  if (verdict === "undeclared" || verdict === "unfulfilled") return "rejected";
@@ -400,6 +433,124 @@ export function reconcileDisposition(verdict, constraints = []) {
400
433
  return "accepted";
401
434
  }
402
435
 
436
+ /**
437
+ * The input latch for `changeFold`: names the first way its inputs are not a
438
+ * shape the fold can count, or `null` when they are. A returned problem
439
+ * becomes the fold's refusal — never a throw, the same in-band lane
440
+ * `deltaFold`'s latch takes one module over.
441
+ *
442
+ * @param {object} reconciliation From `reconcileMaterialDelta`.
443
+ * @param {object[]} constraints Judged `fitnessVerdict` rows.
444
+ * @param {string[]} unprovenReasons Why the base identity could not be proven.
445
+ * @returns {string|null}
446
+ */
447
+ function changeFoldInputProblem(reconciliation, constraints, unprovenReasons) {
448
+ for (const key of ["matched", "unexpected", "missingExpected"]) {
449
+ if (!Array.isArray(reconciliation?.[key])) {
450
+ return `the change fold counts "reconciliation.${key}", which is ${describe(reconciliation?.[key])} — a list that is not an array counts as empty in every lane, and an empty reconciliation reads "matched". This is a bug in archkeep, not a fact about the workspace.`;
451
+ }
452
+ }
453
+ if (!Array.isArray(constraints)) {
454
+ return `the change fold counts "constraints", which is ${describe(constraints)} — a list that is not an array counts as empty in every lane, and an empty constraint list reads "every declared constraint passing". This is a bug in archkeep, not a fact about the workspace.`;
455
+ }
456
+ for (const [index, row] of constraints.entries()) {
457
+ if (!isVerdict(row?.verdict)) {
458
+ return `the change fold counts the verdict of constraint row ${index}, which is ${describe(row?.verdict)} — a verdict outside ${VERDICTS.join(", ")} matches neither the fail nor the unknown lane, and reads as consent. This is a bug in archkeep, not a fact about the workspace.`;
459
+ }
460
+ }
461
+ if (!isStringArray(unprovenReasons)) {
462
+ return `the change fold reads "unprovenReasons", which is ${describe(unprovenReasons)} — reasons that are not a string array read as "no reasons" below, and an unproven base then reads "matched". This is a bug in archkeep, not a fact about the workspace.`;
463
+ }
464
+ return null;
465
+ }
466
+
467
+ /**
468
+ * The `change` exit fold, lifted out of `changeCommand` as a pure function so
469
+ * the mapping from the reconciliation lists and the judged constraints to the
470
+ * verdict is a fact a test can pin (`reconcileDisposition` directly above is
471
+ * the arrangement's precedent — it latches the EVENT disposition; this is the
472
+ * STATUS fold the envelope and the exit code read). Lane order: an unproven
473
+ * base identity or any undetermined constraint → `no-verdict`/3; else any
474
+ * unexpected fact, missing expected fact, or failed constraint →
475
+ * `findings`/1; else `ok`/0.
476
+ *
477
+ * The input latch runs first: input the fold cannot read is refused as a
478
+ * no-verdict whose single reason names it (`refused`), never folded past as a
479
+ * silently-empty count — `reconciliationVerdict` reads `unprovenReasons`
480
+ * before any list, so a malformed one reaching it unguarded can name the
481
+ * whole run unproven or matched for reasons the counts never supported. The
482
+ * literals stay hand-rolled per site by decision — the carrier folds are the
483
+ * pinned baseline INV-2 names, not a table to be converged
484
+ * (`docs/architecture/refactor/AUTHORITY-MAP.md`).
485
+ *
486
+ * @param {{matched: object[], unexpected: object[], missingExpected: object[]}} reconciliation
487
+ * @param {{verdict: string}[]} constraints Judged `fitnessVerdict` rows —
488
+ * empty when none were declared or the base identity was unproven.
489
+ * @param {string[]} unprovenReasons Why the base identity could not be proven.
490
+ * @returns {{status: "ok"|"findings"|"no-verdict", exitCode: 0|1|3,
491
+ * decision: object, verdict: "matched"|"undeclared"|"unfulfilled"|"unproven",
492
+ * refused?: undefined}|
493
+ * {status: "no-verdict", exitCode: 3, decision: object, verdict?: undefined,
494
+ * refused: string}}
495
+ * `verdict` rides every judged lane because the event's `declaredIntentRows`
496
+ * and `result.reconciliation` report it whichever lane fired; a refusal
497
+ * carries `refused` and no verdict, and is the one return whose
498
+ * `decision.reason` names a malformed input rather than a fact about the
499
+ * trees.
500
+ */
501
+ export function changeFold(reconciliation, constraints, unprovenReasons) {
502
+ const problem = changeFoldInputProblem(reconciliation, constraints, unprovenReasons);
503
+ if (problem !== null) {
504
+ return {
505
+ status: "no-verdict",
506
+ exitCode: 3,
507
+ decision: buildDecision({
508
+ status: "no-verdict",
509
+ coverageComplete: true,
510
+ findings: 0,
511
+ reason: problem,
512
+ }),
513
+ refused: problem,
514
+ };
515
+ }
516
+ const verdict = reconciliationVerdict(reconciliation, unprovenReasons);
517
+ const failedConstraints = constraints.filter((row) => row.verdict === "fail").length;
518
+ const unknownConstraints = constraints.filter((row) => row.verdict === "unknown").length;
519
+ const findings =
520
+ reconciliation.unexpected.length + reconciliation.missingExpected.length + failedConstraints;
521
+
522
+ if (verdict === "unproven" || unknownConstraints > 0) {
523
+ return {
524
+ status: "no-verdict",
525
+ exitCode: 3,
526
+ decision: buildDecision({
527
+ status: "no-verdict",
528
+ coverageComplete: true,
529
+ findings: 0,
530
+ reason:
531
+ verdict === "unproven"
532
+ ? `the change intent could not be verified against the declared base: ${unprovenReasons[0]}`
533
+ : `${unknownConstraints} declared constraint${unknownConstraints === 1 ? "" : "s"} could not be determined`,
534
+ }),
535
+ verdict,
536
+ };
537
+ }
538
+ if (findings > 0) {
539
+ return {
540
+ status: "findings",
541
+ exitCode: 1,
542
+ decision: buildDecision({ status: "findings", coverageComplete: true, findings }),
543
+ verdict,
544
+ };
545
+ }
546
+ return {
547
+ status: "ok",
548
+ exitCode: 0,
549
+ decision: buildDecision({ status: "ok", coverageComplete: true, findings: 0 }),
550
+ verdict,
551
+ };
552
+ }
553
+
403
554
  /**
404
555
  * Judges the constraints the contract declares, through the shared engine —
405
556
  * both sides re-judged under the CURRENT law and one shared instant, exactly
@@ -509,7 +660,7 @@ function judgeDeclaredConstraints(intent, io) {
509
660
  * architecture-intent seam `drift` uses (defaults to `loadIntent`); the
510
661
  * change event's `debt` diff judges the intent over this run's base and
511
662
  * head graphs and would be untestable without it.
512
- * @returns {Promise<{status: "ok"|"findings"|"no-verdict",
663
+ * @returns {Promise<{status: "ok"|"findings"|"no-verdict", exitCode: 0|1|3,
513
664
  * changeIntent?: object, coverage: object, report: {text: string, json: string}}>}
514
665
  * `status: "no-verdict"` from the coverage refusal (#608) carries no
515
666
  * `changeIntent` payload — the reconciliation was withheld, and the
@@ -648,6 +799,28 @@ export async function changeCommand(
648
799
  dependencies: buildDependencies(graph.dependencies),
649
800
  };
650
801
  const structural = computeDiff(baseGraphForDiff, headGraphForDiff);
802
+ // The event's identity sides are frozen HERE — at the same instant the diff
803
+ // above was computed from these same graph objects — so the identity always
804
+ // describes the graph the diff actually compared. Everything between this
805
+ // line and the event assembly (constraint re-judgement, custom rules,
806
+ // classification, debt) reads the graphs too; the engine's purity is what
807
+ // keeps them honest today, but the WS-I adversarial audit named the seam:
808
+ // identity and diff agreeing must not rest on purity conventions alone when
809
+ // one structural line closes it.
810
+ const baseCommit = baseline.provenance?.commit;
811
+ const headCommit = headProvenance?.commit;
812
+ const eventBase = eventSnapshotSide({
813
+ revision: baseCommit,
814
+ projects: baseGraphForDiff.projects,
815
+ dependencies: baseGraphForDiff.dependencies,
816
+ policyFingerprint: baseline.policyFingerprint,
817
+ });
818
+ const eventHead = eventSnapshotSide({
819
+ revision: headCommit,
820
+ projects: headGraphForDiff.projects,
821
+ dependencies: headGraphForDiff.dependencies,
822
+ policyFingerprint: headFingerprint,
823
+ });
651
824
  const reconciliation = reconcileMaterialDelta(intent, structural);
652
825
 
653
826
  // Constraints and the law axis are computed over both sides re-judged under
@@ -694,38 +867,13 @@ export async function changeCommand(
694
867
  liveViolations = headEval.violations.length;
695
868
  }
696
869
 
697
- const verdict = reconciliationVerdict(reconciliation, unprovenReasons);
698
- const failedConstraints = constraints.filter((row) => row.verdict === "fail").length;
699
- const unknownConstraints = constraints.filter((row) => row.verdict === "unknown").length;
700
- const findings =
701
- reconciliation.unexpected.length + reconciliation.missingExpected.length + failedConstraints;
702
-
703
- /** @type {"ok"|"findings"|"no-verdict"} */
704
- let status;
705
- /** @type {0|1|3} */
706
- let exitCode;
707
- let decision;
708
- if (verdict === "unproven" || unknownConstraints > 0) {
709
- status = "no-verdict";
710
- exitCode = 3;
711
- decision = buildDecision({
712
- status,
713
- coverageComplete: true,
714
- findings: 0,
715
- reason:
716
- verdict === "unproven"
717
- ? `the change intent could not be verified against the declared base: ${unprovenReasons[0]}`
718
- : `${unknownConstraints} declared constraint${unknownConstraints === 1 ? "" : "s"} could not be determined`,
719
- });
720
- } else if (findings > 0) {
721
- status = "findings";
722
- exitCode = 1;
723
- decision = buildDecision({ status, coverageComplete: true, findings });
724
- } else {
725
- status = "ok";
726
- exitCode = 0;
727
- decision = buildDecision({ status, coverageComplete: true, findings: 0 });
728
- }
870
+ // The verdict comes from the one fold — `changeFold` above owns the lane
871
+ // order and the input latch but it is TAKEN after the notes and coverage
872
+ // blocks below, so a refusal can carry the disclosures (policy-changed,
873
+ // dirty sides) the run already computed: a withheld verdict that dropped
874
+ // them would hide a weaker-evidence fact from the only envelope the run
875
+ // emits. Nothing between this comment and the fold call reads the fold's
876
+ // inputs or outputs.
729
877
 
730
878
  /** @type {string[]} */
731
879
  const notes = [];
@@ -736,16 +884,10 @@ export async function changeCommand(
736
884
  );
737
885
  }
738
886
  if (meta.dirtyBaseline) {
739
- notes.push(
740
- "the baseline was captured from a dirty working tree — its evidence is not a reproducible " +
741
- "claim about the commit the contract pins",
742
- );
887
+ notes.push(dirtyBaselineNote(true));
743
888
  }
744
889
  if (meta.dirtyHead) {
745
- notes.push(
746
- "this run's working tree is dirty — the head side describes uncommitted state, not the " +
747
- "commit HEAD names",
748
- );
890
+ notes.push(dirtyHeadNote());
749
891
  }
750
892
  if (meta.provenanceOneSided) {
751
893
  notes.push(
@@ -769,6 +911,40 @@ export async function changeCommand(
769
911
  notes,
770
912
  };
771
913
 
914
+ // The exit fold, at the input boundary: input the fold cannot read is
915
+ // refused in-band (no-verdict, exit 3, the field named), never counted past
916
+ // as a silently-empty list. The refusal withholds `result` and the event
917
+ // exactly the coverage refusal does: a comparison whose counts could not be
918
+ // read has no verdict to report, and the refusal rides the envelope beside
919
+ // the coverage notes the run already earned.
920
+ const fold = changeFold(reconciliation, constraints, unprovenReasons);
921
+ if (fold.refused !== undefined) {
922
+ const refusalCoverage = { ...coverage, notes: [...notes, fold.refused] };
923
+ return {
924
+ status: fold.status,
925
+ exitCode: fold.exitCode,
926
+ coverage: refusalCoverage,
927
+ report: {
928
+ text: `change: no verdict — ${fold.refused}\n`,
929
+ json: renderJson(
930
+ jsonEnvelope({
931
+ command: "change",
932
+ context: { root, provider, marker, provenance: headProvenance },
933
+ status: fold.status,
934
+ exitCode: fold.exitCode,
935
+ coverage: refusalCoverage,
936
+ // The refusal-withheld payload, stated the way `coverageRefusal`
937
+ // states it: `jsonEnvelope` requires the key, the refusal has no
938
+ // result to report.
939
+ result: undefined,
940
+ decision: fold.decision,
941
+ }),
942
+ ),
943
+ },
944
+ };
945
+ }
946
+ const { status, exitCode, decision, verdict } = fold;
947
+
772
948
  // Wave 3 (design §1, §2, §5): the evolution classification and the reconcile
773
949
  // event. The classification is ALWAYS computed — it rides the envelope
774
950
  // result in memory even when no event is written (design §4) — and the
@@ -855,23 +1031,24 @@ export async function changeCommand(
855
1031
  }
856
1032
  }
857
1033
 
858
- const baseCommit = baseline.provenance?.commit;
859
- const headCommit = headProvenance?.commit;
860
1034
  /** @type {object} */
861
1035
  const event = {
862
1036
  schemaVersion: EVOLUTION_EVENT_SCHEMA_VERSION,
863
1037
  kind: "reconcile",
864
1038
  source: "change",
865
- base: {
866
- ...(typeof baseCommit === "string" ? { revision: baseCommit } : {}),
867
- // The caller's own evidence ref the baseline file this run consumed,
868
- // spelled as the run received it (the same convention `declaration.file`
869
- // uses for the intent path).
870
- evidence: baselinePath,
871
- },
872
- head: {
873
- ...(typeof headCommit === "string" ? { revision: headCommit } : {}),
874
- },
1039
+ // Both sides were frozen through the ONE identity spelling
1040
+ // (`eventSnapshotSide`, ./history.mjs) at the diff site above: a revision
1041
+ // when one is known, plus the snapshot identity of the graph the diff
1042
+ // compared never the baseline's storage path, which is machine-local
1043
+ // and must not make the identity a per-machine property.
1044
+ base: eventBase,
1045
+ head: eventHead,
1046
+ // The caller's own evidence ref — the baseline file this run consumed,
1047
+ // spelled as the run received it (the same convention `declaration.file`
1048
+ // uses for the intent path). Disclosed OUTSIDE the identity: the tuple
1049
+ // above names the state itself, so a relocated baseline is still the
1050
+ // same event.
1051
+ evidence: baselinePath,
875
1052
  declaration: { file: intentPath, digest: declarationDigest(intent) },
876
1053
  observed,
877
1054
  affected: evolution.affected,
@@ -983,6 +1160,7 @@ export async function changeCommand(
983
1160
 
984
1161
  return {
985
1162
  status,
1163
+ exitCode,
986
1164
  changeIntent: result,
987
1165
  coverage,
988
1166
  report: {
@@ -997,3 +1175,85 @@ export async function changeCommand(
997
1175
  },
998
1176
  };
999
1177
  }
1178
+
1179
+ /**
1180
+ * The paths `change` runs over, resolved once and shared by the CLI entry
1181
+ * and the output guard below — one spelling of "where does this flag point",
1182
+ * the same relative-to-cwd resolution every path flag uses.
1183
+ *
1184
+ * @param {{intent: string, paths: string[]}} options This run's parsed flags.
1185
+ * @param {string} cwd The run's working directory.
1186
+ * @returns {{baselinePath: string, intentPath: string}}
1187
+ */
1188
+ function changePaths(options, cwd) {
1189
+ return {
1190
+ baselinePath: isAbsolute(options.paths[0]) ? options.paths[0] : resolve(cwd, options.paths[0]),
1191
+ intentPath: isAbsolute(options.intent) ? options.intent : resolve(cwd, options.intent),
1192
+ };
1193
+ }
1194
+
1195
+ /**
1196
+ * `change`'s self-footgun guard, the same shape `history`'s holds: writing
1197
+ * the reconciliation report over the very manifest this run just read would
1198
+ * destroy the declaration it verified, with the loss surfacing only later —
1199
+ * the first time someone tries to re-run the verification. Declared by the
1200
+ * command that owns the law and enforced by the driver's write door; `null`
1201
+ * means no refusal.
1202
+ *
1203
+ * @param {{output: string|null, intent: string, paths: string[]}} options
1204
+ * This run's parsed flags.
1205
+ * @param {string} cwd The run's working directory, for relative flag
1206
+ * resolution.
1207
+ * @returns {string|null} The refusal message, or `null` when the output is
1208
+ * safe.
1209
+ */
1210
+ export function changeOutputRefusal(options, cwd) {
1211
+ if (!options.output) return null;
1212
+ const outputAbs = isAbsolute(options.output)
1213
+ ? resolve(options.output)
1214
+ : resolve(cwd, options.output);
1215
+ if (outputAbs === changePaths(options, cwd).intentPath) {
1216
+ return (
1217
+ `archkeep: --output '${options.output}' resolves to the change-intent manifest itself — ` +
1218
+ `overwriting the declaration with its own reconciliation report would destroy it. ` +
1219
+ `Write the report somewhere else.`
1220
+ );
1221
+ }
1222
+ return null;
1223
+ }
1224
+
1225
+ /**
1226
+ * `change` as the CLI drives it: the baseline and manifest paths resolved,
1227
+ * then the shared preamble — command context, boundary law, the optional
1228
+ * `--event-out` directory — so `../../cli.mjs`'s driver only wires options,
1229
+ * IO seams, and where output lands (`./README.md`). The engine this returns
1230
+ * from is `changeCommand` above, unchanged.
1231
+ *
1232
+ * @param {{config: string|null, eventOut?: string|null, intent: string, paths: string[]}} options
1233
+ * This run's parsed flags; `paths[0]` is the baseline evidence snapshot.
1234
+ * @param {{cwd: string, readGraph?: Function, listFiles?: Function}} io The
1235
+ * seams a test injects, the same ones `check` takes.
1236
+ * @returns {Promise<object>} `changeCommand`'s result, unmodified.
1237
+ */
1238
+ export async function change(options, { cwd, readGraph, listFiles }) {
1239
+ const { baselinePath, intentPath } = changePaths(options, cwd);
1240
+ const commandContext = resolveCommandContext({ cwd }, { readGraph, listFiles });
1241
+ // Declared constraints are judged under whichever law THIS run resolves,
1242
+ // and the envelope records that law's fingerprint beside the baseline's —
1243
+ // the same loading every judging command does (`resolvePolicy`),
1244
+ // profile-aware the same way `check` is.
1245
+ const { config } = await resolvePolicy(options, commandContext, cwd);
1246
+ // `--event-out` names the reconcile event store directory, resolved from
1247
+ // cwd like the other path flags; `undefined` when absent, so a run
1248
+ // without the flag writes no event and stays byte-identical.
1249
+ const eventOut =
1250
+ typeof options.eventOut === "string" && options.eventOut !== ""
1251
+ ? isAbsolute(options.eventOut)
1252
+ ? options.eventOut
1253
+ : resolve(cwd, options.eventOut)
1254
+ : undefined;
1255
+ return changeCommand(baselinePath, intentPath, commandContext, {
1256
+ config,
1257
+ ...(eventOut === undefined ? {} : { eventOut }),
1258
+ });
1259
+ }