@ecoma-io/archkeep 0.13.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 (131) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +262 -0
  3. package/cli.mjs +2792 -0
  4. package/index.mjs +85 -0
  5. package/lsp.mjs +81 -0
  6. package/nx.mjs +24 -0
  7. package/package.json +81 -0
  8. package/presets/clean-architecture.json +78 -0
  9. package/presets/ddd-bounded-contexts.json +88 -0
  10. package/presets/hexagonal.json +68 -0
  11. package/presets/layered.json +92 -0
  12. package/presets/modular-monolith.json +85 -0
  13. package/presets/vertical-slice.json +68 -0
  14. package/src/analysis/analyze.mjs +218 -0
  15. package/src/analysis/contract.md +259 -0
  16. package/src/analysis/go.mjs +414 -0
  17. package/src/analysis/manifest-util.mjs +68 -0
  18. package/src/analysis/python.mjs +1266 -0
  19. package/src/analysis/registry.mjs +74 -0
  20. package/src/analysis/rust.mjs +674 -0
  21. package/src/analysis/source-util.mjs +230 -0
  22. package/src/analysis/typescript.mjs +1034 -0
  23. package/src/analysis/vue.mjs +156 -0
  24. package/src/architecture-intent/intent-fingerprint.mjs +29 -0
  25. package/src/architecture-intent/judge.mjs +539 -0
  26. package/src/architecture-intent/model.mjs +703 -0
  27. package/src/architecture-intent/selectors.mjs +170 -0
  28. package/src/canonical.mjs +48 -0
  29. package/src/commands/README.md +266 -0
  30. package/src/commands/adr.mjs +248 -0
  31. package/src/commands/check.mjs +989 -0
  32. package/src/commands/context-command.mjs +212 -0
  33. package/src/commands/context.mjs +790 -0
  34. package/src/commands/custom-rules.mjs +428 -0
  35. package/src/commands/debt.mjs +218 -0
  36. package/src/commands/diff.mjs +523 -0
  37. package/src/commands/discover.mjs +159 -0
  38. package/src/commands/drift.mjs +473 -0
  39. package/src/commands/edge-constraints.mjs +355 -0
  40. package/src/commands/explain.mjs +359 -0
  41. package/src/commands/fitness.mjs +226 -0
  42. package/src/commands/graph.mjs +297 -0
  43. package/src/commands/health.mjs +213 -0
  44. package/src/commands/history.mjs +614 -0
  45. package/src/commands/impact.mjs +226 -0
  46. package/src/commands/plan-context-command.mjs +496 -0
  47. package/src/commands/policy.mjs +138 -0
  48. package/src/commands/provenance-command.mjs +352 -0
  49. package/src/commands/provenance.mjs +159 -0
  50. package/src/commands/reconcile.mjs +219 -0
  51. package/src/commands/report.mjs +553 -0
  52. package/src/commands/snapshot-meta.mjs +107 -0
  53. package/src/commands/waivers.mjs +240 -0
  54. package/src/config.mjs +1308 -0
  55. package/src/containment.mjs +234 -0
  56. package/src/custom-rules/evidence.mjs +340 -0
  57. package/src/custom-rules/host.mjs +1023 -0
  58. package/src/custom-rules/values.mjs +43 -0
  59. package/src/entry-point.mjs +55 -0
  60. package/src/errors.mjs +36 -0
  61. package/src/eslint-config.mjs +542 -0
  62. package/src/go-work.mjs +394 -0
  63. package/src/governance/adr-registry.mjs +539 -0
  64. package/src/governance/clock.mjs +69 -0
  65. package/src/governance/debt-ledger.mjs +274 -0
  66. package/src/governance/discovery-proposal.mjs +423 -0
  67. package/src/governance/fitness-registry.mjs +504 -0
  68. package/src/governance/fitness-rules.mjs +668 -0
  69. package/src/governance/metrics.mjs +392 -0
  70. package/src/governance/preset-fingerprints.json +16 -0
  71. package/src/governance/profile-registry.mjs +366 -0
  72. package/src/governance/provenance-record.mjs +177 -0
  73. package/src/governance/reconcile-candidates.mjs +301 -0
  74. package/src/governance/reconcile-score.mjs +503 -0
  75. package/src/governance/row-schema.mjs +208 -0
  76. package/src/governance/verdict.mjs +127 -0
  77. package/src/governance/waiver.mjs +105 -0
  78. package/src/graph/create-dependencies.mjs +96 -0
  79. package/src/intent/intent-manifest.json +347 -0
  80. package/src/intent/mask-non-code.mjs +640 -0
  81. package/src/lsp/boundary-config.mjs +225 -0
  82. package/src/lsp/diagnose.mjs +202 -0
  83. package/src/lsp/diagnostics.mjs +241 -0
  84. package/src/lsp/protocol.mjs +215 -0
  85. package/src/lsp/server.mjs +922 -0
  86. package/src/lsp/workspace-index.mjs +891 -0
  87. package/src/nx-json.mjs +95 -0
  88. package/src/options.mjs +611 -0
  89. package/src/process.mjs +91 -0
  90. package/src/providers/moon.mjs +733 -0
  91. package/src/providers/native/README.md +204 -0
  92. package/src/providers/native/coverage.mjs +74 -0
  93. package/src/providers/native/differential.fixtures.mjs +1277 -0
  94. package/src/providers/native/discover.mjs +431 -0
  95. package/src/providers/native/graph.mjs +234 -0
  96. package/src/providers/native/index.mjs +152 -0
  97. package/src/providers/native/model.mjs +755 -0
  98. package/src/providers/nx.mjs +178 -0
  99. package/src/report/README.md +89 -0
  100. package/src/report/adr-text.mjs +129 -0
  101. package/src/report/context-text.mjs +109 -0
  102. package/src/report/debt-text.mjs +105 -0
  103. package/src/report/diff-text.mjs +219 -0
  104. package/src/report/discover-text.mjs +186 -0
  105. package/src/report/drift-text.mjs +194 -0
  106. package/src/report/envelope-shape.mjs +161 -0
  107. package/src/report/evidence.mjs +157 -0
  108. package/src/report/explain-text.mjs +159 -0
  109. package/src/report/graph-text.mjs +116 -0
  110. package/src/report/health-text.mjs +123 -0
  111. package/src/report/history-text.mjs +204 -0
  112. package/src/report/impact-text.mjs +128 -0
  113. package/src/report/json.mjs +173 -0
  114. package/src/report/plan-context-text.mjs +159 -0
  115. package/src/report/provenance-text.mjs +78 -0
  116. package/src/report/reconcile-text.mjs +159 -0
  117. package/src/report/report-text.mjs +264 -0
  118. package/src/report/sarif.mjs +953 -0
  119. package/src/report/text.mjs +823 -0
  120. package/src/report/waivers-text.mjs +100 -0
  121. package/src/rules/README.md +123 -0
  122. package/src/rules/index.mjs +962 -0
  123. package/src/rules/match.mjs +1708 -0
  124. package/src/rules/messages.mjs +73 -0
  125. package/src/rules/reachability.mjs +224 -0
  126. package/src/rules/specifiers.mjs +300 -0
  127. package/src/rules/tags.mjs +238 -0
  128. package/src/rules/topology.mjs +333 -0
  129. package/src/tsconfig-paths.mjs +237 -0
  130. package/src/verdict.mjs +145 -0
  131. package/src/workspace.mjs +580 -0
