@ecoma-io/archkeep 0.17.0 → 0.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/cli.mjs CHANGED
@@ -93,6 +93,11 @@
93
93
  */
94
94
  import { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
95
95
  import { dirname, isAbsolute, join, resolve } from "node:path";
96
+ import { createRequire } from "node:module";
97
+
98
+ const require = createRequire(import.meta.url);
99
+ /*** @type {{name: string, version: string}} */
100
+ const { name: TOOL_NAME, version: TOOL_VERSION } = require("./package.json");
96
101
 
97
102
  import { containmentViolation } from "./src/containment.mjs";
98
103
  import { UsageError } from "./src/errors.mjs";
@@ -931,12 +936,8 @@ async function runDiff(options, { cwd, env }) {
931
936
  *
932
937
  * `--capture` writes the evidence snapshot a later run compares against;
933
938
  * `delta <baseline>` loads one, re-judges both sides under the current law,
934
- * and folds the classification into the exit code — the one descriptive-family
935
- * verb beside `check` and `fitness` whose verdict carries exit 1
936
- * (`./src/commands/delta.mjs` owns the fold).
937
- *
938
939
  * @param {{format: string, output: string|null, config: string|null, capture: boolean,
939
- * paths: string[]}} options
940
+ * eventOut: string|null, paths: string[]}} options
940
941
  * @param {{cwd: string, env: {out: Function, err: Function, readGraph?: Function, listFiles?: Function}}} runContext
941
942
  * @returns {Promise<number>}
942
943
  */
@@ -948,6 +949,13 @@ async function runDelta(options, { cwd, env }) {
948
949
  );
949
950
  return EXIT.usage;
950
951
  }
