@ecoma-io/archkeep 0.25.0 → 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 (120) 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 +247 -34
  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.mjs +263 -49
  20. package/src/commands/diff.mjs +45 -0
  21. package/src/commands/discover.mjs +37 -0
  22. package/src/commands/drift.mjs +45 -0
  23. package/src/commands/evolution.mjs +22 -2
  24. package/src/commands/explain-capability.mjs +11 -0
  25. package/src/commands/explain.mjs +24 -0
  26. package/src/commands/fitness.mjs +119 -10
  27. package/src/commands/govern-capability.mjs +16 -0
  28. package/src/commands/graph.mjs +39 -0
  29. package/src/commands/health.mjs +34 -0
  30. package/src/commands/history.mjs +85 -2
  31. package/src/commands/impact.mjs +23 -0
  32. package/src/commands/inspect-capability.mjs +16 -0
  33. package/src/commands/plan-context-command.mjs +35 -0
  34. package/src/commands/policy.mjs +42 -4
  35. package/src/commands/provenance-command.mjs +15 -24
  36. package/src/commands/report.mjs +38 -0
  37. package/src/commands/rules-capability.mjs +16 -0
  38. package/src/commands/rules.mjs +3 -1
  39. package/src/commands/scenario.mjs +23 -0
  40. package/src/commands/trajectory.mjs +35 -0
  41. package/src/commands/waivers.mjs +30 -0
  42. package/src/corpus/goldens/.gitkeep +0 -0
  43. package/src/corpus/goldens/adr.json +62 -0
  44. package/src/corpus/goldens/adr.text +6 -0
  45. package/src/corpus/goldens/change.json +98 -0
  46. package/src/corpus/goldens/change.text +6 -0
  47. package/src/corpus/goldens/check.json +90 -0
  48. package/src/corpus/goldens/check.sarif +479 -0
  49. package/src/corpus/goldens/check.text +9 -0
  50. package/src/corpus/goldens/context.json +47 -0
  51. package/src/corpus/goldens/context.text +6 -0
  52. package/src/corpus/goldens/debt.json +59 -0
  53. package/src/corpus/goldens/debt.text +7 -0
  54. package/src/corpus/goldens/decisions.json +74 -0
  55. package/src/corpus/goldens/decisions.text +7 -0
  56. package/src/corpus/goldens/delta.json +94 -0
  57. package/src/corpus/goldens/delta.sarif +472 -0
  58. package/src/corpus/goldens/delta.text +5 -0
  59. package/src/corpus/goldens/diff.json +58 -0
  60. package/src/corpus/goldens/diff.text +4 -0
  61. package/src/corpus/goldens/discover.json +77 -0
  62. package/src/corpus/goldens/discover.text +10 -0
  63. package/src/corpus/goldens/drift.json +43 -0
  64. package/src/corpus/goldens/drift.text +3 -0
  65. package/src/corpus/goldens/evolution.json +328 -0
  66. package/src/corpus/goldens/evolution.text +32 -0
  67. package/src/corpus/goldens/explain.json +60 -0
  68. package/src/corpus/goldens/explain.text +7 -0
  69. package/src/corpus/goldens/fitness.json +45 -0
  70. package/src/corpus/goldens/fitness.text +3 -0
  71. package/src/corpus/goldens/graph.json +78 -0
  72. package/src/corpus/goldens/graph.text +10 -0
  73. package/src/corpus/goldens/health.json +72 -0
  74. package/src/corpus/goldens/health.text +10 -0
  75. package/src/corpus/goldens/help.text +104 -0
  76. package/src/corpus/goldens/history.json +43 -0
  77. package/src/corpus/goldens/history.text +4 -0
  78. package/src/corpus/goldens/impact.json +327 -0
  79. package/src/corpus/goldens/impact.text +10 -0
  80. package/src/corpus/goldens/provenance.json +453 -0
  81. package/src/corpus/goldens/provenance.text +14 -0
  82. package/src/corpus/goldens/reconcile.json +168 -0
  83. package/src/corpus/goldens/reconcile.text +3 -0
  84. package/src/corpus/goldens/report.json +168 -0
  85. package/src/corpus/goldens/report.text +42 -0
  86. package/src/corpus/goldens/rules verify.json +0 -0
  87. package/src/corpus/goldens/rules verify.text +0 -0
  88. package/src/corpus/goldens/scenario.json +441 -0
  89. package/src/corpus/goldens/scenario.text +28 -0
  90. package/src/corpus/goldens/trajectory.json +83 -0
  91. package/src/corpus/goldens/trajectory.text +10 -0
  92. package/src/corpus/goldens/usage-error.text +105 -0
  93. package/src/corpus/goldens/waivers.json +40 -0
  94. package/src/corpus/goldens/waivers.text +1 -0
  95. package/src/corpus/goldens-lsp/initialize.result.json +15 -0
  96. package/src/corpus/goldens-lsp/publish-cleared-empty.json +4 -0
  97. package/src/corpus/goldens-lsp/publish-fixed-empty.json +5 -0
  98. package/src/corpus/goldens-lsp/publish-unparseable.json +54 -0
  99. package/src/corpus/goldens-lsp/publish-violation.json +22 -0
  100. package/src/corpus/goldens-lsp/register-watched-files.json +72 -0
  101. package/src/go-work.mjs +8 -23
  102. package/src/governance/evolution-event.mjs +15 -0
  103. package/src/governance/evolution-store.mjs +59 -31
  104. package/src/governance/provenance-record.mjs +0 -152
  105. package/src/governance/verdict.mjs +26 -3
  106. package/src/intent/intent-manifest.json +14 -14
  107. package/src/lsp/server.mjs +5 -8
  108. package/src/lsp/workspace-index.mjs +55 -220
  109. package/src/options.mjs +10 -0
  110. package/src/providers/native/discover.mjs +13 -12
  111. package/src/providers/native/model.mjs +8 -3
  112. package/src/providers/nx-static.mjs +231 -0
  113. package/src/report/json.mjs +3 -2
  114. package/src/report/sarif.mjs +8 -3
  115. package/src/rules/edge-constraints.mjs +9 -0
  116. package/src/rules/index.mjs +26 -10
  117. package/src/rules/messages.mjs +64 -14
  118. package/src/rules/specifiers.mjs +21 -20
  119. package/src/tsconfig-paths.mjs +8 -14
  120. 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.25.0",
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,11 +101,12 @@ 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
107
  import { eventSnapshotSide } from "./history.mjs";