@@ -0,0 +1,523 @@
1
+ /**
2
+ * The `diff` command: two graph snapshots compared, edge by edge.
3
+ *
4
+ * `diff` takes a baseline snapshot FILE (not a git ref —
5
+ * a ref baseline means building a second graph at another commit, a much larger
6
+ * claim about what the tool is allowed to do to a repository) and the head
7
+ * context this run resolves, and computes what changed between them: projects
8
+ * added, removed, or changed in metadata, and edges added, removed, or changed
9
+ * in type. It is descriptive: it never exits 1, because a description of what changed is
10
+ * never a finding.
11
+ *
12
+ * When a boundary config is provided (via `--config` or the workspace's own
13
+ * declaration), `diff` also computes the rule-impact: which boundary violations
14
+ * the added edges introduce and which the removed edges resolve. This is
15
+ * narrower than `check` — it checks only `depConstraints` (tag-based), not
16
+ * npm/circular/lazy-load rules that need import-site details. A consumer who
17
+ * needs the complete verdict should run `check`.
18
+ *
19
+ * `diff` refuses an incomplete baseline or head. If either side could not read
20
+ * part of the tree, every "removed" project and edge is ambiguous between
21
+ * "gone" and "never seen" — reporting the diff anyway would manufacture
22
+ * architectural changes out of a broken run. Exit 3 with that sentence in the
23
+ * message.
24
+ *
25
+ * What it needs from its caller is a `CommandContext` for the head, the path
26
+ * to the baseline file, and an optional `readBaseline` seam (injected the same
27
+ * way every IO seam in this codebase is — pure function of its arguments, no
28
+ * filesystem in unit tests). What it gives back is a `status`, the diff
29
+ * payload for both the text and the JSON renderers, and enough coverage
30
+ * information to build a correct envelope. It does not print, and it does not
31
+ * decide the process's exit code — `../../cli.mjs` owns those
32
+ * (`./README.md`).
33
+ *
34
+ * ## The unregistered-plugin refusal
35
+ *
36
+ * Same as `graph`: on an Nx workspace whose `nx.json` does not register this
37
+ * plugin but whose tracked files include polyglot manifests under project
38
+ * roots, `diff` refuses loudly rather than computing a diff against a head
39
+ * whose edges silently under-represent the real architecture.
40
+ */
41
+ import { readFileSync } from "node:fs";
42
+
43
+ import { isWholeFileFailure } from "../analysis/source-util.mjs";
44
+ import { buildDependencies, buildProjects, computePolicyFingerprint } from "./graph.mjs";
45
+ import { computeRuleImpact } from "./edge-constraints.mjs";
46
+ import { SCHEMA_VERSION } from "../report/json.mjs";
47
+ import { jsonEnvelope, renderJson } from "../report/json.mjs";
48
+ import { formatDiffReport } from "../report/diff-text.mjs";
49
+ import { compareSnapshotMetadata } from "./snapshot-meta.mjs";
50
+ import { resolveProvenance } from "./provenance.mjs";
51
+
52
+ /**
53
+ * Reads and validates a baseline snapshot from `path`.
54
+ *
55
+ * Returns the parsed envelope's `result` (the `{projects, dependencies}`
56
+ * payload) and `coverage`. Throws on every condition that would make the diff
57
+ * dishonest: a file that cannot be read, an envelope whose schema version this
58
+ * tool does not know, or a baseline whose coverage is not complete (every
59
+ * "removed" entry would be ambiguous between "gone" and "never seen").
60
+ *
61
+ * @param {string} path Absolute path to the baseline JSON file.
62
+ * @returns {{projects: object[], dependencies: object[], coverage: object, policy: object|null,
63
+ * provider: string|null, provenance: {commit: string, remote: string|null, dirty: boolean}|null}}
64
+ * @throws {Error}
65
+ */
66
+ function readBaselineFromDisk(path) {
67
+ let text;
68
+ try {
69
+ text = readFileSync(path, "utf8");
70
+ } catch (cause) {
71
+ throw new Error(
72
+ `archkeep: cannot read the baseline snapshot from '${path}': ${cause?.message ?? cause}`,
73
+ { cause },
74
+ );
75
+ }
76
+
77
+ return parseBaseline(text, path);
78
+ }
79
+
80
+ /**
81
+ * Parses and validates a baseline snapshot from its text content. Separated
82
+ * from `readBaselineFromDisk` so a test can inject the content without the
83
+ * filesystem.
84
+ *
85
+ * @param {string} text The raw JSON text of the baseline envelope.
86
+ * @param {string} path The path to name in error messages.
87
+ * @returns {{projects: object[], dependencies: object[], coverage: object, policy: object|null,
88
+ * provider: string|null, provenance: {commit: string, remote: string|null, dirty: boolean}|null,
89
+ * toolVersion: string|null}}
90
+ * @throws {Error}
91
+ */
92
+ export function parseBaseline(text, path) {
93
+ let envelope;
94
+ try {
95
+ envelope = JSON.parse(text);
96
+ } catch (cause) {
97
+ throw new Error(
98
+ `archkeep: the baseline snapshot at '${path}' is not valid JSON: ${cause?.message ?? cause}`,
99
+ { cause },
100
+ );
101
+ }
102
+
103
+ if (envelope === null || typeof envelope !== "object" || Array.isArray(envelope)) {
104
+ throw new Error(
105
+ `archkeep: the baseline snapshot at '${path}' is not a JSON object — it is not a ` +
106
+ `archkeep graph envelope`,
107
+ );
108
+ }
109
+
110
+ if (typeof envelope.schemaVersion !== "number") {
111
+ throw new Error(
112
+ `archkeep: the baseline snapshot at '${path}' has no schemaVersion field — it is not a ` +
113
+ `archkeep graph envelope`,
114
+ );
115
+ }
116
+
117
+ // A consumer that reads a schemaVersion it does not recognise should refuse
118
+ // to parse the rest (`docs/reference/json-output.md`). This tool IS that
119
+ // consumer when reading a baseline — a future schema version could change
120
+ // the edge shape in ways this diff code would silently misinterpret.
121
+ if (envelope.schemaVersion !== SCHEMA_VERSION) {
122
+ throw new Error(
123
+ `archkeep: the baseline snapshot at '${path}' uses schemaVersion ${envelope.schemaVersion}, ` +
124
+ `but this build only understands schemaVersion ${SCHEMA_VERSION}. A baseline from a later ` +
125
+ `major version may have a different shape this diff would silently misread; upgrade ` +
126
+ `archkeep or regenerate the snapshot.`,
127
+ );
128
+ }
129
+
130
+ if (envelope.command !== "graph") {
131
+ throw new Error(
132
+ `archkeep: the baseline snapshot at '${path}' is a '${envelope.command}' envelope, not a ` +
133
+ `'graph' envelope — diff requires a graph snapshot as its baseline`,
134
+ );
135
+ }
136
+
137
+ if (!envelope.coverage?.complete) {
138
+ throw new Error(
139
+ `archkeep: the baseline snapshot at '${path}' has incomplete coverage — every "removed" ` +
140
+ `entry in the diff would be ambiguous between "gone" and "never seen", so diff refuses ` +
141
+ `to compute against a baseline that could not fully read its tree`,
142
+ );
143
+ }
144
+
145
+ if (!Array.isArray(envelope.result?.projects)) {
146
+ throw new Error(
147
+ `archkeep: the baseline snapshot at '${path}' has no result.projects array — it is not a ` +
148
+ `archkeep graph snapshot`,
149
+ );
150
+ }
151
+
152
+ if (!Array.isArray(envelope.result?.dependencies)) {
153
+ throw new Error(
154
+ `archkeep: the baseline snapshot at '${path}' has no result.dependencies array — it is not a ` +
155
+ `archkeep graph snapshot`,
156
+ );
157
+ }
158
+
159
+ // Per-record validation: every project must have a name, root, and — when
160
+ // present — a `tags` array (an absent tags is the pre-tags shape; a malformed
161
+ // one would make the diff and every renderer misread the record). Every
162
+ // dependency must have source, target, and type. A malformed record would
163
+ // make the diff silently miscompute which projects or edges changed.
164
+ for (const [i, project] of envelope.result.projects.entries()) {
165
+ if (typeof project.name !== "string" || typeof project.root !== "string") {
166
+ throw new Error(
167
+ `archkeep: the baseline snapshot at '${path}' has a result.projects[${i}] record ` +
168
+ `missing 'name' or 'root' — it is not a valid archkeep project record`,
169
+ );
170
+ }
171
+ if (project.tags !== undefined && !Array.isArray(project.tags)) {
172
+ throw new Error(
173
+ `archkeep: the baseline snapshot at '${path}' has a result.projects[${i}] record whose ` +
174
+ `'tags' is not an array — it is not a valid archkeep project record`,
175
+ );
176
+ }
177
+ }
178
+
179
+ for (const [i, edge] of envelope.result.dependencies.entries()) {
180
+ if (
181
+ typeof edge.source !== "string" ||
182
+ typeof edge.target !== "string" ||
183
+ typeof edge.type !== "string"
184
+ ) {
185
+ throw new Error(
186
+ `archkeep: the baseline snapshot at '${path}' has a result.dependencies[${i}] record ` +
187
+ `missing 'source', 'target', or 'type' — it is not a valid archkeep dependency record`,
188
+ );
189
+ }
190
+ }
191
+
192
+ return {
193
+ projects: envelope.result.projects,
194
+ dependencies: envelope.result.dependencies,
195
+ coverage: envelope.coverage,
196
+ policy: envelope.result.policy ?? null,
197
+ provider: envelope.workspace?.provider ?? null,
198
+ provenance: envelope.workspace?.provenance ?? null,
199
+ toolVersion: envelope.tool?.version ?? null,
200
+ };
201
+ }
202
+
203
+ /**
204
+ * Builds the head snapshot from the command context using the same
205
+ * `buildProjects`/`buildDependencies` functions `graphCommand` uses, so the
206
+ * baseline is the only thing that comes from outside the run.
207
+ *
208
+ * @param {object} commandContext
209
+ * @returns {{projects: object[], dependencies: object[]}}
210
+ */
211
+ function buildHeadSnapshot(commandContext) {
212
+ const { graph } = commandContext;
213
+ return {
214
+ projects: buildProjects(graph.nodes),
215
+ dependencies: buildDependencies(graph.dependencies),
216
+ };
217
+ }
218
+
219
+ /**
220
+ * Computes the diff between two graph snapshots.
221
+ *
222
+ * Edge identity is `(source, target, type)` — a `static` edge becoming
223
+ * `dynamic` is an added edge under the new type and a removed edge under the
224
+ * old one, which is exactly what a consumer wants to see: it is a real
225
+ * architectural event, not an implementation detail.
226
+ *
227
+ * @param {{projects: object[], dependencies: object[]}} baseline
228
+ * @param {{projects: object[], dependencies: object[]}} head
229
+ * @returns {{addedProjects: object[], removedProjects: object[],
230
+ * changedProjects: {name: string, changes: {field: string, baseline: *, head: *}[]}[],
231
+ * addedEdges: object[], removedEdges: object[]}}
232
+ */
233
+ export function computeDiff(baseline, head) {
234
+ const baselineProjects = new Map(baseline.projects.map((p) => [p.name, p]));
235
+ const headProjects = new Map(head.projects.map((p) => [p.name, p]));
236
+
237
+ const addedProjects = [];
238
+ const removedProjects = [];
239
+ const changedProjects = [];
240
+
241
+ for (const [name, project] of headProjects) {
242
+ if (!baselineProjects.has(name)) {
243
+ addedProjects.push(project);
244
+ continue;
245
+ }
246
+ // Project exists in both — detect metadata changes.
247
+ const baselineProject = baselineProjects.get(name);
248
+ const changes = [];
249
+
250
+ // Tags change: array content differs.
251
+ const baselineTags = baselineProject.tags ?? [];
252
+ const headTags = project.tags ?? [];
253
+ if (baselineTags.length !== headTags.length || baselineTags.some((t, i) => t !== headTags[i])) {
254
+ changes.push({ field: "tags", baseline: baselineTags, head: headTags });
255
+ }
256
+
257
+ // Type change: one is undefined and the other is not, or both defined but different.
258
+ const baselineType = baselineProject.type ?? undefined;
259
+ const headType = project.type ?? undefined;
260
+ if (baselineType !== headType) {
261
+ changes.push({
262
+ field: "type",
263
+ baseline: baselineType ?? null,
264
+ head: headType ?? null,
265
+ });
266
+ }
267
+
268
+ // Root change: project directory moved.
269
+ if (baselineProject.root !== project.root) {
270
+ changes.push({ field: "root", baseline: baselineProject.root, head: project.root });
271
+ }
272
+
273
+ if (changes.length > 0) {
274
+ changedProjects.push({ name, changes });
275
+ }
276
+ }
277
+ for (const [name, project] of baselineProjects) {
278
+ if (!headProjects.has(name)) removedProjects.push(project);
279
+ }
280
+ // Deterministic order — plain string comparison.
281
+ addedProjects.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
282
+ removedProjects.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
283
+ changedProjects.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
284
+
285
+ // Index edges by the (source, target, type) triple — the identity key.
286
+ const baselineEdges = new Map(
287
+ baseline.dependencies.map((e) => [`${e.source}\0${e.target}\0${e.type}`, e]),
288
+ );
289
+ const headEdges = new Map(
290
+ head.dependencies.map((e) => [`${e.source}\0${e.target}\0${e.type}`, e]),
291
+ );
292
+
293
+ const addedEdges = [];
294
+ const removedEdges = [];
295
+
296
+ for (const [key, edge] of headEdges) {
297
+ if (!baselineEdges.has(key)) addedEdges.push(edge);
298
+ }
299
+ for (const [key, edge] of baselineEdges) {
300
+ if (!headEdges.has(key)) removedEdges.push(edge);
301
+ }
302
+
303
+ const edgeSort = (a, b) => {
304
+ if (a.source < b.source) return -1;
305
+ if (a.source > b.source) return 1;
306
+ if (a.target < b.target) return -1;
307
+ if (a.target > b.target) return 1;
308
+ if (a.type < b.type) return -1;
309
+ if (a.type > b.type) return 1;
310
+ return 0;
311
+ };
312
+ addedEdges.sort(edgeSort);
313
+ removedEdges.sort(edgeSort);
314
+
315
+ return { addedProjects, removedProjects, changedProjects, addedEdges, removedEdges };
316
+ }
317
+
318
+ /**
319
+ * Runs the `diff` command: reads the baseline, resolves the head context,
320
+ * checks refusals, and computes the diff.
321
+ *
322
+ * @param {string} baselinePath Absolute path to the baseline JSON file.
323
+ * @param {object} commandContext From `resolveCommandContext`.
324
+ * @param {{readBaseline?: (path: string) => {projects: object[], dependencies: object[], coverage: object, policy?: object|null,
325
+ * provider?: string|null, provenance?: {commit: string, remote: string|null, dirty: boolean}|null,
326
+ * toolVersion?: string|null},
327
+ * config?: object}} [io]
328
+ * Injectable baseline reader, so a test drives the diff without a real file.
329
+ * When omitted, reads from the real filesystem. `config` is the loaded
330
+ * boundary config; when provided, rule-impact analysis is computed alongside
331
+ * the structural diff.
332
+ * @returns {{status: "ok"|"no-verdict", diff: object, coverage: object,
333
+ * report: {text: string, json: string}}}
334
+ * @throws {Error} when the baseline cannot be read or is incomplete, when
335
+ * the head is incomplete, or when an Nx workspace has polyglot manifests
336
+ * but the plugin is not registered.
337
+ */
338
+ export function diffCommand(
339
+ baselinePath,
340
+ commandContext,
341
+ { readBaseline = readBaselineFromDisk, config = null } = {},
342
+ ) {
343
+ const { root, provider, marker, pluginGap } = commandContext;
344
+
345
+ // Descriptive commands refuse when the graph is known to be incomplete.
346
+ if (provider === "nx" && !pluginGap.registered && pluginGap.manifests.length > 0) {
347
+ throw new Error(
348
+ `archkeep: refusing to compute a diff for an Nx workspace where this plugin is ` +
349
+ `not registered but polyglot manifests exist under project roots ` +
350
+ `(${pluginGap.manifests.join(", ")}). The head graph would carry no polyglot edges, ` +
351
+ `so the diff would silently under-represent the real architecture. ` +
352
+ `Register the plugin in nx.json: ` +
353
+ `"plugins": [{ "plugin": "@ecoma-io/archkeep/nx" }], or remove the polyglot manifests ` +
354
+ `if they are not in use.`,
355
+ );
356
+ }
357
+
358
+ // Read and validate the baseline before looking at the head — a bad baseline
359
+ // is a caller error, not a workspace fact, and it should name the file.
360
+ const baseline = readBaseline(baselinePath);
361
+
362
+ // Refuse an incomplete head — same reasoning as the incomplete baseline
363
+ // refusal: every "added" or "removed" entry would be ambiguous.
364
+ const notAnalyzed = commandContext.analysis.failures
365
+ .filter(isWholeFileFailure)
366
+ .map(({ sourceFile, reason }) => ({ file: sourceFile, reason }));
367
+
368
+ if (notAnalyzed.length > 0) {
369
+ throw new Error(
370
+ `archkeep: the head graph has incomplete coverage — ${notAnalyzed.length} file` +
371
+ `${notAnalyzed.length === 1 ? "" : "s"} could not be analyzed, so every "added" or ` +
372
+ `"removed" entry in the diff would be ambiguous between a real change and a coverage ` +
373
+ `gap. Fix the unanalyzed files and re-run.`,
374
+ );
375
+ }
376
+
377
+ const head = buildHeadSnapshot(commandContext);
378
+
379
+ const diff = computeDiff(baseline, head);
380
+
381
+ const coverage = {
382
+ complete: true,
383
+ projects: head.projects.length,
384
+ analyzedFiles: commandContext.analysis.analyzed,
385
+ imports: commandContext.analysis.imports.length,
386
+ notAnalyzed: [],
387
+ blindSpots: commandContext.analysis.failures
388
+ .filter((f) => !isWholeFileFailure(f))
389
+ .map(({ sourceFile, line, column, reason }) => ({ file: sourceFile, line, column, reason })),
390
+ notes: [],
391
+ };
392
+
393
+ const context = { root, provider, marker, provenance: resolveProvenance(root) };
394
+ const result = {
395
+ baseline: {
396
+ path: baselinePath,
397
+ projects: baseline.projects.length,
398
+ edges: baseline.dependencies.length,
399
+ toolVersion: baseline.toolVersion,
400
+ },
401
+ head: { projects: head.projects.length, edges: head.dependencies.length },
402
+ addedProjects: diff.addedProjects,
403
+ removedProjects: diff.removedProjects,
404
+ changedProjects: diff.changedProjects,
405
+ addedEdges: diff.addedEdges,
406
+ removedEdges: diff.removedEdges,
407
+ };
408
+
409
+ // Provider, provenance, and policy comparison — the same facts `history`
410
+ // classifies as changes, compared here through the shared
411
+ // `./snapshot-meta.mjs` so the two commands cannot disagree about them.
412
+ // Each mismatch becomes a `coverage.notes` warning rather than a refusal:
413
+ // a provider migration, a cross-repository diff, or a policy change between
414
+ // baseline and head are all legitimate states a consumer must be told about.
415
+ const headProvenance = resolveProvenance(root);
416
+ const headFingerprint = config ? computePolicyFingerprint(config) : null;
417
+ const meta = compareSnapshotMetadata({
418
+ baselineProvider: baseline.provider,
419
+ headProvider: provider,
420
+ baselineProvenance: baseline.provenance,
421
+ headProvenance,
422
+ baselineFingerprint: baseline.policy?.fingerprint ?? null,
423
+ headFingerprint,
424
+ });
425
+
426
+ if (meta.providerChanged) {
427
+ coverage.notes.push(
428
+ `baseline provider (${baseline.provider}) differs from head provider (${provider}) — ` +
429
+ `structural differences may be provider-artefacts rather than real architectural changes`,
430
+ );
431
+ }
432
+
433
+ if (meta.crossRepo) {
434
+ coverage.notes.push(
435
+ `baseline provenance remote (${baseline.provenance.remote}) differs from head provenance remote (${headProvenance.remote}) — ` +
436
+ `the diff may be across unrelated repositories rather than two revisions of the same one`,
437
+ );
438
+ } else if (baseline.provenance && !headProvenance) {
439
+ coverage.notes.push(
440
+ `baseline carries provenance but the head does not — the consumer cannot verify the diff is between revisions of the same repository`,
441
+ );
442
+ } else if (!baseline.provenance && headProvenance) {
443
+ coverage.notes.push(
444
+ `head carries provenance but the baseline does not — the consumer cannot verify the diff is between revisions of the same repository`,
445
+ );
446
+ }
447
+
448
+ if (meta.policyChanged === true) {
449
+ // A policy change between baseline and head means every "introduced" or
450
+ // "resolved" violation in the rule-impact analysis may be an artefact of
451
+ // the policy change, not of a structural change — recorded so the
452
+ // consumer knows to interpret the diff with caution.
453
+ result.policyMismatch = meta.policyMismatch;
454
+ }
455
+
456
+ if (meta.policyOneSided) {
457
+ // One-sided policy warning: when only one side carries a policy
458
+ // fingerprint, rule-impact numbers (if any) are based on an incomplete
459
+ // picture. The consumer should interpret them with caution.
460
+ const side = baseline.policy?.fingerprint ? "baseline" : "head";
461
+ coverage.notes.push(
462
+ `policy fingerprint is present only on the ${side} side — ` +
463
+ `rule-impact results may not reflect the policy the other side was judged under`,
464
+ );
465
+ }
466
+
467
+ // Rule-impact analysis: when the boundary config is provided, judge each
468
+ // added/removed edge against the constraint table. This is not the full
469
+ // `evaluate` pipeline — it checks only the `depConstraints` violations
470
+ // that depend on project tags (3 of 15 violation types; not npm/circular/
471
+ // lazy-load rules, which need import-site details). A consumer who needs
472
+ // the complete verdict should run `check`.
473
+ if (config && config.depConstraints) {
474
+ const ruleImpact = computeRuleImpact(
475
+ diff,
476
+ commandContext.graph.nodes,
477
+ commandContext.graph.dependencies,
478
+ baseline.projects,
479
+ baseline.dependencies,
480
+ config.depConstraints,
481
+ );
482
+ result.ruleImpact = {
483
+ introduced: ruleImpact.introduced,
484
+ resolved: ruleImpact.resolved,
485
+ };
486
+ // The rule-impact analysis covers depConstraints only (3 of 15 violation
487
+ // types). A consumer seeing no introduced/resolved violations must not
488
+ // conclude the workspace is free of all boundary violations — only that
489
+ // no depConstraints violations were introduced or resolved on the changed
490
+ // edges. Run `check` for the complete verdict.
491
+ coverage.notes.push(
492
+ "per-edge rule-impact covers only depConstraints (3 of 15 violation types). " +
493
+ "A dependency with no rule-impact may still violate npm-ban, circular-dependency, " +
494
+ "lazy-load, or other rules that require import-site details. Run check for the " +
495
+ "complete verdict.",
496
+ );
497
+ }
498
+
499
+ // Diff is descriptive — always status "ok" when it completes. Even an
500
+ // architecture with many changes is not a "finding" in the boundary-enforcement
501
+ // sense, so this command never claims a violation's exit code.
502
+ const status = "ok";
503
+ const exitCode = 0;
504
+
505
+ const envelope = jsonEnvelope({
506
+ command: "diff",
507
+ context,
508
+ status,
509
+ exitCode,
510
+ coverage,
511
+ result,
512
+ });
513
+
514
+ return {
515
+ status,
516
+ diff: result,
517
+ coverage,
518
+ report: {
519
+ text: formatDiffReport({ diff: result, coverage }),
520
+ json: renderJson(envelope),
521
+ },
522
+ };
523
+ }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * The `discover` command: observed facts first, and — under `--propose` — the
3
+ * candidate architecture those facts imply, marked as proposals that are never
4
+ * decisions.
5
+ *
6
+ * `discover` is descriptive, exactly like `graph`/`diff`/`drift`: it reads the
7
+ * resolved `CommandContext` (the same project model and analysis every command
8
+ * shares) and returns a report. It never exits 1. The two modes:
9
+ *
10
+ * - **descriptive (default)** — reports what was observed: projects, edges,
11
+ * tags, and the analysis coverage. This is the read-only "what is here"
12
+ * answer, one level richer than `graph` because it also states the coverage
13
+ * a verdict over this tree could trust.
14
+ * - **`--propose`** — computes the candidate architecture over those same
15
+ * observations (`src/governance/discovery-proposal.mjs`'s
16
+ * `evaluateDiscovery`) and emits it with `proposed: true` and
17
+ * `notAuthoritative: true` on every candidate. It never writes
18
+ * `architecture-intent.json`, never mutates the workspace, and never hands
19
+ * a candidate the authority of a decision.
20
+ *
21
+ * ## The empty-result invariant
22
+ *
23
+ * A workspace the run could not fully read (`notAnalyzed` non-empty) returns
24
+ * `status: "no-verdict"` → exit 3, the same refusal `graph`/`drift` make:
25
+ * every missing edge would be ambiguous between "gone" and "never seen".
26
+ * An Nx workspace with polyglot manifests and no plugin registration is
27
+ * refused the same way `graph` refuses it — the graph would silently
28
+ * under-represent the real architecture, and a candidate derived from it
29
+ * would be a fabrication wearing a proposal's name.
30
+ *
31
+ * A workspace with zero projects is NOT a refusal: it is the empty proposal
32
+ * with `unknown: true` (`evaluateDiscovery`'s contract), because zero observed
33
+ * projects is a complete observation — the honest answer is "nothing to
34
+ * propose", not a fabricated candidate set.
35
+ *
36
+ * ## Determinism
37
+ *
38
+ * The proposal evaluator sorts every leaf by plain string comparison, and the
39
+ * report renderers never re-sort, so two runs over an unchanged tree produce
40
+ * byte-identical text and JSON — the same promise `graph`'s snapshots make,
41
+ * which is what lets a consumer `diff` two proposals meaningfully.
42
+ */
43
+ import { isWholeFileFailure } from "../analysis/source-util.mjs";
44
+ import { evaluateDiscovery } from "../governance/discovery-proposal.mjs";
45
+ import { jsonEnvelope, renderJson } from "../report/json.mjs";
46
+ import { formatDiscoverReport } from "../report/discover-text.mjs";
47
+ import { buildDependencies, buildProjects } from "./graph.mjs";
48
+ import { resolveProvenance } from "./provenance.mjs";
49
+ import { refuseIncompleteGraph } from "./drift.mjs";
50
+
51
+ /**
52
+ * The observed side of discovery: the same project model `graph` builds,
53
+ * shared with `graph`/`drift` so the facts `discover` reports are the facts
54
+ * every other command judges. Edges whose target is not a project in the
55
+ * model are dropped, the same filter drift applies (`./drift.mjs`): an
56
+ * external package is not a project a candidate can ever name.
57
+ *
58
+ * @param {object} commandContext From `resolveCommandContext`.
59
+ * @returns {{projects: object[], edges: object[]}}
60
+ */
61
+ export function buildObserved(commandContext) {
62
+ const { graph } = commandContext;
63
+ const projects = buildProjects(graph.nodes);
64
+ const projectNames = new Set(projects.map((p) => p.name));
65
+ const edges = [];
66
+ for (const edge of buildDependencies(graph.dependencies)) {
67
+ if (edge.type === "implicit") continue;
68
+ if (projectNames.has(edge.source) && projectNames.has(edge.target)) {
69
+ edges.push(edge);
70
+ }
71
+ }
72
+ return { projects, edges };
73
+ }
74
+
75
+ /**
76
+ * Runs the `discover` command: observes the workspace, optionally proposes the
77
+ * candidate architecture over it, and returns the report.
78
+ *
79
+ * @param {object} commandContext From `resolveCommandContext`.
80
+ * @param {{propose?: boolean}} [options]
81
+ * @returns {{status: "ok"|"no-verdict", discovery: object, proposal: object|null,
82
+ * coverage: object, report: {text: string, json: string}}}
83
+ * @throws {Error} on every condition the header lists, all exit-3 class.
84
+ */
85
+ export function discoverCommand(commandContext, { propose = false } = {}) {
86
+ const { root, provider, marker, analysis } = commandContext;
87
+
88
+ refuseIncompleteGraph(commandContext);
89
+
90
+ const notAnalyzed = analysis.failures
91
+ .filter(isWholeFileFailure)
92
+ .map(({ sourceFile, reason }) => ({ file: sourceFile, reason }));
93
+
94
+ const observed = buildObserved(commandContext);
95
+
96
+ const proposal = propose ? evaluateDiscovery(observed) : null;
97
+
98
+ // A proposal over an unread tree would be a fabrication wearing a
99
+ // proposal's name: every candidate edge would be ambiguous between "gone"
100
+ // and "never seen". Refuse loudly — the same reasoning `drift`'s refusal
101
+ // gives — rather than print a proposal and a warning that it may be lying.
102
+ if (propose && notAnalyzed.length > 0) {
103
+ throw new Error(
104
+ `archkeep: discover --propose has incomplete coverage — ${notAnalyzed.length} file` +
105
+ `${notAnalyzed.length === 1 ? "" : "s"} could not be analyzed, so every candidate ` +
106
+ `would be ambiguous between "gone" and "never seen". Fix the unanalyzed files and ` +
107
+ `re-run.`,
108
+ );
109
+ }
110
+
111
+ const complete = notAnalyzed.length === 0;
112
+ const status = complete ? "ok" : "no-verdict";
113
+ const exitCode = complete ? 0 : 3;
114
+
115
+ const coverage = {
116
+ complete,
117
+ projects: observed.projects.length,
118
+ analyzedFiles: analysis.analyzed,
119
+ imports: analysis.imports.length,
120
+ notAnalyzed,
121
+ blindSpots: analysis.failures
122
+ .filter((failure) => !isWholeFileFailure(failure))
123
+ .map(({ sourceFile, line, column, reason }) => ({ file: sourceFile, line, column, reason })),
124
+ notes: [],
125
+ };
126
+
127
+ const context = { root, provider, marker, provenance: resolveProvenance(root) };
128
+ const discovery = {
129
+ projects: observed.projects,
130
+ edges: observed.edges,
131
+ tags: Array.from(new Set(observed.projects.flatMap((project) => project.tags))).sort((a, b) =>
132
+ a < b ? -1 : a > b ? 1 : 0,
133
+ ),
134
+ };
135
+
136
+ const envelope = jsonEnvelope({
137
+ command: "discover",
138
+ context,
139
+ status,
140
+ exitCode,
141
+ coverage,
142
+ result: { discovery, ...(proposal ? { proposal } : {}) },
143
+ });
144
+
145
+ return {
146
+ status,
147
+ discovery,
148
+ proposal,
149
+ coverage,
150
+ report: {
151
+ text: formatDiscoverReport({
152
+ discovery,
153
+ proposal,
154
+ coverage,
155
+ }),
156
+ json: renderJson(envelope),
157
+ },
158
+ };
159
+ }