952
+ if (options.eventOut !== null) {
953
+ env.err(
954
+ `archkeep: delta --capture does not take --event-out — an event records a transition, ` +
955
+ `and a capture is one side of it`,
956
+ );
957
+ return EXIT.usage;
958
+ }
951
959
  } else if (options.paths.length !== 1) {
952
960
  env.err(
953
961
  `archkeep: delta takes exactly one positional argument (the baseline evidence snapshot), ` +
@@ -972,7 +980,6 @@ async function runDelta(options, { cwd, env }) {
972
980
  const { text } = captureDelta(commandContext, { config });
973
981
  if (options.output) {
974
982
  // Atomic, symlink-safe write — `writeOutputReport`'s own docstring
975
- // owns the mechanism and the threat it closes.
976
983
  if (!writeOutputReport(options.output, text, env, cwd, options.config)) return EXIT.error;
977
984
  env.err(`archkeep: delta baseline captured → ${options.output}`);
978
985
  } else {
@@ -985,7 +992,10 @@ async function runDelta(options, { cwd, env }) {
985
992
  const baselinePath = isAbsolute(options.paths[0])
986
993
  ? resolve(options.paths[0])
987
994
  : resolve(cwd, options.paths[0]);
988
- result = await deltaCommand(baselinePath, commandContext, { config });
995
+ result = await deltaCommand(baselinePath, commandContext, {
996
+ config,
997
+ eventOut: options.eventOut,
998
+ });
989
999
  } catch (error) {
990
1000
  const usageError = error instanceof UsageError;
991
1001
  env.err(String(error?.message ?? error));
@@ -1009,6 +1019,18 @@ async function runDelta(options, { cwd, env }) {
1009
1019
  env.out(report);
1010
1020
  }
1011
1021
 
1022
+ // The event the run recorded, when `--event-out` was given — the store's
1023
+ // own `duplicate` answer, so a rerun over the same transition says so
1024
+ // instead of implying a second event was appended. Capture mode refuses the
1025
+ // flag upstream; this line runs only for a compare.
1026
+ if (result.eventWrite !== null) {
1027
+ env.err(
1028
+ `archkeep: evolution event ${
1029
+ result.eventWrite.duplicate ? "duplicate, already recorded" : "recorded"
1030
+ } → ${options.eventOut}`,
1031
+ );
1032
+ }
1033
+
1012
1034
  // The exit fold `deltaCommand` computed: a non-waived introduced violation
1013
1035
  // is a finding, an unclassifiable item is a no-verdict, anything else is
1014
1036
  // clean — mapped here the same way `fitness`'s status is.
@@ -1214,7 +1236,7 @@ async function runReconcile(options, { cwd, env }) {
1214
1236
  * `check` remains the authority on the law.
1215
1237
  *
1216
1238
  * @param {{format: string, output: string|null, config: string|null,
1217
- * intent: string|null, paths: string[]}} options
1239
+ * intent: string|null, eventOut?: string|null, paths: string[]}} options
1218
1240
  * @param {{cwd: string, env: {out: Function, err: Function, readGraph?: Function,
1219
1241
  * listFiles?: Function}}} runContext
1220
1242
  * @returns {Promise<number>}
@@ -1271,7 +1293,19 @@ async function runChange(options, { cwd, env }) {
1271
1293
  // profile-aware the same way `check` is.
1272
1294
  const { config } = await resolvePolicy(options, commandContext, cwd);
1273
1295
 
1274
- result = await changeCommand(baselinePath, intentPath, commandContext, { config });
1296
+ // `--event-out` names the reconcile event store directory, resolved from
1297
+ // cwd like the other path flags; `undefined` when absent, so a run
1298
+ // without the flag writes no event and stays byte-identical.
1299
+ const eventOut =
1300
+ typeof options.eventOut === "string" && options.eventOut !== ""
1301
+ ? isAbsolute(options.eventOut)
1302
+ ? options.eventOut
1303
+ : resolve(cwd, options.eventOut)
1304
+ : undefined;
1305
+ result = await changeCommand(baselinePath, intentPath, commandContext, {
1306
+ config,
1307
+ ...(eventOut === undefined ? {} : { eventOut }),
1308
+ });
1275
1309
  } catch (error) {
1276
1310
  const usageError = error instanceof UsageError;
1277
1311
  env.err(String(error?.message ?? error));
@@ -1581,7 +1615,7 @@ async function runExplain(options, { cwd, env }) {
1581
1615
  * same as `check` and `explain`, because the answer depends on which boundary
1582
1616
  * law is in effect.
1583
1617
  *
1584
- * @param {{format: string, output: string|null, config: string|null, plan: boolean, paths: string[]}} options
1618
+ * @param {{format: string, output: string|null, config: string|null, plan: boolean, paths: string[], historyDir: string|null}} options
1585
1619
  * @param {{cwd: string, env: {out: Function, err: Function, readGraph?: Function, listFiles?: Function}}} runContext
1586
1620
  * @returns {Promise<number>}
1587
1621
  */
@@ -1622,8 +1656,14 @@ async function runContextCommand(options, { cwd, env }) {
1622
1656
  // profile-aware the same way `check` is.
1623
1657
  const { config } = await resolvePolicy(options, commandContext, cwd);
1624
1658
 
1659
+ const historyDir = options.historyDir
1660
+ ? isAbsolute(options.historyDir)
1661
+ ? options.historyDir
1662
+ : resolve(cwd, options.historyDir)
1663
+ : null;
1664
+
1625
1665
  result = options.plan
1626
- ? await planContextCommand(projectName, scopePaths, commandContext, config)
1666
+ ? await planContextCommand(projectName, scopePaths, commandContext, config, historyDir)
1627
1667
  : contextCommand(projectName, commandContext, config);
1628
1668
  } catch (error) {
1629
1669
  const usageError = error instanceof UsageError;
@@ -2026,7 +2066,7 @@ async function runTrajectory(options, { cwd, env }) {
2026
2066
  * production both are undefined and every revision is read for real.
2027
2067
  *
2028
2068
  * @param {{format: string, output: string|null, base: string|null, head: string|null,
2029
- * paths: string[]}} options
2069
+ * eventOut: string|null, paths: string[]}} options
2030
2070
  * @param {{cwd: string, env: {out: Function, err: Function, readGraph?: Function, listFiles?: Function}}} runContext
2031
2071
  * @returns {Promise<number>}
2032
2072
  */
@@ -2057,9 +2097,18 @@ async function runEvolution(options, { cwd, env }) {
2057
2097
 
2058
2098
  let result;
2059
2099
  try {
2100
+ // `--event-out` names the transition event store directory, resolved from
2101
+ // cwd like the other path flags; `null` when absent, so a run without the
2102
+ // flag writes no event and stays byte-identical.
2103
+ const eventOut =
2104
+ typeof options.eventOut === "string" && options.eventOut !== ""
2105
+ ? isAbsolute(options.eventOut)
2106
+ ? options.eventOut
2107
+ : resolve(cwd, options.eventOut)
2108
+ : null;
2060
2109
  result = await evolutionCommand(
2061
2110
  root,
2062
- { base: options.base, head: options.head },
2111
+ { base: options.base, head: options.head, eventOut },
2063
2112
  {
2064
2113
  readGraph: env.readGraph,
2065
2114
  listFiles: env.listFiles,
@@ -2098,7 +2147,7 @@ async function runEvolution(options, { cwd, env }) {
2098
2147
  * consumer-managed directory `history` reads, so a ledger ages across the
2099
2148
  * same snapshots the evolution record is built from.
2100
2149
  *
2101
- * @param {{format: string, output: string|null, config: string|null, paths: string[]}} options
2150
+ * @param {{format: string, output: string|null, config: string|null, events: string|null, paths: string[]}} options
2102
2151
  * @param {{cwd: string, env: {out: Function, err: Function, readGraph?: Function, listFiles?: Function}}} runContext
2103
2152
  * @returns {Promise<number>}
2104
2153
  */
@@ -2126,7 +2175,10 @@ async function runDebt(options, { cwd, env }) {
2126
2175
  // profile-selected workspace resolves the same way `check` does.
2127
2176
  const { config } = await resolvePolicy(options, commandContext, cwd);
2128
2177
 
2129
- result = await debtCommand(dir, commandContext, { config });
2178
+ result = await debtCommand(dir, commandContext, {
2179
+ config,
2180
+ events: options.events,
2181
+ });
2130
2182
  } catch (error) {
2131
2183
  const usageError = error instanceof UsageError;
2132
2184
  env.err(String(error?.message ?? error));
@@ -2494,9 +2546,10 @@ const DELTA_FLAG_HELP = Object.freeze([
2494
2546
  key: "capture",
2495
2547
  arg: "",
2496
2548
  describe: Object.freeze([
2497
- "Write an evidence snapshot of the current tree",
2549
+ "Print an evidence snapshot of the current tree",
2498
2550
  "(raw import records, graph, coverage, policy",
2499
- "fingerprint) for a later delta run to compare against",
2551
+ "fingerprint) for a later delta run to compare against.",
2552
+ "Without --output, the snapshot goes to stdout.",
2500
2553
  ]),
2501
2554
  }),
2502
2555
  Object.freeze({
@@ -2530,6 +2583,17 @@ const DELTA_FLAG_HELP = Object.freeze([
2530
2583
  : `<workspace root>/${boundaryConfig}`,
2531
2584
  ]),
2532
2585
  }),
2586
+ Object.freeze({
2587
+ flag: "--event-out",
2588
+ key: "eventOut",
2589
+ arg: "<dir>",
2590
+ describe: Object.freeze([
2591
+ "Append the delta's evolution event to this directory",
2592
+ "(one canonical record per transition; idempotent — a",
2593
+ "rerun over the same transition writes nothing new).",
2594
+ "Absent: no event file is written",
2595
+ ]),
2596
+ }),
2533
2597
  ]);
2534
2598
 
2535
2599
  /**
@@ -2566,6 +2630,16 @@ const CHANGE_FLAG_HELP = Object.freeze([
2566
2630
  arg: "<file>",
2567
2631
  describe: Object.freeze(["Write the report to a file instead of stdout"]),
2568
2632
  }),
2633
+ Object.freeze({
2634
+ flag: "--event-out",
2635
+ key: "eventOut",
2636
+ arg: "<dir>",
2637
+ describe: Object.freeze([
2638
+ "Also write the reconcile EvolutionEvent to this directory",
2639
+ "(one file per run, idempotent; the classification always",
2640
+ "rides the envelope result — see docs/concepts/evolution.md)",
2641
+ ]),
2642
+ }),
2569
2643
  Object.freeze({
2570
2644
  flag: "--config",
2571
2645
  key: "config",
@@ -2839,6 +2913,16 @@ const EVOLUTION_FLAG_HELP = Object.freeze([
2839
2913
  "linear descendant of --base with no merges between",
2840
2914
  ]),
2841
2915
  }),
2916
+ Object.freeze({
2917
+ flag: "--event-out",
2918
+ key: "eventOut",
2919
+ arg: "<dir>",
2920
+ describe: Object.freeze([
2921
+ "Append one EvolutionEvent per revision pair to this directory",
2922
+ "(idempotent — a re-run over the same pair records a duplicate, never",
2923
+ "a second event). docs/concepts/evolution.md owns the event model.",
2924
+ ]),
2925
+ }),
2842
2926
  ]);
2843
2927
 
2844
2928
  /**
@@ -2981,6 +3065,15 @@ const DEBT_FLAG_HELP = Object.freeze([
2981
3065
  : `<workspace root>/${boundaryConfig}`,
2982
3066
  ]),
2983
3067
  }),
3068
+ Object.freeze({
3069
+ flag: "--events",
3070
+ key: "events",
3071
+ arg: "<dir>",
3072
+ describe: Object.freeze([
3073
+ "Link the evolution event store in <dir> so debt entries carry",
3074
+ "introducedBy/resolvedBy refs and a resolved list",
3075
+ ]),
3076
+ }),
2984
3077
  ]);
2985
3078
 
2986
3079
  /**
@@ -3139,6 +3232,18 @@ const CONTEXT_FLAG_HELP = Object.freeze([
3139
3232
  : `<workspace root>/${boundaryConfig}`,
3140
3233
  ]),
3141
3234
  }),
3235
+ Object.freeze({
3236
+ flag: "--history-dir",
3237
+ key: "historyDir",
3238
+ arg: "<dir>",
3239
+ describe: Object.freeze([
3240
+ "Path to the workspace's history directory. When given and",
3241
+ "the directory holds archived snapshots, the planning context",
3242
+ "includes the architecture-debt snapshot: current violations,",
3243
+ "exemptions, and gaps aged across the history. Used only with",
3244
+ "`--plan`; ignored otherwise.",
3245
+ ]),
3246
+ }),
3142
3247
  ]);
3143
3248
 
3144
3249
  /**
@@ -3252,7 +3357,13 @@ const COMMANDS = Object.freeze({
3252
3357
  summary: "Classify how boundary violations moved between a captured baseline and head",
3253
3358
  flagHelp: DELTA_FLAG_HELP,
3254
3359
  flags: Object.freeze(Object.fromEntries(DELTA_FLAG_HELP.map((f) => [f.flag, f.key]))),
3255
- defaults: Object.freeze({ format: "text", output: null, config: null, capture: false }),
3360
+ defaults: Object.freeze({
3361
+ format: "text",
3362
+ output: null,
3363
+ config: null,
3364
+ capture: false,
3365
+ eventOut: null,
3366
+ }),
3256
3367
  formats: DELTA_FORMATS,
3257
3368
  booleans: Object.freeze(["capture"]),
3258
3369
  run: runDelta,
@@ -3263,7 +3374,13 @@ const COMMANDS = Object.freeze({
3263
3374
  summary: "Reconcile a declared change intent against the architectural delta",
3264
3375
  flagHelp: CHANGE_FLAG_HELP,
3265
3376
  flags: Object.freeze(Object.fromEntries(CHANGE_FLAG_HELP.map((f) => [f.flag, f.key]))),
3266
- defaults: Object.freeze({ format: "text", output: null, config: null, intent: null }),
3377
+ defaults: Object.freeze({
3378
+ format: "text",
3379
+ output: null,
3380
+ config: null,
3381
+ intent: null,
3382
+ eventOut: null,
3383
+ }),
3267
3384
  formats: DESCRIBABLE_FORMATS,
3268
3385
  run: runChange,
3269
3386
  }),
@@ -3346,7 +3463,13 @@ const COMMANDS = Object.freeze({
3346
3463
  summary: "Describe how the architecture evolved across a Git revision range",
3347
3464
  flagHelp: EVOLUTION_FLAG_HELP,
3348
3465
  flags: Object.freeze(Object.fromEntries(EVOLUTION_FLAG_HELP.map((f) => [f.flag, f.key]))),
3349
- defaults: Object.freeze({ format: "text", output: null, base: null, head: null }),
3466
+ defaults: Object.freeze({
3467
+ format: "text",
3468
+ output: null,
3469
+ base: null,
3470
+ head: null,
3471
+ eventOut: null,
3472
+ }),
3350
3473
  formats: DESCRIBABLE_FORMATS,
3351
3474
  run: runEvolution,
3352
3475
  }),
@@ -3376,7 +3499,7 @@ const COMMANDS = Object.freeze({
3376
3499
  summary: "Print the architecture-debt ledger across snapshots",
3377
3500
  flagHelp: DEBT_FLAG_HELP,
3378
3501
  flags: Object.freeze(Object.fromEntries(DEBT_FLAG_HELP.map((f) => [f.flag, f.key]))),
3379
- defaults: Object.freeze({ format: "text", output: null, config: null }),
3502
+ defaults: Object.freeze({ format: "text", output: null, config: null, events: null }),
3380
3503
  formats: DESCRIBABLE_FORMATS,
3381
3504
  run: runDebt,
3382
3505
  }),
@@ -3406,7 +3529,13 @@ const COMMANDS = Object.freeze({
3406
3529
  summary: "Show the architecture constraints that apply to a project",
3407
3530
  flagHelp: CONTEXT_FLAG_HELP,
3408
3531
  flags: Object.freeze(Object.fromEntries(CONTEXT_FLAG_HELP.map((f) => [f.flag, f.key]))),
3409
- defaults: Object.freeze({ format: "text", output: null, config: null, plan: false }),
3532
+ defaults: Object.freeze({
3533
+ format: "text",
3534
+ output: null,
3535
+ config: null,
3536
+ plan: false,
3537
+ historyDir: null,
3538
+ }),
3410
3539
  formats: DESCRIBABLE_FORMATS,
3411
3540
  booleans: Object.freeze(["plan"]),
3412
3541
  run: runContextCommand,
@@ -3498,10 +3627,15 @@ export async function runCli(argv, env) {
3498
3627
  // one root-marker read and a clean run pays none.
3499
3628
  const help = () => usage(optionsForUsage(cwd));
3500
3629
 
3630
+ // --help and --version are universal flags, handled before command dispatch.
3501
3631
  if (argv[0] === "--help" || argv[0] === "-h") {
3502
3632
  env.out(help());
3503
3633
  return EXIT.ok;
3504
3634
  }
3635
+ if (argv[0] === "--version" || argv[0] === "-v") {
3636
+ env.out(`${TOOL_NAME} ${TOOL_VERSION}`);
3637
+ return EXIT.ok;
3638
+ }
3505
3639
 
3506
3640
  const [maybeCommand, ...maybeRest] = argv;
3507
3641
  let commandName;
@@ -3512,6 +3646,11 @@ export async function runCli(argv, env) {
3512
3646
  } else if (Object.hasOwn(COMMANDS, maybeCommand)) {
3513
3647
  commandName = maybeCommand;
3514
3648
  rest = maybeRest;
3649
+ // Subcommand --help: show the main help without "unknown option" error.
3650
+ if (rest.includes("--help") || rest.includes("-h")) {
3651
+ env.out(help());
3652
+ return EXIT.ok;
3653
+ }
3515
3654
  } else if (
3516
3655
  maybeCommand !== "" &&
3517
3656
  existsSync(isAbsolute(maybeCommand) ? maybeCommand : join(cwd, maybeCommand))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ecoma-io/archkeep",
3
- "version": "0.17.0",
3
+ "version": "0.18.1",
4
4
  "description": "Architecture enforcement for polyglot repositories — dependency graphs and module boundaries for Go, Rust, Python, TypeScript, JavaScript, Vue, Java and Kotlin",
5
5
  "keywords": [
6
6
  "architecture",
@@ -53,7 +53,7 @@
53
53
  },
54
54
  "peerDependencies": {
55
55
  "@nx/eslint-plugin": ">=21",
56
- "fast-xml-parser": "5.11.0",
56
+ "fast-xml-parser": "5.11.1",
57
57
  "nx": ">=21",
58
58
  "typescript": ">=5 <7",
59
59
  "vue": ">=3"
@@ -168,12 +168,15 @@ function codeDependencies(graph) {
168
168
  * @param {object} intent The normalized model from `./model.mjs`.
169
169
  * @param {{nodes: object, dependencies?: object}} graph
170
170
  * @returns {{verdict: "ok"|"findings"|"no-verdict",
171
- * findings: object[], unresolved: object[], boundaries: object[], notes: string[]}}
171
+ * findings: object[], unresolved: object[], boundaries: object[], notes: string[],
172
+ * gaps: {from: string, to: string, note: string}[]}}
172
173
  * `findings` are `{source, target, rule, boundaryFrom, boundaryTo, message}`;
173
174
  * `unresolved` are `{boundary, issue}` for every empty side or empty
174
175
  * boundary; `boundaries` are `{name, projects[]}` (sorted members); `notes`
175
176
  * are coverage notes that change no verdict — today only an
176
- * `"optional": true` `allowed` row whose statement is not yet built.
177
+ * `"optional": true` `allowed` row whose statement is not yet built; `gaps`
178
+ * carry the same rows' structured `{from, to}` identities (F-DEB-8), the
179
+ * stable key the debt ledger hashes an aspirational-gap entry by.
177
180
  */
178
181
  export function judgeIntent(intent, graph) {
179
182
  const nodes = graph.nodes ?? {};
@@ -191,6 +194,7 @@ export function judgeIntent(intent, graph) {
191
194
  const findings = [];
192
195
  const unresolved = [];
193
196
  const notes = [];
197
+ const gaps = [];
194
198
 
195
199
  for (const boundary of boundaries) {
196
200
  if (boundary.projects.length === 0) {
@@ -291,9 +295,18 @@ export function judgeIntent(intent, graph) {
291
295
  // Absence tolerated — aspirational, not drift — but it is still a
292
296
  // coverage note and the caller threads it into the report's coverage
293
297
  // notes, so a reader can tell "optional and absent" from "never checked".
294
- notes.push(
295
- `optional allowed intent "${row.from}" → "${row.to}" is not yet observed — aspirational, not drift`,
296
- );
298
+ const note = `optional allowed intent "${row.from}" → "${row.to}" is not yet observed — aspirational, not drift`;
299
+ notes.push(note);
300
+ // The structured `{from, to}` is the STABLE identity of the gap — the
301
+ // debt ledger keys an aspirational-gap entry by it (F-DEB-8), never by
302
+ // the prose note, so a reworded note does not re-key the fact.
303
+ gaps.push({
304
+ from: row.from,
305
+ to: row.to,
306
+ note,
307
+ boundaryFrom: from.boundaryName ?? row.from,
308
+ boundaryTo: to.boundaryName ?? row.to,
309
+ });
297
310
  return;
298
311
  }
299
312
  const pairs = [];
@@ -535,5 +548,5 @@ export function judgeIntent(intent, graph) {
535
548
 
536
549
  const verdict = findings.length > 0 ? "findings" : unresolved.length > 0 ? "no-verdict" : "ok";
537
550
 
538
- return { verdict, findings, unresolved, boundaries, notes };
551
+ return { verdict, findings, unresolved, boundaries, notes, gaps };
539
552
  }
@@ -313,6 +313,43 @@ export function findChangeIntentViolations(raw) {
313
313
 
314
314
  return violations;
315
315
  }
316
+ /**
317
+ * The breadth guard (wave 3, design §5): everything wrong with a NORMALIZED
318
+ * intent whose declaration is empty while its own prose asserts a change.
319
+ *
320
+ * An intent with zero declared rows — no project rows, no edge rows, no
321
+ * declared constraints — declares that NOTHING material will change. Its
322
+ * `summary`, if present, is the author's own statement of what the change
323
+ * does; when the rows are empty that statement is a claim nothing in the
324
+ * declaration can be verified against, and the reconciliation would read
325
+ * `matched` over an unchanged tree while the author's prose says the tree
326
+ * moved. Prose cannot assert what rows must state: this is the one bypass
327
+ * around the grammar's reject-by-name discipline, and it is refused loudly
328
+ * (`parseChangeIntent` throws, exit 3 upstream) instead of reconciling.
329
+ *
330
+ * The rule is deliberately stricter than "empty projects/edges": it also
331
+ * requires the constraints section empty. A declared constraint IS a row —
332
+ * `noNewViolations: true` states a verifiable promise, and an intent that
333
+ * declares one is not a catch-all no matter what its summary says.
334
+ *
335
+ * @param {object} intent A `parseChangeIntent` result — the normalized shape,
336
+ * with absent raw sections already normalized to empty arrays.
337
+ * @returns {string[]} Messages; empty when the intent is not a catch-all.
338
+ */
339
+ export function findChangeIntentBreadthViolations(intent) {
340
+ const declaredRows =
341
+ intent.projects.add.length +
342
+ intent.projects.remove.length +
343
+ intent.edges.add.length +
344
+ intent.edges.remove.length +
345
+ Object.keys(intent.constraints).length;
346
+ if (declaredRows > 0 || intent.summary === undefined) return [];
347
+ return [
348
+ "summary: the intent declares no rows (no projects, no edges, no constraints) while its " +
349
+ "summary asserts a material change — prose cannot assert what rows must state; declare " +
350
+ "the material consequences in the rows, or drop the summary",
351
+ ];
352
+ }
316
353
 
317
354
  /**
318
355
  * Parses and validates change-intent text into the normalized shape the
@@ -346,16 +383,18 @@ export function parseChangeIntent(text, path) {
346
383
  { cause },
347
384
  );
348
385
  }
386
+ // Shape validation first, then the breadth guard over the NORMALIZED
387
+ // intent — the guard's subject is the intent as the command will consume
388
+ // it, with absent sections already normalized to empty expectations. Both
389
+ // name every violation at once in the one throw below, so a malformed
390
+ // catch-all is reported whole rather than piecemeal.
349
391
  const violations = findChangeIntentViolations(parsed);
350
- if (violations.length > 0) {
351
- throw new Error(
352
- `archkeep: the change intent '${path}' is not a usable contract:\n ` +
353
- violations.join("\n "),
354
- );
355
- }
356
- return {
392
+ const intent = {
357
393
  version: parsed.version,
358
- base: { commit: parsed.base.commit },
394
+ // Defensive access: an invalid `base` is caught by the shape violations
395
+ // below and thrown before this object is ever returned, so a missing
396
+ // section must not crash the normalization itself.
397
+ base: { commit: parsed.base?.commit },
359
398
  ...(parsed.summary === undefined ? {} : { summary: parsed.summary }),
360
399
  projects: {
361
400
  add: parsed.projects?.add ?? [],
@@ -367,6 +406,14 @@ export function parseChangeIntent(text, path) {
367
406
  },
368
407
  constraints: { ...(parsed.constraints ?? {}) },
369
408
  };
409
+ violations.push(...findChangeIntentBreadthViolations(intent));
410
+ if (violations.length > 0) {
411
+ throw new Error(
412
+ `archkeep: the change intent '${path}' is not a usable contract:\n ` +
413
+ violations.join("\n "),
414
+ );
415
+ }
416
+ return intent;
370
417
  }
371
418
 
372
419
  /**