@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 +161 -22
- package/package.json +2 -2
- package/src/architecture-intent/judge.mjs +19 -6
- 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/delta-classify.mjs +257 -0
- package/src/commands/delta.mjs +269 -8
- package/src/commands/diff.mjs +15 -7
- package/src/commands/evolution.mjs +758 -5
- package/src/commands/explain.mjs +82 -1
- package/src/commands/history.mjs +81 -5
- package/src/commands/plan-context-command.mjs +163 -2
- package/src/commands/rules.mjs +3 -1
- package/src/commands/trajectory.mjs +89 -3
- package/src/fixtures/evolution-lifecycle/workspace.mjs +242 -0
- package/src/governance/debt-ledger.mjs +261 -19
- 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/report/change-text.mjs +21 -3
- package/src/report/debt-text.mjs +42 -6
- package/src/report/delta-text.mjs +36 -1
- package/src/report/evolution-text.mjs +231 -2
- package/src/report/explain-text.mjs +45 -0
- package/src/report/history-text.mjs +9 -3
- package/src/report/plan-context-text.mjs +94 -0
- package/src/report/snapshot-text.mjs +35 -1
- package/src/report/trajectory-text.mjs +30 -1
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, {
|
|
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
|
-
|
|
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, {
|
|
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
|
-
"
|
|
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({
|
|
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({
|
|
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({
|
|
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({
|
|
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.
|
|
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.
|
|
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
|
}
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
/**
|