@ecoma-io/archkeep 0.13.0 → 0.15.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 +9 -3
- package/cli.mjs +599 -55
- package/commands.mjs +51 -0
- package/package.json +3 -1
- package/src/analysis/typescript.mjs +2 -1
- package/src/commands/README.md +70 -1
- package/src/commands/change-intent.mjs +461 -0
- package/src/commands/change.mjs +612 -0
- package/src/commands/check.mjs +84 -17
- package/src/commands/context.mjs +92 -16
- package/src/commands/coverage-acceptance.mjs +113 -0
- package/src/commands/custom-rules.mjs +286 -2
- package/src/commands/delta-classify.mjs +664 -0
- package/src/commands/delta-snapshot.mjs +672 -0
- package/src/commands/delta.mjs +606 -0
- package/src/commands/diff.mjs +41 -13
- package/src/commands/evolution.mjs +473 -0
- package/src/commands/explain.mjs +39 -0
- package/src/commands/history.mjs +130 -103
- package/src/commands/policy.mjs +93 -1
- package/src/commands/trajectory.mjs +437 -0
- package/src/commands/waivers.mjs +53 -3
- package/src/config.mjs +129 -11
- package/src/lsp/boundary-config.mjs +9 -4
- package/src/path-util.mjs +40 -0
- package/src/providers/native/model.mjs +17 -0
- package/src/report/change-text.mjs +148 -0
- package/src/report/delta-text.mjs +264 -0
- package/src/report/evolution-text.mjs +83 -0
- package/src/report/explain-text.mjs +27 -0
- package/src/report/history-text.mjs +4 -114
- package/src/report/sarif.mjs +280 -0
- package/src/report/snapshot-text.mjs +123 -0
- package/src/report/text.mjs +36 -0
- package/src/report/trajectory-text.mjs +143 -0
- package/src/report/waivers-text.mjs +35 -2
- package/src/tsconfig-paths.mjs +3 -2
package/cli.mjs
CHANGED
|
@@ -51,15 +51,19 @@
|
|
|
51
51
|
* tree is dirty" from "you typed it wrong" from "the checker itself broke":
|
|
52
52
|
* 0 no violations, and every selected file was analyzed
|
|
53
53
|
* 1 findings — boundary violations, go.work drift, dead tsconfig path
|
|
54
|
-
* aliases,
|
|
55
|
-
*
|
|
54
|
+
* aliases, architecture-intent findings, a non-waived violation `delta`
|
|
55
|
+
* classifies as introduced, or a change-intent reconciliation that found
|
|
56
|
+
* undeclared material changes, unfulfilled declarations, or a failed
|
|
57
|
+
* declared constraint. `check`, `fitness`, `delta` and `change` are the
|
|
58
|
+
* verbs whose verdicts carry this code — every other verb in this table
|
|
56
59
|
* only ever reads.
|
|
57
60
|
* 2 usage error — unknown command, unknown flag, missing argument, path
|
|
58
61
|
* outside the tree
|
|
59
62
|
* 3 no verdict — no workspace, malformed config, the graph provider or git
|
|
60
63
|
* failed, a selected file could not be analyzed, an architecture-intent
|
|
61
|
-
* boundary matched no observed project,
|
|
62
|
-
* accepts nothing this run judged
|
|
64
|
+
* boundary matched no observed project, a `boundarySuppressions` row
|
|
65
|
+
* accepts nothing this run judged, or a change intent could not be
|
|
66
|
+
* verified against its declared base. Distinct from
|
|
63
67
|
* 1 on purpose: a checker that could not look must never be mistaken for
|
|
64
68
|
* one that looked and found nothing.
|
|
65
69
|
*
|
|
@@ -93,7 +97,7 @@ import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
|
93
97
|
import { containmentViolation } from "./src/containment.mjs";
|
|
94
98
|
import { UsageError } from "./src/errors.mjs";
|
|
95
99
|
import { check, sortViolations } from "./src/commands/check.mjs";
|
|
96
|
-
import {
|
|
100
|
+
import { resolveDescribedPolicy, resolvePolicy } from "./src/commands/policy.mjs";
|
|
97
101
|
import {
|
|
98
102
|
DEFAULT_OPTIONS,
|
|
99
103
|
WORKSPACE_MARKERS,
|
|
@@ -104,12 +108,16 @@ import { contextCommand } from "./src/commands/context-command.mjs";
|
|
|
104
108
|
import { planContextCommand } from "./src/commands/plan-context-command.mjs";
|
|
105
109
|
import { adrCommand } from "./src/commands/adr.mjs";
|
|
106
110
|
import { diffCommand } from "./src/commands/diff.mjs";
|
|
111
|
+
import { captureDelta, deltaCommand } from "./src/commands/delta.mjs";
|
|
107
112
|
import { discoverCommand } from "./src/commands/discover.mjs";
|
|
108
113
|
import { driftCommand } from "./src/commands/drift.mjs";
|
|
109
114
|
import { fitnessCommand } from "./src/commands/fitness.mjs";
|
|
110
115
|
import { reconcileCommand } from "./src/commands/reconcile.mjs";
|
|
116
|
+
import { changeCommand } from "./src/commands/change.mjs";
|
|
111
117
|
import { computePolicyFingerprint, graphCommand } from "./src/commands/graph.mjs";
|
|
112
118
|
import { historyCommand } from "./src/commands/history.mjs";
|
|
119
|
+
import { trajectoryCommand } from "./src/commands/trajectory.mjs";
|
|
120
|
+
import { evolutionCommand } from "./src/commands/evolution.mjs";
|
|
113
121
|
import { healthCommand } from "./src/commands/health.mjs";
|
|
114
122
|
import { reportCommand } from "./src/commands/report.mjs";
|
|
115
123
|
import { debtCommand } from "./src/commands/debt.mjs";
|
|
@@ -169,6 +177,16 @@ const CHECK_FORMATS = Object.freeze(["text", "sarif", "json"]);
|
|
|
169
177
|
*/
|
|
170
178
|
const DESCRIBABLE_FORMATS = Object.freeze(["text", "json"]);
|
|
171
179
|
|
|
180
|
+
/**
|
|
181
|
+
* Every format `delta --format` accepts. SARIF joins the two descriptive
|
|
182
|
+
* formats because `delta`'s compare mode is a gate that produces findings —
|
|
183
|
+
* the introduced bucket is exactly what a code-scanning upload annotates at
|
|
184
|
+
* head sites — while every other descriptive-family verb stays on
|
|
185
|
+
* `DESCRIBABLE_FORMATS`: none of them produces findings, and SARIF's
|
|
186
|
+
* `results[]` is a findings container.
|
|
187
|
+
*/
|
|
188
|
+
const DELTA_FORMATS = Object.freeze(["text", "sarif", "json"]);
|
|
189
|
+
|
|
172
190
|
/**
|
|
173
191
|
* Column `usage()`'s Options block aligns flag descriptions to. Matches the
|
|
174
192
|
* hand-written text this table-driven rendering replaced, so deriving the
|
|
@@ -803,54 +821,14 @@ async function runGraph(options, { cwd, env }) {
|
|
|
803
821
|
//
|
|
804
822
|
// `graph` describes the project graph, not the boundary law — it reads no
|
|
805
823
|
// constraint row and judges nothing against one — so a workspace that has
|
|
806
|
-
// not written a law yet must not be refused here.
|
|
807
|
-
//
|
|
808
|
-
//
|
|
809
|
-
//
|
|
810
|
-
//
|
|
811
|
-
//
|
|
812
|
-
//
|
|
813
|
-
|
|
814
|
-
// policy.
|
|
815
|
-
//
|
|
816
|
-
// What is skipped is the load of a file that is NOT THERE. A boundary
|
|
817
|
-
// config that exists and will not load still fails the run, because an
|
|
818
|
-
// absent law and a broken one must not report alike; a `--config`, a
|
|
819
|
-
// profile, and an inline `archkeep.json` policy are explicit declarations
|
|
820
|
-
// and stay loud. Every command that JUDGES against the law keeps loading
|
|
821
|
-
// it unconditionally — making it optional for those would turn a missing
|
|
822
|
-
// file into a silent no-law run.
|
|
823
|
-
//
|
|
824
|
-
// `boundaryConfigDeclared` is what keeps this guard to the un-overridden
|
|
825
|
-
// default, and it is load-bearing rather than belt-and-braces. The name
|
|
826
|
-
// alone cannot answer it: `commandContext.options.boundaryConfig` is a
|
|
827
|
-
// string BOTH when it came from `./src/options.mjs`'s `DEFAULT_OPTIONS`
|
|
828
|
-
// and when the consumer WROTE it into `nx.json`'s plugin options or
|
|
829
|
-
// `archkeep.json`, and a workspace is free to declare the convention
|
|
830
|
-
// filename itself, so comparing against the default would still read a
|
|
831
|
-
// deliberate declaration as an assumption. Without the bit, measured on a
|
|
832
|
-
// committed native tree whose `archkeep.json` declares `boundaryConfig:
|
|
833
|
-
// "policy-we-declared.mjs"` and does not contain that file: `graph` exited
|
|
834
|
-
// 0 with a snapshot carrying no `policy` field — byte-identical to a
|
|
835
|
-
// workspace that never had a law — where the same tree with that file
|
|
836
|
-
// present but unparseable exited 3. A law someone named and then renamed
|
|
837
|
-
// or deleted is exactly the case that must stay loud, so the provenance
|
|
838
|
-
// survives the options layer instead (`./src/options.mjs`'s
|
|
839
|
-
// `resolveOptions`, `./src/providers/native/model.mjs`'s
|
|
840
|
-
// `normalizeNativeModel`, and `./src/commands/context.mjs`'s three
|
|
841
|
-
// branches carry it; Moon answers `false` because it has no table to
|
|
842
|
-
// declare one in).
|
|
843
|
-
const workspaceDefault =
|
|
844
|
-
!options.config &&
|
|
845
|
-
!hasProfiles(commandContext.options) &&
|
|
846
|
-
commandContext.options.boundaryConfigDeclared === false &&
|
|
847
|
-
typeof commandContext.options.boundaryConfig === "string"
|
|
848
|
-
? resolve(commandContext.root, commandContext.options.boundaryConfig)
|
|
849
|
-
: null;
|
|
850
|
-
const { config } =
|
|
851
|
-
workspaceDefault !== null && !existsSync(workspaceDefault)
|
|
852
|
-
? { config: null }
|
|
853
|
-
: await resolvePolicy(options, commandContext, cwd);
|
|
824
|
+
// not written a law yet must not be refused here. Every arm of that
|
|
825
|
+
// decision — what is skipped is the load of a file that is NOT THERE, the
|
|
826
|
+
// `boundaryConfigDeclared` bit that keeps the guard to the un-overridden
|
|
827
|
+
// default, and why a law someone named and then deleted stays loud — lives
|
|
828
|
+
// in `resolveDescribedPolicy` (`./src/commands/policy.mjs`) rather than
|
|
829
|
+
// here, so the descriptive commands and the MCP face that serves them
|
|
830
|
+
// cannot disagree about what "no law declared" means.
|
|
831
|
+
const { config } = await resolveDescribedPolicy(options, commandContext, cwd);
|
|
854
832
|
|
|
855
833
|
result = graphCommand(commandContext, { config });
|
|
856
834
|
} catch (error) {
|
|
@@ -941,6 +919,98 @@ async function runDiff(options, { cwd, env }) {
|
|
|
941
919
|
return EXIT.ok;
|
|
942
920
|
}
|
|
943
921
|
|
|
922
|
+
/**
|
|
923
|
+
* `delta`'s `run`: two modes behind one verb.
|
|
924
|
+
*
|
|
925
|
+
* `--capture` writes the evidence snapshot a later run compares against;
|
|
926
|
+
* `delta <baseline>` loads one, re-judges both sides under the current law,
|
|
927
|
+
* and folds the classification into the exit code — the one descriptive-family
|
|
928
|
+
* verb beside `check` and `fitness` whose verdict carries exit 1
|
|
929
|
+
* (`./src/commands/delta.mjs` owns the fold).
|
|
930
|
+
*
|
|
931
|
+
* @param {{format: string, output: string|null, config: string|null, capture: boolean,
|
|
932
|
+
* paths: string[]}} options
|
|
933
|
+
* @param {{cwd: string, env: {out: Function, err: Function, readGraph?: Function, listFiles?: Function}}} runContext
|
|
934
|
+
* @returns {Promise<number>}
|
|
935
|
+
*/
|
|
936
|
+
async function runDelta(options, { cwd, env }) {
|
|
937
|
+
if (options.capture) {
|
|
938
|
+
if (options.paths.length !== 0) {
|
|
939
|
+
env.err(
|
|
940
|
+
`archkeep: delta --capture takes no positional arguments; got ${options.paths.join(", ")}`,
|
|
941
|
+
);
|
|
942
|
+
return EXIT.usage;
|
|
943
|
+
}
|
|
944
|
+
} else if (options.paths.length !== 1) {
|
|
945
|
+
env.err(
|
|
946
|
+
`archkeep: delta takes exactly one positional argument (the baseline evidence snapshot), ` +
|
|
947
|
+
`or --capture to write one; got ${options.paths.length}`,
|
|
948
|
+
);
|
|
949
|
+
return EXIT.usage;
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
let result;
|
|
953
|
+
try {
|
|
954
|
+
const commandContext = resolveCommandContext(
|
|
955
|
+
{ cwd },
|
|
956
|
+
{ readGraph: env.readGraph, listFiles: env.listFiles },
|
|
957
|
+
);
|
|
958
|
+
|
|
959
|
+
// Both modes need the boundary law: capture fingerprints it, compare
|
|
960
|
+
// re-judges both sides under it — the same ladder every judging command
|
|
961
|
+
// resolves through (`resolvePolicy`).
|
|
962
|
+
const { config } = await resolvePolicy(options, commandContext, cwd);
|
|
963
|
+
|
|
964
|
+
if (options.capture) {
|
|
965
|
+
const { text } = captureDelta(commandContext, { config });
|
|
966
|
+
if (options.output) {
|
|
967
|
+
// Atomic, symlink-safe write — `writeOutputReport`'s own docstring
|
|
968
|
+
// owns the mechanism and the threat it closes.
|
|
969
|
+
if (!writeOutputReport(options.output, text, env, cwd, options.config)) return EXIT.error;
|
|
970
|
+
env.err(`archkeep: delta baseline captured → ${options.output}`);
|
|
971
|
+
} else {
|
|
972
|
+
env.out(text);
|
|
973
|
+
}
|
|
974
|
+
// Capture is descriptive: 0 on success, 3 on any failure, never 1.
|
|
975
|
+
return EXIT.ok;
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
const baselinePath = isAbsolute(options.paths[0])
|
|
979
|
+
? resolve(options.paths[0])
|
|
980
|
+
: resolve(cwd, options.paths[0]);
|
|
981
|
+
result = await deltaCommand(baselinePath, commandContext, { config });
|
|
982
|
+
} catch (error) {
|
|
983
|
+
const usageError = error instanceof UsageError;
|
|
984
|
+
env.err(String(error?.message ?? error));
|
|
985
|
+
return usageError ? EXIT.usage : EXIT.error;
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
const report =
|
|
989
|
+
options.format === "json"
|
|
990
|
+
? result.report.json
|
|
991
|
+
: options.format === "sarif"
|
|
992
|
+
? result.report.sarif
|
|
993
|
+
: result.report.text;
|
|
994
|
+
|
|
995
|
+
if (options.output) {
|
|
996
|
+
// Atomic, symlink-safe write — `writeOutputReport`'s own docstring owns
|
|
997
|
+
// the mechanism and the threat it closes.
|
|
998
|
+
const reportText = report.endsWith("\n") ? report : `${report}\n`;
|
|
999
|
+
if (!writeOutputReport(options.output, reportText, env, cwd, options.config)) return EXIT.error;
|
|
1000
|
+
env.err(`archkeep: delta complete → ${options.output}`);
|
|
1001
|
+
} else {
|
|
1002
|
+
env.out(report);
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
// The exit fold `deltaCommand` computed: a non-waived introduced violation
|
|
1006
|
+
// is a finding, an unclassifiable item is a no-verdict, anything else is
|
|
1007
|
+
// clean — mapped here the same way `fitness`'s status is.
|
|
1008
|
+
return (
|
|
1009
|
+
{ ok: EXIT.ok, findings: EXIT.violations, "no-verdict": EXIT.error }[result.status] ??
|
|
1010
|
+
EXIT.error
|
|
1011
|
+
);
|
|
1012
|
+
}
|
|
1013
|
+
|
|
944
1014
|
/**
|
|
945
1015
|
* `drift`'s `run`: resolves the command context, drives `driftCommand`, writes
|
|
946
1016
|
* the report where it belongs, and returns the process's exit code.
|
|
@@ -1124,6 +1194,108 @@ async function runReconcile(options, { cwd, env }) {
|
|
|
1124
1194
|
return EXIT.ok;
|
|
1125
1195
|
}
|
|
1126
1196
|
|
|
1197
|
+
/**
|
|
1198
|
+
* `change`'s `run`: resolves the command context and the boundary law, drives
|
|
1199
|
+
* `changeCommand` over the baseline evidence snapshot and the intent manifest,
|
|
1200
|
+
* writes the report where it belongs, and returns the process's exit code.
|
|
1201
|
+
*
|
|
1202
|
+
* The fourth verb whose verdict carries exit 1, beside `check`, `fitness` and
|
|
1203
|
+
* `delta`: an undeclared material change, an unfulfilled declaration, or a
|
|
1204
|
+
* failed declared constraint is a finding; an unproven base identity or an
|
|
1205
|
+
* undeterminable constraint is a no-verdict. The workspace-law axis the
|
|
1206
|
+
* envelope reports is informational — it never moves this exit code, because
|
|
1207
|
+
* `check` remains the authority on the law.
|
|
1208
|
+
*
|
|
1209
|
+
* @param {{format: string, output: string|null, config: string|null,
|
|
1210
|
+
* intent: string|null, paths: string[]}} options
|
|
1211
|
+
* @param {{cwd: string, env: {out: Function, err: Function, readGraph?: Function,
|
|
1212
|
+
* listFiles?: Function}}} runContext
|
|
1213
|
+
* @returns {Promise<number>}
|
|
1214
|
+
*/
|
|
1215
|
+
async function runChange(options, { cwd, env }) {
|
|
1216
|
+
if (options.paths.length !== 1) {
|
|
1217
|
+
env.err(
|
|
1218
|
+
`archkeep: change takes exactly one positional argument (the baseline evidence snapshot ` +
|
|
1219
|
+
`from 'delta --capture'); got ${options.paths.length}`,
|
|
1220
|
+
);
|
|
1221
|
+
return EXIT.usage;
|
|
1222
|
+
}
|
|
1223
|
+
if (!options.intent) {
|
|
1224
|
+
env.err(
|
|
1225
|
+
"archkeep: change needs '--intent <file>' naming the change-intent manifest — without a " +
|
|
1226
|
+
"declaration there is nothing to reconcile against",
|
|
1227
|
+
);
|
|
1228
|
+
return EXIT.usage;
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
const baselinePath = isAbsolute(options.paths[0])
|
|
1232
|
+
? options.paths[0]
|
|
1233
|
+
: resolve(cwd, options.paths[0]);
|
|
1234
|
+
const intentPath = isAbsolute(options.intent) ? options.intent : resolve(cwd, options.intent);
|
|
1235
|
+
|
|
1236
|
+
// A self-footgun guard, the same shape `history`'s holds: writing the
|
|
1237
|
+
// reconciliation report over the very manifest this run just read would
|
|
1238
|
+
// destroy the declaration it verified, with the loss surfacing only later —
|
|
1239
|
+
// the first time someone tries to re-run the verification.
|
|
1240
|
+
if (options.output) {
|
|
1241
|
+
const outputAbs = isAbsolute(options.output)
|
|
1242
|
+
? resolve(options.output)
|
|
1243
|
+
: resolve(cwd, options.output);
|
|
1244
|
+
if (outputAbs === intentPath) {
|
|
1245
|
+
env.err(
|
|
1246
|
+
`archkeep: --output '${options.output}' resolves to the change-intent manifest itself — ` +
|
|
1247
|
+
`overwriting the declaration with its own reconciliation report would destroy it. ` +
|
|
1248
|
+
`Write the report somewhere else.`,
|
|
1249
|
+
);
|
|
1250
|
+
return EXIT.usage;
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
let result;
|
|
1255
|
+
try {
|
|
1256
|
+
const commandContext = resolveCommandContext(
|
|
1257
|
+
{ cwd },
|
|
1258
|
+
{ readGraph: env.readGraph, listFiles: env.listFiles },
|
|
1259
|
+
);
|
|
1260
|
+
|
|
1261
|
+
// Declared constraints are judged under whichever law THIS run resolves,
|
|
1262
|
+
// and the envelope records that law's fingerprint beside the baseline's —
|
|
1263
|
+
// the same loading every judging command does (`resolvePolicy`),
|
|
1264
|
+
// profile-aware the same way `check` is.
|
|
1265
|
+
const { config } = await resolvePolicy(options, commandContext, cwd);
|
|
1266
|
+
|
|
1267
|
+
result = await changeCommand(baselinePath, intentPath, commandContext, { config });
|
|
1268
|
+
} catch (error) {
|
|
1269
|
+
const usageError = error instanceof UsageError;
|
|
1270
|
+
env.err(String(error?.message ?? error));
|
|
1271
|
+
return usageError ? EXIT.usage : EXIT.error;
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
const report = options.format === "json" ? result.report.json : result.report.text;
|
|
1275
|
+
|
|
1276
|
+
if (options.output) {
|
|
1277
|
+
// Atomic, symlink-safe write — `writeOutputReport`'s own docstring owns
|
|
1278
|
+
// the mechanism and the threat it closes.
|
|
1279
|
+
const reportText = report.endsWith("\n") ? report : `${report}\n`;
|
|
1280
|
+
if (!writeOutputReport(options.output, reportText, env, cwd, options.config)) return EXIT.error;
|
|
1281
|
+
env.err(
|
|
1282
|
+
`archkeep: change ${result.changeIntent.reconciliation.verdict} ` +
|
|
1283
|
+
`(+${result.changeIntent.reconciliation.matched.length} matched, ` +
|
|
1284
|
+
`!${result.changeIntent.reconciliation.unexpected.length} undeclared, ` +
|
|
1285
|
+
`?${result.changeIntent.reconciliation.missingExpected.length} unfulfilled) → ${options.output}`,
|
|
1286
|
+
);
|
|
1287
|
+
} else {
|
|
1288
|
+
env.out(report);
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
// The verdict fold `changeCommand` computed, mapped here the way `delta`'s
|
|
1292
|
+
// and `fitness`' are.
|
|
1293
|
+
return (
|
|
1294
|
+
{ ok: EXIT.ok, findings: EXIT.violations, "no-verdict": EXIT.error }[result.status] ??
|
|
1295
|
+
EXIT.error
|
|
1296
|
+
);
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1127
1299
|
/**
|
|
1128
1300
|
* `waivers`' `run`: resolves the command context, drives `waiversCommand`,
|
|
1129
1301
|
* writes the report where it belongs, and returns the process's exit code.
|
|
@@ -1156,9 +1328,14 @@ async function runWaivers(options, { cwd, env }) {
|
|
|
1156
1328
|
// against this tool's own location, and a `profiles` registry resolves
|
|
1157
1329
|
// `--config`/`boundaryConfig` as a profile NAME the same way `check`
|
|
1158
1330
|
// does. A malformed law throws here, exit 3, exactly as in `check`.
|
|
1159
|
-
const { config } = await resolvePolicy(options, commandContext, cwd);
|
|
1331
|
+
const { config, source } = await resolvePolicy(options, commandContext, cwd);
|
|
1160
1332
|
|
|
1161
|
-
|
|
1333
|
+
// `source` rides along for one job: `waiversCommand` subtracts the law's
|
|
1334
|
+
// own file from the unowned-file set the `coverage.unowned` acceptances
|
|
1335
|
+
// are matched against, exactly as `check` does — the law is not source
|
|
1336
|
+
// judged by the law (`../src/commands/context.mjs`'s
|
|
1337
|
+
// `unownedGapWithoutRunConfiguration`).
|
|
1338
|
+
result = await waiversCommand(commandContext, config, { policySource: source });
|
|
1162
1339
|
} catch (error) {
|
|
1163
1340
|
const usageError = error instanceof UsageError;
|
|
1164
1341
|
env.err(String(error?.message ?? error));
|
|
@@ -1622,6 +1799,157 @@ async function runHistory(options, { cwd, env }) {
|
|
|
1622
1799
|
return EXIT.ok;
|
|
1623
1800
|
}
|
|
1624
1801
|
|
|
1802
|
+
/**
|
|
1803
|
+
* `trajectory`'s run: resolves the command context, drives
|
|
1804
|
+
* `trajectoryCommand`, writes the report, and returns the exit code.
|
|
1805
|
+
*
|
|
1806
|
+
* The history directory is the single positional argument — the same
|
|
1807
|
+
* consumer-managed directory `history` and `debt` read. No boundary law is
|
|
1808
|
+
* loaded and no snapshot is captured: the fingerprints compared travel inside
|
|
1809
|
+
* the stored snapshots (`src/commands/trajectory.mjs`'s header owns both
|
|
1810
|
+
* halves of that posture).
|
|
1811
|
+
*
|
|
1812
|
+
* @param {{format: string, output: string|null, paths: string[]}} options
|
|
1813
|
+
* @param {{cwd: string, env: {out: Function, err: Function, readGraph?: Function, listFiles?: Function}}} runContext
|
|
1814
|
+
* @returns {Promise<number>}
|
|
1815
|
+
*/
|
|
1816
|
+
async function runTrajectory(options, { cwd, env }) {
|
|
1817
|
+
if (options.paths.length !== 1) {
|
|
1818
|
+
env.err(
|
|
1819
|
+
`archkeep: trajectory takes exactly one positional argument (the history directory); ` +
|
|
1820
|
+
`got ${options.paths.length}`,
|
|
1821
|
+
);
|
|
1822
|
+
return EXIT.usage;
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1825
|
+
const dir = isAbsolute(options.paths[0])
|
|
1826
|
+
? resolve(options.paths[0])
|
|
1827
|
+
: resolve(cwd, options.paths[0]);
|
|
1828
|
+
|
|
1829
|
+
// The same self-footgun guard `runHistory` applies: a report written into
|
|
1830
|
+
// the directory being read would be read back as a snapshot on the next run
|
|
1831
|
+
// (the envelope is not a `graph` snapshot, which `parseBaseline` refuses) —
|
|
1832
|
+
// poison the record loudly refused rather than quietly planted.
|
|
1833
|
+
if (options.output) {
|
|
1834
|
+
const outputAbs = isAbsolute(options.output)
|
|
1835
|
+
? resolve(options.output)
|
|
1836
|
+
: resolve(cwd, options.output);
|
|
1837
|
+
if (dirname(outputAbs) === dir) {
|
|
1838
|
+
env.err(
|
|
1839
|
+
`archkeep: --output '${options.output}' is inside the history directory '${dir}' — ` +
|
|
1840
|
+
`writing the report there would be read back as a snapshot on the next run. ` +
|
|
1841
|
+
`Write it somewhere else.`,
|
|
1842
|
+
);
|
|
1843
|
+
return EXIT.usage;
|
|
1844
|
+
}
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1847
|
+
let result;
|
|
1848
|
+
try {
|
|
1849
|
+
const commandContext = resolveCommandContext(
|
|
1850
|
+
{ cwd },
|
|
1851
|
+
{ readGraph: env.readGraph, listFiles: env.listFiles },
|
|
1852
|
+
);
|
|
1853
|
+
result = trajectoryCommand(dir, commandContext);
|
|
1854
|
+
} catch (error) {
|
|
1855
|
+
const usageError = error instanceof UsageError;
|
|
1856
|
+
env.err(String(error?.message ?? error));
|
|
1857
|
+
return usageError ? EXIT.usage : EXIT.error;
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
const report = options.format === "json" ? result.report.json : result.report.text;
|
|
1861
|
+
|
|
1862
|
+
if (options.output) {
|
|
1863
|
+
// Atomic, symlink-safe write — `writeOutputReport`'s own docstring owns
|
|
1864
|
+
// the mechanism and the threat it closes.
|
|
1865
|
+
const reportText = report.endsWith("\n") ? report : `${report}\n`;
|
|
1866
|
+
// No `--config` flag (`TRAJECTORY_FLAG_HELP`) — there is no override to pass.
|
|
1867
|
+
if (!writeOutputReport(options.output, reportText, env, cwd, null)) return EXIT.error;
|
|
1868
|
+
env.err(`archkeep: trajectory complete → ${options.output}`);
|
|
1869
|
+
} else {
|
|
1870
|
+
env.out(report);
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1873
|
+
// Trajectory is descriptive: 0 when the aggregation completes, never 1.
|
|
1874
|
+
return EXIT.ok;
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
/**
|
|
1878
|
+
* `evolution`'s `run`: resolves the workspace root the way `adr` does (a light
|
|
1879
|
+
* marker walk — this command describes OTHER trees, so it must not fail
|
|
1880
|
+
* because the caller's own working tree does not fully analyze right now),
|
|
1881
|
+
* drives `evolutionCommand`, writes the report, and returns the exit code.
|
|
1882
|
+
*
|
|
1883
|
+
* The range is `--base` plus optional `--head`; there are no positional
|
|
1884
|
+
* arguments. The Nx and git seams in `env` thread into EVERY analyzed
|
|
1885
|
+
* revision's context, the same way they thread into one tree elsewhere — in
|
|
1886
|
+
* production both are undefined and every revision is read for real.
|
|
1887
|
+
*
|
|
1888
|
+
* @param {{format: string, output: string|null, base: string|null, head: string|null,
|
|
1889
|
+
* paths: string[]}} options
|
|
1890
|
+
* @param {{cwd: string, env: {out: Function, err: Function, readGraph?: Function, listFiles?: Function}}} runContext
|
|
1891
|
+
* @returns {Promise<number>}
|
|
1892
|
+
*/
|
|
1893
|
+
async function runEvolution(options, { cwd, env }) {
|
|
1894
|
+
if (!options.base) {
|
|
1895
|
+
env.err(
|
|
1896
|
+
`archkeep: evolution needs --base <rev> — a commit, branch, or tag the range starts at ` +
|
|
1897
|
+
`(--head defaults to HEAD).`,
|
|
1898
|
+
);
|
|
1899
|
+
return EXIT.usage;
|
|
1900
|
+
}
|
|
1901
|
+
if (options.paths.length > 0) {
|
|
1902
|
+
env.err(
|
|
1903
|
+
`archkeep: evolution takes no positional arguments; the range is --base plus --head. ` +
|
|
1904
|
+
`Got '${options.paths.join("', '")}'`,
|
|
1905
|
+
);
|
|
1906
|
+
return EXIT.usage;
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1909
|
+
const root = findWorkspaceRoot(cwd, WORKSPACE_MARKERS);
|
|
1910
|
+
if (root === null) {
|
|
1911
|
+
env.err(
|
|
1912
|
+
`archkeep: evolution needs a workspace root — no nx.json, archkeep.json, or .moon marker found ` +
|
|
1913
|
+
`walking up from ${cwd}`,
|
|
1914
|
+
);
|
|
1915
|
+
return EXIT.error;
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1918
|
+
let result;
|
|
1919
|
+
try {
|
|
1920
|
+
result = await evolutionCommand(
|
|
1921
|
+
root,
|
|
1922
|
+
{ base: options.base, head: options.head },
|
|
1923
|
+
{
|
|
1924
|
+
readGraph: env.readGraph,
|
|
1925
|
+
listFiles: env.listFiles,
|
|
1926
|
+
},
|
|
1927
|
+
);
|
|
1928
|
+
} catch (error) {
|
|
1929
|
+
const usageError = error instanceof UsageError;
|
|
1930
|
+
env.err(String(error?.message ?? error));
|
|
1931
|
+
return usageError ? EXIT.usage : EXIT.error;
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1934
|
+
const report = options.format === "json" ? result.report.json : result.report.text;
|
|
1935
|
+
|
|
1936
|
+
if (options.output) {
|
|
1937
|
+
// Atomic, symlink-safe write — `writeOutputReport`'s own docstring owns
|
|
1938
|
+
// the mechanism and the threat it closes.
|
|
1939
|
+
const reportText = report.endsWith("\n") ? report : `${report}\n`;
|
|
1940
|
+
// No `--config` flag exists here (`EVOLUTION_FLAG_HELP`) — there is no
|
|
1941
|
+
// override to pass, only the workspace's un-overridden default to guard.
|
|
1942
|
+
if (!writeOutputReport(options.output, reportText, env, cwd, null)) return EXIT.error;
|
|
1943
|
+
env.err(`archkeep: evolution complete → ${options.output}`);
|
|
1944
|
+
} else {
|
|
1945
|
+
env.out(report);
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
// Descriptive: where the architecture changed is a fact about history,
|
|
1949
|
+
// never a finding.
|
|
1950
|
+
return EXIT.ok;
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1625
1953
|
/**
|
|
1626
1954
|
* `debt`'s `run`: resolves the command context, drives `debtCommand`, writes
|
|
1627
1955
|
* the ledger report, and returns the exit code.
|
|
@@ -2011,6 +2339,107 @@ const DIFF_FLAG_HELP = Object.freeze([
|
|
|
2011
2339
|
}),
|
|
2012
2340
|
]);
|
|
2013
2341
|
|
|
2342
|
+
/**
|
|
2343
|
+
* `delta`'s flags: `--capture` writes the evidence snapshot a later run
|
|
2344
|
+
* compares against; without it the baseline file is the single positional
|
|
2345
|
+
* argument. `--config` overrides the boundary law the same way `check`'s
|
|
2346
|
+
* does, because both sides are re-judged under whichever law this run
|
|
2347
|
+
* resolves.
|
|
2348
|
+
*
|
|
2349
|
+
* @type {readonly FlagHelp[]}
|
|
2350
|
+
*/
|
|
2351
|
+
const DELTA_FLAG_HELP = Object.freeze([
|
|
2352
|
+
Object.freeze({
|
|
2353
|
+
flag: "--capture",
|
|
2354
|
+
key: "capture",
|
|
2355
|
+
arg: "",
|
|
2356
|
+
describe: Object.freeze([
|
|
2357
|
+
"Write an evidence snapshot of the current tree",
|
|
2358
|
+
"(raw import records, graph, coverage, policy",
|
|
2359
|
+
"fingerprint) for a later delta run to compare against",
|
|
2360
|
+
]),
|
|
2361
|
+
}),
|
|
2362
|
+
Object.freeze({
|
|
2363
|
+
flag: "--format",
|
|
2364
|
+
key: "format",
|
|
2365
|
+
arg: "text|sarif|json",
|
|
2366
|
+
describe: Object.freeze([
|
|
2367
|
+
"Terminal report (default), SARIF 2.1.0 of the introduced",
|
|
2368
|
+
"findings for GitHub code scanning, or the versioned JSON",
|
|
2369
|
+
"envelope docs/reference/json-output.md documents",
|
|
2370
|
+
]),
|
|
2371
|
+
}),
|
|
2372
|
+
Object.freeze({
|
|
2373
|
+
flag: "--output",
|
|
2374
|
+
key: "output",
|
|
2375
|
+
arg: "<file>",
|
|
2376
|
+
describe: Object.freeze([
|
|
2377
|
+
"Write the report — or, with --capture, the snapshot —",
|
|
2378
|
+
"to a file instead of stdout",
|
|
2379
|
+
]),
|
|
2380
|
+
}),
|
|
2381
|
+
Object.freeze({
|
|
2382
|
+
flag: "--config",
|
|
2383
|
+
key: "config",
|
|
2384
|
+
arg: "<file>",
|
|
2385
|
+
describe: ({ boundaryConfig, inline }) =>
|
|
2386
|
+
Object.freeze([
|
|
2387
|
+
"Read the boundary law from here instead of",
|
|
2388
|
+
inline
|
|
2389
|
+
? "the inline boundaryConfig in archkeep.json"
|
|
2390
|
+
: `<workspace root>/${boundaryConfig}`,
|
|
2391
|
+
]),
|
|
2392
|
+
}),
|
|
2393
|
+
]);
|
|
2394
|
+
|
|
2395
|
+
/**
|
|
2396
|
+
* `change`'s flags: text or JSON envelope, optional file output, and the
|
|
2397
|
+
* required `--intent` naming the change-intent manifest. `--config` joins for
|
|
2398
|
+
* the same reason `delta`'s does: declared constraints are re-judged under
|
|
2399
|
+
* whichever law this run resolves.
|
|
2400
|
+
*
|
|
2401
|
+
* @type {readonly FlagHelp[]}
|
|
2402
|
+
*/
|
|
2403
|
+
const CHANGE_FLAG_HELP = Object.freeze([
|
|
2404
|
+
Object.freeze({
|
|
2405
|
+
flag: "--intent",
|
|
2406
|
+
key: "intent",
|
|
2407
|
+
arg: "<file>",
|
|
2408
|
+
describe: Object.freeze([
|
|
2409
|
+
"The change-intent manifest declaring the material",
|
|
2410
|
+
"architectural consequences this change expects",
|
|
2411
|
+
"(required; see docs/usage/change.md)",
|
|
2412
|
+
]),
|
|
2413
|
+
}),
|
|
2414
|
+
Object.freeze({
|
|
2415
|
+
flag: "--format",
|
|
2416
|
+
key: "format",
|
|
2417
|
+
arg: "text|json",
|
|
2418
|
+
describe: Object.freeze([
|
|
2419
|
+
"Terminal report (default) or the versioned JSON envelope",
|
|
2420
|
+
"docs/reference/json-output.md documents",
|
|
2421
|
+
]),
|
|
2422
|
+
}),
|
|
2423
|
+
Object.freeze({
|
|
2424
|
+
flag: "--output",
|
|
2425
|
+
key: "output",
|
|
2426
|
+
arg: "<file>",
|
|
2427
|
+
describe: Object.freeze(["Write the report to a file instead of stdout"]),
|
|
2428
|
+
}),
|
|
2429
|
+
Object.freeze({
|
|
2430
|
+
flag: "--config",
|
|
2431
|
+
key: "config",
|
|
2432
|
+
arg: "<file>",
|
|
2433
|
+
describe: ({ boundaryConfig, inline }) =>
|
|
2434
|
+
Object.freeze([
|
|
2435
|
+
"Read the boundary law from here instead of",
|
|
2436
|
+
inline
|
|
2437
|
+
? "the inline boundaryConfig in archkeep.json"
|
|
2438
|
+
: `<workspace root>/${boundaryConfig}`,
|
|
2439
|
+
]),
|
|
2440
|
+
}),
|
|
2441
|
+
]);
|
|
2442
|
+
|
|
2014
2443
|
/**
|
|
2015
2444
|
* `drift`'s flags: text or JSON envelope, optional file output. The intent is
|
|
2016
2445
|
* always read from the tracked root `architecture-intent.json` — the same one
|
|
@@ -2227,6 +2656,51 @@ const HISTORY_FLAG_HELP = Object.freeze([
|
|
|
2227
2656
|
}),
|
|
2228
2657
|
]);
|
|
2229
2658
|
|
|
2659
|
+
/**
|
|
2660
|
+
* `evolution`'s flags: text or JSON envelope, optional file output, and the
|
|
2661
|
+
* two revisions that bound the range. There is no `--config` — each analyzed
|
|
2662
|
+
* revision is judged under the law its own tree declares (`src/commands/evolution.mjs`),
|
|
2663
|
+
* and a law carried from outside would misattribute policy changes to
|
|
2664
|
+
* revisions that never made them.
|
|
2665
|
+
*
|
|
2666
|
+
* @type {readonly FlagHelp[]}
|
|
2667
|
+
*/
|
|
2668
|
+
const EVOLUTION_FLAG_HELP = Object.freeze([
|
|
2669
|
+
Object.freeze({
|
|
2670
|
+
flag: "--format",
|
|
2671
|
+
key: "format",
|
|
2672
|
+
arg: "text|json",
|
|
2673
|
+
describe: Object.freeze([
|
|
2674
|
+
"Terminal report (default) or the versioned JSON envelope",
|
|
2675
|
+
"docs/reference/json-output.md documents",
|
|
2676
|
+
]),
|
|
2677
|
+
}),
|
|
2678
|
+
Object.freeze({
|
|
2679
|
+
flag: "--output",
|
|
2680
|
+
key: "output",
|
|
2681
|
+
arg: "<file>",
|
|
2682
|
+
describe: Object.freeze(["Write the report to a file instead of stdout"]),
|
|
2683
|
+
}),
|
|
2684
|
+
Object.freeze({
|
|
2685
|
+
flag: "--base",
|
|
2686
|
+
key: "base",
|
|
2687
|
+
arg: "<rev>",
|
|
2688
|
+
describe: Object.freeze([
|
|
2689
|
+
"The baseline revision — a commit, branch, tag,",
|
|
2690
|
+
"or HEAD~n; the first revision analyzed",
|
|
2691
|
+
]),
|
|
2692
|
+
}),
|
|
2693
|
+
Object.freeze({
|
|
2694
|
+
flag: "--head",
|
|
2695
|
+
key: "head",
|
|
2696
|
+
arg: "<rev>",
|
|
2697
|
+
describe: Object.freeze([
|
|
2698
|
+
"The tip revision (default HEAD); must be a",
|
|
2699
|
+
"linear descendant of --base with no merges between",
|
|
2700
|
+
]),
|
|
2701
|
+
}),
|
|
2702
|
+
]);
|
|
2703
|
+
|
|
2230
2704
|
/**
|
|
2231
2705
|
* `health`'s flags: text or JSON envelope, optional file output. The optional
|
|
2232
2706
|
* positional argument is the snapshot directory for trends, the same directory
|
|
@@ -2303,6 +2777,35 @@ const REPORT_FLAG_HELP = Object.freeze([
|
|
|
2303
2777
|
}),
|
|
2304
2778
|
]);
|
|
2305
2779
|
|
|
2780
|
+
/**
|
|
2781
|
+
* `trajectory`'s flags: text or JSON envelope and optional file output — and
|
|
2782
|
+
* deliberately no `--config` and no `--capture`. There is no current-law
|
|
2783
|
+
* input to override: the fingerprints being compared travel inside the
|
|
2784
|
+
* snapshots, so the law each observation is judged under is the one it was
|
|
2785
|
+
* captured under. And the command writes nothing into the history directory —
|
|
2786
|
+
* capture stays `history --capture`'s job, so there is exactly one way a
|
|
2787
|
+
* snapshot enters the record.
|
|
2788
|
+
*
|
|
2789
|
+
* @type {readonly FlagHelp[]}
|
|
2790
|
+
*/
|
|
2791
|
+
const TRAJECTORY_FLAG_HELP = Object.freeze([
|
|
2792
|
+
Object.freeze({
|
|
2793
|
+
flag: "--format",
|
|
2794
|
+
key: "format",
|
|
2795
|
+
arg: "text|json",
|
|
2796
|
+
describe: Object.freeze([
|
|
2797
|
+
"Terminal report (default) or the versioned JSON envelope",
|
|
2798
|
+
"docs/reference/json-output.md documents",
|
|
2799
|
+
]),
|
|
2800
|
+
}),
|
|
2801
|
+
Object.freeze({
|
|
2802
|
+
flag: "--output",
|
|
2803
|
+
key: "output",
|
|
2804
|
+
arg: "<file>",
|
|
2805
|
+
describe: Object.freeze(["Write the report to a file instead of stdout"]),
|
|
2806
|
+
}),
|
|
2807
|
+
]);
|
|
2808
|
+
|
|
2306
2809
|
/**
|
|
2307
2810
|
* `debt`'s flags: text or JSON envelope, optional file output, and the same
|
|
2308
2811
|
* `--config` the other descriptive commands take, so a ledger ages the
|
|
@@ -2527,6 +3030,27 @@ const COMMANDS = Object.freeze({
|
|
|
2527
3030
|
formats: DESCRIBABLE_FORMATS,
|
|
2528
3031
|
run: runDiff,
|
|
2529
3032
|
}),
|
|
3033
|
+
delta: Object.freeze({
|
|
3034
|
+
name: "delta",
|
|
3035
|
+
args: "<baseline> | --capture",
|
|
3036
|
+
summary: "Classify how boundary violations moved between a captured baseline and head",
|
|
3037
|
+
flagHelp: DELTA_FLAG_HELP,
|
|
3038
|
+
flags: Object.freeze(Object.fromEntries(DELTA_FLAG_HELP.map((f) => [f.flag, f.key]))),
|
|
3039
|
+
defaults: Object.freeze({ format: "text", output: null, config: null, capture: false }),
|
|
3040
|
+
formats: DELTA_FORMATS,
|
|
3041
|
+
booleans: Object.freeze(["capture"]),
|
|
3042
|
+
run: runDelta,
|
|
3043
|
+
}),
|
|
3044
|
+
change: Object.freeze({
|
|
3045
|
+
name: "change",
|
|
3046
|
+
args: "<baseline> --intent <file>",
|
|
3047
|
+
summary: "Reconcile a declared change intent against the architectural delta",
|
|
3048
|
+
flagHelp: CHANGE_FLAG_HELP,
|
|
3049
|
+
flags: Object.freeze(Object.fromEntries(CHANGE_FLAG_HELP.map((f) => [f.flag, f.key]))),
|
|
3050
|
+
defaults: Object.freeze({ format: "text", output: null, config: null, intent: null }),
|
|
3051
|
+
formats: DESCRIBABLE_FORMATS,
|
|
3052
|
+
run: runChange,
|
|
3053
|
+
}),
|
|
2530
3054
|
discover: Object.freeze({
|
|
2531
3055
|
name: "discover",
|
|
2532
3056
|
args: "[--propose]",
|
|
@@ -2590,6 +3114,26 @@ const COMMANDS = Object.freeze({
|
|
|
2590
3114
|
booleans: Object.freeze(["capture"]),
|
|
2591
3115
|
run: runHistory,
|
|
2592
3116
|
}),
|
|
3117
|
+
trajectory: Object.freeze({
|
|
3118
|
+
name: "trajectory",
|
|
3119
|
+
args: "<dir>",
|
|
3120
|
+
summary: "Aggregate the deterministic drift trajectory across snapshots",
|
|
3121
|
+
flagHelp: TRAJECTORY_FLAG_HELP,
|
|
3122
|
+
flags: Object.freeze(Object.fromEntries(TRAJECTORY_FLAG_HELP.map((f) => [f.flag, f.key]))),
|
|
3123
|
+
defaults: Object.freeze({ format: "text", output: null }),
|
|
3124
|
+
formats: DESCRIBABLE_FORMATS,
|
|
3125
|
+
run: runTrajectory,
|
|
3126
|
+
}),
|
|
3127
|
+
evolution: Object.freeze({
|
|
3128
|
+
name: "evolution",
|
|
3129
|
+
args: "",
|
|
3130
|
+
summary: "Describe how the architecture evolved across a Git revision range",
|
|
3131
|
+
flagHelp: EVOLUTION_FLAG_HELP,
|
|
3132
|
+
flags: Object.freeze(Object.fromEntries(EVOLUTION_FLAG_HELP.map((f) => [f.flag, f.key]))),
|
|
3133
|
+
defaults: Object.freeze({ format: "text", output: null, base: null, head: null }),
|
|
3134
|
+
formats: DESCRIBABLE_FORMATS,
|
|
3135
|
+
run: runEvolution,
|
|
3136
|
+
}),
|
|
2593
3137
|
health: Object.freeze({
|
|
2594
3138
|
name: "health",
|
|
2595
3139
|
args: "[<snapshot-dir>]",
|