@ecoma-io/archkeep 0.13.0 → 0.15.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 (37) hide show
  1. package/README.md +9 -3
  2. package/cli.mjs +599 -55
  3. package/commands.mjs +51 -0
  4. package/package.json +3 -1
  5. package/src/analysis/typescript.mjs +2 -1
  6. package/src/commands/README.md +70 -1
  7. package/src/commands/change-intent.mjs +461 -0
  8. package/src/commands/change.mjs +612 -0
  9. package/src/commands/check.mjs +84 -17
  10. package/src/commands/context.mjs +92 -16
  11. package/src/commands/coverage-acceptance.mjs +113 -0
  12. package/src/commands/custom-rules.mjs +286 -2
  13. package/src/commands/delta-classify.mjs +664 -0
  14. package/src/commands/delta-snapshot.mjs +672 -0
  15. package/src/commands/delta.mjs +606 -0
  16. package/src/commands/diff.mjs +41 -13
  17. package/src/commands/evolution.mjs +473 -0
  18. package/src/commands/explain.mjs +39 -0
  19. package/src/commands/history.mjs +130 -103
  20. package/src/commands/policy.mjs +93 -1
  21. package/src/commands/trajectory.mjs +437 -0
  22. package/src/commands/waivers.mjs +53 -3
  23. package/src/config.mjs +129 -11
  24. package/src/lsp/boundary-config.mjs +9 -4
  25. package/src/path-util.mjs +40 -0
  26. package/src/providers/native/model.mjs +17 -0
  27. package/src/report/change-text.mjs +148 -0
  28. package/src/report/delta-text.mjs +264 -0
  29. package/src/report/evolution-text.mjs +83 -0
  30. package/src/report/explain-text.mjs +27 -0
  31. package/src/report/history-text.mjs +4 -114
  32. package/src/report/sarif.mjs +280 -0
  33. package/src/report/snapshot-text.mjs +123 -0
  34. package/src/report/text.mjs +36 -0
  35. package/src/report/trajectory-text.mjs +143 -0
  36. package/src/report/waivers-text.mjs +35 -2
  37. package/src/tsconfig-paths.mjs +3 -2
package/src/config.mjs CHANGED
@@ -96,7 +96,8 @@
96
96
  *
97
97
  * The `.mjs`/`.js` and `.json` dialects share the same top-level key law: a
98
98
  * top-level export (or key) beyond `depConstraints`, `moduleBoundaryOptions`,
99
- * `boundarySuppressions`, `fitness` and `customRules` is rejected by name. The
99
+ * `boundarySuppressions`, `fitness`, `customRules` and `coverage` is rejected
100
+ * by name. The
100
101
  * `.mjs` dialect's tolerance for a helper export used to let a misspelled key
101
102
  * (`moduleBoundaryOptions` → `moduleBoundaryOption`) disappear into silence —
102
103
  * a typo'd law is a law that is not enforced, the exact silent direction this
@@ -557,6 +558,94 @@ function suppressionRowViolations(row, index) {
557
558
  return violations;
558
559
  }
559
560
 
