@ecoma-io/archkeep 0.14.0 → 0.16.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.
Files changed (68) hide show
  1. package/README.md +11 -5
  2. package/cli.mjs +571 -61
  3. package/commands.mjs +57 -0
  4. package/lsp.mjs +15 -2
  5. package/package.json +8 -2
  6. package/src/analysis/analyze.mjs +15 -0
  7. package/src/analysis/contract.md +36 -18
  8. package/src/analysis/csharp.mjs +485 -0
  9. package/src/analysis/dotnet/csproj.mjs +380 -0
  10. package/src/analysis/dotnet/mask.mjs +178 -0
  11. package/src/analysis/dotnet/namespaces.mjs +172 -0
  12. package/src/analysis/dotnet/resolve.mjs +89 -0
  13. package/src/analysis/go.mjs +289 -5
  14. package/src/analysis/java.mjs +329 -0
  15. package/src/analysis/jvm/gradle.mjs +545 -0
  16. package/src/analysis/jvm/mask.mjs +170 -0
  17. package/src/analysis/jvm/maven.mjs +612 -0
  18. package/src/analysis/jvm/packages.mjs +209 -0
  19. package/src/analysis/jvm/resolve.mjs +139 -0
  20. package/src/analysis/kotlin.mjs +210 -0
  21. package/src/analysis/manifest-util.mjs +30 -0
  22. package/src/analysis/python.mjs +3 -2
  23. package/src/analysis/registry.mjs +11 -0
  24. package/src/analysis/rust.mjs +171 -17
  25. package/src/analysis/source-util.mjs +155 -6
  26. package/src/analysis/typescript.mjs +11 -3
  27. package/src/commands/README.md +52 -1
  28. package/src/commands/change-intent.mjs +461 -0
  29. package/src/commands/change.mjs +612 -0
  30. package/src/commands/check.mjs +2 -1
  31. package/src/commands/context.mjs +124 -16
  32. package/src/commands/custom-rules.mjs +286 -2
  33. package/src/commands/delta-classify.mjs +195 -33
  34. package/src/commands/delta-snapshot.mjs +156 -1
  35. package/src/commands/delta.mjs +142 -17
  36. package/src/commands/diff.mjs +41 -13
  37. package/src/commands/evolution.mjs +473 -0
  38. package/src/commands/history.mjs +130 -103
  39. package/src/commands/policy.mjs +57 -0
  40. package/src/commands/provenance.mjs +7 -44
  41. package/src/commands/rules.mjs +775 -0
  42. package/src/commands/trajectory.mjs +437 -0
  43. package/src/governance/profile-registry.mjs +0 -1
  44. package/src/graph/create-dependencies.mjs +138 -15
  45. package/src/lsp/diagnose.mjs +1 -1
  46. package/src/lsp/server.mjs +97 -1
  47. package/src/lsp/workspace-index.mjs +106 -15
  48. package/src/options.mjs +30 -7
  49. package/src/path-util.mjs +40 -0
  50. package/src/process.mjs +10 -1
  51. package/src/providers/moon.mjs +287 -36
  52. package/src/providers/native/differential.fixtures.mjs +32 -6
  53. package/src/providers/native/discover.mjs +83 -4
  54. package/src/providers/native/graph.mjs +58 -0
  55. package/src/providers/native/model.mjs +59 -1
  56. package/src/report/change-text.mjs +148 -0
  57. package/src/report/delta-text.mjs +82 -1
  58. package/src/report/evolution-text.mjs +83 -0
  59. package/src/report/history-text.mjs +4 -114
  60. package/src/report/sarif.mjs +255 -0
  61. package/src/report/snapshot-text.mjs +123 -0
  62. package/src/report/trajectory-text.mjs +143 -0
  63. package/src/rules/index.mjs +21 -6
  64. package/src/rules/reachability.mjs +2 -0
  65. package/src/rules/tags.mjs +7 -5
  66. package/src/rules/topology.mjs +5 -3
  67. package/src/tsconfig-paths.mjs +3 -2
  68. package/src/workspace.mjs +115 -23
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, or architecture-intent findings. `check` is the only command
55
- * that can produce this exit code every other verb this table might grow
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, or a `boundarySuppressions` row
62
- * accepts nothing this run judged. Distinct from
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 { hasProfiles, resolvePolicy } from "./src/commands/policy.mjs";
100
+ import { resolveDescribedPolicy, resolvePolicy } from "./src/commands/policy.mjs";
97
101
  import {
98
102
  DEFAULT_OPTIONS,
99
103
  WORKSPACE_MARKERS,
@@ -109,14 +113,23 @@ 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";
117
124
  import { explainCommand } from "./src/commands/explain.mjs";
118
125
  import { impactCommand } from "./src/commands/impact.mjs";
119
126
  import { provenanceCommand } from "./src/commands/provenance-command.mjs";
127
+ import {
128
+ rulesAddCommand,
129
+ rulesInfoCommand,
130
+ rulesListCommand,
131
+ rulesVerifyCommand,
132
+ } from "./src/commands/rules.mjs";
120
133
  import { waiversCommand } from "./src/commands/waivers.mjs";
121
134
  import { INTENT_FILE, loadIntent } from "./src/architecture-intent/model.mjs";
122
135
  import { isProgramEntry } from "./src/entry-point.mjs";
@@ -170,6 +183,16 @@ const CHECK_FORMATS = Object.freeze(["text", "sarif", "json"]);
170
183
  */
171
184
  const DESCRIBABLE_FORMATS = Object.freeze(["text", "json"]);
172
185
 
186
+ /**
187
+ * Every format `delta --format` accepts. SARIF joins the two descriptive
188
+ * formats because `delta`'s compare mode is a gate that produces findings —
189
+ * the introduced bucket is exactly what a code-scanning upload annotates at
190
+ * head sites — while every other descriptive-family verb stays on
191
+ * `DESCRIBABLE_FORMATS`: none of them produces findings, and SARIF's
192
+ * `results[]` is a findings container.
193
+ */
194
+ const DELTA_FORMATS = Object.freeze(["text", "sarif", "json"]);
195
+
173
196
  /**
174
197
  * Column `usage()`'s Options block aligns flag descriptions to. Matches the
175
198
  * hand-written text this table-driven rendering replaced, so deriving the
@@ -804,54 +827,14 @@ async function runGraph(options, { cwd, env }) {
804
827
  //
805
828
  // `graph` describes the project graph, not the boundary law — it reads no
806
829
  // constraint row and judges nothing against one — so a workspace that has
807
- // not written a law yet must not be refused here. It was, with exit 3: the
808
- // workspace-default `boundaryConfig` is never absent on the Nx and Moon
809
- // paths (`readPluginOptions` falls back to `DEFAULT_OPTIONS`, and Moon
810
- // takes the same default by convention), so that arm of `resolvePolicy`
811
- // fired unconditionally and a missing file became the command's exit code.
812
- // `discover`, the other descriptive verb over the same graph, answered
813
- // fine on the identical tree — and `graph` is what a workspace runs to see
814
- // what Archkeep found, which is what it needs in order to WRITE a first
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);
830
+ // not written a law yet must not be refused here. Every arm of that
831
+ // decision what is skipped is the load of a file that is NOT THERE, the
832
+ // `boundaryConfigDeclared` bit that keeps the guard to the un-overridden
833
+ // default, and why a law someone named and then deleted stays loud — lives
834
+ // in `resolveDescribedPolicy` (`./src/commands/policy.mjs`) rather than
835
+ // here, so the descriptive commands and the MCP face that serves them
836
+ // cannot disagree about what "no law declared" means.
837
+ const { config } = await resolveDescribedPolicy(options, commandContext, cwd);
855
838
 
856
839
  result = graphCommand(commandContext, { config });
857
840
  } catch (error) {
@@ -1001,14 +984,19 @@ async function runDelta(options, { cwd, env }) {
1001
984
  const baselinePath = isAbsolute(options.paths[0])
1002
985
  ? resolve(options.paths[0])
1003
986
  : resolve(cwd, options.paths[0]);
1004
- result = deltaCommand(baselinePath, commandContext, { config });
987
+ result = await deltaCommand(baselinePath, commandContext, { config });
1005
988
  } catch (error) {
1006
989
  const usageError = error instanceof UsageError;
1007
990
  env.err(String(error?.message ?? error));
1008
991
  return usageError ? EXIT.usage : EXIT.error;
1009
992
  }
1010
993
 
1011
- const report = options.format === "json" ? result.report.json : result.report.text;
994
+ const report =
995
+ options.format === "json"
996
+ ? result.report.json
997
+ : options.format === "sarif"
998
+ ? result.report.sarif
999
+ : result.report.text;
1012
1000
 
1013
1001
  if (options.output) {
1014
1002
  // Atomic, symlink-safe write — `writeOutputReport`'s own docstring owns
@@ -1212,6 +1200,108 @@ async function runReconcile(options, { cwd, env }) {
1212
1200
  return EXIT.ok;
1213
1201
  }
1214
1202
 
1203
+ /**
1204
+ * `change`'s `run`: resolves the command context and the boundary law, drives
1205
+ * `changeCommand` over the baseline evidence snapshot and the intent manifest,
1206
+ * writes the report where it belongs, and returns the process's exit code.
1207
+ *
1208
+ * The fourth verb whose verdict carries exit 1, beside `check`, `fitness` and
1209
+ * `delta`: an undeclared material change, an unfulfilled declaration, or a
1210
+ * failed declared constraint is a finding; an unproven base identity or an
1211
+ * undeterminable constraint is a no-verdict. The workspace-law axis the
1212
+ * envelope reports is informational — it never moves this exit code, because
1213
+ * `check` remains the authority on the law.
1214
+ *
1215
+ * @param {{format: string, output: string|null, config: string|null,
1216
+ * intent: string|null, paths: string[]}} options
1217
+ * @param {{cwd: string, env: {out: Function, err: Function, readGraph?: Function,
1218
+ * listFiles?: Function}}} runContext
1219
+ * @returns {Promise<number>}
1220
+ */
1221
+ async function runChange(options, { cwd, env }) {
1222
+ if (options.paths.length !== 1) {
1223
+ env.err(
1224
+ `archkeep: change takes exactly one positional argument (the baseline evidence snapshot ` +
1225
+ `from 'delta --capture'); got ${options.paths.length}`,
1226
+ );
1227
+ return EXIT.usage;
1228
+ }
1229
+ if (!options.intent) {
1230
+ env.err(
1231
+ "archkeep: change needs '--intent <file>' naming the change-intent manifest — without a " +
1232
+ "declaration there is nothing to reconcile against",
1233
+ );
1234
+ return EXIT.usage;
1235
+ }
1236
+
1237
+ const baselinePath = isAbsolute(options.paths[0])
1238
+ ? options.paths[0]
1239
+ : resolve(cwd, options.paths[0]);
1240
+ const intentPath = isAbsolute(options.intent) ? options.intent : resolve(cwd, options.intent);
1241
+
1242
+ // A self-footgun guard, the same shape `history`'s holds: writing the
1243
+ // reconciliation report over the very manifest this run just read would
1244
+ // destroy the declaration it verified, with the loss surfacing only later —
1245
+ // the first time someone tries to re-run the verification.
1246
+ if (options.output) {
1247
+ const outputAbs = isAbsolute(options.output)
1248
+ ? resolve(options.output)
1249
+ : resolve(cwd, options.output);
1250
+ if (outputAbs === intentPath) {
1251
+ env.err(
1252
+ `archkeep: --output '${options.output}' resolves to the change-intent manifest itself — ` +
1253
+ `overwriting the declaration with its own reconciliation report would destroy it. ` +
1254
+ `Write the report somewhere else.`,
1255
+ );
1256
+ return EXIT.usage;
1257
+ }
1258
+ }
1259
+
1260
+ let result;
1261
+ try {
1262
+ const commandContext = resolveCommandContext(
1263
+ { cwd },
1264
+ { readGraph: env.readGraph, listFiles: env.listFiles },
1265
+ );
1266
+
1267
+ // Declared constraints are judged under whichever law THIS run resolves,
1268
+ // and the envelope records that law's fingerprint beside the baseline's —
1269
+ // the same loading every judging command does (`resolvePolicy`),
1270
+ // profile-aware the same way `check` is.
1271
+ const { config } = await resolvePolicy(options, commandContext, cwd);
1272
+
1273
+ result = await changeCommand(baselinePath, intentPath, commandContext, { config });
1274
+ } catch (error) {
1275
+ const usageError = error instanceof UsageError;
1276
+ env.err(String(error?.message ?? error));
1277
+ return usageError ? EXIT.usage : EXIT.error;
1278
+ }
1279
+
1280
+ const report = options.format === "json" ? result.report.json : result.report.text;
1281
+
1282
+ if (options.output) {
1283
+ // Atomic, symlink-safe write — `writeOutputReport`'s own docstring owns
1284
+ // the mechanism and the threat it closes.
1285
+ const reportText = report.endsWith("\n") ? report : `${report}\n`;
1286
+ if (!writeOutputReport(options.output, reportText, env, cwd, options.config)) return EXIT.error;
1287
+ env.err(
1288
+ `archkeep: change ${result.changeIntent.reconciliation.verdict} ` +
1289
+ `(+${result.changeIntent.reconciliation.matched.length} matched, ` +
1290
+ `!${result.changeIntent.reconciliation.unexpected.length} undeclared, ` +
1291
+ `?${result.changeIntent.reconciliation.missingExpected.length} unfulfilled) → ${options.output}`,
1292
+ );
1293
+ } else {
1294
+ env.out(report);
1295
+ }
1296
+
1297
+ // The verdict fold `changeCommand` computed, mapped here the way `delta`'s
1298
+ // and `fitness`' are.
1299
+ return (
1300
+ { ok: EXIT.ok, findings: EXIT.violations, "no-verdict": EXIT.error }[result.status] ??
1301
+ EXIT.error
1302
+ );
1303
+ }
1304
+
1215
1305
  /**
1216
1306
  * `waivers`' `run`: resolves the command context, drives `waiversCommand`,
1217
1307
  * writes the report where it belongs, and returns the process's exit code.
@@ -1590,8 +1680,8 @@ async function runAdr(options, { cwd, env }) {
1590
1680
  const root = resolveWorkspaceRootForUsage(cwd);
1591
1681
  if (root === null) {
1592
1682
  env.err(
1593
- `archkeep: adr needs a workspace root — no nx.json, archkeep.json, or .moon marker found ` +
1594
- `walking up from ${cwd}`,
1683
+ `archkeep: adr needs a workspace root — no nx.json, archkeep.json, or ` +
1684
+ `.moon/workspace.yml marker found walking up from ${cwd}`,
1595
1685
  );
1596
1686
  return EXIT.error;
1597
1687
  }
@@ -1623,6 +1713,72 @@ async function runAdr(options, { cwd, env }) {
1623
1713
  return result.status === "ok" ? EXIT.ok : EXIT.error;
1624
1714
  }
1625
1715
 
1716
+ /**
1717
+ * `rules`'s `run`: dispatches to the appropriate subcommand (list/info/verify/add),
1718
+ * drives it, writes the report, and returns the exit code.
1719
+ *
1720
+ * @param {{format: string, output: string|null, catalog: string|null, to: string|null, paths: string[]}} options
1721
+ * @param {{cwd: string, env: {out: Function, err: Function}}} runContext
1722
+ * @returns {Promise<number>}
1723
+ */
1724
+ async function runRules(options, { cwd, env }) {
1725
+ if (options.paths.length === 0) {
1726
+ env.err(`archkeep: rules requires a subcommand (list, info, verify, or add)`);
1727
+ return EXIT.usage;
1728
+ }
1729
+
1730
+ const subcommand = options.paths[0];
1731
+ const ruleName = options.paths[1];
1732
+
1733
+ const dispatch = {
1734
+ list: rulesListCommand,
1735
+ info: rulesInfoCommand,
1736
+ verify: rulesVerifyCommand,
1737
+ add: rulesAddCommand,
1738
+ }[subcommand];
1739
+
1740
+ if (!dispatch) {
1741
+ env.err(
1742
+ `archkeep: unknown rules subcommand '${subcommand}' — must be list, info, verify, or add`,
1743
+ );
1744
+ return EXIT.usage;
1745
+ }
1746
+
1747
+ if (subcommand === "info" && !ruleName) {
1748
+ env.err(`archkeep: rules info requires a rule name`);
1749
+ return EXIT.usage;
1750
+ }
1751
+
1752
+ if (subcommand === "add" && !ruleName) {
1753
+ env.err(`archkeep: rules add requires a rule name`);
1754
+ return EXIT.usage;
1755
+ }
1756
+
1757
+ let result;
1758
+ try {
1759
+ const commandOptions = { catalog: options.catalog, to: options.to };
1760
+ result = await dispatch(commandOptions, { cwd, ruleName });
1761
+ } catch (error) {
1762
+ env.err(String(error?.message ?? error));
1763
+ return EXIT.error;
1764
+ }
1765
+
1766
+ const report = options.format === "json" ? result.report.json : result.report.text;
1767
+
1768
+ if (options.output) {
1769
+ const reportText = report.endsWith("\n") ? report : `${report}\n`;
1770
+ if (!writeOutputReport(options.output, reportText, env, cwd, null)) return EXIT.error;
1771
+ env.err(`archkeep: rules ${subcommand} complete → ${options.output}`);
1772
+ } else {
1773
+ env.out(report);
1774
+ }
1775
+
1776
+ // Exit codes: 0 for ok, 1 for findings (verify only), 3 for no-verdict
1777
+ if (result.status === "ok") return EXIT.ok;
1778
+ if (result.status === "findings") return EXIT.violations;
1779
+ return EXIT.error;
1780
+ }
1781
+
1626
1782
  /**
1627
1783
  * `history`'s `run`: resolves the command context, optionally captures a
1628
1784
  * snapshot of the current workspace, drives `historyCommand`, writes the
@@ -1715,6 +1871,157 @@ async function runHistory(options, { cwd, env }) {
1715
1871
  return EXIT.ok;
1716
1872
  }
1717
1873
 
1874
+ /**
1875
+ * `trajectory`'s run: resolves the command context, drives
1876
+ * `trajectoryCommand`, writes the report, and returns the exit code.
1877
+ *
1878
+ * The history directory is the single positional argument — the same
1879
+ * consumer-managed directory `history` and `debt` read. No boundary law is
1880
+ * loaded and no snapshot is captured: the fingerprints compared travel inside
1881
+ * the stored snapshots (`src/commands/trajectory.mjs`'s header owns both
1882
+ * halves of that posture).
1883
+ *
1884
+ * @param {{format: string, output: string|null, paths: string[]}} options
1885
+ * @param {{cwd: string, env: {out: Function, err: Function, readGraph?: Function, listFiles?: Function}}} runContext
1886
+ * @returns {Promise<number>}
1887
+ */
1888
+ async function runTrajectory(options, { cwd, env }) {
1889
+ if (options.paths.length !== 1) {
1890
+ env.err(
1891
+ `archkeep: trajectory takes exactly one positional argument (the history directory); ` +
1892
+ `got ${options.paths.length}`,
1893
+ );
1894
+ return EXIT.usage;
1895
+ }
1896
+
1897
+ const dir = isAbsolute(options.paths[0])
1898
+ ? resolve(options.paths[0])
1899
+ : resolve(cwd, options.paths[0]);
1900
+
1901
+ // The same self-footgun guard `runHistory` applies: a report written into
1902
+ // the directory being read would be read back as a snapshot on the next run
1903
+ // (the envelope is not a `graph` snapshot, which `parseBaseline` refuses) —
1904
+ // poison the record loudly refused rather than quietly planted.
1905
+ if (options.output) {
1906
+ const outputAbs = isAbsolute(options.output)
1907
+ ? resolve(options.output)
1908
+ : resolve(cwd, options.output);
1909
+ if (dirname(outputAbs) === dir) {
1910
+ env.err(
1911
+ `archkeep: --output '${options.output}' is inside the history directory '${dir}' — ` +
1912
+ `writing the report there would be read back as a snapshot on the next run. ` +
1913
+ `Write it somewhere else.`,
1914
+ );
1915
+ return EXIT.usage;
1916
+ }
1917
+ }
1918
+
1919
+ let result;
1920
+ try {
1921
+ const commandContext = resolveCommandContext(
1922
+ { cwd },
1923
+ { readGraph: env.readGraph, listFiles: env.listFiles },
1924
+ );
1925
+ result = trajectoryCommand(dir, commandContext);
1926
+ } catch (error) {
1927
+ const usageError = error instanceof UsageError;
1928
+ env.err(String(error?.message ?? error));
1929
+ return usageError ? EXIT.usage : EXIT.error;
1930
+ }
1931
+
1932
+ const report = options.format === "json" ? result.report.json : result.report.text;
1933
+
1934
+ if (options.output) {
1935
+ // Atomic, symlink-safe write — `writeOutputReport`'s own docstring owns
1936
+ // the mechanism and the threat it closes.
1937
+ const reportText = report.endsWith("\n") ? report : `${report}\n`;
1938
+ // No `--config` flag (`TRAJECTORY_FLAG_HELP`) — there is no override to pass.
1939
+ if (!writeOutputReport(options.output, reportText, env, cwd, null)) return EXIT.error;
1940
+ env.err(`archkeep: trajectory complete → ${options.output}`);
1941
+ } else {
1942
+ env.out(report);
1943
+ }
1944
+
1945
+ // Trajectory is descriptive: 0 when the aggregation completes, never 1.
1946
+ return EXIT.ok;
1947
+ }
1948
+
1949
+ /**
1950
+ * `evolution`'s `run`: resolves the workspace root the way `adr` does (a light
1951
+ * marker walk — this command describes OTHER trees, so it must not fail
1952
+ * because the caller's own working tree does not fully analyze right now),
1953
+ * drives `evolutionCommand`, writes the report, and returns the exit code.
1954
+ *
1955
+ * The range is `--base` plus optional `--head`; there are no positional
1956
+ * arguments. The Nx and git seams in `env` thread into EVERY analyzed
1957
+ * revision's context, the same way they thread into one tree elsewhere — in
1958
+ * production both are undefined and every revision is read for real.
1959
+ *
1960
+ * @param {{format: string, output: string|null, base: string|null, head: string|null,
1961
+ * paths: string[]}} options
1962
+ * @param {{cwd: string, env: {out: Function, err: Function, readGraph?: Function, listFiles?: Function}}} runContext
1963
+ * @returns {Promise<number>}
1964
+ */
1965
+ async function runEvolution(options, { cwd, env }) {
1966
+ if (!options.base) {
1967
+ env.err(
1968
+ `archkeep: evolution needs --base <rev> — a commit, branch, or tag the range starts at ` +
1969
+ `(--head defaults to HEAD).`,
1970
+ );
1971
+ return EXIT.usage;
1972
+ }
1973
+ if (options.paths.length > 0) {
1974
+ env.err(
1975
+ `archkeep: evolution takes no positional arguments; the range is --base plus --head. ` +
1976
+ `Got '${options.paths.join("', '")}'`,
1977
+ );
1978
+ return EXIT.usage;
1979
+ }
1980
+
1981
+ const root = findWorkspaceRoot(cwd, WORKSPACE_MARKERS);
1982
+ if (root === null) {
1983
+ env.err(
1984
+ `archkeep: evolution needs a workspace root — no nx.json, archkeep.json, or ` +
1985
+ `.moon/workspace.yml marker found walking up from ${cwd}`,
1986
+ );
1987
+ return EXIT.error;
1988
+ }
1989
+
1990
+ let result;
1991
+ try {
1992
+ result = await evolutionCommand(
1993
+ root,
1994
+ { base: options.base, head: options.head },
1995
+ {
1996
+ readGraph: env.readGraph,
1997
+ listFiles: env.listFiles,
1998
+ },
1999
+ );
2000
+ } catch (error) {
2001
+ const usageError = error instanceof UsageError;
2002
+ env.err(String(error?.message ?? error));
2003
+ return usageError ? EXIT.usage : EXIT.error;
2004
+ }
2005
+
2006
+ const report = options.format === "json" ? result.report.json : result.report.text;
2007
+
2008
+ if (options.output) {
2009
+ // Atomic, symlink-safe write — `writeOutputReport`'s own docstring owns
2010
+ // the mechanism and the threat it closes.
2011
+ const reportText = report.endsWith("\n") ? report : `${report}\n`;
2012
+ // No `--config` flag exists here (`EVOLUTION_FLAG_HELP`) — there is no
2013
+ // override to pass, only the workspace's un-overridden default to guard.
2014
+ if (!writeOutputReport(options.output, reportText, env, cwd, null)) return EXIT.error;
2015
+ env.err(`archkeep: evolution complete → ${options.output}`);
2016
+ } else {
2017
+ env.out(report);
2018
+ }
2019
+
2020
+ // Descriptive: where the architecture changed is a fact about history,
2021
+ // never a finding.
2022
+ return EXIT.ok;
2023
+ }
2024
+
1718
2025
  /**
1719
2026
  * `debt`'s `run`: resolves the command context, drives `debtCommand`, writes
1720
2027
  * the ledger report, and returns the exit code.
@@ -2127,10 +2434,11 @@ const DELTA_FLAG_HELP = Object.freeze([
2127
2434
  Object.freeze({
2128
2435
  flag: "--format",
2129
2436
  key: "format",
2130
- arg: "text|json",
2437
+ arg: "text|sarif|json",
2131
2438
  describe: Object.freeze([
2132
- "Terminal report (default) or the versioned JSON envelope",
2133
- "docs/reference/json-output.md documents",
2439
+ "Terminal report (default), SARIF 2.1.0 of the introduced",
2440
+ "findings for GitHub code scanning, or the versioned JSON",
2441
+ "envelope docs/reference/json-output.md documents",
2134
2442
  ]),
2135
2443
  }),
2136
2444
  Object.freeze({
@@ -2156,6 +2464,54 @@ const DELTA_FLAG_HELP = Object.freeze([
2156
2464
  }),
2157
2465
  ]);
2158
2466
 
2467
+ /**
2468
+ * `change`'s flags: text or JSON envelope, optional file output, and the
2469
+ * required `--intent` naming the change-intent manifest. `--config` joins for
2470
+ * the same reason `delta`'s does: declared constraints are re-judged under
2471
+ * whichever law this run resolves.
2472
+ *
2473
+ * @type {readonly FlagHelp[]}
2474
+ */
2475
+ const CHANGE_FLAG_HELP = Object.freeze([
2476
+ Object.freeze({
2477
+ flag: "--intent",
2478
+ key: "intent",
2479
+ arg: "<file>",
2480
+ describe: Object.freeze([
2481
+ "The change-intent manifest declaring the material",
2482
+ "architectural consequences this change expects",
2483
+ "(required; see docs/usage/change.md)",
2484
+ ]),
2485
+ }),
2486
+ Object.freeze({
2487
+ flag: "--format",
2488
+ key: "format",
2489
+ arg: "text|json",
2490
+ describe: Object.freeze([
2491
+ "Terminal report (default) or the versioned JSON envelope",
2492
+ "docs/reference/json-output.md documents",
2493
+ ]),
2494
+ }),
2495
+ Object.freeze({
2496
+ flag: "--output",
2497
+ key: "output",
2498
+ arg: "<file>",
2499
+ describe: Object.freeze(["Write the report to a file instead of stdout"]),
2500
+ }),
2501
+ Object.freeze({
2502
+ flag: "--config",
2503
+ key: "config",
2504
+ arg: "<file>",
2505
+ describe: ({ boundaryConfig, inline }) =>
2506
+ Object.freeze([
2507
+ "Read the boundary law from here instead of",
2508
+ inline
2509
+ ? "the inline boundaryConfig in archkeep.json"
2510
+ : `<workspace root>/${boundaryConfig}`,
2511
+ ]),
2512
+ }),
2513
+ ]);
2514
+
2159
2515
  /**
2160
2516
  * `drift`'s flags: text or JSON envelope, optional file output. The intent is
2161
2517
  * always read from the tracked root `architecture-intent.json` — the same one
@@ -2372,6 +2728,51 @@ const HISTORY_FLAG_HELP = Object.freeze([
2372
2728
  }),
2373
2729
  ]);
2374
2730
 
2731
+ /**
2732
+ * `evolution`'s flags: text or JSON envelope, optional file output, and the
2733
+ * two revisions that bound the range. There is no `--config` — each analyzed
2734
+ * revision is judged under the law its own tree declares (`src/commands/evolution.mjs`),
2735
+ * and a law carried from outside would misattribute policy changes to
2736
+ * revisions that never made them.
2737
+ *
2738
+ * @type {readonly FlagHelp[]}
2739
+ */
2740
+ const EVOLUTION_FLAG_HELP = Object.freeze([
2741
+ Object.freeze({
2742
+ flag: "--format",
2743
+ key: "format",
2744
+ arg: "text|json",
2745
+ describe: Object.freeze([
2746
+ "Terminal report (default) or the versioned JSON envelope",
2747
+ "docs/reference/json-output.md documents",
2748
+ ]),
2749
+ }),
2750
+ Object.freeze({
2751
+ flag: "--output",
2752
+ key: "output",
2753
+ arg: "<file>",
2754
+ describe: Object.freeze(["Write the report to a file instead of stdout"]),
2755
+ }),
2756
+ Object.freeze({
2757
+ flag: "--base",
2758
+ key: "base",
2759
+ arg: "<rev>",
2760
+ describe: Object.freeze([
2761
+ "The baseline revision — a commit, branch, tag,",
2762
+ "or HEAD~n; the first revision analyzed",
2763
+ ]),
2764
+ }),
2765
+ Object.freeze({
2766
+ flag: "--head",
2767
+ key: "head",
2768
+ arg: "<rev>",
2769
+ describe: Object.freeze([
2770
+ "The tip revision (default HEAD); must be a",
2771
+ "linear descendant of --base with no merges between",
2772
+ ]),
2773
+ }),
2774
+ ]);
2775
+
2375
2776
  /**
2376
2777
  * `health`'s flags: text or JSON envelope, optional file output. The optional
2377
2778
  * positional argument is the snapshot directory for trends, the same directory
@@ -2448,6 +2849,35 @@ const REPORT_FLAG_HELP = Object.freeze([
2448
2849
  }),
2449
2850
  ]);
2450
2851
 
2852
+ /**
2853
+ * `trajectory`'s flags: text or JSON envelope and optional file output — and
2854
+ * deliberately no `--config` and no `--capture`. There is no current-law
2855
+ * input to override: the fingerprints being compared travel inside the
2856
+ * snapshots, so the law each observation is judged under is the one it was
2857
+ * captured under. And the command writes nothing into the history directory —
2858
+ * capture stays `history --capture`'s job, so there is exactly one way a
2859
+ * snapshot enters the record.
2860
+ *
2861
+ * @type {readonly FlagHelp[]}
2862
+ */
2863
+ const TRAJECTORY_FLAG_HELP = Object.freeze([
2864
+ Object.freeze({
2865
+ flag: "--format",
2866
+ key: "format",
2867
+ arg: "text|json",
2868
+ describe: Object.freeze([
2869
+ "Terminal report (default) or the versioned JSON envelope",
2870
+ "docs/reference/json-output.md documents",
2871
+ ]),
2872
+ }),
2873
+ Object.freeze({
2874
+ flag: "--output",
2875
+ key: "output",
2876
+ arg: "<file>",
2877
+ describe: Object.freeze(["Write the report to a file instead of stdout"]),
2878
+ }),
2879
+ ]);
2880
+
2451
2881
  /**
2452
2882
  * `debt`'s flags: text or JSON envelope, optional file output, and the same
2453
2883
  * `--config` the other descriptive commands take, so a ledger ages the
@@ -2633,6 +3063,46 @@ const ADR_FLAG_HELP = Object.freeze([
2633
3063
  }),
2634
3064
  ]);
2635
3065
 
3066
+ /**
3067
+ * `rules`'s flags: catalog path, format, output, and target directory for add.
3068
+ * The first positional is the subcommand (list/info/verify/add), the second is
3069
+ * the rule name for info and add.
3070
+ *
3071
+ * @type {readonly FlagHelp[]}
3072
+ */
3073
+ const RULES_FLAG_HELP = Object.freeze([
3074
+ Object.freeze({
3075
+ flag: "--catalog",
3076
+ key: "catalog",
3077
+ arg: "<path>",
3078
+ describe: Object.freeze([
3079
+ "Path to the catalog file",
3080
+ "(default: node_modules/@ecoma-io/archkeep-rules/catalog.json)",
3081
+ ]),
3082
+ }),
3083
+ Object.freeze({
3084
+ flag: "--format",
3085
+ key: "format",
3086
+ arg: "text|json",
3087
+ describe: Object.freeze([
3088
+ "Terminal report (default) or the versioned JSON envelope",
3089
+ "docs/reference/json-output.md documents",
3090
+ ]),
3091
+ }),
3092
+ Object.freeze({
3093
+ flag: "--output",
3094
+ key: "output",
3095
+ arg: "<file>",
3096
+ describe: Object.freeze(["Write the report to a file instead of stdout"]),
3097
+ }),
3098
+ Object.freeze({
3099
+ flag: "--to",
3100
+ key: "to",
3101
+ arg: "<dir>",
3102
+ describe: Object.freeze(["Target directory for the .wasm file", "(default: tools/rules/)"]),
3103
+ }),
3104
+ ]);
3105
+
2636
3106
  /**
2637
3107
  * The command table `usage()` and `runCli` both read from — a command added
2638
3108
  * later is a new entry here, not a new branch in either. `args` is the
@@ -2679,10 +3149,20 @@ const COMMANDS = Object.freeze({
2679
3149
  flagHelp: DELTA_FLAG_HELP,
2680
3150
  flags: Object.freeze(Object.fromEntries(DELTA_FLAG_HELP.map((f) => [f.flag, f.key]))),
2681
3151
  defaults: Object.freeze({ format: "text", output: null, config: null, capture: false }),
2682
- formats: DESCRIBABLE_FORMATS,
3152
+ formats: DELTA_FORMATS,
2683
3153
  booleans: Object.freeze(["capture"]),
2684
3154
  run: runDelta,
2685
3155
  }),
3156
+ change: Object.freeze({
3157
+ name: "change",
3158
+ args: "<baseline> --intent <file>",
3159
+ summary: "Reconcile a declared change intent against the architectural delta",
3160
+ flagHelp: CHANGE_FLAG_HELP,
3161
+ flags: Object.freeze(Object.fromEntries(CHANGE_FLAG_HELP.map((f) => [f.flag, f.key]))),
3162
+ defaults: Object.freeze({ format: "text", output: null, config: null, intent: null }),
3163
+ formats: DESCRIBABLE_FORMATS,
3164
+ run: runChange,
3165
+ }),
2686
3166
  discover: Object.freeze({
2687
3167
  name: "discover",
2688
3168
  args: "[--propose]",
@@ -2746,6 +3226,26 @@ const COMMANDS = Object.freeze({
2746
3226
  booleans: Object.freeze(["capture"]),
2747
3227
  run: runHistory,
2748
3228
  }),
3229
+ trajectory: Object.freeze({
3230
+ name: "trajectory",
3231
+ args: "<dir>",
3232
+ summary: "Aggregate the deterministic drift trajectory across snapshots",
3233
+ flagHelp: TRAJECTORY_FLAG_HELP,
3234
+ flags: Object.freeze(Object.fromEntries(TRAJECTORY_FLAG_HELP.map((f) => [f.flag, f.key]))),
3235
+ defaults: Object.freeze({ format: "text", output: null }),
3236
+ formats: DESCRIBABLE_FORMATS,
3237
+ run: runTrajectory,
3238
+ }),
3239
+ evolution: Object.freeze({
3240
+ name: "evolution",
3241
+ args: "",
3242
+ summary: "Describe how the architecture evolved across a Git revision range",
3243
+ flagHelp: EVOLUTION_FLAG_HELP,
3244
+ flags: Object.freeze(Object.fromEntries(EVOLUTION_FLAG_HELP.map((f) => [f.flag, f.key]))),
3245
+ defaults: Object.freeze({ format: "text", output: null, base: null, head: null }),
3246
+ formats: DESCRIBABLE_FORMATS,
3247
+ run: runEvolution,
3248
+ }),
2749
3249
  health: Object.freeze({
2750
3250
  name: "health",
2751
3251
  args: "[<snapshot-dir>]",
@@ -2827,6 +3327,16 @@ const COMMANDS = Object.freeze({
2827
3327
  formats: DESCRIBABLE_FORMATS,
2828
3328
  run: runAdr,
2829
3329
  }),
3330
+ rules: Object.freeze({
3331
+ name: "rules",
3332
+ args: "<list|info|verify|add> [<rule-name>]",
3333
+ summary: "List official rules, show details, verify catalog integrity, or add a rule",
3334
+ flagHelp: RULES_FLAG_HELP,
3335
+ flags: Object.freeze(Object.fromEntries(RULES_FLAG_HELP.map((f) => [f.flag, f.key]))),
3336
+ defaults: Object.freeze({ format: "text", output: null, catalog: null, to: null }),
3337
+ formats: DESCRIBABLE_FORMATS,
3338
+ run: runRules,
3339
+ }),
2830
3340
  });
2831
3341
 
2832
3342
  /**