107
108
  import { VERDICTS, fitnessVerdict, isVerdict } from "../governance/verdict.mjs";
108
- import { describe } from "../values.mjs";
109
+ import { describe, isStringArray } from "../values.mjs";
109
110
  import { buildDecision } from "../report/evidence.mjs";
110
111
  import { jsonEnvelope, renderJson } from "../report/json.mjs";
111
112
  import { formatChangeReport } from "../report/change-text.mjs";
@@ -126,6 +127,8 @@ import { writeEvent } from "../governance/evolution-store.mjs";
126
127
  import { judgeIntent } from "../architecture-intent/judge.mjs";
127
128
  import { INTENT_FILE, loadIntent } from "../architecture-intent/model.mjs";
128
129
  import { debtChangeDiff } from "../governance/debt-ledger.mjs";
130
+ import { resolveCommandContext } from "./context.mjs";
131
+ import { resolvePolicy } from "./policy.mjs";
129
132
 
130
133
  /**
131
134
  * One expected-fact row as the report and JSON carry it. Kept in one builder
@@ -430,6 +433,124 @@ export function reconcileDisposition(verdict, constraints = []) {
430
433
  return "accepted";
431
434
  }
432
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
+
433
554
  /**
434
555
  * Judges the constraints the contract declares, through the shared engine —
435
556
  * both sides re-judged under the CURRENT law and one shared instant, exactly
@@ -539,7 +660,7 @@ function judgeDeclaredConstraints(intent, io) {
539
660
  * architecture-intent seam `drift` uses (defaults to `loadIntent`); the
540
661
  * change event's `debt` diff judges the intent over this run's base and
541
662
  * head graphs and would be untestable without it.
542
- * @returns {Promise<{status: "ok"|"findings"|"no-verdict",
663
+ * @returns {Promise<{status: "ok"|"findings"|"no-verdict", exitCode: 0|1|3,
543
664
  * changeIntent?: object, coverage: object, report: {text: string, json: string}}>}
544
665
  * `status: "no-verdict"` from the coverage refusal (#608) carries no
545
666
  * `changeIntent` payload — the reconciliation was withheld, and the
@@ -746,38 +867,13 @@ export async function changeCommand(
746
867
  liveViolations = headEval.violations.length;
747
868
  }
748
869
 
749
- const verdict = reconciliationVerdict(reconciliation, unprovenReasons);
750
- const failedConstraints = constraints.filter((row) => row.verdict === "fail").length;
751
- const unknownConstraints = constraints.filter((row) => row.verdict === "unknown").length;
752
- const findings =
753
- reconciliation.unexpected.length + reconciliation.missingExpected.length + failedConstraints;
754
-
755
- /** @type {"ok"|"findings"|"no-verdict"} */
756
- let status;
757
- /** @type {0|1|3} */
758
- let exitCode;
759
- let decision;
760
- if (verdict === "unproven" || unknownConstraints > 0) {
761
- status = "no-verdict";
762
- exitCode = 3;
763
- decision = buildDecision({
764
- status,
765
- coverageComplete: true,
766
- findings: 0,
767
- reason:
768
- verdict === "unproven"
769
- ? `the change intent could not be verified against the declared base: ${unprovenReasons[0]}`
770
- : `${unknownConstraints} declared constraint${unknownConstraints === 1 ? "" : "s"} could not be determined`,
771
- });
772
- } else if (findings > 0) {
773
- status = "findings";
774
- exitCode = 1;
775
- decision = buildDecision({ status, coverageComplete: true, findings });
776
- } else {
777
- status = "ok";
778
- exitCode = 0;
779
- decision = buildDecision({ status, coverageComplete: true, findings: 0 });
780
- }
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.
781
877
 
