@ecoma-io/archkeep 0.17.0 → 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/cli.mjs +143 -20
- 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/evolution.mjs +758 -5
- package/src/commands/explain.mjs +71 -1
- package/src/commands/history.mjs +81 -5
- package/src/commands/plan-context-command.mjs +163 -2
- 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
|
@@ -931,12 +931,8 @@ async function runDiff(options, { cwd, env }) {
|
|
|
931
931
|
*
|
|
932
932
|
* `--capture` writes the evidence snapshot a later run compares against;
|
|
933
933
|
* `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
934
|
* @param {{format: string, output: string|null, config: string|null, capture: boolean,
|
|
939
|
-
* paths: string[]}} options
|
|
935
|
+
* eventOut: string|null, paths: string[]}} options
|
|
940
936
|
* @param {{cwd: string, env: {out: Function, err: Function, readGraph?: Function, listFiles?: Function}}} runContext
|
|
941
937
|
* @returns {Promise<number>}
|
|
942
938
|
*/
|
|
@@ -948,6 +944,13 @@ async function runDelta(options, { cwd, env }) {
|
|
|
948
944
|
);
|
|
949
945
|
return EXIT.usage;
|
|
950
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
|
+
}
|
|
951
954
|
} else if (options.paths.length !== 1) {
|
|
952
955
|
env.err(
|
|
953
956
|
`archkeep: delta takes exactly one positional argument (the baseline evidence snapshot), ` +
|
|
@@ -972,7 +975,6 @@ async function runDelta(options, { cwd, env }) {
|
|
|
972
975
|
const { text } = captureDelta(commandContext, { config });
|
|
973
976
|
if (options.output) {
|
|
974
977
|
// Atomic, symlink-safe write — `writeOutputReport`'s own docstring
|
|
975
|
-
// owns the mechanism and the threat it closes.
|
|
976
978
|
if (!writeOutputReport(options.output, text, env, cwd, options.config)) return EXIT.error;
|
|
977
979
|
env.err(`archkeep: delta baseline captured → ${options.output}`);
|
|
978
980
|
} else {
|
|
@@ -985,7 +987,10 @@ async function runDelta(options, { cwd, env }) {
|
|
|
985
987
|
const baselinePath = isAbsolute(options.paths[0])
|
|
986
988
|
? resolve(options.paths[0])
|
|
987
989
|
: resolve(cwd, options.paths[0]);
|
|
988
|
-
result = await deltaCommand(baselinePath, commandContext, {
|
|
990
|
+
result = await deltaCommand(baselinePath, commandContext, {
|
|
991
|
+
config,
|
|
992
|
+
eventOut: options.eventOut,
|
|
993
|
+
});
|
|
989
994
|
} catch (error) {
|
|
990
995
|
const usageError = error instanceof UsageError;
|
|
991
996
|
env.err(String(error?.message ?? error));
|
|
@@ -1009,6 +1014,18 @@ async function runDelta(options, { cwd, env }) {
|
|
|
1009
1014
|
env.out(report);
|
|
1010
1015
|
}
|
|
1011
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
|
+
|
|
1012
1029
|
// The exit fold `deltaCommand` computed: a non-waived introduced violation
|
|
1013
1030
|
// is a finding, an unclassifiable item is a no-verdict, anything else is
|
|
1014
1031
|
// clean — mapped here the same way `fitness`'s status is.
|
|
@@ -1214,7 +1231,7 @@ async function runReconcile(options, { cwd, env }) {
|
|
|
1214
1231
|
* `check` remains the authority on the law.
|
|
1215
1232
|
*
|
|
1216
1233
|
* @param {{format: string, output: string|null, config: string|null,
|
|
1217
|
-
* intent: string|null, paths: string[]}} options
|
|
1234
|
+
* intent: string|null, eventOut?: string|null, paths: string[]}} options
|
|
1218
1235
|
* @param {{cwd: string, env: {out: Function, err: Function, readGraph?: Function,
|
|
1219
1236
|
* listFiles?: Function}}} runContext
|
|
1220
1237
|
* @returns {Promise<number>}
|
|
@@ -1271,7 +1288,19 @@ async function runChange(options, { cwd, env }) {
|
|
|
1271
1288
|
// profile-aware the same way `check` is.
|
|
1272
1289
|
const { config } = await resolvePolicy(options, commandContext, cwd);
|
|
1273
1290
|
|
|
1274
|
-
|
|
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
|
+
});
|
|
1275
1304
|
} catch (error) {
|
|
1276
1305
|
const usageError = error instanceof UsageError;
|
|
1277
1306
|
env.err(String(error?.message ?? error));
|
|
@@ -1581,7 +1610,7 @@ async function runExplain(options, { cwd, env }) {
|
|
|
1581
1610
|
* same as `check` and `explain`, because the answer depends on which boundary
|
|
1582
1611
|
* law is in effect.
|
|
1583
1612
|
*
|
|
1584
|
-
* @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
|
|
1585
1614
|
* @param {{cwd: string, env: {out: Function, err: Function, readGraph?: Function, listFiles?: Function}}} runContext
|
|
1586
1615
|
* @returns {Promise<number>}
|
|
1587
1616
|
*/
|
|
@@ -1622,8 +1651,14 @@ async function runContextCommand(options, { cwd, env }) {
|
|
|
1622
1651
|
// profile-aware the same way `check` is.
|
|
1623
1652
|
const { config } = await resolvePolicy(options, commandContext, cwd);
|
|
1624
1653
|
|
|
1654
|
+
const historyDir = options.historyDir
|
|
1655
|
+
? isAbsolute(options.historyDir)
|
|
1656
|
+
? options.historyDir
|
|
1657
|
+
: resolve(cwd, options.historyDir)
|
|
1658
|
+
: null;
|
|
1659
|
+
|
|
1625
1660
|
result = options.plan
|
|
1626
|
-
? await planContextCommand(projectName, scopePaths, commandContext, config)
|
|
1661
|
+
? await planContextCommand(projectName, scopePaths, commandContext, config, historyDir)
|
|
1627
1662
|
: contextCommand(projectName, commandContext, config);
|
|
1628
1663
|
} catch (error) {
|
|
1629
1664
|
const usageError = error instanceof UsageError;
|
|
@@ -2026,7 +2061,7 @@ async function runTrajectory(options, { cwd, env }) {
|
|
|
2026
2061
|
* production both are undefined and every revision is read for real.
|
|
2027
2062
|
*
|
|
2028
2063
|
* @param {{format: string, output: string|null, base: string|null, head: string|null,
|
|
2029
|
-
* paths: string[]}} options
|
|
2064
|
+
* eventOut: string|null, paths: string[]}} options
|
|
2030
2065
|
* @param {{cwd: string, env: {out: Function, err: Function, readGraph?: Function, listFiles?: Function}}} runContext
|
|
2031
2066
|
* @returns {Promise<number>}
|
|
2032
2067
|
*/
|
|
@@ -2057,9 +2092,18 @@ async function runEvolution(options, { cwd, env }) {
|
|
|
2057
2092
|
|
|
2058
2093
|
let result;
|
|
2059
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;
|
|
2060
2104
|
result = await evolutionCommand(
|
|
2061
2105
|
root,
|
|
2062
|
-
{ base: options.base, head: options.head },
|
|
2106
|
+
{ base: options.base, head: options.head, eventOut },
|
|
2063
2107
|
{
|
|
2064
2108
|
readGraph: env.readGraph,
|
|
2065
2109
|
listFiles: env.listFiles,
|
|
@@ -2098,7 +2142,7 @@ async function runEvolution(options, { cwd, env }) {
|
|
|
2098
2142
|
* consumer-managed directory `history` reads, so a ledger ages across the
|
|
2099
2143
|
* same snapshots the evolution record is built from.
|
|
2100
2144
|
*
|
|
2101
|
-
* @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
|
|
2102
2146
|
* @param {{cwd: string, env: {out: Function, err: Function, readGraph?: Function, listFiles?: Function}}} runContext
|
|
2103
2147
|
* @returns {Promise<number>}
|
|
2104
2148
|
*/
|
|
@@ -2126,7 +2170,10 @@ async function runDebt(options, { cwd, env }) {
|
|
|
2126
2170
|
// profile-selected workspace resolves the same way `check` does.
|
|
2127
2171
|
const { config } = await resolvePolicy(options, commandContext, cwd);
|
|
2128
2172
|
|
|
2129
|
-
result = await debtCommand(dir, commandContext, {
|
|
2173
|
+
result = await debtCommand(dir, commandContext, {
|
|
2174
|
+
config,
|
|
2175
|
+
events: options.events,
|
|
2176
|
+
});
|
|
2130
2177
|
} catch (error) {
|
|
2131
2178
|
const usageError = error instanceof UsageError;
|
|
2132
2179
|
env.err(String(error?.message ?? error));
|
|
@@ -2530,6 +2577,17 @@ const DELTA_FLAG_HELP = Object.freeze([
|
|
|
2530
2577
|
: `<workspace root>/${boundaryConfig}`,
|
|
2531
2578
|
]),
|
|
2532
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
|
+
}),
|
|
2533
2591
|
]);
|
|
2534
2592
|
|
|
2535
2593
|
/**
|
|
@@ -2566,6 +2624,16 @@ const CHANGE_FLAG_HELP = Object.freeze([
|
|
|
2566
2624
|
arg: "<file>",
|
|
2567
2625
|
describe: Object.freeze(["Write the report to a file instead of stdout"]),
|
|
2568
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
|
+
}),
|
|
2569
2637
|
Object.freeze({
|
|
2570
2638
|
flag: "--config",
|
|
2571
2639
|
key: "config",
|
|
@@ -2839,6 +2907,16 @@ const EVOLUTION_FLAG_HELP = Object.freeze([
|
|
|
2839
2907
|
"linear descendant of --base with no merges between",
|
|
2840
2908
|
]),
|
|
2841
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
|
+
}),
|
|
2842
2920
|
]);
|
|
2843
2921
|
|
|
2844
2922
|
/**
|
|
@@ -2981,6 +3059,15 @@ const DEBT_FLAG_HELP = Object.freeze([
|
|
|
2981
3059
|
: `<workspace root>/${boundaryConfig}`,
|
|
2982
3060
|
]),
|
|
2983
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
|
+
}),
|
|
2984
3071
|
]);
|
|
2985
3072
|
|
|
2986
3073
|
/**
|
|
@@ -3139,6 +3226,18 @@ const CONTEXT_FLAG_HELP = Object.freeze([
|
|
|
3139
3226
|
: `<workspace root>/${boundaryConfig}`,
|
|
3140
3227
|
]),
|
|
3141
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
|
+
}),
|
|
3142
3241
|
]);
|
|
3143
3242
|
|
|
3144
3243
|
/**
|
|
@@ -3252,7 +3351,13 @@ const COMMANDS = Object.freeze({
|
|
|
3252
3351
|
summary: "Classify how boundary violations moved between a captured baseline and head",
|
|
3253
3352
|
flagHelp: DELTA_FLAG_HELP,
|
|
3254
3353
|
flags: Object.freeze(Object.fromEntries(DELTA_FLAG_HELP.map((f) => [f.flag, f.key]))),
|
|
3255
|
-
defaults: Object.freeze({
|
|
3354
|
+
defaults: Object.freeze({
|
|
3355
|
+
format: "text",
|
|
3356
|
+
output: null,
|
|
3357
|
+
config: null,
|
|
3358
|
+
capture: false,
|
|
3359
|
+
eventOut: null,
|
|
3360
|
+
}),
|
|
3256
3361
|
formats: DELTA_FORMATS,
|
|
3257
3362
|
booleans: Object.freeze(["capture"]),
|
|
3258
3363
|
run: runDelta,
|
|
@@ -3263,7 +3368,13 @@ const COMMANDS = Object.freeze({
|
|
|
3263
3368
|
summary: "Reconcile a declared change intent against the architectural delta",
|
|
3264
3369
|
flagHelp: CHANGE_FLAG_HELP,
|
|
3265
3370
|
flags: Object.freeze(Object.fromEntries(CHANGE_FLAG_HELP.map((f) => [f.flag, f.key]))),
|
|
3266
|
-
defaults: Object.freeze({
|
|
3371
|
+
defaults: Object.freeze({
|
|
3372
|
+
format: "text",
|
|
3373
|
+
output: null,
|
|
3374
|
+
config: null,
|
|
3375
|
+
intent: null,
|
|
3376
|
+
eventOut: null,
|
|
3377
|
+
}),
|
|
3267
3378
|
formats: DESCRIBABLE_FORMATS,
|
|
3268
3379
|
run: runChange,
|
|
3269
3380
|
}),
|
|
@@ -3346,7 +3457,13 @@ const COMMANDS = Object.freeze({
|
|
|
3346
3457
|
summary: "Describe how the architecture evolved across a Git revision range",
|
|
3347
3458
|
flagHelp: EVOLUTION_FLAG_HELP,
|
|
3348
3459
|
flags: Object.freeze(Object.fromEntries(EVOLUTION_FLAG_HELP.map((f) => [f.flag, f.key]))),
|
|
3349
|
-
defaults: Object.freeze({
|
|
3460
|
+
defaults: Object.freeze({
|
|
3461
|
+
format: "text",
|
|
3462
|
+
output: null,
|
|
3463
|
+
base: null,
|
|
3464
|
+
head: null,
|
|
3465
|
+
eventOut: null,
|
|
3466
|
+
}),
|
|
3350
3467
|
formats: DESCRIBABLE_FORMATS,
|
|
3351
3468
|
run: runEvolution,
|
|
3352
3469
|
}),
|
|
@@ -3376,7 +3493,7 @@ const COMMANDS = Object.freeze({
|
|
|
3376
3493
|
summary: "Print the architecture-debt ledger across snapshots",
|
|
3377
3494
|
flagHelp: DEBT_FLAG_HELP,
|
|
3378
3495
|
flags: Object.freeze(Object.fromEntries(DEBT_FLAG_HELP.map((f) => [f.flag, f.key]))),
|
|
3379
|
-
defaults: Object.freeze({ format: "text", output: null, config: null }),
|
|
3496
|
+
defaults: Object.freeze({ format: "text", output: null, config: null, events: null }),
|
|
3380
3497
|
formats: DESCRIBABLE_FORMATS,
|
|
3381
3498
|
run: runDebt,
|
|
3382
3499
|
}),
|
|
@@ -3406,7 +3523,13 @@ const COMMANDS = Object.freeze({
|
|
|
3406
3523
|
summary: "Show the architecture constraints that apply to a project",
|
|
3407
3524
|
flagHelp: CONTEXT_FLAG_HELP,
|
|
3408
3525
|
flags: Object.freeze(Object.fromEntries(CONTEXT_FLAG_HELP.map((f) => [f.flag, f.key]))),
|
|
3409
|
-
defaults: Object.freeze({
|
|
3526
|
+
defaults: Object.freeze({
|
|
3527
|
+
format: "text",
|
|
3528
|
+
output: null,
|
|
3529
|
+
config: null,
|
|
3530
|
+
plan: false,
|
|
3531
|
+
historyDir: null,
|
|
3532
|
+
}),
|
|
3410
3533
|
formats: DESCRIBABLE_FORMATS,
|
|
3411
3534
|
booleans: Object.freeze(["plan"]),
|
|
3412
3535
|
run: runContextCommand,
|
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
|
}
|
|
@@ -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
|
/**
|