@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
@@ -251,6 +251,135 @@ export function nextSequence(read) {
251
251
  return String(max + 1).padStart(width, "0");
252
252
  }
253
253
 
254
+ /**
255
+ * Classifies ONE transition — the record `computeEvolution` pushes for the
256
+ * consecutive pair `(from, to)` — together with the raw metadata comparison it
257
+ * was decided over. Split out of `computeEvolution` so a second consumer
258
+ * (`./trajectory.mjs`) aggregates the SAME classification instead of growing a
259
+ * second copy of it: the signals, the disclosure notes and the three-state
260
+ * metadata facts are decided here once (`../README.md`'s single-home rule),
261
+ * and both commands read them from this one place.
262
+ *
263
+ * The returned `record` is exactly what lands in `history`'s envelope. The
264
+ * returned `meta` is the untouched `compareSnapshotMetadata` result — the
265
+ * per-side facts (`policyOneSided`, `provenanceOneSided`, `crossRepo`,
266
+ * `dirtyBaseline`, `dirtyHead`) that the record's `notes[]` render as prose.
267
+ * An aggregator that needs to COUNT those facts rather than print them reads
268
+ * `meta`; parsing `notes` strings would be a second copy of the decision
269
+ * wearing a parser's name.
270
+ *
271
+ * @param {{name: string, path: string, envelope: object, id: string}} from
272
+ * @param {{name: string, path: string, envelope: object, id: string}} to
273
+ * @returns {{record: {from: string, to: string, architectureChanged: boolean,
274
+ * changes: object|null, policyChanged: boolean|null, providerChanged: boolean,
275
+ * codeDrift: boolean, notes: string[]},
276
+ * meta: object}} `meta` is `compareSnapshotMetadata`'s result.
277
+ */
278
+ export function classifyTransition(from, to) {
279
+ const meta = compareSnapshotMetadata({
280
+ baselineProvider: from.envelope.workspace.provider,
281
+ headProvider: to.envelope.workspace.provider,
282
+ baselineProvenance: from.envelope.workspace.provenance,
283
+ headProvenance: to.envelope.workspace.provenance,
284
+ baselineFingerprint: from.envelope.result.policy?.fingerprint ?? null,
285
+ headFingerprint: to.envelope.result.policy?.fingerprint ?? null,
286
+ });
287
+
288
+ const notes = [];
289
+ if (meta.policyChanged === true) {
290
+ // A policy change is disclosed the way `diff` discloses it — a fact
291
+ // about how the transition must be interpreted, not a structural
292
+ // change and not a refusal.
293
+ notes.push(
294
+ "policy (the declared architectural intent) changed between these snapshots — " +
295
+ "the boundary law differs even though the graph may not",
296
+ );
297
+ }
298
+ if (meta.providerChanged) {
299
+ notes.push(
300
+ `provider changed (${from.envelope.workspace.provider} → ${to.envelope.workspace.provider}) — ` +
301
+ "structural differences may be provider-artefacts rather than real architectural changes",
302
+ );
303
+ }
304
+ if (meta.crossRepo) {
305
+ notes.push("provenance remotes differ — these snapshots may be from unrelated repositories");
306
+ }
307
+ // The one-sided cases are the silent direction: a fingerprint or
308
+ // provenance on one snapshot and not the other cannot be asserted "the
309
+ // same", so it is disclosed rather than read as unchanged.
310
+ if (meta.policyOneSided) {
311
+ notes.push(
312
+ "policy (the declared architectural intent) could not be compared — one snapshot " +
313
+ "records the boundary law and the other does not",
314
+ );
315
+ }
316
+ if (meta.provenanceOneSided) {
317
+ notes.push(
318
+ "repository provenance could not be compared — one snapshot records its origin and the other does not",
319
+ );
320
+ }
321
+ // A snapshot taken from a dirty tree is not a reproducible claim about the
322
+ // commit it names, so the transition says which side came from one rather
323
+ // than reading it as a claim about committed history.
324
+ if (meta.dirtyBaseline) {
325
+ const commit = from.envelope.workspace.provenance?.commit;
326
+ notes.push(
327
+ "the baseline snapshot was captured from an uncommitted (dirty) tree — its architecture " +
328
+ `is a claim about uncommitted state${typeof commit === "string" ? `, not about commit '${commit}'` : ""}`,
329
+ );
330
+ }
331
+ if (meta.dirtyHead) {
332
+ const commit = to.envelope.workspace.provenance?.commit;
333
+ notes.push(
334
+ "the head snapshot was captured from an uncommitted (dirty) tree — its architecture " +
335
+ `is a claim about uncommitted state${typeof commit === "string" ? `, not about commit '${commit}'` : ""}`,
336
+ );
337
+ }
338
+
339
+ const diff = computeDiff(
340
+ {
341
+ projects: from.envelope.result.projects,
342
+ dependencies: from.envelope.result.dependencies,
343
+ },
344
+ {
345
+ projects: to.envelope.result.projects,
346
+ dependencies: to.envelope.result.dependencies,
347
+ },
348
+ );
349
+ const architectureChanged =
350
+ diff.addedProjects.length > 0 ||
351
+ diff.removedProjects.length > 0 ||
352
+ diff.changedProjects.length > 0 ||
353
+ diff.addedEdges.length > 0 ||
354
+ diff.removedEdges.length > 0;
355
+
356
+ // Code drift is a disclosure, so it is only asserted when every signal
357
+ // that could refute it is verifiable and unchanged: the architecture did
358
+ // not move, the policy was actually compared and did not change, and
359
+ // provenance advanced. A `null` policyChanged (one-sided, or neither
360
+ // snapshot carries a fingerprint) is "could not be compared", not "the
361
+ // same" — asserting code drift on an unverifiable policy would report a
362
+ // clean transition where the tool cannot look.
363
+ const codeDrift =
364
+ !architectureChanged && meta.policyChanged === false && meta.provenanceChanged === true;
365
+
366
+ const record = {
367
+ from: from.name,
368
+ to: to.name,
369
+ architectureChanged,
370
+ // A provider change is rendered with an empty diff (no graph change on
371
+ // top of a carrier change), a policy-only transition with null — so a
372
+ // consumer can tell "the carrier changed" from "only the record's
373
+ // interpretation changed".
374
+ changes: architectureChanged || meta.providerChanged ? diff : null,
375
+ policyChanged: meta.policyChanged,
376
+ providerChanged: meta.providerChanged,
377
+ codeDrift,
378
+ notes,
379
+ };
380
+ return { record, meta };
381
+ }
382
+
254
383
  /**
255
384
  * Computes the evolution record from a list of snapshots: history order,
256
385
  * each snapshot's identity, and the classified transition from each to the
@@ -274,109 +403,7 @@ export function computeEvolution(files) {
274
403
  const transitions = [];
275
404
 
276
405
  for (let i = 0; i + 1 < files.length; i++) {
277
- const from = files[i];
278
- const to = files[i + 1];
279
- const meta = compareSnapshotMetadata({
280
- baselineProvider: from.envelope.workspace.provider,
281
- headProvider: to.envelope.workspace.provider,
282
- baselineProvenance: from.envelope.workspace.provenance,
283
- headProvenance: to.envelope.workspace.provenance,
284
- baselineFingerprint: from.envelope.result.policy?.fingerprint ?? null,
285
- headFingerprint: to.envelope.result.policy?.fingerprint ?? null,
286
- });
287
-
288
- const notes = [];
289
- if (meta.policyChanged === true) {
290
- // A policy change is disclosed the way `diff` discloses it — a fact
291
- // about how the transition must be interpreted, not a structural
292
- // change and not a refusal.
293
- notes.push(
294
- "policy (the declared architectural intent) changed between these snapshots — " +
295
- "the boundary law differs even though the graph may not",
296
- );
297
- }
298
- if (meta.providerChanged) {
299
- notes.push(
300
- `provider changed (${from.envelope.workspace.provider} → ${to.envelope.workspace.provider}) — ` +
301
- "structural differences may be provider-artefacts rather than real architectural changes",
302
- );
303
- }
304
- if (meta.crossRepo) {
305
- notes.push("provenance remotes differ — these snapshots may be from unrelated repositories");
306
- }
307
- // The one-sided cases are the silent direction: a fingerprint or
308
- // provenance on one snapshot and not the other cannot be asserted "the
309
- // same", so it is disclosed rather than read as unchanged.
310
- if (meta.policyOneSided) {
311
- notes.push(
312
- "policy (the declared architectural intent) could not be compared — one snapshot " +
313
- "records the boundary law and the other does not",
314
- );
315
- }
316
- if (meta.provenanceOneSided) {
317
- notes.push(
318
- "repository provenance could not be compared — one snapshot records its origin and the other does not",
319
- );
320
- }
321
- // A snapshot taken from a dirty tree is not a reproducible claim about the
322
- // commit it names, so the transition says which side came from one rather
323
- // than reading it as a claim about committed history.
324
- if (meta.dirtyBaseline) {
325
- const commit = from.envelope.workspace.provenance?.commit;
326
- notes.push(
327
- "the baseline snapshot was captured from an uncommitted (dirty) tree — its architecture " +
328
- `is a claim about uncommitted state${typeof commit === "string" ? `, not about commit '${commit}'` : ""}`,
329
- );
330
- }
331
- if (meta.dirtyHead) {
332
- const commit = to.envelope.workspace.provenance?.commit;
333
- notes.push(
334
- "the head snapshot was captured from an uncommitted (dirty) tree — its architecture " +
335
- `is a claim about uncommitted state${typeof commit === "string" ? `, not about commit '${commit}'` : ""}`,
336
- );
337
- }
338
-
339
- const diff = computeDiff(
340
- {
341
- projects: from.envelope.result.projects,
342
- dependencies: from.envelope.result.dependencies,
343
- },
344
- {
345
- projects: to.envelope.result.projects,
346
- dependencies: to.envelope.result.dependencies,
347
- },
348
- );
349
- const architectureChanged =
350
- diff.addedProjects.length > 0 ||
351
- diff.removedProjects.length > 0 ||
352
- diff.changedProjects.length > 0 ||
353
- diff.addedEdges.length > 0 ||
354
- diff.removedEdges.length > 0;
355
-
356
- // Code drift is a disclosure, so it is only asserted when every signal
357
- // that could refute it is verifiable and unchanged: the architecture did
358
- // not move, the policy was actually compared and did not change, and
359
- // provenance advanced. A `null` policyChanged (one-sided, or neither
360
- // snapshot carries a fingerprint) is "could not be compared", not "the
361
- // same" — asserting code drift on an unverifiable policy would report a
362
- // clean transition where the tool cannot look.
363
- const codeDrift =
364
- !architectureChanged && meta.policyChanged === false && meta.provenanceChanged === true;
365
-
366
- transitions.push({
367
- from: from.name,
368
- to: to.name,
369
- architectureChanged,
370
- // A provider change is rendered with an empty diff (no graph change on
371
- // top of a carrier change), a policy-only transition with null — so a
372
- // consumer can tell "the carrier changed" from "only the record's
373
- // interpretation changed".
374
- changes: architectureChanged || meta.providerChanged ? diff : null,
375
- policyChanged: meta.policyChanged,
376
- providerChanged: meta.providerChanged,
377
- codeDrift,
378
- notes,
379
- });
406
+ transitions.push(classifyTransition(files[i], files[i + 1]).record);
380
407
  }
381
408
 
382
409
  return { snapshots, transitions };
@@ -8,6 +8,7 @@
8
8
  * below argues the order, each arm, and what `profile`/`source` name.
9
9
  */