561
+ /**
562
+ * The keys a `coverage.unowned` row may carry. `reason` is not optional, for
563
+ * the same reason it is not optional on a suppression row above and on a
564
+ * native `coverage.exempt` row (`./providers/native/model.mjs`'s
565
+ * `exemptRowViolations`, whose semantics this row copies exactly): an
566
+ * accepted coverage hole with no reason written down is indistinguishable
567
+ * from coverage that quietly stopped being enforced.
568
+ */
569
+ const COVERAGE_UNOWNED_KEYS = ["path", "reason"];
570
+
571
+ /**
572
+ * One `coverage.unowned` row's problems, prefixed with its index.
573
+ *
574
+ * @param {unknown} row
575
+ * @param {number} index
576
+ * @returns {string[]}
577
+ */
578
+ function coverageUnownedRowViolations(row, index) {
579
+ const at = `coverage.unowned[${index}]`;
580
+ if (!isPlainObject(row)) return [`${at}: must be an object, got ${describe(row)}`];
581
+
582
+ const violations = [];
583
+ if (typeof row.path !== "string" || row.path === "") {
584
+ violations.push(
585
+ `${at}.path: must be a non-empty glob over a workspace-relative path, matched with ` +
586
+ `\`path.posix.matchesGlob\`, got ${describe(row.path)}`,
587
+ );
588
+ } else {
589
+ const problem = globComplexityError(row.path);
590
+ if (problem) violations.push(`${at}.path: '${row.path}' ${problem}`);
591
+ }
592
+ if (typeof row.reason !== "string" || row.reason.trim() === "") {
593
+ violations.push(
594
+ `${at}.reason: must be a non-empty string — an accepted coverage hole with no reason ` +
595
+ `written down reads as coverage that is still enforced`,
596
+ );
597
+ }
598
+ for (const key of Object.keys(row)) {
599
+ if (!COVERAGE_UNOWNED_KEYS.includes(key)) {
600
+ violations.push(
601
+ `${at}.${key}: not a coverage.unowned field — expected one of ` +
602
+ COVERAGE_UNOWNED_KEYS.join(", "),
603
+ );
604
+ }
605
+ }
606
+ return violations;
607
+ }
608
+
609
+ /**
610
+ * Everything wrong with a policy's `coverage` key, as messages; empty when it
611
+ * is well-formed.
612
+ *
613
+ * The key holds exactly one field: `unowned`, an array of `{path, reason}`
614
+ * rows — a recorded acceptance of tracked files no project owns, for the Nx
615
+ * and Moon providers whose project model has no channel of its own for that
616
+ * decision (`../../../docs/reference/policy-schema.md`, "`coverage`"). A row
617
+ * is matched ONLY against files already decided to be unowned, so even a `**`
618
+ * row can never silence a verdict about an owned file — the same guarantee
619
+ * the native provider's `coverage.exempt` states
620
+ * (`./providers/native/coverage.mjs`). An empty `unowned` list is accepted
621
+ * for the reason an empty suppression list is: it accepts nothing, which is
622
+ * the direction that cannot hide anything.
623
+ *
624
+ * @param {unknown} value The parsed `coverage` value.
625
+ * @returns {string[]}
626
+ */
627
+ function findCoverageViolations(value) {
628
+ if (!isPlainObject(value)) {
629
+ return [`coverage: must be an object carrying an 'unowned' array, got ${describe(value)}`];
630
+ }
631
+ const violations = [];
632
+ if (!Array.isArray(value.unowned)) {
633
+ violations.push(
634
+ `coverage.unowned: must be an array of {path, reason} rows, got ${describe(value.unowned)}`,
635
+ );
636
+ } else {
637
+ value.unowned.forEach((row, index) =>
638
+ violations.push(...coverageUnownedRowViolations(row, index)),
639
+ );
640
+ }
641
+ for (const key of Object.keys(value)) {
642
+ if (key !== "unowned") {
643
+ violations.push(`coverage.${key}: not a coverage field — expected 'unowned'`);
644
+ }
645
+ }
646
+ return violations;
647
+ }
648
+
560
649
  /**
561
650
  * The grammar a custom rule's `name` is written in: lowercase letters and
562
651
  * digits, single `-` separators, nothing else.
@@ -863,8 +952,14 @@ export function findBoundaryConfigViolations(module, io = {}) {
863
952
  if (!isPlainObject(module)) return [`config: expected a module object, got ${describe(module)}`];
864
953
 
865
954
  const violations = [];
866
- const { depConstraints, moduleBoundaryOptions, boundarySuppressions, fitness, customRules } =
867
- module;
955
+ const {
956
+ depConstraints,
957
+ moduleBoundaryOptions,
958
+ boundarySuppressions,
959
+ fitness,
960
+ customRules,
961
+ coverage,
962
+ } = module;
868
963
  // F05: the resolution half of the governance block (`row-schema.mjs`'s
869
964
  // `io.resolve`) was validator-only until now — no production caller passed
870
965
  // one, so a row bound to a fitness rule that does not exist loaded and ran
@@ -909,6 +1004,16 @@ export function findBoundaryConfigViolations(module, io = {}) {
909
1004
  violations.push(...findCustomRuleViolations(customRules, io));
910
1005
  }
911
1006
 
1007
+ // The unowned-file acceptances — the sixth top-level law, shaped here and
1008
+ // matched by `./commands/coverage-acceptance.mjs` against files already
1009
+ // decided to be unowned, never against owned ones. Absent means "no
1010
+ // acceptance recorded"; present and malformed is refused loudly, because a
1011
+ // row this reader could not understand is an acceptance that would not
1012
+ // apply while the policy still says it does.
1013
+ if (coverage !== undefined) {
1014
+ violations.push(...findCoverageViolations(coverage));
1015
+ }
1016
+
912
1017
  // Absent means "nothing is suppressed", which is the only default that fails
913
1018
  // toward reporting — unlike the eight options above, where a missing value
914
1019
  // would be a second copy of something ESLint also reads and this module has
@@ -996,8 +1101,15 @@ export function findBoundaryConfigViolations(module, io = {}) {
996
1101
  * general "ignore unknown" rule, which is exactly the leniency this file's
997
1102
  * header argues a JSON object must not get). `fitness` is the fourth: the
998
1103
  * boundary dialect's key for the fitness-functions list, validated as an array
999
- * of fitness rows. `customRules` is the fifth and newest — the declared rules
1104
+ * of fitness rows. `customRules` is the fifth — the declared rules
1000
1105
  * this engine did not write, validated as an array of custom-rule rows.
1106
+ * `coverage` is the sixth and newest: the recorded acceptances of unowned
1107
+ * files on an Nx or Moon workspace (`findCoverageViolations` above owns the
1108
+ * shape). On a native tree the key is refused — `archkeep.json`'s own
1109
+ * `coverage.exempt` is that provider's one channel for the same decision —
1110
+ * and the refusal lives in `./commands/policy.mjs`'s `resolvePolicy` and
1111
+ * `./providers/native/model.mjs`'s inline-policy check, because only they
1112
+ * know which provider is reading.
1001
1113
  *
1002
1114
  * The name says `.json` and the list binds both file dialects: `loadModulePolicy`
1003
1115
  * runs the same check over an ES module's exports, which is what makes a
@@ -1010,6 +1122,7 @@ const JSON_POLICY_KEYS = [
1010
1122
  "boundarySuppressions",
1011
1123
  "fitness",
1012
1124
  "customRules",
1125
+ "coverage",
1013
1126
  ];
1014
1127
 
1015
1128
  /**
@@ -1086,7 +1199,7 @@ export function policyKeyViolations(parsed, { allowSchema }) {
1086
1199
  * inline one.
1087
1200
  * @param {string[]} [extraViolations] Violations the caller already found that
1088
1201
  * `findBoundaryConfigViolations` does not check on its own.
1089
- * @returns {{ depConstraints: object[], options: object, suppressions: object[], fitness?: object[], customRules?: object[] }}
1202
+ * @returns {{ depConstraints: object[], options: object, suppressions: object[], fitness?: object[], customRules?: object[], coverage?: object }}
1090
1203
  * `fitness` and `customRules` are present only when the config declares
1091
1204
  * them — a workspace without one carries no key, the same "absent is a
1092
1205
  * decision" posture `cli.mjs`'s `check` uses for a missing
@@ -1109,6 +1222,7 @@ export function policyFrom(parsed, sourceLabel, extraViolations = []) {
1109
1222
  suppressions: parsed.boundarySuppressions ?? [],
1110
1223
  ...(parsed.fitness === undefined ? {} : { fitness: parsed.fitness }),
1111
1224
  ...(parsed.customRules === undefined ? {} : { customRules: parsed.customRules }),
1225
+ ...(parsed.coverage === undefined ? {} : { coverage: parsed.coverage }),
1112
1226
  };
1113
1227
  }
1114
1228
 
@@ -1125,7 +1239,7 @@ export function policyFrom(parsed, sourceLabel, extraViolations = []) {
1125
1239
  * legitimate namespace to share a helper in gave a typo the same silence.
1126
1240
  *
1127
1241
  * @param {string} path Absolute path of the config file.
1128
- * @returns {Promise<{ depConstraints: object[], options: object, suppressions: object[], fitness?: object[], customRules?: object[] }>}
1242
+ * @returns {Promise<{ depConstraints: object[], options: object, suppressions: object[], fitness?: object[], customRules?: object[], coverage?: object }>}
1129
1243
  * @throws {Error} when the file is missing, unloadable, or malformed.
1130
1244
  */
