@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.
- package/LICENSE +202 -0
- package/README.md +262 -0
- package/cli.mjs +2792 -0
- package/index.mjs +85 -0
- package/lsp.mjs +81 -0
- package/nx.mjs +24 -0
- package/package.json +81 -0
- package/presets/clean-architecture.json +78 -0
- package/presets/ddd-bounded-contexts.json +88 -0
- package/presets/hexagonal.json +68 -0
- package/presets/layered.json +92 -0
- package/presets/modular-monolith.json +85 -0
- package/presets/vertical-slice.json +68 -0
- package/src/analysis/analyze.mjs +218 -0
- package/src/analysis/contract.md +259 -0
- package/src/analysis/go.mjs +414 -0
- package/src/analysis/manifest-util.mjs +68 -0
- package/src/analysis/python.mjs +1266 -0
- package/src/analysis/registry.mjs +74 -0
- package/src/analysis/rust.mjs +674 -0
- package/src/analysis/source-util.mjs +230 -0
- package/src/analysis/typescript.mjs +1034 -0
- package/src/analysis/vue.mjs +156 -0
- package/src/architecture-intent/intent-fingerprint.mjs +29 -0
- package/src/architecture-intent/judge.mjs +539 -0
- package/src/architecture-intent/model.mjs +703 -0
- package/src/architecture-intent/selectors.mjs +170 -0
- package/src/canonical.mjs +48 -0
- package/src/commands/README.md +266 -0
- package/src/commands/adr.mjs +248 -0
- package/src/commands/check.mjs +989 -0
- package/src/commands/context-command.mjs +212 -0
- package/src/commands/context.mjs +790 -0
- package/src/commands/custom-rules.mjs +428 -0
- package/src/commands/debt.mjs +218 -0
- package/src/commands/diff.mjs +523 -0
- package/src/commands/discover.mjs +159 -0
- package/src/commands/drift.mjs +473 -0
- package/src/commands/edge-constraints.mjs +355 -0
- package/src/commands/explain.mjs +359 -0
- package/src/commands/fitness.mjs +226 -0
- package/src/commands/graph.mjs +297 -0
- package/src/commands/health.mjs +213 -0
- package/src/commands/history.mjs +614 -0
- package/src/commands/impact.mjs +226 -0
- package/src/commands/plan-context-command.mjs +496 -0
- package/src/commands/policy.mjs +138 -0
- package/src/commands/provenance-command.mjs +352 -0
- package/src/commands/provenance.mjs +159 -0
- package/src/commands/reconcile.mjs +219 -0
- package/src/commands/report.mjs +553 -0
- package/src/commands/snapshot-meta.mjs +107 -0
- package/src/commands/waivers.mjs +240 -0
- package/src/config.mjs +1308 -0
- package/src/containment.mjs +234 -0
- package/src/custom-rules/evidence.mjs +340 -0
- package/src/custom-rules/host.mjs +1023 -0
- package/src/custom-rules/values.mjs +43 -0
- package/src/entry-point.mjs +55 -0
- package/src/errors.mjs +36 -0
- package/src/eslint-config.mjs +542 -0
- package/src/go-work.mjs +394 -0
- package/src/governance/adr-registry.mjs +539 -0
- package/src/governance/clock.mjs +69 -0
- package/src/governance/debt-ledger.mjs +274 -0
- package/src/governance/discovery-proposal.mjs +423 -0
- package/src/governance/fitness-registry.mjs +504 -0
- package/src/governance/fitness-rules.mjs +668 -0
- package/src/governance/metrics.mjs +392 -0
- package/src/governance/preset-fingerprints.json +16 -0
- package/src/governance/profile-registry.mjs +366 -0
- package/src/governance/provenance-record.mjs +177 -0
- package/src/governance/reconcile-candidates.mjs +301 -0
- package/src/governance/reconcile-score.mjs +503 -0
- package/src/governance/row-schema.mjs +208 -0
- package/src/governance/verdict.mjs +127 -0
- package/src/governance/waiver.mjs +105 -0
- package/src/graph/create-dependencies.mjs +96 -0
- package/src/intent/intent-manifest.json +347 -0
- package/src/intent/mask-non-code.mjs +640 -0
- package/src/lsp/boundary-config.mjs +225 -0
- package/src/lsp/diagnose.mjs +202 -0
- package/src/lsp/diagnostics.mjs +241 -0
- package/src/lsp/protocol.mjs +215 -0
- package/src/lsp/server.mjs +922 -0
- package/src/lsp/workspace-index.mjs +891 -0
- package/src/nx-json.mjs +95 -0
- package/src/options.mjs +611 -0
- package/src/process.mjs +91 -0
- package/src/providers/moon.mjs +733 -0
- package/src/providers/native/README.md +204 -0
- package/src/providers/native/coverage.mjs +74 -0
- package/src/providers/native/differential.fixtures.mjs +1277 -0
- package/src/providers/native/discover.mjs +431 -0
- package/src/providers/native/graph.mjs +234 -0
- package/src/providers/native/index.mjs +152 -0
- package/src/providers/native/model.mjs +755 -0
- package/src/providers/nx.mjs +178 -0
- package/src/report/README.md +89 -0
- package/src/report/adr-text.mjs +129 -0
- package/src/report/context-text.mjs +109 -0
- package/src/report/debt-text.mjs +105 -0
- package/src/report/diff-text.mjs +219 -0
- package/src/report/discover-text.mjs +186 -0
- package/src/report/drift-text.mjs +194 -0
- package/src/report/envelope-shape.mjs +161 -0
- package/src/report/evidence.mjs +157 -0
- package/src/report/explain-text.mjs +159 -0
- package/src/report/graph-text.mjs +116 -0
- package/src/report/health-text.mjs +123 -0
- package/src/report/history-text.mjs +204 -0
- package/src/report/impact-text.mjs +128 -0
- package/src/report/json.mjs +173 -0
- package/src/report/plan-context-text.mjs +159 -0
- package/src/report/provenance-text.mjs +78 -0
- package/src/report/reconcile-text.mjs +159 -0
- package/src/report/report-text.mjs +264 -0
- package/src/report/sarif.mjs +953 -0
- package/src/report/text.mjs +823 -0
- package/src/report/waivers-text.mjs +100 -0
- package/src/rules/README.md +123 -0
- package/src/rules/index.mjs +962 -0
- package/src/rules/match.mjs +1708 -0
- package/src/rules/messages.mjs +73 -0
- package/src/rules/reachability.mjs +224 -0
- package/src/rules/specifiers.mjs +300 -0
- package/src/rules/tags.mjs +238 -0
- package/src/rules/topology.mjs +333 -0
- package/src/tsconfig-paths.mjs +237 -0
- package/src/verdict.mjs +145 -0
- 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
|
+
}
|