10
10
 
11
+ import { existsSync } from "node:fs";
11
12
  import { isAbsolute, relative, resolve } from "node:path";
12
13
 
13
14
  import { containmentViolation } from "../containment.mjs";
@@ -118,6 +119,62 @@ export async function resolvePolicy(options, commandContext, cwd) {
118
119
  return resolved;
119
120
  }
120
121
 
122
+ /**
123
+ * The policy load for a command that DESCRIBES the workspace rather than
124
+ * judging it — `graph`'s own arm, held here beside the ladder it wraps so
125
+ * every future descriptive surface (`./graph.mjs`'s MCP caller among them)
126
+ * answers from one copy of the "absent default law" decision rather than
127
+ * re-deriving it.
128
+ *
129
+ * What is skipped is the load of a file that is NOT THERE. A boundary config
130
+ * that exists and will not load still fails the run, because an absent law
131
+ * and a broken one must not report alike; a `--config`, a profile, and an
132
+ * inline `archkeep.json` policy are explicit declarations and stay loud.
133
+ * Every command that JUDGES against the law keeps loading it unconditionally
134
+ * through `resolvePolicy` — making it optional for those would turn a missing
135
+ * file into a silent no-law run.
136
+ *
137
+ * `boundaryConfigDeclared` is what keeps this guard to the un-overridden
138
+ * default, and it is load-bearing rather than belt-and-braces. The name
139
+ * alone cannot answer it: `commandContext.options.boundaryConfig` is a
140
+ * string BOTH when it came from `../options.mjs`'s `DEFAULT_OPTIONS` and
141
+ * when the consumer WROTE it into `nx.json`'s plugin options or
142
+ * `archkeep.json`, and a workspace is free to declare the convention
143
+ * filename itself, so comparing against the default would still read a
144
+ * deliberate declaration as an assumption. Measured on a committed native
145
+ * tree whose `archkeep.json` declares a `boundaryConfig` that file does not
146
+ * contain: skipping on name alone made `graph` exit 0 with no `policy` field
147
+ * — byte-identical to a workspace that never had a law — where the same tree
148
+ * with that file present but unparseable exited 3. A law someone named and
149
+ * then renamed or deleted is exactly the case that must stay loud, so the
150
+ * provenance survives the options layer instead
151
+ * (`../options.mjs`'s `resolveOptions`,
152
+ * `../providers/native/model.mjs`'s `normalizeNativeModel`, and
153
+ * `./context.mjs`'s three branches carry it; Moon answers `false` because it
154
+ * has no table to declare one in).
155
+ *
156
+ * @param {{config: string|null}} options The command's own parsed flags.
157
+ * @param {object} commandContext From `resolveCommandContext`.
158
+ * @param {string} cwd The process's working directory a relative `--config`
159
+ * resolves against.
160
+ * @returns {Promise<{config: object|null, profile: string|null, source: string|null}>}
161
+ * `null` config only on the absent-un-overridden-default arm.
162
+ * @throws {Error} through `resolvePolicy` for every law that is named but
163
+ * cannot be loaded.
164
+ */
165
+ export async function resolveDescribedPolicy(options, commandContext, cwd) {
166
+ const workspaceDefault =
167
+ !options.config &&
168
+ !hasProfiles(commandContext.options) &&
169
+ commandContext.options.boundaryConfigDeclared === false &&
170
+ typeof commandContext.options.boundaryConfig === "string"
171
+ ? resolve(commandContext.root, commandContext.options.boundaryConfig)
172
+ : null;
173
+ return workspaceDefault !== null && !existsSync(workspaceDefault)
174
+ ? { config: null, profile: null, source: null }
175
+ : resolvePolicy(options, commandContext, cwd);
176
+ }
177
+
121
178
  /**
122
179
  * The four arms of the ladder, unguarded — `resolvePolicy` above wraps this
123
180
  * with the one post-resolution refusal that needs both the provider and the
@@ -24,9 +24,7 @@
24
24
  * timestamp is not.
25
25
  */