1131
1245
  async function loadModulePolicy(path) {
@@ -1151,7 +1265,7 @@ async function loadModulePolicy(path) {
1151
1265
  * @param {{readFile?: (path: string, encoding: "utf8") => Promise<string>}} [io]
1152
1266
  * Injectable read, defaulting to `node:fs/promises`' `readFile` — the only
1153
1267
  * code in this function that reaches outside the process.
1154
- * @returns {Promise<{ depConstraints: object[], options: object, suppressions: object[], fitness?: object[], customRules?: object[] }>}
1268
+ * @returns {Promise<{ depConstraints: object[], options: object, suppressions: object[], fitness?: object[], customRules?: object[], coverage?: object }>}
1155
1269
  * @throws {Error} when the file is missing, unreadable, not valid JSON, or
1156
1270
  * malformed — either by `findBoundaryConfigViolations`' rules or by carrying
1157
1271
  * a top-level key none of those rules knows about.
@@ -1198,7 +1312,7 @@ async function loadJsonPolicy(path, { readFile = readFileFromDisk } = {}) {
1198
1312
  * @param {string} path Absolute path of the config file.
1199
1313
  * @param {{readFile?: (path: string, encoding: "utf8") => Promise<string>}} [io]
1200
1314
  * Injectable read, used only by the `.json` dialect — see `loadJsonPolicy`.
1201
- * @returns {Promise<{ depConstraints: object[], options: object, suppressions: object[], fitness?: object[], customRules?: object[], notes?: string[] }>}
1315
+ * @returns {Promise<{ depConstraints: object[], options: object, suppressions: object[], fitness?: object[], customRules?: object[], coverage?: object, notes?: string[] }>}
1202
1316
  * `suppressions` is `[]` when the config declares none. `notes` is present
1203
1317
  * only under the ESLint dialect, and only when `./eslint-config.mjs` has
1204
1318
  * something worth telling a reader about which entry it bound — see
@@ -1250,8 +1364,12 @@ export async function loadBoundaryConfigFile(path, io = {}) {
1250
1364
  // from (see this module's header). Never populated under this dialect —
1251
1365
  // `policyFrom` already resolves that to `[]` since the object above
1252
1366
  // states no `boundarySuppressions` key, and it leaves `customRules`
1253
- // absent for the same reason, the header's own distinction between an
1254
- // absent law and an empty one.
1367
+ // and `coverage` absent for the same reason, the header's own
1368
+ // distinction between an absent law and an empty one: a flat config's
1369
+ // one rule entry is a constraint table and its options, with nowhere to
1370
+ // name a rule artifact or an unowned-file acceptance
1371
+ // (`../../../docs/reference/policy-schema.md`'s dialect table names both
1372
+ // gaps on the consumer-facing side).
1255
1373
  ...(note !== undefined ? { notes: [note] } : {}),
1256
1374
  };
1257
1375
  }
@@ -1279,7 +1397,7 @@ export async function loadBoundaryConfigFile(path, io = {}) {
1279
1397
  * misconfigured tool.
1280
1398
  * @param {{readFile?: (path: string, encoding: "utf8") => Promise<string>}} [io]
1281
1399
  * Forwarded to `loadBoundaryConfigFile` — see there.
1282
- * @returns {Promise<{ depConstraints: object[], options: object, suppressions: object[], fitness?: object[], customRules?: object[], notes?: string[] }>}
1400
+ * @returns {Promise<{ depConstraints: object[], options: object, suppressions: object[], fitness?: object[], customRules?: object[], coverage?: object, notes?: string[] }>}
1283
1401
  * @throws {Error} as `loadBoundaryConfigFile`.
1284
1402
  */
1285
1403
  export async function loadBoundaryConfig(workspaceRoot, boundaryConfig, io = {}) {
@@ -62,7 +62,7 @@ import { ARCHKEEP_MODEL_FILE } from "../providers/native/model.mjs";
62
62
  *
63
63
  * @param {string} path Absolute path of the config file.
64
64
  * @param {string|number} revision Busts the ESM module cache across edits.
65
- * @returns {Promise<{ depConstraints: object[], options: object, suppressions: object[], fitness?: object[], customRules?: object[] }>}
65
+ * @returns {Promise<{ depConstraints: object[], options: object, suppressions: object[], fitness?: object[], customRules?: object[], coverage?: object }>}
66
66
  * @throws {Error} when the file is missing, unloadable, or malformed.
67
67
  */
68
68
  async function readModulePolicy(path, revision) {
@@ -86,7 +86,7 @@ async function readModulePolicy(path, revision) {
86
86
  * @param {(path: string, encoding: "utf8") => Promise<string>} readFile
87
87
  * Injected so a test can drive this without a real file — see
88
88
  * `readBoundaryConfig` below.
89
- * @returns {Promise<{ depConstraints: object[], options: object, suppressions: object[], fitness?: object[], customRules?: object[] }>}
89
+ * @returns {Promise<{ depConstraints: object[], options: object, suppressions: object[], fitness?: object[], customRules?: object[], coverage?: object }>}
90
90
  * @throws {Error} when the file is missing, unreadable, not valid JSON, or
91
91
  * malformed — either by `../config.mjs`'s `findBoundaryConfigViolations` or
92
92
  * by carrying a top-level key none of those rules knows about.
@@ -144,14 +144,19 @@ async function readJsonPolicy(path, readFile) {
144
144
  * @param {{readFile?: (path: string, encoding: "utf8") => Promise<string>}} [io]
145
145
  * Injectable read, used only by the `.json` dialect, defaulting to
146
146
  * `node:fs/promises`'s `readFile`.
147
- * @returns {Promise<{depConstraints: object[], options: object, suppressions: object[], fitness?: object[], customRules?: object[]}>}
147
+ * @returns {Promise<{depConstraints: object[], options: object, suppressions: object[], fitness?: object[], customRules?: object[], coverage?: object}>}
148
148
  * `suppressions` is `[]` when the config declares none; `fitness` and
149
149
  * `customRules` are present only when the policy declares them, the same
150
150
  * absent-is-a-decision shape `../config.mjs`'s `policyFrom` returns to
151
151
  * every other face. This server READS both and evaluates neither — a
152
152
  * fitness function and a custom rule are per-run workspace judgments, not
153
153
  * per-file diagnostics — so what it owes them is to load them and to fail
154
- * loudly on a row it cannot read.
154
+ * loudly on a row it cannot read. `coverage` rides the same bargain one
155
+ * key further: unowned-file acceptance is a whole-run coverage decision
156
+ * (`../commands/coverage-acceptance.mjs`), never a per-file diagnostic, so
157
+ * the server's whole duty to the key is to ACCEPT a config carrying it —
158
+ * the same `policyKeyViolations` the CLI runs — rather than reject in the
159
+ * editor a law `check` enforces.
155
160
  * @throws {Error} when `boundaryConfig` names the ESLint flat-config dialect
156
161
  * or a legacy `.eslintrc*` file (this reader does not read either yet — see
157
162
  * above), or — for a dialect it does read — when the file is missing,
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Path utilities — operations that would otherwise be scattered as inline
3
+ * expressions, each a copy that could drift.
4
+ *
5
+ * Every function here is pure and typed, so tests need no filesystem.
6
+ */
7
+
8
+ /**
9
+ * Strip trailing slashes from a path — the O(n) alternative to
10
+ * `.replace(/\/+$/u, "")`.
11
+ *
12
+ * The regex form is **vulnerable to polynomial ReDoS** on a run of slashes
13
+ * that does not reach the string end: `/" + "/×n + "a"` forces the engine to
14
+ * scan from each position within the run, turning linear work into O(n²).
15
+ * Measured on V8:
16
+ *
17
+ * | n (slash count) | regex | linear scan |
18
+ * |-----------------|--------------|-------------|
19
+ * | 20,000 | 0.24 s | <0.001 s |
20
+ * | 40,000 | 0.86 s | <0.001 s |
21
+ * | 80,000 | 3.4 s | 0.004 s |
22
+ * | 160,000 | 13.6 s | 0.14 ms |
23
+ *
24
+ * Workspace roots are normally bounded by PATH_MAX (~4096), so the practical
25
+ * impact is low; but this function is shipped in a published package and
26
+ * CodeQL correctly flags the `workspaceRoot` argument as library input — the
27
+ * fix removes the alert class rather than arguing severity. Five call sites
28
+ * in the codebase used this pattern; all now route through this one helper.
29
+ *
30
+ * Semantics are identical to the regex on all edge cases (empty string,
31
+ * single slash, multiple slashes, no trailing slash, mixed content).
32
+ *
33
+ * @param {string} path The path to strip.
34
+ * @returns {string} `path` with trailing `/` characters removed.
35
+ */
36
+ export function stripTrailingSlashes(path) {
37
+ let end = path.length;
38
+ while (end > 0 && path[end - 1] === "/") end -= 1;
39
+ return path.slice(0, end);
40
+ }
@@ -520,6 +520,23 @@ export function findNativeModelViolations(raw) {
520
520
  (message) => `boundaryConfig.${message}`,
521
521
  ),
522
522
  );
523
+ // The policy's `coverage` key is an Nx/Moon channel, refused here BY
524
+ // NAME on the one provider that already has its own: this very file's
525
+ // `coverage.exempt` is where a native tree records an unowned-file
526
+ // acceptance, and two channels for one decision on one tree is how
527
+ // copies drift (the same posture as the `.moon`-beside-`archkeep.json`
528
+ // refusal in `../../commands/context.mjs`'s `requireSingleProjectModel`).
529
+ // The file-dialect spelling of the same mistake — a native tree whose
530
+ // boundaryConfig FILE declares `coverage` — is refused by
531
+ // `../../commands/policy.mjs`'s `resolvePolicy`, which is the first
532
+ // point that knows both the provider and the loaded policy.
533
+ if ("coverage" in raw.boundaryConfig) {
534
+ violations.push(
535
+ `boundaryConfig.coverage: not accepted on a native workspace — this tree records ` +
536
+ `unowned-file acceptances on archkeep.json's own coverage.exempt, and a second ` +
537
+ `channel for the same decision is a copy that will drift. Move the rows there`,
538
+ );
539
+ }
523
540
  } else if (typeof raw.boundaryConfig !== "string") {
524
541
  violations.push(
525
542
  `boundaryConfig: must be a string (a filename) or an object (an inline policy), got ` +
@@ -0,0 +1,148 @@
1
+ /**
2
+ * The terminal report for the `change` command: a declared change-intent
3
+ * contract against the architectural delta actually observed
4
+ * (`../commands/change.mjs`).
5
+ *
6
+ * The report is a review document, so it renders every axis separately and in
7
+ * full: what was declared, what matched, what appeared without a declaration,
8
+ * which declared changes never happened, how each declared constraint judged,
9
+ * and — informational, never this command's verdict — how many live boundary
10
+ * violations the tree currently carries under the current law, with `check`
11
+ * named as the authority on that axis. A section renders only when it has
12
+ * content; the closing line always states what was compared and with what
13
+ * outcome, so an empty reconciliation is a verifiable claim rather than
14
+ * silence (`../../../../AGENTS.md`).
15
+ *
16
+ * This module decides nothing. A formatter that filtered would be a rule
17
+ * wearing a formatter's name (`./README.md`).
18
+ */
19
+
20
+ /** One expected-fact row as report lines. */
21
+ function factLines(entry) {
22
+ switch (entry.kind) {
23
+ case "project-added":
24
+ return [` + project ${entry.project}`];
25
+ case "project-removed":
26
+ return [` - project ${entry.project}`];
27
+ case "edge-added":
28
+ return [
29
+ ` + edge ${entry.from} -> ${entry.to}${entry.type === undefined ? "" : ` (${entry.type})`}`,
30
+ ];
31
+ case "edge-removed":
32
+ return [
33
+ ` - edge ${entry.from} -> ${entry.to}${entry.type === undefined ? "" : ` (${entry.type})`}`,
34
+ ];
35
+ case "project-changed": {
36
+ const lines = [` ! project ${entry.project} changed:`];
37
+ for (const change of entry.changes ?? []) {
38
+ lines.push(
39
+ ` ${change.field}: ${JSON.stringify(change.baseline)} -> ${JSON.stringify(change.head)}`,
40
+ );
41
+ }
42
+ return lines;
43
+ }
44
+ default:
45
+ return [` ? ${entry.kind} ${entry.project ?? `${entry.from} -> ${entry.to}`}`];
46
+ }
47
+ }
48
+
49
+ /** One constraint verdict row as its report lines. */
50
+ function constraintLines(row) {
51
+ const glyph = row.verdict === "pass" ? "✔" : row.verdict === "fail" ? "✗" : "?";
52
+ return [`${glyph} ${row.name}: ${row.verdict} — ${row.message}`];
53
+ }
54
+
55
+ /** A side's identity for prose: its commit prefix, or an honest absence. */
56
+ function describeOrigin(provenance) {
57
+ if (!provenance || typeof provenance.commit !== "string") return "unverified origin";
58
+ const dirty = provenance.dirty ? ", dirty" : "";
59
+ return `${provenance.commit.slice(0, 8)}${dirty}`;
60
+ }
61
+
62
+ /**
63
+ * The whole change report.
64
+ *
65
+ * @param {{change: object, coverage: object}} input `change` is
66
+ * `../commands/change.mjs`'s result payload; `coverage` its coverage block.
67
+ * @returns {string}
68
+ */
69
+ export function formatChangeReport({ change, coverage }) {
70
+ const { intent, baseline, head, reconciliation, constraints, policy } = change;
71
+ const sections = [];
72
+
73
+ sections.push(
74
+ `intent ${intent.file} — base ${intent.base.commit.slice(0, 8)}` +
75
+ (intent.summary === undefined ? "" : `\n "${intent.summary}"`),
76
+ );
77
+ sections.push(
78
+ `baseline ${baseline.path} — ${describeOrigin(baseline.provenance)}, ` +
79
+ `${baseline.records} record${baseline.records === 1 ? "" : "s"}, ` +
80
+ `${baseline.projects} project${baseline.projects === 1 ? "" : "s"}`,
81
+ );
82
+ sections.push(
83
+ `head ${describeOrigin(head.provenance)}, ` +
84
+ `${head.projects} project${head.projects === 1 ? "" : "s"}`,
85
+ );
86
+
87
+ for (const note of coverage.notes ?? []) sections.push(`⚠ ${note}`);
88
+
89
+ const verdictLine = {
90
+ matched: "✔ MATCHED — the delta is exactly the declared change",
91
+ undeclared: "⚠ UNDECLARED — the delta contains changes no declaration covers",
92
+ unfulfilled: "✗ UNFULFILLED — nothing undeclared, but declared changes never happened",
93
+ unproven: "? UNPROVEN — the base identity could not be established",
94
+ }[reconciliation.verdict];
95
+ sections.push(`reconciliation ${verdictLine}`);
96
+ for (const reason of reconciliation.reasons) sections.push(` because: ${reason}`);
97
+
98
+ if (reconciliation.matched.length > 0) {
99
+ sections.push(
100
+ `✔ ${reconciliation.matched.length} declared change${reconciliation.matched.length === 1 ? "" : "s"} observed`,
101
+ ...reconciliation.matched.flatMap(factLines),
102
+ );
103
+ }
104
+ if (reconciliation.unexpected.length > 0) {
105
+ sections.push(
106
+ `! ${reconciliation.unexpected.length} undeclared material change${reconciliation.unexpected.length === 1 ? "" : "s"} — a review signal, not a law verdict`,
107
+ ...reconciliation.unexpected.flatMap(factLines),
108
+ );
109
+ }
110
+ if (reconciliation.missingExpected.length > 0) {
111
+ sections.push(
112
+ `? ${reconciliation.missingExpected.length} declared change${reconciliation.missingExpected.length === 1 ? "" : "s"} never observed`,
113
+ ...reconciliation.missingExpected.flatMap(factLines),
114
+ );
115
+ }
116
+
117
+ if (constraints.length > 0) {
118
+ sections.push("declared constraints");
119
+ sections.push(...constraints.flatMap(constraintLines));
120
+ }
121
+
122
+ // Informational on purpose: this number says what `check` would count right
123
+ // now. It gates nothing here — collapsing it into the intent verdict would
124
+ // hide one signal behind the other.
125
+ sections.push(
126
+ policy.liveViolations === null
127
+ ? `workspace law not evaluated — the run could not prove the base identity (archkeep check remains the authority)`
128
+ : `workspace law ${policy.liveViolations} live violation${policy.liveViolations === 1 ? "" : "s"} under the current law${policy.changedSinceBase ? ", which changed since capture" : ""} — informational; archkeep check remains the authoritative verdict`,
129
+ );
130
+
131
+ // The closing claim always states what was compared, so every outcome —
132
+ // including a full match over an unchanged tree — is a verifiable statement
133
+ // rather than silence.
134
+ const declaredCount =
135
+ intent.declared.projectsAdd +
136
+ intent.declared.projectsRemove +
137
+ intent.declared.edgesAdd +
138
+ intent.declared.edgesRemove;
139
+ sections.push(
140
+ `reconciled ${declaredCount} declared change${declaredCount === 1 ? "" : "s"} and ` +
141
+ `${constraints.length} declared constraint${constraints.length === 1 ? "" : "s"} — ` +
142
+ `base ${describeOrigin(baseline.provenance)} (${baseline.projects} projects, ` +
143
+ `${baseline.records} records) against head ${describeOrigin(head.provenance)} ` +
144
+ `(${head.projects} projects)`,
145
+ );
146
+
147
+ return sections.join("\n");
148
+ }