782
878
  /** @type {string[]} */
783
879
  const notes = [];
@@ -815,6 +911,40 @@ export async function changeCommand(
815
911
  notes,
816
912
  };
817
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
+
818
948
  // Wave 3 (design §1, §2, §5): the evolution classification and the reconcile
819
949
  // event. The classification is ALWAYS computed — it rides the envelope
820
950
  // result in memory even when no event is written (design §4) — and the
@@ -1030,6 +1160,7 @@ export async function changeCommand(
1030
1160
 
1031
1161
  return {
1032
1162
  status,
1163
+ exitCode,
1033
1164
  changeIntent: result,
1034
1165
  coverage,
1035
1166
  report: {
@@ -1044,3 +1175,85 @@ export async function changeCommand(
1044
1175
  },
1045
1176
  };
1046
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
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * `check` capability facade — the word's verb roster as explicit named
3
+ * re-exports: `check`, `fitness`, `scenario`.
4
+ *
5
+ * Pure surface module: no judgment, no defaults, no logic — every export is
6
+ * a re-export of a command module. `cli.mjs` routes its verb imports through
7
+ * the facade so the vocabulary's code referent is visible to the import
8
+ * graph, not just to a comment (PD-18,
9
+ * ../../../../docs/architecture/refactor/DECISIONS.md).
10
+ */
11
+ export { check, sortViolations } from "./check.mjs";
12
+ export { fitness } from "./fitness.mjs";
13
+ export { scenario } from "./scenario.mjs";
@@ -209,7 +209,7 @@ function declaredEdgeManifest({ provider, graph }, sourceProject) {
209
209
  * evidenceOut?: string|null}} options
210
210
  * @param {{cwd: string, readGraph?: Function, listFiles?: Function,
211
211
  * listUntracked?: Function}} context
212
- * @returns {Promise<{report: string, violations: number, declaredEdgeFindings: number,
212
+ * @returns {Promise<{exitCode: 0|1|3, report: string, violations: number, declaredEdgeFindings: number,
213
213
  * goWorkDrift: number, tsconfigPathsDead: number, intentFindings: number,
214
214
  * intentUnresolved: number, intentUnresolvedDecisionRefs: number, fitnessFail: number,
215
215
  * fitnessUnknown: number, customRuleFail: number, customRuleUnknown: number,
@@ -521,6 +521,15 @@ export async function check(
521
521
  // from the MARKER (the line a reader edits is the document's), project and
522
522
  // constraint from the edge — so suppression, waiver annotation, sorting and
523
523
  // the SARIF face all treat it as the ordinary violation it is.
524
+ //
525
+ // PD-13 (2026-09-06) pins THIS fold as the documented normalization seam
526
+ // (`../../../../docs/architecture/refactor/DECISIONS.md`): the one place
527
+ // `judgeEdge`'s markdown-pairing verdicts are reshaped into the exact
528
+ // `Violation` record `violationOf` (`../rules/index.mjs`) builds — the
529
+ // families then fold into the one verdict lane as count keys into
530
+ // `verdictFor` (`../verdict.mjs`). The canonical statement is the
531
+ // "Finding — the unowned concept" section of
532
+ // `../../../../docs/architecture/refactor/SEMANTIC-MODEL.md`.
524
533
  /** @type {object[]} */
525
534
  const markdownRaw = [];
526
535
  if (config !== null && markdownTrack !== null && config.depConstraints.length > 0) {
@@ -1289,6 +1298,7 @@ export async function check(
1289
1298
  });
1290
1299
 
1291
1300
  return {
1301
+ exitCode: verdict.exitCode,
1292
1302
  report,
1293
1303
  violations: violations.length,
1294
1304
  waived,
@@ -1311,8 +1321,8 @@ export async function check(
1311
1321
  customRulesDeclared: customRules !== null,
1312
1322
  analyzed,
1313
1323
  unchecked,
1314
- // The site-level count `verdictFor` needs: `cli.mjs` passes this whole
1315
- // return through `verdictFor` for the process's exit code, so a count the
1324
+ // The exit authority is `verdict` above, surfaced on this return: the
1325
+ // process exit code comes straight from this field, so a count the
1316
1326
  // envelope saw but the exit code did not would let the two disagree.
1317
1327
  blindSpots: blindSpotCount,
1318
1328
  };
@@ -0,0 +1,20 @@
1
+ /**
2
+ * `compare` capability facade — the word's verb roster as explicit named
3
+ * re-exports: `diff`, `delta` (both modes), `change`, `drift`, `reconcile`,
4
+ * `history`, `trajectory`, `evolution`, plus the output guards the two
5
+ * verbs that write reports declare (`change`, `history`, `trajectory`).
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 { diff } from "./diff.mjs";
14
+ export { captureBaseline, delta } from "./delta.mjs";
15
+ export { change, changeOutputRefusal } from "./change.mjs";
16
+ export { drift } from "./drift.mjs";
17
+ export { reconcileCommand } from "./reconcile.mjs";
18
+ export { history, historyOutputRefusal } from "./history.mjs";
19
+ export { trajectoryCommand, trajectoryOutputRefusal } from "./trajectory.mjs";
20
+ export { evolutionCommand } from "./evolution.mjs";
@@ -24,6 +24,18 @@
24
24
  * `overallComplete` implies ALL gates pass. Any failed gate MUST prevent
25
25
  * `overallComplete = true`.
26
26
  *
27
+ * ## Not the coverage refusal (register R1)
28
+ *
29
+ * These statuses grade the composed evaluation — how completely the
30
+ * Evidence-Complete gates were satisfied for the statement being made. A
31
+ * different question is whether the run saw enough input to make any claim
32
+ * at all, and that one is answered only by the graph family's shared
33
+ * refusal contract (`./coverage-verdict.mjs`): `coverageVerdict` refuses
34
+ * `no-verdict` (exit 3), and no status here is derived from it or folds
35
+ * into it. Two models of "did we see everything", owned by different
36
+ * waves — register R1 in `docs/architecture/refactor/SEMANTIC-MODEL.md`
37
+ * keeps them separate.
38
+ *
27
39
  * @module
28
40
  */
29
41