@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,359 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `explain` command: the judgment for one import site, explained.
|
|
3
|
+
*
|
|
4
|
+
* `explain` takes a `file:line:column` site, finds the matching import
|
|
5
|
+
* record, and explains the judgment: which constraint row matched, which tags
|
|
6
|
+
* applied, whether it is a violation and why. It is descriptive: it never
|
|
7
|
+
* exits 1, because an explanation of what the rules decided is never a finding
|
|
8
|
+
* .
|
|
9
|
+
*
|
|
10
|
+
* What it needs from its caller is a site string, a `CommandContext`, and the
|
|
11
|
+
* loaded boundary config — the preamble every command shares
|
|
12
|
+
* (`./context.mjs`) plus the law the judgment was made under
|
|
13
|
+
* (`../config.mjs`). What it gives back is a `status`, the explanation payload
|
|
14
|
+
* for both the text and the JSON renderers, and enough coverage information to
|
|
15
|
+
* build a correct envelope. It does not print, and it does not decide the
|
|
16
|
+
* process's exit code — `../../cli.mjs` owns those (`./README.md`).
|
|
17
|
+
*
|
|
18
|
+
* ## The unregistered-plugin refusal
|
|
19
|
+
*
|
|
20
|
+
* Same as `graph`: on an Nx workspace whose `nx.json` does not register this
|
|
21
|
+
* plugin but whose tracked files include polyglot manifests under project
|
|
22
|
+
* roots, `explain` refuses loudly rather than explaining a judgment from a
|
|
23
|
+
* graph whose edges silently under-represent the real architecture.
|
|
24
|
+
*/
|
|
25
|
+
import { isWholeFileFailure } from "../analysis/source-util.mjs";
|
|
26
|
+
import { UsageError } from "../errors.mjs";
|
|
27
|
+
import { evaluate } from "../rules/index.mjs";
|
|
28
|
+
import { findConstraintsFor } from "../rules/tags.mjs";
|
|
29
|
+
import { findProjectForPath, createProjectRootMappings } from "../rules/specifiers.mjs";
|
|
30
|
+
import { jsonEnvelope, renderJson } from "../report/json.mjs";
|
|
31
|
+
import { formatExplainReport } from "../report/explain-text.mjs";
|
|
32
|
+
import { resolveProvenance } from "./provenance.mjs";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Parses a `file:line:column` site string into its components.
|
|
36
|
+
*
|
|
37
|
+
* All three parts are required and must be positive integers. The file part
|
|
38
|
+
* may contain any characters except the two colons that delimit the segments.
|
|
39
|
+
*
|
|
40
|
+
* @param {string} site A `file:line:column` string.
|
|
41
|
+
* @returns {{ sourceFile: string, line: number, column: number }}
|
|
42
|
+
* @throws {UsageError} when the site string is malformed.
|
|
43
|
+
*/
|
|
44
|
+
export function parseSite(site) {
|
|
45
|
+
const lastColon = site.lastIndexOf(":");
|
|
46
|
+
if (lastColon === -1 || lastColon === 0) {
|
|
47
|
+
throw new UsageError(
|
|
48
|
+
`archkeep: '${site}' is not a valid site — expected <file>:<line>:<column>`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
const secondLastColon = site.lastIndexOf(":", lastColon - 1);
|
|
52
|
+
if (secondLastColon === -1) {
|
|
53
|
+
throw new UsageError(
|
|
54
|
+
`archkeep: '${site}' is not a valid site — expected <file>:<line>:<column>`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
const sourceFile = site.slice(0, secondLastColon);
|
|
58
|
+
const lineStr = site.slice(secondLastColon + 1, lastColon);
|
|
59
|
+
const columnStr = site.slice(lastColon + 1);
|
|
60
|
+
|
|
61
|
+
if (!sourceFile) {
|
|
62
|
+
throw new UsageError(`archkeep: '${site}' is not a valid site — the file part is empty`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const line = Number(lineStr);
|
|
66
|
+
const column = Number(columnStr);
|
|
67
|
+
if (!Number.isInteger(line) || line < 1) {
|
|
68
|
+
throw new UsageError(
|
|
69
|
+
`archkeep: '${site}' is not a valid site — line must be a positive integer, got '${lineStr}'`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
if (!Number.isInteger(column) || column < 1) {
|
|
73
|
+
throw new UsageError(
|
|
74
|
+
`archkeep: '${site}' is not a valid site — column must be a positive integer, got '${columnStr}'`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
return { sourceFile, line, column };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Finds the import record matching a parsed site.
|
|
82
|
+
*
|
|
83
|
+
* An import record matches when its `sourceFile`, `line`, and `column` all
|
|
84
|
+
* agree. At most one record should match a given site.
|
|
85
|
+
*
|
|
86
|
+
* @param {{sourceFile: string, line: number, column: number}} parsed
|
|
87
|
+
* @param {object[]} imports The analysis import records.
|
|
88
|
+
* @returns {object|null} The matching record, or `null`.
|
|
89
|
+
*/
|
|
90
|
+
export function findSite(parsed, imports) {
|
|
91
|
+
return (
|
|
92
|
+
imports.find(
|
|
93
|
+
(site) =>
|
|
94
|
+
site.sourceFile === parsed.sourceFile &&
|
|
95
|
+
site.line === parsed.line &&
|
|
96
|
+
site.column === parsed.column,
|
|
97
|
+
) ?? null
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Finds the constraint rows that match a source project's tags.
|
|
103
|
+
*
|
|
104
|
+
* This is the "allowed" counterpart to `evaluate`: `evaluate` returns
|
|
105
|
+
* violations, and this returns the constraints that WERE satisfied. A project
|
|
106
|
+
* with no matching constraints would have been reported as
|
|
107
|
+
* `projectWithoutTagsCannotHaveDependencies` by `evaluate`, so reaching this
|
|
108
|
+
* function with no matches means the site was judged before the constraint
|
|
109
|
+
* table was consulted (e.g. it was `allow`ed, or it reached an app).
|
|
110
|
+
*
|
|
111
|
+
* @param {object[]} depConstraints The config's constraint table.
|
|
112
|
+
* @param {object} sourceProjectNode The graph node for the source project.
|
|
113
|
+
* @returns {object[]}
|
|
114
|
+
*/
|
|
115
|
+
function findMatchingConstraints(depConstraints, sourceProjectNode) {
|
|
116
|
+
return findConstraintsFor(depConstraints, sourceProjectNode);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Runs the `explain` command: resolves the command context, finds the import
|
|
121
|
+
* site, evaluates the rules, and returns the explanation.
|
|
122
|
+
*
|
|
123
|
+
* @param {string} site A `file:line:column` string.
|
|
124
|
+
* @param {object} commandContext From `resolveCommandContext`.
|
|
125
|
+
* @param {object} config The loaded boundary config (from `loadBoundaryConfig`).
|
|
126
|
+
* @returns {{status: "ok"|"no-verdict", explanation: object, coverage: object,
|
|
127
|
+
* report: {text: string, json: string}}}
|
|
128
|
+
* @throws {Error} when the plugin is unregistered on a polyglot Nx workspace,
|
|
129
|
+
* when the site string is malformed, or when the site cannot be found.
|
|
130
|
+
*/
|
|
131
|
+
export function explainCommand(site, commandContext, config) {
|
|
132
|
+
const { root, provider, marker, graph } = commandContext;
|
|
133
|
+
|
|
134
|
+
// Descriptive commands refuse when the graph is known to be incomplete.
|
|
135
|
+
if (
|
|
136
|
+
provider === "nx" &&
|
|
137
|
+
!commandContext.pluginGap.registered &&
|
|
138
|
+
commandContext.pluginGap.manifests.length > 0
|
|
139
|
+
) {
|
|
140
|
+
throw new Error(
|
|
141
|
+
`archkeep: refusing to explain a judgment for an Nx workspace where this plugin is ` +
|
|
142
|
+
`not registered but polyglot manifests exist under project roots ` +
|
|
143
|
+
`(${commandContext.pluginGap.manifests.join(", ")}). The graph would carry no polyglot edges, ` +
|
|
144
|
+
`so the judgment would be against an incomplete graph. ` +
|
|
145
|
+
`Register the plugin in nx.json: ` +
|
|
146
|
+
`"plugins": [{ "plugin": "@ecoma-io/archkeep/nx" }], or remove the polyglot manifests ` +
|
|
147
|
+
`if they are not in use.`,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const parsed = parseSite(site);
|
|
152
|
+
|
|
153
|
+
const notAnalyzed = commandContext.analysis.failures
|
|
154
|
+
.filter(isWholeFileFailure)
|
|
155
|
+
.map(({ sourceFile, reason }) => ({ file: sourceFile, reason }));
|
|
156
|
+
|
|
157
|
+
const complete = notAnalyzed.length === 0;
|
|
158
|
+
const status = complete ? "ok" : "no-verdict";
|
|
159
|
+
|
|
160
|
+
// Find the import record at this site.
|
|
161
|
+
const record = findSite(parsed, commandContext.analysis.imports);
|
|
162
|
+
|
|
163
|
+
if (record === null) {
|
|
164
|
+
// Check whether the site's file had a whole-file failure.
|
|
165
|
+
const wholeFileFailure = commandContext.analysis.failures.find(
|
|
166
|
+
(f) => isWholeFileFailure(f) && f.sourceFile === parsed.sourceFile,
|
|
167
|
+
);
|
|
168
|
+
if (wholeFileFailure) {
|
|
169
|
+
throw new Error(
|
|
170
|
+
`archkeep: ${parsed.sourceFile} could not be analyzed at all ` +
|
|
171
|
+
`(${wholeFileFailure.reason}), so no import site at ` +
|
|
172
|
+
`${parsed.sourceFile}:${parsed.line}:${parsed.column} exists to explain`,
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Check whether this specific site had a site-level failure.
|
|
177
|
+
const siteFailure = commandContext.analysis.failures.find(
|
|
178
|
+
(f) =>
|
|
179
|
+
!isWholeFileFailure(f) &&
|
|
180
|
+
f.sourceFile === parsed.sourceFile &&
|
|
181
|
+
f.line === parsed.line &&
|
|
182
|
+
f.column === parsed.column,
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
if (siteFailure) {
|
|
186
|
+
// Site-level failure: the file was analyzed but this import site is
|
|
187
|
+
// unresolvable. Report it as such — the file was judged, one position
|
|
188
|
+
// in it has no answer.
|
|
189
|
+
const explanation = {
|
|
190
|
+
site: { file: parsed.sourceFile, line: parsed.line, column: parsed.column },
|
|
191
|
+
import: null,
|
|
192
|
+
sourceProject: null,
|
|
193
|
+
targetProject: null,
|
|
194
|
+
sourceTags: [],
|
|
195
|
+
targetTags: [],
|
|
196
|
+
matchedConstraints: [],
|
|
197
|
+
violations: null,
|
|
198
|
+
unresolvable: true,
|
|
199
|
+
reason: siteFailure.reason,
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const context = { root, provider, marker, provenance: resolveProvenance(root) };
|
|
203
|
+
const coverage = {
|
|
204
|
+
complete,
|
|
205
|
+
projects: Object.keys(graph.nodes).length,
|
|
206
|
+
analyzedFiles: commandContext.analysis.analyzed,
|
|
207
|
+
imports: commandContext.analysis.imports.length,
|
|
208
|
+
notAnalyzed,
|
|
209
|
+
blindSpots: commandContext.analysis.failures
|
|
210
|
+
.filter((f) => !isWholeFileFailure(f))
|
|
211
|
+
.map(({ sourceFile, line, column, reason }) => ({
|
|
212
|
+
file: sourceFile,
|
|
213
|
+
line,
|
|
214
|
+
column,
|
|
215
|
+
reason,
|
|
216
|
+
})),
|
|
217
|
+
notes: [],
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const result = {
|
|
221
|
+
site: explanation.site,
|
|
222
|
+
unresolvable: true,
|
|
223
|
+
reason: siteFailure.reason,
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const envelope = jsonEnvelope({
|
|
227
|
+
command: "explain",
|
|
228
|
+
context,
|
|
229
|
+
status,
|
|
230
|
+
exitCode: complete ? 0 : 3,
|
|
231
|
+
coverage,
|
|
232
|
+
result,
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
status,
|
|
237
|
+
explanation,
|
|
238
|
+
coverage,
|
|
239
|
+
report: {
|
|
240
|
+
text: formatExplainReport({ explanation, coverage }),
|
|
241
|
+
json: renderJson(envelope),
|
|
242
|
+
},
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// No record and no failure at this site — the position does not exist.
|
|
247
|
+
throw new Error(
|
|
248
|
+
`archkeep: no import site at ${parsed.sourceFile}:${parsed.line}:${parsed.column} — ` +
|
|
249
|
+
`that position does not correspond to any import this tool found. ` +
|
|
250
|
+
`Check the file, line and column; remember that line and column are 1-based.`,
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Evaluate the rules to find violations for ALL sites, then filter to this
|
|
255
|
+
// one. We run the full evaluation rather than a single-site evaluation
|
|
256
|
+
// because `evaluate` is pure and some rules (circular dependencies, lazy
|
|
257
|
+
// loading) depend on the whole file graph — they cannot be computed on one
|
|
258
|
+
// site in isolation.
|
|
259
|
+
const allViolations = evaluate(commandContext.analysis.imports, graph, config);
|
|
260
|
+
const siteViolations = allViolations.filter(
|
|
261
|
+
(v) =>
|
|
262
|
+
v.sourceFile === parsed.sourceFile && v.line === parsed.line && v.column === parsed.column,
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
// Derive source and target projects from the graph.
|
|
266
|
+
// `sourceProject` is NOT on the import record — the analysis contract
|
|
267
|
+
// (`../analysis/contract.md`) keeps no project name on the record. It is
|
|
268
|
+
// derived the same way `evaluate` derives it: by walking up the source
|
|
269
|
+
// file's directory path against the project root mappings. `target` comes
|
|
270
|
+
// from the record's resolved field, which the analysis resolver provides.
|
|
271
|
+
const mappings = createProjectRootMappings(graph.nodes);
|
|
272
|
+
const sourceProjectName = findProjectForPath(record.sourceFile, mappings) ?? null;
|
|
273
|
+
const targetProjectName = record.resolved?.target ?? null;
|
|
274
|
+
|
|
275
|
+
const sourceProjectNode = sourceProjectName ? (graph.nodes[sourceProjectName] ?? null) : null;
|
|
276
|
+
const targetProjectNode = targetProjectName ? (graph.nodes[targetProjectName] ?? null) : null;
|
|
277
|
+
|
|
278
|
+
const sourceTags = sourceProjectNode ? (sourceProjectNode.data?.tags ?? []) : [];
|
|
279
|
+
const targetTags = targetProjectNode ? (targetProjectNode.data?.tags ?? []) : [];
|
|
280
|
+
|
|
281
|
+
// Find which constraint rows match the source project's tags — this is the
|
|
282
|
+
// "allowed" explanation. A project with no matching constraints would have
|
|
283
|
+
// been flagged by `evaluate` as `projectWithoutTagsCannotHaveDependencies`.
|
|
284
|
+
const matchedConstraints = sourceProjectNode
|
|
285
|
+
? findMatchingConstraints(config.depConstraints, sourceProjectNode)
|
|
286
|
+
: [];
|
|
287
|
+
|
|
288
|
+
let violations = null;
|
|
289
|
+
if (siteViolations.length > 0) {
|
|
290
|
+
// A site can produce multiple violations — e.g. `bannedExternalImports`
|
|
291
|
+
// and `noTransitiveDependencies` can fire together. An agent seeing only
|
|
292
|
+
// the first might fix it and be confused when `check` still fails. Return
|
|
293
|
+
// all of them so the consumer sees the complete picture.
|
|
294
|
+
violations = siteViolations.map((v) => ({
|
|
295
|
+
messageId: v.messageId,
|
|
296
|
+
message: v.message,
|
|
297
|
+
constraint: v.constraint,
|
|
298
|
+
}));
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const explanation = {
|
|
302
|
+
site: { file: parsed.sourceFile, line: parsed.line, column: parsed.column },
|
|
303
|
+
import: {
|
|
304
|
+
specifier: record.specifier,
|
|
305
|
+
kind: record.kind,
|
|
306
|
+
sourceProject: sourceProjectName,
|
|
307
|
+
targetProject: targetProjectName,
|
|
308
|
+
},
|
|
309
|
+
sourceProject: sourceProjectName,
|
|
310
|
+
targetProject: targetProjectName,
|
|
311
|
+
sourceTags,
|
|
312
|
+
targetTags,
|
|
313
|
+
matchedConstraints,
|
|
314
|
+
violations,
|
|
315
|
+
unresolvable: false,
|
|
316
|
+
reason: null,
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
const context = { root, provider, marker, provenance: resolveProvenance(root) };
|
|
320
|
+
const coverage = {
|
|
321
|
+
complete,
|
|
322
|
+
projects: Object.keys(graph.nodes).length,
|
|
323
|
+
analyzedFiles: commandContext.analysis.analyzed,
|
|
324
|
+
imports: commandContext.analysis.imports.length,
|
|
325
|
+
notAnalyzed,
|
|
326
|
+
blindSpots: commandContext.analysis.failures
|
|
327
|
+
.filter((f) => !isWholeFileFailure(f))
|
|
328
|
+
.map(({ sourceFile, line, column, reason }) => ({ file: sourceFile, line, column, reason })),
|
|
329
|
+
notes: [],
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
const result = {
|
|
333
|
+
site: explanation.site,
|
|
334
|
+
import: explanation.import,
|
|
335
|
+
sourceTags,
|
|
336
|
+
targetTags,
|
|
337
|
+
matchedConstraints,
|
|
338
|
+
violations,
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
const envelope = jsonEnvelope({
|
|
342
|
+
command: "explain",
|
|
343
|
+
context,
|
|
344
|
+
status,
|
|
345
|
+
exitCode: complete ? 0 : 3,
|
|
346
|
+
coverage,
|
|
347
|
+
result,
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
return {
|
|
351
|
+
status,
|
|
352
|
+
explanation,
|
|
353
|
+
coverage,
|
|
354
|
+
report: {
|
|
355
|
+
text: formatExplainReport({ explanation, coverage }),
|
|
356
|
+
json: renderJson(envelope),
|
|
357
|
+
},
|
|
358
|
+
};
|
|
359
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `fitness` command: every declared fitness function judged against the
|
|
3
|
+
* observed workspace, as a verdict table.
|
|
4
|
+
*
|
|
5
|
+
* Fitness functions are the workspace's executable quality gates
|
|
6
|
+
* (`module-boundaries.config.mjs`'s `fitness` export, validated by
|
|
7
|
+
* `../config.mjs`): "the graph stays cycle-free", "no adapter reaches the
|
|
8
|
+
* domain", "at least 90% of files are analyzed", "no more than N boundary
|
|
9
|
+
* suppressions". Each is judged deterministically against the SAME observed
|
|
10
|
+
* facts `check` reads — the project graph, the workspace analysis, the
|
|
11
|
+
* architecture intent, and the boundary suppressions — through the registry
|
|
12
|
+
* `../governance/fitness-registry.mjs` (which reuses `resolveMembers` and the
|
|
13
|
+
* E0 verdict envelope, never duplicating a judge).
|
|
14
|
+
*
|
|
15
|
+
* ## Posture
|
|
16
|
+
*
|
|
17
|
+
* `fitness` prints a verdict table, and the verdict carries the exit code: a
|
|
18
|
+
* failing function is a finding (exit 1) and an undetermined one is a
|
|
19
|
+
* could-not-determine (exit 3) — the same two lanes `check` uses, argued at
|
|
20
|
+
* the status↔exitCode mapping below (D-09). `check` also folds fitness in by
|
|
21
|
+
* presence — a workspace whose policy declares fitness gets its per-function
|
|
22
|
+
* verdicts counted into the same verdict machinery (exit 1 for any `fail`,
|
|
23
|
+
* exit 3 for any `unknown`, never a new exit code). There is no `--fitness`
|
|
24
|
+
* flag: an opt-in flag would make a forgotten flag byte-identical to "no
|
|
25
|
+
* fitness checked", the silent direction this tool exists to end.
|
|
26
|
+
*
|
|
27
|
+
* ## Fail-closed
|
|
28
|
+
*
|
|
29
|
+
* A declared function that cannot be determined yields `unknown` — never
|
|
30
|
+
* `pass`. The mirror of `drift`'s refusals: a `layer-dependency` tag no
|
|
31
|
+
* matched project carries, a `coverage-minimum` over zero owned files, and a
|
|
32
|
+
* `drift-free` over no architecture-intent.json all answer `unknown` with the
|
|
33
|
+
* missing fact named. A function whose `match` selects no project is
|
|
34
|
+
* `skipped` — loud ("declared but matches nothing"), never folded into
|
|
35
|
+
* `pass`; a `coverage-minimum` row judged from a path-scoped run joins it
|
|
36
|
+
* there rather than `unknown` (`../governance/fitness-rules.mjs`), because a
|
|
37
|
+
* scoped `check <path>` structurally cannot answer a whole-tree coverage
|
|
38
|
+
* question — that is not evidence of a hole, and folding it into `unknown`
|
|
39
|
+
* used to make `check <path>` exit 3 unconditionally in any workspace
|
|
40
|
+
* declaring `coverage-minimum` (P1-19).
|
|
41
|
+
*
|
|
42
|
+
* ## Determinism
|
|
43
|
+
*
|
|
44
|
+
* Rows are judged in declaration order; edges and evidence are plain-`<`
|
|
45
|
+
* sorted; JSON rides `canonicalizeJson`. Two runs over an unchanged tree and
|
|
46
|
+
* policy produce byte-identical text and JSON.
|
|
47
|
+
*/
|
|
48
|
+
import { isWholeFileFailure } from "../analysis/source-util.mjs";
|
|
49
|
+
import { jsonEnvelope, renderJson } from "../report/json.mjs";
|
|
50
|
+
import { formatFitnessSection } from "../report/text.mjs";
|
|
51
|
+
import { resolveProvenance } from "./provenance.mjs";
|
|
52
|
+
import { driftForCheck } from "./drift.mjs";
|
|
53
|
+
import {
|
|
54
|
+
evaluateFitness,
|
|
55
|
+
fitnessSnapshot,
|
|
56
|
+
fitnessVerdictFor,
|
|
57
|
+
} from "../governance/fitness-registry.mjs";
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The `fitness` command's own text report: the same verdict table `check`'s
|
|
61
|
+
* fold renders (`formatFitnessSection`), so the two faces can never disagree
|
|
62
|
+
* about a function's verdict.
|
|
63
|
+
*
|
|
64
|
+
* @param {object[]} decisions Per-function verdict records.
|
|
65
|
+
* @param {{verdict: string}} overall From `fitnessVerdictFor`.
|
|
66
|
+
* @returns {string}
|
|
67
|
+
*/
|
|
68
|
+
function formatFitnessReport(decisions, overall) {
|
|
69
|
+
return formatFitnessSection(decisions, overall);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The fitness verdicts for `check`'s fold — the policy's rows evaluated
|
|
74
|
+
* against the run's own facts (`check` needs no override: it already holds the
|
|
75
|
+
* intent verdict it judged and the suppressions it enforced).
|
|
76
|
+
*
|
|
77
|
+
* @param {object} commandContext From `resolveCommandContext`.
|
|
78
|
+
* @param {{rows: object[], intent: object|null, suppressions: object[],
|
|
79
|
+
* scoped: boolean}} policy The validated fitness rows plus the run's own
|
|
80
|
+
* intent verdict and suppressions, scoped by `paths`.
|
|
81
|
+
* @returns {{decisions: object[], overall: {verdict: string}}}
|
|
82
|
+
*/
|
|
83
|
+
export function fitnessForCheck(commandContext, { rows, intent, suppressions, scoped }) {
|
|
84
|
+
const snapshot = fitnessSnapshot(commandContext, { intent, suppressions, scoped });
|
|
85
|
+
const decisions = evaluateFitness(rows, snapshot);
|
|
86
|
+
return { decisions, overall: fitnessVerdictFor(decisions) };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Whether a policy declares fitness functions at all — the one condition that
|
|
91
|
+
* separates "this workspace has no quality gates to judge" from "the gates
|
|
92
|
+
* could not be judged". `fitnessCommand` refuses (exit 3) when it is false,
|
|
93
|
+
* because a `fitness` run asked for a table that does not exist; a composing
|
|
94
|
+
* report reads the same predicate to render `not_applicable` instead, which is
|
|
95
|
+
* the correct verdict for a workspace that declared none
|
|
96
|
+
* (`../governance/metrics.mjs`'s header owns that distinction). Exported so the
|
|
97
|
+
* two callers cannot come to disagree about what "declares fitness" means.
|
|
98
|
+
*
|
|
99
|
+
* @param {{fitness?: unknown}|null|undefined} config The loaded boundary policy.
|
|
100
|
+
* @returns {boolean}
|
|
101
|
+
*/
|
|
102
|
+
export function declaresFitness(config) {
|
|
103
|
+
return config !== null && config !== undefined && config.fitness !== undefined;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Runs the `fitness` command: loads the boundary policy, evaluates every
|
|
108
|
+
* declared function against the workspace's facts, and renders the verdict
|
|
109
|
+
* table.
|
|
110
|
+
*
|
|
111
|
+
* The command re-reads the intent itself (its own `architecture-intent.json`
|
|
112
|
+
* load) and the suppressions from the policy, so its verdict is the same facts
|
|
113
|
+
* `check` folds — one registry, one snapshot shape, two faces.
|
|
114
|
+
*
|
|
115
|
+
* @param {object} commandContext From `resolveCommandContext`.
|
|
116
|
+
* @param {{config?: object|null}} [io] The loaded policy, injectable for tests.
|
|
117
|
+
* @returns {Promise<{status: "ok"|"findings"|"no-verdict", fitness: object, coverage: object,
|
|
118
|
+
* report: {text: string, json: string}}>}
|
|
119
|
+
* @throws {Error} on every condition the header lists, all exit-3 class.
|
|
120
|
+
*/
|
|
121
|
+
export async function fitnessCommand(commandContext, io = {}) {
|
|
122
|
+
const { root, provider, marker, analysis } = commandContext;
|
|
123
|
+
|
|
124
|
+
const config = io.config ?? null;
|
|
125
|
+
if (!declaresFitness(config)) {
|
|
126
|
+
throw new Error(
|
|
127
|
+
`archkeep: fitness requires a policy that declares fitness functions — ` +
|
|
128
|
+
`module-boundary config has no \`fitness\` export, so there is nothing to judge`,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// A verdict over a tree it could not fully read is a guess. Same refusal
|
|
133
|
+
// `drift`/`graph`/`diff` make for the same condition.
|
|
134
|
+
const notAnalyzed = analysis.failures
|
|
135
|
+
.filter(isWholeFileFailure)
|
|
136
|
+
.map(({ sourceFile, reason }) => ({ file: sourceFile, reason }));
|
|
137
|
+
if (notAnalyzed.length > 0) {
|
|
138
|
+
throw new Error(
|
|
139
|
+
`archkeep: fitness has incomplete coverage — ${notAnalyzed.length} file` +
|
|
140
|
+
`${notAnalyzed.length === 1 ? "" : "s"} could not be analyzed, so every coverage ` +
|
|
141
|
+
`and graph claim would be ambiguous between "clean" and "never seen". Fix the ` +
|
|
142
|
+
`unanalyzed files and re-run.`,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// `drift-free` judges the SAME verdict-shaped intent `check`'s fold builds —
|
|
147
|
+
// `driftForCheck` (not raw `loadIntent`) — so the two faces can never
|
|
148
|
+
// disagree about whether the declared intent matches the observed graph. A
|
|
149
|
+
// drift comparison that cannot be completed surfaces as an `unknown` verdict
|
|
150
|
+
// on the function, never a `fail` reading "0 findings" over an intent the
|
|
151
|
+
// command never actually judged. `driftForCheck` is called UNCONDITIONALLY —
|
|
152
|
+
// never gated on `tracked` the way `check`/`plan-context-command.mjs` gate
|
|
153
|
+
// their own calls — because this command must still reach
|
|
154
|
+
// `driftForCheck`'s `refuseIncompleteGraph` guard whether or not intent is
|
|
155
|
+
// declared: an Nx workspace with an unregistered plugin over polyglot
|
|
156
|
+
// manifests must refuse loudly regardless of intent. `driftForCheck` itself
|
|
157
|
+
// resolves an absent intent (`drift.intent === undefined`) to a quiet
|
|
158
|
+
// result rather than judging one, which is what makes this unconditional
|
|
159
|
+
// call safe; that signal becomes `intent: null` here, the same value
|
|
160
|
+
// `check`'s fold passes when no intent file is tracked at all, and
|
|
161
|
+
// `drift-free` below reads it as `unknown`. `driftForCheck` is NOT caught
|
|
162
|
+
// here: every OTHER fail-closed condition — that same unregistered-plugin
|
|
163
|
+
// refusal, an unreadable or invalid intent — must exit 3 exactly as they do
|
|
164
|
+
// for `drift`/`graph`/`impact`/`explain`, not fold into a verdict-bearing
|
|
165
|
+
// run over a graph that cannot see the workspace.
|
|
166
|
+
const drift = await driftForCheck(commandContext);
|
|
167
|
+
const intent =
|
|
168
|
+
drift.intent === undefined
|
|
169
|
+
? null
|
|
170
|
+
: {
|
|
171
|
+
verdict:
|
|
172
|
+
drift.findings.length > 0
|
|
173
|
+
? "findings"
|
|
174
|
+
: drift.unresolved.length > 0
|
|
175
|
+
? "no-verdict"
|
|
176
|
+
: "ok",
|
|
177
|
+
boundaries: drift.boundaries,
|
|
178
|
+
findings: drift.findings,
|
|
179
|
+
unresolved: drift.unresolved,
|
|
180
|
+
notes: drift.notes,
|
|
181
|
+
};
|
|
182
|
+
const snapshot = fitnessSnapshot(commandContext, {
|
|
183
|
+
intent,
|
|
184
|
+
suppressions: config.suppressions,
|
|
185
|
+
});
|
|
186
|
+
const decisions = evaluateFitness(config.fitness, snapshot);
|
|
187
|
+
const overall = fitnessVerdictFor(decisions);
|
|
188
|
+
|
|
189
|
+
const coverage = {
|
|
190
|
+
complete: true,
|
|
191
|
+
projects: Object.keys(commandContext.graph.nodes).length,
|
|
192
|
+
analyzedFiles: analysis.analyzed,
|
|
193
|
+
imports: analysis.imports.length,
|
|
194
|
+
notAnalyzed: [],
|
|
195
|
+
blindSpots: analysis.failures
|
|
196
|
+
.filter((failure) => !isWholeFileFailure(failure))
|
|
197
|
+
.map(({ sourceFile, line, column, reason }) => ({ file: sourceFile, line, column, reason })),
|
|
198
|
+
notes: [],
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const context = { root, provider, marker, provenance: resolveProvenance(root) };
|
|
202
|
+
// D-09: a `fitness` run is a verdict, not a print job. `fail` is a finding
|
|
203
|
+
// (exit 1) and `unknown` is a could-not-determine (exit 3) — the same two
|
|
204
|
+
// lanes `check` uses, so a CI that gates on `archkeep fitness` cannot be
|
|
205
|
+
// green over a function the run could not determine. `pass` and a run whose
|
|
206
|
+
// every function is `not_applicable` are both `ok`: nothing failed and
|
|
207
|
+
// nothing stayed undetermined. The status↔exitCode pair is asserted by
|
|
208
|
+
// `jsonEnvelope` (3-on-no-verdict), so a wrong mapping here cannot ship.
|
|
209
|
+
/** @type {{status: "ok"|"findings"|"no-verdict", exitCode: 0|1|3}} */
|
|
210
|
+
const { status, exitCode } =
|
|
211
|
+
overall.verdict === "fail"
|
|
212
|
+
? { status: "findings", exitCode: 1 }
|
|
213
|
+
: overall.verdict === "unknown"
|
|
214
|
+
? { status: "no-verdict", exitCode: 3 }
|
|
215
|
+
: { status: "ok", exitCode: 0 };
|
|
216
|
+
const result = { verdict: overall.verdict, functions: decisions };
|
|
217
|
+
|
|
218
|
+
const report = {
|
|
219
|
+
text: formatFitnessReport(decisions, overall),
|
|
220
|
+
json: renderJson(
|
|
221
|
+
jsonEnvelope({ command: "fitness", context, status, exitCode, coverage, result }),
|
|
222
|
+
),
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
return { status, fitness: result, coverage, report };
|
|
226
|
+
}
|