@ecoma-io/archkeep 0.16.1 → 0.18.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 (42) hide show
  1. package/README.md +1 -1
  2. package/cli.mjs +258 -20
  3. package/package.json +2 -2
  4. package/src/architecture-intent/judge.mjs +19 -6
  5. package/src/commands/adr.mjs +45 -4
  6. package/src/commands/change-intent.mjs +55 -8
  7. package/src/commands/change.mjs +332 -11
  8. package/src/commands/debt.mjs +26 -5
  9. package/src/commands/decisions.mjs +291 -0
  10. package/src/commands/delta-classify.mjs +257 -0
  11. package/src/commands/delta.mjs +269 -8
  12. package/src/commands/evolution.mjs +758 -5
  13. package/src/commands/explain.mjs +207 -1
  14. package/src/commands/history.mjs +81 -5
  15. package/src/commands/plan-context-command.mjs +163 -2
  16. package/src/commands/provenance-command.mjs +86 -17
  17. package/src/commands/provenance.mjs +60 -0
  18. package/src/commands/report.mjs +48 -1
  19. package/src/commands/trajectory.mjs +89 -3
  20. package/src/fixtures/evolution-lifecycle/workspace.mjs +242 -0
  21. package/src/governance/adr-registry.mjs +252 -15
  22. package/src/governance/debt-ledger.mjs +261 -19
  23. package/src/governance/decision-fitness.mjs +213 -0
  24. package/src/governance/decision-graph.mjs +483 -0
  25. package/src/governance/decision-lineage.mjs +250 -0
  26. package/src/governance/evolution-event.mjs +470 -0
  27. package/src/governance/evolution-store.mjs +362 -0
  28. package/src/governance/provenance-record.mjs +150 -0
  29. package/src/providers/native/model.mjs +18 -4
  30. package/src/report/adr-text.mjs +109 -4
  31. package/src/report/change-text.mjs +21 -3
  32. package/src/report/debt-text.mjs +42 -6
  33. package/src/report/decisions-text.mjs +164 -0
  34. package/src/report/delta-text.mjs +36 -1
  35. package/src/report/evolution-text.mjs +231 -2
  36. package/src/report/explain-text.mjs +122 -1
  37. package/src/report/history-text.mjs +9 -3
  38. package/src/report/plan-context-text.mjs +94 -0
  39. package/src/report/provenance-text.mjs +67 -1
  40. package/src/report/report-text.mjs +53 -18
  41. package/src/report/snapshot-text.mjs +35 -1
  42. package/src/report/trajectory-text.mjs +30 -1
package/README.md CHANGED
@@ -225,7 +225,7 @@ Ten minutes end to end, most of it spent deciding what your tags mean:
225
225
  Ten minutes end to end, most of it spent deciding what your tags mean:
226
226
  [**Getting started →**](https://github.com/ecoma-io/archkeep/blob/main/docs/getting-started/installation.md). `graph`, `diff`,
227
227
  `history`, `trajectory`, `evolution`, `drift`, `impact`, `explain`,
228
- `context` and the rest of the 22-command surface are in the
228
+ `context` and the rest of the 23-command surface are in the
229
229
  [CLI reference](https://github.com/ecoma-io/archkeep/blob/main/docs/reference/cli.md).
230
230
 
231
231
  ## Documentation map
package/cli.mjs CHANGED
@@ -107,6 +107,7 @@ import {
107
107
  import { contextCommand } from "./src/commands/context-command.mjs";
108
108
  import { planContextCommand } from "./src/commands/plan-context-command.mjs";
109
109
  import { adrCommand } from "./src/commands/adr.mjs";
110
+ import { decisionsCommand } from "./src/commands/decisions.mjs";
110
111
  import { diffCommand } from "./src/commands/diff.mjs";
111
112
  import { captureDelta, deltaCommand } from "./src/commands/delta.mjs";
112
113
  import { discoverCommand } from "./src/commands/discover.mjs";
@@ -930,12 +931,8 @@ async function runDiff(options, { cwd, env }) {
930
931
  *
931
932
  * `--capture` writes the evidence snapshot a later run compares against;
932
933
  * `delta <baseline>` loads one, re-judges both sides under the current law,
933
- * and folds the classification into the exit code — the one descriptive-family
934
- * verb beside `check` and `fitness` whose verdict carries exit 1
935
- * (`./src/commands/delta.mjs` owns the fold).
936
- *
937
934
  * @param {{format: string, output: string|null, config: string|null, capture: boolean,
938
- * paths: string[]}} options
935
+ * eventOut: string|null, paths: string[]}} options
939
936
  * @param {{cwd: string, env: {out: Function, err: Function, readGraph?: Function, listFiles?: Function}}} runContext
940
937
  * @returns {Promise<number>}
941
938
  */
@@ -947,6 +944,13 @@ async function runDelta(options, { cwd, env }) {
947
944
  );
948
945
  return EXIT.usage;
949
946
  }
947
+ if (options.eventOut !== null) {
948
+ env.err(
949
+ `archkeep: delta --capture does not take --event-out — an event records a transition, ` +
950
+ `and a capture is one side of it`,
951
+ );
952
+ return EXIT.usage;
953
+ }
950
954
  } else if (options.paths.length !== 1) {
951
955
  env.err(
952
956
  `archkeep: delta takes exactly one positional argument (the baseline evidence snapshot), ` +
@@ -971,7 +975,6 @@ async function runDelta(options, { cwd, env }) {
971
975
  const { text } = captureDelta(commandContext, { config });
972
976
  if (options.output) {
973
977
  // Atomic, symlink-safe write — `writeOutputReport`'s own docstring
974
- // owns the mechanism and the threat it closes.
975
978
  if (!writeOutputReport(options.output, text, env, cwd, options.config)) return EXIT.error;
976
979
  env.err(`archkeep: delta baseline captured → ${options.output}`);
977
980
  } else {
@@ -984,7 +987,10 @@ async function runDelta(options, { cwd, env }) {
984
987
  const baselinePath = isAbsolute(options.paths[0])
985
988
  ? resolve(options.paths[0])
986
989
  : resolve(cwd, options.paths[0]);
987
- result = await deltaCommand(baselinePath, commandContext, { config });
990
+ result = await deltaCommand(baselinePath, commandContext, {
991
+ config,
992
+ eventOut: options.eventOut,
993
+ });
988
994
  } catch (error) {
989
995
  const usageError = error instanceof UsageError;
990
996
  env.err(String(error?.message ?? error));
@@ -1008,6 +1014,18 @@ async function runDelta(options, { cwd, env }) {
1008
1014
  env.out(report);
1009
1015
  }
1010
1016
 
1017
+ // The event the run recorded, when `--event-out` was given — the store's
1018
+ // own `duplicate` answer, so a rerun over the same transition says so
1019
+ // instead of implying a second event was appended. Capture mode refuses the
1020
+ // flag upstream; this line runs only for a compare.
1021
+ if (result.eventWrite !== null) {
1022
+ env.err(
1023
+ `archkeep: evolution event ${
1024
+ result.eventWrite.duplicate ? "duplicate, already recorded" : "recorded"
1025
+ } → ${options.eventOut}`,
1026
+ );
1027
+ }
1028
+
1011
1029
  // The exit fold `deltaCommand` computed: a non-waived introduced violation
1012
1030
  // is a finding, an unclassifiable item is a no-verdict, anything else is
1013
1031
  // clean — mapped here the same way `fitness`'s status is.
@@ -1213,7 +1231,7 @@ async function runReconcile(options, { cwd, env }) {
1213
1231
  * `check` remains the authority on the law.
1214
1232
  *
1215
1233
  * @param {{format: string, output: string|null, config: string|null,
1216
- * intent: string|null, paths: string[]}} options
1234
+ * intent: string|null, eventOut?: string|null, paths: string[]}} options
1217
1235
  * @param {{cwd: string, env: {out: Function, err: Function, readGraph?: Function,
1218
1236
  * listFiles?: Function}}} runContext
1219
1237
  * @returns {Promise<number>}
@@ -1270,7 +1288,19 @@ async function runChange(options, { cwd, env }) {
1270
1288
  // profile-aware the same way `check` is.
1271
1289
  const { config } = await resolvePolicy(options, commandContext, cwd);
1272
1290
 
1273
- result = await changeCommand(baselinePath, intentPath, commandContext, { config });
1291
+ // `--event-out` names the reconcile event store directory, resolved from
1292
+ // cwd like the other path flags; `undefined` when absent, so a run
1293
+ // without the flag writes no event and stays byte-identical.
1294
+ const eventOut =
1295
+ typeof options.eventOut === "string" && options.eventOut !== ""
1296
+ ? isAbsolute(options.eventOut)
1297
+ ? options.eventOut
1298
+ : resolve(cwd, options.eventOut)
1299
+ : undefined;
1300
+ result = await changeCommand(baselinePath, intentPath, commandContext, {
1301
+ config,
1302
+ ...(eventOut === undefined ? {} : { eventOut }),
1303
+ });
1274
1304
  } catch (error) {
1275
1305
  const usageError = error instanceof UsageError;
1276
1306
  env.err(String(error?.message ?? error));
@@ -1580,7 +1610,7 @@ async function runExplain(options, { cwd, env }) {
1580
1610
  * same as `check` and `explain`, because the answer depends on which boundary
1581
1611
  * law is in effect.
1582
1612
  *
1583
- * @param {{format: string, output: string|null, config: string|null, plan: boolean, paths: string[]}} options
1613
+ * @param {{format: string, output: string|null, config: string|null, plan: boolean, paths: string[], historyDir: string|null}} options
1584
1614
  * @param {{cwd: string, env: {out: Function, err: Function, readGraph?: Function, listFiles?: Function}}} runContext
1585
1615
  * @returns {Promise<number>}
1586
1616
  */
@@ -1621,8 +1651,14 @@ async function runContextCommand(options, { cwd, env }) {
1621
1651
  // profile-aware the same way `check` is.
1622
1652
  const { config } = await resolvePolicy(options, commandContext, cwd);
1623
1653
 
1654
+ const historyDir = options.historyDir
1655
+ ? isAbsolute(options.historyDir)
1656
+ ? options.historyDir
1657
+ : resolve(cwd, options.historyDir)
1658
+ : null;
1659
+
1624
1660
  result = options.plan
1625
- ? await planContextCommand(projectName, scopePaths, commandContext, config)
1661
+ ? await planContextCommand(projectName, scopePaths, commandContext, config, historyDir)
1626
1662
  : contextCommand(projectName, commandContext, config);
1627
1663
  } catch (error) {
1628
1664
  const usageError = error instanceof UsageError;
@@ -1712,6 +1748,73 @@ async function runAdr(options, { cwd, env }) {
1712
1748
  // Descriptive: 0 for answered, 3 for incomplete coverage.
1713
1749
  return result.status === "ok" ? EXIT.ok : EXIT.error;
1714
1750
  }
1751
+ /**
1752
+ * `decisions`'s run: the deterministic chain behind one recorded decision —
1753
+ * decision → governed rows → projects → current findings, with the
1754
+ * per-decision verification level. Exactly one positional: the ADR id.
1755
+ *
1756
+ * The law is resolved the way `report` resolves it (`resolvePolicy`) because
1757
+ * the chain's Fitness leg reads the workspace's declared gates, and `--config`
1758
+ * wins the same way. Fitness verdicts are derived inside the command from the
1759
+ * declared list; a declared gate that fails to evaluate THROWS (exit 3), it
1760
+ * never silently walks clean. `0` when every hop of the chain resolved, `3`
1761
+ * when any did not — never `1`.
1762
+ *
1763
+ * @param {{format: string, output: string|null, config: string|null, paths: string[]}} options
1764
+ * @param {{cwd: string, env: {out: Function, err: Function, readGraph?: Function, listFiles?: Function}}} runContext
1765
+ * @returns {Promise<number>}
1766
+ */
1767
+ async function runDecisions(options, { cwd, env }) {
1768
+ if (options.paths.length !== 1) {
1769
+ env.err(
1770
+ `archkeep: decisions takes exactly one positional argument (an ADR id); ` +
1771
+ `got ${options.paths.length}`,
1772
+ );
1773
+ return EXIT.usage;
1774
+ }
1775
+
1776
+ let result;
1777
+ try {
1778
+ const commandContext = resolveCommandContext(
1779
+ { cwd },
1780
+ { readGraph: env.readGraph, listFiles: env.listFiles },
1781
+ );
1782
+
1783
+ // ONE law for the chain, resolved exactly like `report` — the Fitness leg
1784
+ // reads this law's declared gates, so a `--config` override must reach it.
1785
+ const { config } = await resolvePolicy(options, commandContext, cwd);
1786
+
1787
+ const intent = commandContext.tracked.includes(INTENT_FILE)
1788
+ ? await loadIntent(commandContext.root, { tracked: commandContext.tracked })
1789
+ : null;
1790
+
1791
+ result = decisionsCommand(options.paths[0], commandContext, config, { intent });
1792
+ } catch (error) {
1793
+ const usageError = error instanceof UsageError;
1794
+ env.err(String(error?.message ?? error));
1795
+ return usageError ? EXIT.usage : EXIT.error;
1796
+ }
1797
+
1798
+ const report = options.format === "json" ? result.report.json : result.report.text;
1799
+
1800
+ if (options.output) {
1801
+ // Atomic, symlink-safe write — `writeOutputReport`'s own docstring owns
1802
+ // the mechanism and the threat it closes.
1803
+ const reportText = report.endsWith("\n") ? report : `${report}\n`;
1804
+ if (!writeOutputReport(options.output, reportText, env, cwd, options.config)) return EXIT.error;
1805
+ // The confirmation names the no-verdict case, so a reader who only
1806
+ // glances at stderr cannot mistake a written chain for a resolved one.
1807
+ env.err(
1808
+ `archkeep: decision chain for ${options.paths[0]} ` +
1809
+ `${result.status === "ok" ? "resolved" : "did NOT fully resolve"} → ${options.output}`,
1810
+ );
1811
+ } else {
1812
+ env.out(report);
1813
+ }
1814
+
1815
+ // Descriptive: 0 when every hop resolved, 3 when any could not. Never 1.
1816
+ return result.status === "ok" ? EXIT.ok : EXIT.error;
1817
+ }
1715
1818
 
1716
1819
  /**
1717
1820
  * `rules`'s `run`: dispatches to the appropriate subcommand (list/info/verify/add),
@@ -1958,7 +2061,7 @@ async function runTrajectory(options, { cwd, env }) {
1958
2061
  * production both are undefined and every revision is read for real.
1959
2062
  *
1960
2063
  * @param {{format: string, output: string|null, base: string|null, head: string|null,
1961
- * paths: string[]}} options
2064
+ * eventOut: string|null, paths: string[]}} options
1962
2065
  * @param {{cwd: string, env: {out: Function, err: Function, readGraph?: Function, listFiles?: Function}}} runContext
1963
2066
  * @returns {Promise<number>}
1964
2067
  */
@@ -1989,9 +2092,18 @@ async function runEvolution(options, { cwd, env }) {
1989
2092
 
1990
2093
  let result;
1991
2094
  try {
2095
+ // `--event-out` names the transition event store directory, resolved from
2096
+ // cwd like the other path flags; `null` when absent, so a run without the
2097
+ // flag writes no event and stays byte-identical.
2098
+ const eventOut =
2099
+ typeof options.eventOut === "string" && options.eventOut !== ""
2100
+ ? isAbsolute(options.eventOut)
2101
+ ? options.eventOut
2102
+ : resolve(cwd, options.eventOut)
2103
+ : null;
1992
2104
  result = await evolutionCommand(
1993
2105
  root,
1994
- { base: options.base, head: options.head },
2106
+ { base: options.base, head: options.head, eventOut },
1995
2107
  {
1996
2108
  readGraph: env.readGraph,
1997
2109
  listFiles: env.listFiles,
@@ -2030,7 +2142,7 @@ async function runEvolution(options, { cwd, env }) {
2030
2142
  * consumer-managed directory `history` reads, so a ledger ages across the
2031
2143
  * same snapshots the evolution record is built from.
2032
2144
  *
2033
- * @param {{format: string, output: string|null, config: string|null, paths: string[]}} options
2145
+ * @param {{format: string, output: string|null, config: string|null, events: string|null, paths: string[]}} options
2034
2146
  * @param {{cwd: string, env: {out: Function, err: Function, readGraph?: Function, listFiles?: Function}}} runContext
2035
2147
  * @returns {Promise<number>}
2036
2148
  */
@@ -2058,7 +2170,10 @@ async function runDebt(options, { cwd, env }) {
2058
2170
  // profile-selected workspace resolves the same way `check` does.
2059
2171
  const { config } = await resolvePolicy(options, commandContext, cwd);
2060
2172
 
2061
- result = await debtCommand(dir, commandContext, { config });
2173
+ result = await debtCommand(dir, commandContext, {
2174
+ config,
2175
+ events: options.events,
2176
+ });
2062
2177
  } catch (error) {
2063
2178
  const usageError = error instanceof UsageError;
2064
2179
  env.err(String(error?.message ?? error));
@@ -2462,6 +2577,17 @@ const DELTA_FLAG_HELP = Object.freeze([
2462
2577
  : `<workspace root>/${boundaryConfig}`,
2463
2578
  ]),
2464
2579
  }),
2580
+ Object.freeze({
2581
+ flag: "--event-out",
2582
+ key: "eventOut",
2583
+ arg: "<dir>",
2584
+ describe: Object.freeze([
2585
+ "Append the delta's evolution event to this directory",
2586
+ "(one canonical record per transition; idempotent — a",
2587
+ "rerun over the same transition writes nothing new).",
2588
+ "Absent: no event file is written",
2589
+ ]),
2590
+ }),
2465
2591
  ]);
2466
2592
 
2467
2593
  /**
@@ -2498,6 +2624,16 @@ const CHANGE_FLAG_HELP = Object.freeze([
2498
2624
  arg: "<file>",
2499
2625
  describe: Object.freeze(["Write the report to a file instead of stdout"]),
2500
2626
  }),
2627
+ Object.freeze({
2628
+ flag: "--event-out",
2629
+ key: "eventOut",
2630
+ arg: "<dir>",
2631
+ describe: Object.freeze([
2632
+ "Also write the reconcile EvolutionEvent to this directory",
2633
+ "(one file per run, idempotent; the classification always",
2634
+ "rides the envelope result — see docs/concepts/evolution.md)",
2635
+ ]),
2636
+ }),
2501
2637
  Object.freeze({
2502
2638
  flag: "--config",
2503
2639
  key: "config",
@@ -2771,6 +2907,16 @@ const EVOLUTION_FLAG_HELP = Object.freeze([
2771
2907
  "linear descendant of --base with no merges between",
2772
2908
  ]),
2773
2909
  }),
2910
+ Object.freeze({
2911
+ flag: "--event-out",
2912
+ key: "eventOut",
2913
+ arg: "<dir>",
2914
+ describe: Object.freeze([
2915
+ "Append one EvolutionEvent per revision pair to this directory",
2916
+ "(idempotent — a re-run over the same pair records a duplicate, never",
2917
+ "a second event). docs/concepts/evolution.md owns the event model.",
2918
+ ]),
2919
+ }),
2774
2920
  ]);
2775
2921
 
2776
2922
  /**
@@ -2913,6 +3059,15 @@ const DEBT_FLAG_HELP = Object.freeze([
2913
3059
  : `<workspace root>/${boundaryConfig}`,
2914
3060
  ]),
2915
3061
  }),
3062
+ Object.freeze({
3063
+ flag: "--events",
3064
+ key: "events",
3065
+ arg: "<dir>",
3066
+ describe: Object.freeze([
3067
+ "Link the evolution event store in <dir> so debt entries carry",
3068
+ "introducedBy/resolvedBy refs and a resolved list",
3069
+ ]),
3070
+ }),
2916
3071
  ]);
2917
3072
 
2918
3073
  /**
@@ -2985,6 +3140,42 @@ const EXPLAIN_FLAG_HELP = Object.freeze([
2985
3140
  }),
2986
3141
  ]);
2987
3142
 
3143
+ /**
3144
+ * `decisions`'s flags: text or JSON envelope, optional file output, and the
3145
+ * same `--config` override `report` takes — the chain's Fitness leg reads the
3146
+ * declared gates of one boundary law, and a caller must be able to say which.
3147
+ *
3148
+ * @type {readonly FlagHelp[]}
3149
+ */
3150
+ const DECISIONS_FLAG_HELP = Object.freeze([
3151
+ Object.freeze({
3152
+ flag: "--format",
3153
+ key: "format",
3154
+ arg: "text|json",
3155
+ describe: Object.freeze([
3156
+ "Terminal report (default) or the versioned JSON envelope",
3157
+ "docs/reference/json-output.md documents",
3158
+ ]),
3159
+ }),
3160
+ Object.freeze({
3161
+ flag: "--output",
3162
+ key: "output",
3163
+ arg: "<file>",
3164
+ describe: Object.freeze(["Write the report to a file instead of stdout"]),
3165
+ }),
3166
+ Object.freeze({
3167
+ flag: "--config",
3168
+ key: "config",
3169
+ arg: "<file>",
3170
+ describe: ({ boundaryConfig, inline }) =>
3171
+ Object.freeze([
3172
+ "Read the boundary law from here instead of",
3173
+ inline
3174
+ ? "the inline boundaryConfig in archkeep.json"
3175
+ : `<workspace root>/${boundaryConfig}`,
3176
+ ]),
3177
+ }),
3178
+ ]);
2988
3179
  /**
2989
3180
  * `context`'s flags: text or JSON envelope, optional file output.
2990
3181
  * The project name is a positional argument. `--config` overrides the boundary
@@ -3035,6 +3226,18 @@ const CONTEXT_FLAG_HELP = Object.freeze([
3035
3226
  : `<workspace root>/${boundaryConfig}`,
3036
3227
  ]),
3037
3228
  }),
3229
+ Object.freeze({
3230
+ flag: "--history-dir",
3231
+ key: "historyDir",
3232
+ arg: "<dir>",
3233
+ describe: Object.freeze([
3234
+ "Path to the workspace's history directory. When given and",
3235
+ "the directory holds archived snapshots, the planning context",
3236
+ "includes the architecture-debt snapshot: current violations,",
3237
+ "exemptions, and gaps aged across the history. Used only with",
3238
+ "`--plan`; ignored otherwise.",
3239
+ ]),
3240
+ }),
3038
3241
  ]);
3039
3242
 
3040
3243
  /**
@@ -3148,7 +3351,13 @@ const COMMANDS = Object.freeze({
3148
3351
  summary: "Classify how boundary violations moved between a captured baseline and head",
3149
3352
  flagHelp: DELTA_FLAG_HELP,
3150
3353
  flags: Object.freeze(Object.fromEntries(DELTA_FLAG_HELP.map((f) => [f.flag, f.key]))),
3151
- defaults: Object.freeze({ format: "text", output: null, config: null, capture: false }),
3354
+ defaults: Object.freeze({
3355
+ format: "text",
3356
+ output: null,
3357
+ config: null,
3358
+ capture: false,
3359
+ eventOut: null,
3360
+ }),
3152
3361
  formats: DELTA_FORMATS,
3153
3362
  booleans: Object.freeze(["capture"]),
3154
3363
  run: runDelta,
@@ -3159,7 +3368,13 @@ const COMMANDS = Object.freeze({
3159
3368
  summary: "Reconcile a declared change intent against the architectural delta",
3160
3369
  flagHelp: CHANGE_FLAG_HELP,
3161
3370
  flags: Object.freeze(Object.fromEntries(CHANGE_FLAG_HELP.map((f) => [f.flag, f.key]))),
3162
- defaults: Object.freeze({ format: "text", output: null, config: null, intent: null }),
3371
+ defaults: Object.freeze({
3372
+ format: "text",
3373
+ output: null,
3374
+ config: null,
3375
+ intent: null,
3376
+ eventOut: null,
3377
+ }),
3163
3378
  formats: DESCRIBABLE_FORMATS,
3164
3379
  run: runChange,
3165
3380
  }),
@@ -3242,7 +3457,13 @@ const COMMANDS = Object.freeze({
3242
3457
  summary: "Describe how the architecture evolved across a Git revision range",
3243
3458
  flagHelp: EVOLUTION_FLAG_HELP,
3244
3459
  flags: Object.freeze(Object.fromEntries(EVOLUTION_FLAG_HELP.map((f) => [f.flag, f.key]))),
3245
- defaults: Object.freeze({ format: "text", output: null, base: null, head: null }),
3460
+ defaults: Object.freeze({
3461
+ format: "text",
3462
+ output: null,
3463
+ base: null,
3464
+ head: null,
3465
+ eventOut: null,
3466
+ }),
3246
3467
  formats: DESCRIBABLE_FORMATS,
3247
3468
  run: runEvolution,
3248
3469
  }),
@@ -3272,7 +3493,7 @@ const COMMANDS = Object.freeze({
3272
3493
  summary: "Print the architecture-debt ledger across snapshots",
3273
3494
  flagHelp: DEBT_FLAG_HELP,
3274
3495
  flags: Object.freeze(Object.fromEntries(DEBT_FLAG_HELP.map((f) => [f.flag, f.key]))),
3275
- defaults: Object.freeze({ format: "text", output: null, config: null }),
3496
+ defaults: Object.freeze({ format: "text", output: null, config: null, events: null }),
3276
3497
  formats: DESCRIBABLE_FORMATS,
3277
3498
  run: runDebt,
3278
3499
  }),
@@ -3302,7 +3523,13 @@ const COMMANDS = Object.freeze({
3302
3523
  summary: "Show the architecture constraints that apply to a project",
3303
3524
  flagHelp: CONTEXT_FLAG_HELP,
3304
3525
  flags: Object.freeze(Object.fromEntries(CONTEXT_FLAG_HELP.map((f) => [f.flag, f.key]))),
3305
- defaults: Object.freeze({ format: "text", output: null, config: null, plan: false }),
3526
+ defaults: Object.freeze({
3527
+ format: "text",
3528
+ output: null,
3529
+ config: null,
3530
+ plan: false,
3531
+ historyDir: null,
3532
+ }),
3306
3533
  formats: DESCRIBABLE_FORMATS,
3307
3534
  booleans: Object.freeze(["plan"]),
3308
3535
  run: runContextCommand,
@@ -3317,6 +3544,17 @@ const COMMANDS = Object.freeze({
3317
3544
  formats: DESCRIBABLE_FORMATS,
3318
3545
  run: runProvenance,
3319
3546
  }),
3547
+ decisions: Object.freeze({
3548
+ name: "decisions",
3549
+ args: "<id>",
3550
+ summary:
3551
+ "Walk the full chain behind one recorded decision — decision to bound rows, projects, findings, and its verification level",
3552
+ flagHelp: DECISIONS_FLAG_HELP,
3553
+ flags: Object.freeze(Object.fromEntries(DECISIONS_FLAG_HELP.map((f) => [f.flag, f.key]))),
3554
+ defaults: Object.freeze({ format: "text", output: null, config: null }),
3555
+ formats: DESCRIBABLE_FORMATS,
3556
+ run: runDecisions,
3557
+ }),
3320
3558
  adr: Object.freeze({
3321
3559
  name: "adr",
3322
3560
  args: "[<id>]",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ecoma-io/archkeep",
3
- "version": "0.16.1",
3
+ "version": "0.18.0",
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
  }
@@ -55,6 +55,20 @@
55
55
  * `bindings` beside `knownFitness`, at exit 0. Naming a limit is not a
56
56
  * verdict; leaving it unnamed would be the silent direction
57
57
  * (`../../../../AGENTS.md`).
58
+ * ## What it can say about fitness
59
+ *
60
+ * Wave 2's fitness derivation (`../governance/decision-fitness.mjs`) folds a
61
+ * decision's bound constraints and their verdicts into one per-decision
62
+ * level. It is NOT wired into this command's own read: `adr` stays the
63
+ * registry-only surface it was. The caller may hand verdicts in through
64
+ * `io.fitnessVerdicts` (the same `{name, verdict}` shape `fitness` produces)
65
+ * and every record then renders its level — `verified` only when a bound
66
+ * constraint passes. Without verdicts the derivation still runs, and its
67
+ * honest answer is echoed: a decision with authority but nothing verifiable
68
+ * is `unverifiable` — never healthy — while a status without authority is
69
+ * `not_applicable`. An empty verdict set is not silence; it is the registry
70
+ * alone asserting nothing. Levels never change the exit code: `adr` remains
71
+ * 0/2/3, a description of what is recorded, not a gate.
58
72
  */
59
73
  import { jsonEnvelope, renderJson } from "../report/json.mjs";
60
74
  import {
@@ -65,6 +79,8 @@ import {
65
79
  } from "../report/adr-text.mjs";
66
80
  import { ADR_DIR, stripAdrPrefix } from "../governance/adr-registry.mjs";
67
81
  import { adrsBinding, boundFitnessIds, loadAdrRegistry } from "../governance/adr-registry.mjs";
82
+ import { computeDecisionFitness } from "../governance/decision-fitness.mjs";
83
+ import { stripRuleFitnessPrefix } from "../governance/adr-registry.mjs";
68
84
 
69
85
  /**
70
86
  * The other half of the id name space the positional argument answers
@@ -124,8 +140,11 @@ export function readAdrContext(root, io = {}) {
124
140
  * @param {{id?: string}} options
125
141
  * @param {{loadAdrRegistryOverride?: typeof loadAdrRegistry, tracked?: string[],
126
142
  * lstatSync?: (path: string) => {isSymbolicLink: () => boolean},
127
- * realpathSync?: (path: string) => string}} [io] Forwarded to
128
- * `readAdrContext` unchanged.
143
+ * realpathSync?: (path: string) => string, fitnessVerdicts?:
144
+ * Array<{name: string, verdict: string}>}} [io] `tracked`, `lstatSync` and
145
+ * `realpathSync` are forwarded to `readAdrContext` unchanged; `fitnessVerdicts`
146
+ * feeds the per-decision fitness derivation ("What it can say about fitness"
147
+ * in the module header owns what an absent array means).
129
148
  * @returns {{status: "ok"|"no-verdict", result: object, coverage: object,
130
149
  * report: {text: string, json: string}}}
131
150
  * @throws {Error} on an unreadable registry (exit-3 class).
@@ -135,6 +154,21 @@ export function adrCommand(root, options, io = {}) {
135
154
 
136
155
  const { records, byId, knownFitness } = ctx;
137
156
 
157
+ // Per-decision fitness: the wave-2 derivation, fed a lookup built from
158
+ // whatever verdicts the caller can supply (`io.fitnessVerdicts`, the same
159
+ // `{name, verdict}` shape the `fitness` command emits). A binding's prefix
160
+ // (`rule:`/`fitness:`) is stripped before the lookup — a verdict names a
161
+ // declared fitness id, and `fitness:hotspot` and `hotspot` are the same id.
162
+ // An empty verdict set is a legitimate input: every authority decision then
163
+ // derives `unverifiable`, which is the registry alone asserting nothing —
164
+ // the module header's "What it can say about fitness" owns the wording.
165
+ const verdictByName = new Map((io.fitnessVerdicts ?? []).map((v) => [v.name, v]));
166
+ const fitnessLookup = (bindingId) => verdictByName.get(stripRuleFitnessPrefix(bindingId));
167
+ const fitnessById = new Map(
168
+ computeDecisionFitness(records, null, fitnessLookup).map((entry) => [entry.id, entry]),
169
+ );
170
+ const fitness = [...fitnessById.values()];
171
+
138
172
  // An id the caller asked about that the registry does not know is a named
139
173
  // unknown, not a clean result — the invariant. Two cases, told apart by the
140
174
  // id's shape: a `rule:x`/`fitness:x` ref (`FITNESS_REF_PATTERN`, above) is a
@@ -194,15 +228,22 @@ export function adrCommand(root, options, io = {}) {
194
228
  supersedes: records.flatMap((record) =>
195
229
  record.supersedes.map((ref) => ({ adr: record.id, supersedes: ref })),
196
230
  ),
231
+ // The derived reverse link — the records whose `supersedes` names this
232
+ // one — so a machine reader of the envelope sees the same lineage the
233
+ // text face shows.
234
+ supersededBy: records.flatMap((record) =>
235
+ record.supersededBy.map((id) => ({ adr: record.id, supersededBy: id })),
236
+ ),
237
+ fitness,
197
238
  unresolved,
198
239
  knownFitness: [...knownFitness].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)),
199
240
  };
200
241
 
201
242
  const text =
202
243
  requestedId === undefined
203
- ? formatAdrDump({ records, knownFitness })
244
+ ? formatAdrDump({ records, knownFitness, fitnessById })
204
245
  : byId.has(resolvedAdrId)
205
- ? formatAdrRecord(byId.get(resolvedAdrId), knownFitness)
246
+ ? formatAdrRecord(byId.get(resolvedAdrId), knownFitness, fitnessById)
206
247
  : isFitnessRef
207
248
  ? formatAdrReverse({ fitnessId: requestedId, adrIds: adrsBinding(records, requestedId) })
208
249
  : formatAdrMissing({ adrId: requestedId });