@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.
- package/README.md +1 -1
- package/cli.mjs +258 -20
- package/package.json +2 -2
- package/src/architecture-intent/judge.mjs +19 -6
- package/src/commands/adr.mjs +45 -4
- package/src/commands/change-intent.mjs +55 -8
- package/src/commands/change.mjs +332 -11
- package/src/commands/debt.mjs +26 -5
- package/src/commands/decisions.mjs +291 -0
- package/src/commands/delta-classify.mjs +257 -0
- package/src/commands/delta.mjs +269 -8
- package/src/commands/evolution.mjs +758 -5
- package/src/commands/explain.mjs +207 -1
- package/src/commands/history.mjs +81 -5
- package/src/commands/plan-context-command.mjs +163 -2
- package/src/commands/provenance-command.mjs +86 -17
- package/src/commands/provenance.mjs +60 -0
- package/src/commands/report.mjs +48 -1
- package/src/commands/trajectory.mjs +89 -3
- package/src/fixtures/evolution-lifecycle/workspace.mjs +242 -0
- package/src/governance/adr-registry.mjs +252 -15
- package/src/governance/debt-ledger.mjs +261 -19
- package/src/governance/decision-fitness.mjs +213 -0
- package/src/governance/decision-graph.mjs +483 -0
- package/src/governance/decision-lineage.mjs +250 -0
- package/src/governance/evolution-event.mjs +470 -0
- package/src/governance/evolution-store.mjs +362 -0
- package/src/governance/provenance-record.mjs +150 -0
- package/src/providers/native/model.mjs +18 -4
- package/src/report/adr-text.mjs +109 -4
- package/src/report/change-text.mjs +21 -3
- package/src/report/debt-text.mjs +42 -6
- package/src/report/decisions-text.mjs +164 -0
- package/src/report/delta-text.mjs +36 -1
- package/src/report/evolution-text.mjs +231 -2
- package/src/report/explain-text.mjs +122 -1
- package/src/report/history-text.mjs +9 -3
- package/src/report/plan-context-text.mjs +94 -0
- package/src/report/provenance-text.mjs +67 -1
- package/src/report/report-text.mjs +53 -18
- package/src/report/snapshot-text.mjs +35 -1
- 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
|
|
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, {
|
|
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
|
-
|
|
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, {
|
|
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({
|
|
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({
|
|
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({
|
|
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({
|
|
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.
|
|
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.
|
|
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
|
-
|
|
295
|
-
|
|
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
|
}
|
package/src/commands/adr.mjs
CHANGED
|
@@ -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
|
|
128
|
-
* `
|
|
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 });
|