26
26
 
27
- import { execFileSync } from "node:child_process";
28
-
29
- import { environmentForTree } from "../process.mjs";
27
+ import { runProcess } from "../process.mjs";
30
28
 
31
29
  /**
32
30
  * Resolves repository provenance from the workspace root.
@@ -49,22 +47,12 @@ import { environmentForTree } from "../process.mjs";
49
47
  * @throws {Error} when `root` is a git repository with no commits.
50
48
  */
51
49
  export function resolveProvenance(root) {
52
- // G-09: every git spawn routes through the shared environment guard, so an
53
- // ambient GIT_DIR/GIT_WORK_TREE from a wrapping tool (the editor hooks, an
54
- // outer `git` call) can never make these spawns read a repository other
55
- // than the tree at `root`.
56
- const env = environmentForTree();
57
50
  // First, the "is this even a git repository at all" question, asked before
58
51
  // `rev-parse HEAD` so an unborn HEAD (a commitless repo) is distinguishable
59
52
  // from "not a repo" — the two must not share the `null` answer, because
60
53
  // only the first is a legitimate "no origin claim".
61
54
  try {
62
- execFileSync("git", ["rev-parse", "--is-inside-work-tree"], {
63
- cwd: root,
64
- env,
65
- encoding: "utf-8",
66
- stdio: ["pipe", "pipe", "pipe"],
67
- });
55
+ runProcess("git", ["rev-parse", "--is-inside-work-tree"], root);
68
56
  } catch {
69
57
  // git not available, or not a git repository. Return null — the envelope
70
58
  // carries no origin claim rather than a false one.
@@ -72,12 +60,7 @@ export function resolveProvenance(root) {
72
60
  }
73
61
  let commit;
74
62
  try {
75
- commit = execFileSync("git", ["rev-parse", "--verify", "HEAD"], {
76
- cwd: root,
77
- env,
78
- encoding: "utf-8",
79
- stdio: ["pipe", "pipe", "pipe"],
80
- }).trim();
63
+ commit = runProcess("git", ["rev-parse", "--verify", "HEAD"], root).trim();
81
64
  } catch {
82
65
  // `--is-inside-work-tree` passed above, so a repo EXISTS here, and
83
66
  // `--verify` (the two-argument form) failed to resolve HEAD. Two states
@@ -95,12 +78,7 @@ export function resolveProvenance(root) {
95
78
  // means the HEAD ref is broken.
96
79
  let reachable;
97
80
  try {
98
- reachable = execFileSync("git", ["rev-list", "--count", "--all"], {
99
- cwd: root,
100
- env,
101
- encoding: "utf-8",
102
- stdio: ["pipe", "pipe", "pipe"],
103
- }).trim();
81
+ reachable = runProcess("git", ["rev-list", "--count", "--all"], root).trim();
104
82
  } catch {
105
83
  // `rev-list --all` failing too is a degenerate repo; treat it as unborn
106
84
  // rather than inventing a third class.
@@ -124,21 +102,11 @@ export function resolveProvenance(root) {
124
102
  // the tree.
125
103
  let remote = null;
126
104
  try {
127
- const remotes = execFileSync("git", ["remote"], {
128
- cwd: root,
129
- env,
130
- encoding: "utf-8",
131
- stdio: ["pipe", "pipe", "pipe"],
132
- }).trim();
105
+ const remotes = runProcess("git", ["remote"], root).trim();
133
106
  if (remotes) {
134
107
  // Use the first remote's URL — typically "origin".
135
108
  const firstRemote = remotes.split("\n")[0].trim();
136
- remote = execFileSync("git", ["remote", "get-url", firstRemote], {
137
- cwd: root,
138
- env,
139
- encoding: "utf-8",
140
- stdio: ["pipe", "pipe", "pipe"],
141
- }).trim();
109
+ remote = runProcess("git", ["remote", "get-url", firstRemote], root).trim();
142
110
  }
143
111
  } catch {
144
112
  // No remotes configured — `remote` stays null.
@@ -147,12 +115,7 @@ export function resolveProvenance(root) {
147
115
  // Dirty: any uncommitted change to tracked files means the working tree
148
116
  // does not match the commit. A baseline from a dirty tree is not a
149
117
  // reproducible claim about that commit.
150
- const status = execFileSync("git", ["status", "--porcelain"], {
151
- cwd: root,
152
- env,
153
- encoding: "utf-8",
154
- stdio: ["pipe", "pipe", "pipe"],
155
- }).trim();
118
+ const status = runProcess("git", ["status", "--porcelain"], root).trim();
156
119
  const dirty = status.length > 0;
157
120
 
158
121
  return { commit, remote, dirty };