@ecoma-io/archkeep 0.14.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 +447 -59
- package/commands.mjs +51 -0
- package/package.json +3 -1
- package/src/analysis/typescript.mjs +2 -1
- package/src/commands/README.md +52 -1
- package/src/commands/change-intent.mjs +461 -0
- package/src/commands/change.mjs +612 -0
- package/src/commands/check.mjs +2 -1
- package/src/commands/context.mjs +40 -2
- package/src/commands/custom-rules.mjs +286 -2
- package/src/commands/delta-classify.mjs +195 -33
- package/src/commands/delta-snapshot.mjs +156 -1
- package/src/commands/delta.mjs +142 -17
- package/src/commands/diff.mjs +41 -13
- package/src/commands/evolution.mjs +473 -0
- package/src/commands/history.mjs +130 -103
- package/src/commands/policy.mjs +57 -0
- package/src/commands/trajectory.mjs +437 -0
- package/src/path-util.mjs +40 -0
- package/src/report/change-text.mjs +148 -0
- package/src/report/delta-text.mjs +82 -1
- package/src/report/evolution-text.mjs +83 -0
- package/src/report/history-text.mjs +4 -114
- package/src/report/sarif.mjs +255 -0
- package/src/report/snapshot-text.mjs +123 -0
- package/src/report/trajectory-text.mjs +143 -0
- package/src/tsconfig-paths.mjs +3 -2
package/README.md
CHANGED
|
@@ -191,7 +191,10 @@ a file it cannot edit ([overview.md](https://github.com/ecoma-io/archkeep/blob/m
|
|
|
191
191
|
([nx.md](https://github.com/ecoma-io/archkeep/blob/main/docs/integrations/nx.md) ·
|
|
192
192
|
[moon.md](https://github.com/ecoma-io/archkeep/blob/main/docs/integrations/moon.md)).
|
|
193
193
|
- **Agents** — Claude Code, Codex and opencode run the `arch-*` skills, which
|
|
194
|
-
are host-independent ([supported-hosts.md](https://github.com/ecoma-io/archkeep/blob/main/docs/skills/supported-hosts.md))
|
|
194
|
+
are host-independent ([supported-hosts.md](https://github.com/ecoma-io/archkeep/blob/main/docs/skills/supported-hosts.md));
|
|
195
|
+
agents whose host speaks MCP use `@ecoma-io/archkeep-mcp`, which runs this
|
|
196
|
+
package's own command layer in-process through the `./commands` subpath
|
|
197
|
+
([mcp.md](https://github.com/ecoma-io/archkeep/blob/main/docs/integrations/mcp.md)).
|
|
195
198
|
|
|
196
199
|
## Install and quick start
|
|
197
200
|
|
|
@@ -219,8 +222,11 @@ Exit codes: 0 clean — and every selected file was analyzed; 1 findings;
|
|
|
219
222
|
|
|
220
223
|
Ten minutes end to end, most of it spent deciding what your tags mean:
|
|
221
224
|
[**Getting started →**](https://github.com/ecoma-io/archkeep/blob/main/docs/getting-started/installation.md). `graph`, `diff`,
|
|
222
|
-
|
|
223
|
-
|
|
225
|
+
Ten minutes end to end, most of it spent deciding what your tags mean:
|
|
226
|
+
[**Getting started →**](https://github.com/ecoma-io/archkeep/blob/main/docs/getting-started/installation.md). `graph`, `diff`,
|
|
227
|
+
`history`, `trajectory`, `evolution`, `drift`, `impact`, `explain`,
|
|
228
|
+
`context` and the rest of the 21-command surface are in the
|
|
229
|
+
[CLI reference](https://github.com/ecoma-io/archkeep/blob/main/docs/reference/cli.md).
|
|
224
230
|
|
|
225
231
|
## Documentation map
|
|
226
232
|
|
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,
|
|
@@ -109,8 +113,11 @@ import { discoverCommand } from "./src/commands/discover.mjs";
|
|
|
109
113
|
import { driftCommand } from "./src/commands/drift.mjs";
|
|
110
114
|
import { fitnessCommand } from "./src/commands/fitness.mjs";
|
|
111
115
|
import { reconcileCommand } from "./src/commands/reconcile.mjs";
|
|
116
|
+
import { changeCommand } from "./src/commands/change.mjs";
|
|
112
117
|
import { computePolicyFingerprint, graphCommand } from "./src/commands/graph.mjs";
|
|
113
118
|
import { historyCommand } from "./src/commands/history.mjs";
|
|
119
|
+
import { trajectoryCommand } from "./src/commands/trajectory.mjs";
|
|
120
|
+
import { evolutionCommand } from "./src/commands/evolution.mjs";
|
|
114
121
|
import { healthCommand } from "./src/commands/health.mjs";
|
|
115
122
|
import { reportCommand } from "./src/commands/report.mjs";
|
|
116
123
|
import { debtCommand } from "./src/commands/debt.mjs";
|
|
@@ -170,6 +177,16 @@ const CHECK_FORMATS = Object.freeze(["text", "sarif", "json"]);
|
|
|
170
177
|
*/
|
|
171
178
|
const DESCRIBABLE_FORMATS = Object.freeze(["text", "json"]);
|
|
172
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
|
+
|
|
173
190
|
/**
|
|
174
191
|
* Column `usage()`'s Options block aligns flag descriptions to. Matches the
|
|
175
192
|
* hand-written text this table-driven rendering replaced, so deriving the
|
|
@@ -804,54 +821,14 @@ async function runGraph(options, { cwd, env }) {
|
|
|
804
821
|
//
|
|
805
822
|
// `graph` describes the project graph, not the boundary law — it reads no
|
|
806
823
|
// constraint row and judges nothing against one — so a workspace that has
|
|
807
|
-
// not written a law yet must not be refused here.
|
|
808
|
-
//
|
|
809
|
-
//
|
|
810
|
-
//
|
|
811
|
-
//
|
|
812
|
-
//
|
|
813
|
-
//
|
|
814
|
-
|
|
815
|
-
// policy.
|
|
816
|
-
//
|
|
817
|
-
// What is skipped is the load of a file that is NOT THERE. A boundary
|
|
818
|
-
// config that exists and will not load still fails the run, because an
|
|
819
|
-
// absent law and a broken one must not report alike; a `--config`, a
|
|
820
|
-
// profile, and an inline `archkeep.json` policy are explicit declarations
|
|
821
|
-
// and stay loud. Every command that JUDGES against the law keeps loading
|
|
822
|
-
// it unconditionally — making it optional for those would turn a missing
|
|
823
|
-
// file into a silent no-law run.
|
|
824
|
-
//
|
|
825
|
-
// `boundaryConfigDeclared` is what keeps this guard to the un-overridden
|
|
826
|
-
// default, and it is load-bearing rather than belt-and-braces. The name
|
|
827
|
-
// alone cannot answer it: `commandContext.options.boundaryConfig` is a
|
|
828
|
-
// string BOTH when it came from `./src/options.mjs`'s `DEFAULT_OPTIONS`
|
|
829
|
-
// and when the consumer WROTE it into `nx.json`'s plugin options or
|
|
830
|
-
// `archkeep.json`, and a workspace is free to declare the convention
|
|
831
|
-
// filename itself, so comparing against the default would still read a
|
|
832
|
-
// deliberate declaration as an assumption. Without the bit, measured on a
|
|
833
|
-
// committed native tree whose `archkeep.json` declares `boundaryConfig:
|
|
834
|
-
// "policy-we-declared.mjs"` and does not contain that file: `graph` exited
|
|
835
|
-
// 0 with a snapshot carrying no `policy` field — byte-identical to a
|
|
836
|
-
// workspace that never had a law — where the same tree with that file
|
|
837
|
-
// present but unparseable exited 3. A law someone named and then renamed
|
|
838
|
-
// or deleted is exactly the case that must stay loud, so the provenance
|
|
839
|
-
// survives the options layer instead (`./src/options.mjs`'s
|
|
840
|
-
// `resolveOptions`, `./src/providers/native/model.mjs`'s
|
|
841
|
-
// `normalizeNativeModel`, and `./src/commands/context.mjs`'s three
|
|
842
|
-
// branches carry it; Moon answers `false` because it has no table to
|
|
843
|
-
// declare one in).
|
|
844
|
-
const workspaceDefault =
|
|
845
|
-
!options.config &&
|
|
846
|
-
!hasProfiles(commandContext.options) &&
|
|
847
|
-
commandContext.options.boundaryConfigDeclared === false &&
|
|
848
|
-
typeof commandContext.options.boundaryConfig === "string"
|
|
849
|
-
? resolve(commandContext.root, commandContext.options.boundaryConfig)
|
|
850
|
-
: null;
|
|
851
|
-
const { config } =
|
|
852
|
-
workspaceDefault !== null && !existsSync(workspaceDefault)
|
|
853
|
-
? { config: null }
|
|
854
|
-
: 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);
|
|
855
832
|
|
|
856
833
|
result = graphCommand(commandContext, { config });
|
|
857
834
|
} catch (error) {
|
|
@@ -1001,14 +978,19 @@ async function runDelta(options, { cwd, env }) {
|
|
|
1001
978
|
const baselinePath = isAbsolute(options.paths[0])
|
|
1002
979
|
? resolve(options.paths[0])
|
|
1003
980
|
: resolve(cwd, options.paths[0]);
|
|
1004
|
-
result = deltaCommand(baselinePath, commandContext, { config });
|
|
981
|
+
result = await deltaCommand(baselinePath, commandContext, { config });
|
|
1005
982
|
} catch (error) {
|
|
1006
983
|
const usageError = error instanceof UsageError;
|
|
1007
984
|
env.err(String(error?.message ?? error));
|
|
1008
985
|
return usageError ? EXIT.usage : EXIT.error;
|
|
1009
986
|
}
|
|
1010
987
|
|
|
1011
|
-
const report =
|
|
988
|
+
const report =
|
|
989
|
+
options.format === "json"
|
|
990
|
+
? result.report.json
|
|
991
|
+
: options.format === "sarif"
|
|
992
|
+
? result.report.sarif
|
|
993
|
+
: result.report.text;
|
|
1012
994
|
|
|
1013
995
|
if (options.output) {
|
|
1014
996
|
// Atomic, symlink-safe write — `writeOutputReport`'s own docstring owns
|
|
@@ -1212,6 +1194,108 @@ async function runReconcile(options, { cwd, env }) {
|
|
|
1212
1194
|
return EXIT.ok;
|
|
1213
1195
|
}
|
|
1214
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
|
+
|
|
1215
1299
|
/**
|
|
1216
1300
|
* `waivers`' `run`: resolves the command context, drives `waiversCommand`,
|
|
1217
1301
|
* writes the report where it belongs, and returns the process's exit code.
|
|
@@ -1715,6 +1799,157 @@ async function runHistory(options, { cwd, env }) {
|
|
|
1715
1799
|
return EXIT.ok;
|
|
1716
1800
|
}
|
|
1717
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
|
+
|
|
1718
1953
|
/**
|
|
1719
1954
|
* `debt`'s `run`: resolves the command context, drives `debtCommand`, writes
|
|
1720
1955
|
* the ledger report, and returns the exit code.
|
|
@@ -2127,10 +2362,11 @@ const DELTA_FLAG_HELP = Object.freeze([
|
|
|
2127
2362
|
Object.freeze({
|
|
2128
2363
|
flag: "--format",
|
|
2129
2364
|
key: "format",
|
|
2130
|
-
arg: "text|json",
|
|
2365
|
+
arg: "text|sarif|json",
|
|
2131
2366
|
describe: Object.freeze([
|
|
2132
|
-
"Terminal report (default)
|
|
2133
|
-
"
|
|
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",
|
|
2134
2370
|
]),
|
|
2135
2371
|
}),
|
|
2136
2372
|
Object.freeze({
|
|
@@ -2156,6 +2392,54 @@ const DELTA_FLAG_HELP = Object.freeze([
|
|
|
2156
2392
|
}),
|
|
2157
2393
|
]);
|
|
2158
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
|
+
|
|
2159
2443
|
/**
|
|
2160
2444
|
* `drift`'s flags: text or JSON envelope, optional file output. The intent is
|
|
2161
2445
|
* always read from the tracked root `architecture-intent.json` — the same one
|
|
@@ -2372,6 +2656,51 @@ const HISTORY_FLAG_HELP = Object.freeze([
|
|
|
2372
2656
|
}),
|
|
2373
2657
|
]);
|
|
2374
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
|
+
|
|
2375
2704
|
/**
|
|
2376
2705
|
* `health`'s flags: text or JSON envelope, optional file output. The optional
|
|
2377
2706
|
* positional argument is the snapshot directory for trends, the same directory
|
|
@@ -2448,6 +2777,35 @@ const REPORT_FLAG_HELP = Object.freeze([
|
|
|
2448
2777
|
}),
|
|
2449
2778
|
]);
|
|
2450
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
|
+
|
|
2451
2809
|
/**
|
|
2452
2810
|
* `debt`'s flags: text or JSON envelope, optional file output, and the same
|
|
2453
2811
|
* `--config` the other descriptive commands take, so a ledger ages the
|
|
@@ -2679,10 +3037,20 @@ const COMMANDS = Object.freeze({
|
|
|
2679
3037
|
flagHelp: DELTA_FLAG_HELP,
|
|
2680
3038
|
flags: Object.freeze(Object.fromEntries(DELTA_FLAG_HELP.map((f) => [f.flag, f.key]))),
|
|
2681
3039
|
defaults: Object.freeze({ format: "text", output: null, config: null, capture: false }),
|
|
2682
|
-
formats:
|
|
3040
|
+
formats: DELTA_FORMATS,
|
|
2683
3041
|
booleans: Object.freeze(["capture"]),
|
|
2684
3042
|
run: runDelta,
|
|
2685
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
|
+
}),
|
|
2686
3054
|
discover: Object.freeze({
|
|
2687
3055
|
name: "discover",
|
|
2688
3056
|
args: "[--propose]",
|
|
@@ -2746,6 +3114,26 @@ const COMMANDS = Object.freeze({
|
|
|
2746
3114
|
booleans: Object.freeze(["capture"]),
|
|
2747
3115
|
run: runHistory,
|
|
2748
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
|
+
}),
|
|
2749
3137
|
health: Object.freeze({
|
|
2750
3138
|
name: "health",
|
|
2751
3139
|
args: "[<snapshot-dir>]",
|