@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,823 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The terminal report: violations as lines a developer can act on without
|
|
3
|
+
* opening anything else.
|
|
4
|
+
*
|
|
5
|
+
* The first line of every entry is `file:line:column`, unindented and with no
|
|
6
|
+
* prefix, because that is the shape a terminal turns into a link and an editor
|
|
7
|
+
* turns into a jump — it is also why the analysis record carries 1-based
|
|
8
|
+
* positions at all (`../analysis/contract.md`). Everything after it is
|
|
9
|
+
* indented, so a `grep` for `:` down the left margin lists exactly the sites.
|
|
10
|
+
*
|
|
11
|
+
* Four things are printed per violation and each has a reader in mind: the
|
|
12
|
+
* `messageId` (the same id ESLint would report, so a search finds upstream's
|
|
13
|
+
* documentation and a differential comparison has something to compare), the
|
|
14
|
+
* rendered message (what is wrong), the import and the project pair (which
|
|
15
|
+
* edge), and the constraint row that fired (WHY it is wrong — a message saying
|
|
16
|
+
* a project "can only depend on libs tagged with X" does not say which line of
|
|
17
|
+
* `module-boundaries.config.mjs` said so, and that is the line a fix has to
|
|
18
|
+
* agree with).
|
|
19
|
+
*
|
|
20
|
+
* This module decides nothing. A formatter that filtered would be a rule
|
|
21
|
+
* wearing a formatter's name, and it would disagree with the engine the first
|
|
22
|
+
* time either changed (`README.md` beside this file).
|
|
23
|
+
*/
|
|
24
|
+
import { isWholeFileFailure } from "../analysis/source-util.mjs";
|
|
25
|
+
|
|
26
|
+
/** Two spaces of indent for a violation's detail lines, four for wrapped text. */
|
|
27
|
+
const DETAIL = " ";
|
|
28
|
+
const CONTINUED = " ";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The constraint row that fired, rendered from the row's own keys rather than
|
|
32
|
+
* from a list of the keys we expect. `@nx/enforce-module-boundaries` can grow a
|
|
33
|
+
* constraint field, and a renderer enumerating today's four would silently drop
|
|
34
|
+
* the new one from every report.
|
|
35
|
+
*
|
|
36
|
+
* A `decisionRef` is rendered specially: this is the one key whose value is a
|
|
37
|
+
* CLAIM ("this row is authorized by decision X") rather than a fact about the
|
|
38
|
+
* rule itself, and `resolveDecisionRef` (`../governance/adr-registry.mjs`) is
|
|
39
|
+
* the only thing that can check the claim — a pure renderer cannot, so the
|
|
40
|
+
* caller resolves every row's `decisionRef` once (against the workspace's ADR
|
|
41
|
+
* registry) and hands back the unresolved VALUES here. A ref this function
|
|
42
|
+
* cannot place in `unresolvedDecisionRefs` renders exactly as before —
|
|
43
|
+
* verbatim, the same as every other key — so a caller with nothing to check
|
|
44
|
+
* (no registry consulted) changes no byte of output.
|
|
45
|
+
*
|
|
46
|
+
* @param {object|null} constraint A `depConstraints` row, or `null`.
|
|
47
|
+
* @param {Set<string>} [unresolvedDecisionRefs] `decisionRef` values known not
|
|
48
|
+
* to resolve to any ADR, rule, or fitness record this run's registry knows.
|
|
49
|
+
* @returns {string}
|
|
50
|
+
*/
|
|
51
|
+
export function formatConstraint(constraint, unresolvedDecisionRefs) {
|
|
52
|
+
if (!constraint) {
|
|
53
|
+
// Nine of the fifteen checks are decided before the constraint table is
|
|
54
|
+
// consulted — a relative path across projects, an import of an app, a
|
|
55
|
+
// cycle. Saying so beats printing nothing, which reads as a missing field.
|
|
56
|
+
return "not driven by a depConstraints row — this check fires before the table is read";
|
|
57
|
+
}
|
|
58
|
+
const source =
|
|
59
|
+
"allSourceTags" in constraint
|
|
60
|
+
? `allSourceTags [${constraint.allSourceTags.join(", ")}]`
|
|
61
|
+
: `sourceTag ${constraint.sourceTag}`;
|
|
62
|
+
const rest = Object.entries(constraint)
|
|
63
|
+
.filter(([key]) => key !== "sourceTag" && key !== "allSourceTags")
|
|
64
|
+
.map(([key, value]) => {
|
|
65
|
+
if (key === "decisionRef" && unresolvedDecisionRefs?.has(value)) {
|
|
66
|
+
return `${key} [${value}] (UNRESOLVED — no matching ADR, rule, or fitness record)`;
|
|
67
|
+
}
|
|
68
|
+
return `${key} [${(Array.isArray(value) ? value : [value]).join(", ")}]`;
|
|
69
|
+
});
|
|
70
|
+
return [source, ...rest].join(" → ");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The project pair, with the two shapes a target can have spelled out. */
|
|
74
|
+
function formatEdge(violation) {
|
|
75
|
+
const source = violation.sourceProject ?? "(no project)";
|
|
76
|
+
const target = violation.targetProject ?? "(unresolved)";
|
|
77
|
+
return `${source} → ${target}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* One violation as an entry: a clickable position line plus indented detail.
|
|
82
|
+
*
|
|
83
|
+
* Message templates are multi-line (a circular-dependency report carries the
|
|
84
|
+
* cycle and the file chain), so every line of the message is indented to the
|
|
85
|
+
* same column — an unindented continuation would read as a second violation at
|
|
86
|
+
* a file called whatever the wrapped text started with.
|
|
87
|
+
*
|
|
88
|
+
* @param {object} violation A `Violation` from `../rules/`.
|
|
89
|
+
* @param {Set<string>} [unresolvedDecisionRefs] Forwarded to `formatConstraint`.
|
|
90
|
+
* @returns {string}
|
|
91
|
+
*/
|
|
92
|
+
export function formatViolation(violation, unresolvedDecisionRefs) {
|
|
93
|
+
const message = violation.message
|
|
94
|
+
.split("\n")
|
|
95
|
+
.map((line) => (line === "" ? "" : `${CONTINUED}${line}`))
|
|
96
|
+
.join("\n");
|
|
97
|
+
const lines = [
|
|
98
|
+
`${violation.sourceFile}:${violation.line}:${violation.column} ${violation.messageId}`,
|
|
99
|
+
message,
|
|
100
|
+
`${DETAIL}import ${JSON.stringify(violation.specifier)} (${violation.kind}) ${formatEdge(violation)}`,
|
|
101
|
+
`${DETAIL}constraint ${formatConstraint(violation.constraint, unresolvedDecisionRefs)}`,
|
|
102
|
+
];
|
|
103
|
+
if (violation.constraint?.description) {
|
|
104
|
+
lines.push(`${DETAIL}rule ${violation.constraint.description}`);
|
|
105
|
+
}
|
|
106
|
+
if (violation.constraint?.remediation) {
|
|
107
|
+
lines.push(`${DETAIL}remediation ${violation.constraint.remediation}`);
|
|
108
|
+
}
|
|
109
|
+
if (violation.evidence !== undefined) {
|
|
110
|
+
// The one evidence a violation carries today: `"expired waiver"` — the row
|
|
111
|
+
// that used to accept it lapsed, and the boundary is live again. Rendered
|
|
112
|
+
// here so the re-assert is visible in the line a developer jumps to, not
|
|
113
|
+
// only in the JSON.
|
|
114
|
+
lines.push(`${DETAIL}evidence ${violation.evidence}`);
|
|
115
|
+
}
|
|
116
|
+
return lines.join("\n");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** One failure as `file` or `file:line:column`, then its reason. */
|
|
120
|
+
const formatFailure = (failure) =>
|
|
121
|
+
`${DETAIL}${isWholeFileFailure(failure) ? failure.sourceFile : `${failure.sourceFile}:${failure.line}:${failure.column}`} ${failure.reason}`;
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* What analysis could not read, resolve, or parse — printed on every run,
|
|
125
|
+
* including a clean one, and never counted as a violation.
|
|
126
|
+
*
|
|
127
|
+
* Two sections, because the two things a failure can mean have opposite
|
|
128
|
+
* consequences and one heading for both hid that for as long as it existed.
|
|
129
|
+
*
|
|
130
|
+
* A SITE failure is a blind spot: the file was analyzed, and one specifier in
|
|
131
|
+
* it is not statically knowable — `import(url)` with a computed argument is
|
|
132
|
+
* the honest example, and so is a literal package import that names no
|
|
133
|
+
* declared project and cannot resolve (an uninstalled third-party dependency:
|
|
134
|
+
* a workspace with packages is a normal state, and failing the run on it would
|
|
135
|
+
* block merges over dependencies nobody crossed). Both are legitimately
|
|
136
|
+
* permanent, and the rest of the file still got a verdict.
|
|
137
|
+
*
|
|
138
|
+
* A WHOLE-FILE failure is a hole: nothing was read, parsed, or analyzed — or a
|
|
139
|
+
* literal import that names a DECLARED project could not be resolved, so the
|
|
140
|
+
* edge that workspace-internal dependency would have carried is missing — and
|
|
141
|
+
* this file contributed no verdict at all. The summary line above still counts
|
|
142
|
+
* imports and files, so a reader who sees "no boundary violations" is being
|
|
143
|
+
* told about coverage; a file in this section is coverage that is missing.
|
|
144
|
+
* `cli.mjs` exits non-zero on these for that reason, and the heading says so
|
|
145
|
+
* rather than leaving the exit code to be discovered.
|
|
146
|
+
*
|
|
147
|
+
* @param {object[]} failures `AnalysisFailure` records.
|
|
148
|
+
* @returns {string}
|
|
149
|
+
*/
|
|
150
|
+
export function formatFailures(failures) {
|
|
151
|
+
if (failures.length === 0) return "";
|
|
152
|
+
const unchecked = failures.filter(isWholeFileFailure);
|
|
153
|
+
const blind = failures.filter((failure) => !isWholeFileFailure(failure));
|
|
154
|
+
const sections = [];
|
|
155
|
+
|
|
156
|
+
if (unchecked.length > 0) {
|
|
157
|
+
const files = new Set(unchecked.map((failure) => failure.sourceFile)).size;
|
|
158
|
+
sections.push(
|
|
159
|
+
[
|
|
160
|
+
`✖ ${files} file${files === 1 ? "" : "s"} could not be analyzed at all, so ${files === 1 ? "it is" : "they are"} ` +
|
|
161
|
+
`not covered by the verdict above and the run fails:`,
|
|
162
|
+
...unchecked.map(formatFailure),
|
|
163
|
+
].join("\n"),
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (blind.length > 0) {
|
|
168
|
+
sections.push(
|
|
169
|
+
[
|
|
170
|
+
`${blind.length} import${blind.length === 1 ? "" : "s"} could not be resolved. ` +
|
|
171
|
+
`These are blind spots inside files that were analyzed, not verdicts — the run does not fail on them:`,
|
|
172
|
+
...blind.map(formatFailure),
|
|
173
|
+
].join("\n"),
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
return sections.join("\n\n");
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The go.work drift section — rendered only when the run HAS a go.work
|
|
181
|
+
* verdict, decided nowhere here.
|
|
182
|
+
*
|
|
183
|
+
* `goWork` is `null` (or absent) when the workspace has no tracked root
|
|
184
|
+
* go.work, and then this prints nothing: a workspace without the manifest pays
|
|
185
|
+
* nothing and hears nothing. When the check ran, even a clean result is a
|
|
186
|
+
* line, because "go.work agrees" is a claim about coverage the reader cannot
|
|
187
|
+
* otherwise tell apart from "nothing looked" — the same reason the summary
|
|
188
|
+
* line counts files (`../go-work.mjs` owns the findings' semantics).
|
|
189
|
+
*
|
|
190
|
+
* A finding with a position renders `go.work:line:column` like a violation; a
|
|
191
|
+
* missing-use finding is about an entry that does not exist, so it renders the
|
|
192
|
+
* file alone rather than a fabricated line 1.
|
|
193
|
+
*
|
|
194
|
+
* @param {{findings: object[], moduleProjects: number}|null|undefined} goWork
|
|
195
|
+
* @returns {string} Empty exactly when there is no go.work verdict to render.
|
|
196
|
+
*/
|
|
197
|
+
export function formatGoWork(goWork) {
|
|
198
|
+
if (goWork == null) return "";
|
|
199
|
+
const { findings, moduleProjects } = goWork;
|
|
200
|
+
const modules = `${moduleProjects} Go module project${moduleProjects === 1 ? "" : "s"}`;
|
|
201
|
+
if (findings.length === 0) {
|
|
202
|
+
return `✔ go.work agrees with the project graph (${modules})`;
|
|
203
|
+
}
|
|
204
|
+
const entries = findings.map((finding) => {
|
|
205
|
+
const site =
|
|
206
|
+
finding.line === null ? finding.file : `${finding.file}:${finding.line}:${finding.column}`;
|
|
207
|
+
const message = finding.message
|
|
208
|
+
.split("\n")
|
|
209
|
+
.map((line) => (line === "" ? "" : `${CONTINUED}${line}`))
|
|
210
|
+
.join("\n");
|
|
211
|
+
return [`${site} ${finding.messageId}`, message].join("\n");
|
|
212
|
+
});
|
|
213
|
+
return [
|
|
214
|
+
entries.join("\n\n"),
|
|
215
|
+
`✖ go.work drifts from the project graph: ${findings.length} ` +
|
|
216
|
+
`finding${findings.length === 1 ? "" : "s"} (${modules}) — a developer's go build and ` +
|
|
217
|
+
`CI select different module sets, and the run fails`,
|
|
218
|
+
].join("\n\n");
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* The declared-edge section — rendered only when the graph has at least one
|
|
223
|
+
* `implicit`-typed edge, decided nowhere here.
|
|
224
|
+
*
|
|
225
|
+
* `declaredEdges` is `null` when the graph has no `implicit` edges at all,
|
|
226
|
+
* and then this prints nothing: a workspace that never uses
|
|
227
|
+
* `implicitDependencies` pays nothing and hears nothing, the same bargain
|
|
228
|
+
* `formatGoWork` states. When at least one exists, even a clean result is a
|
|
229
|
+
* line that counts how many were judged, because "no declared-edge
|
|
230
|
+
* violations" is a claim about coverage the reader cannot otherwise tell
|
|
231
|
+
* apart from "nothing looked" — go.work's own reasoning.
|
|
232
|
+
*
|
|
233
|
+
* These findings have no import site by construction — an `implicit` edge is
|
|
234
|
+
* declared, not written as an import — so every entry renders the file that
|
|
235
|
+
* declared it alone, never a fabricated line 1, the same convention
|
|
236
|
+
* `formatGoWork`'s missing-use findings use.
|
|
237
|
+
*
|
|
238
|
+
* `declaration` is what the RUN's provider calls that field, handed in by
|
|
239
|
+
* `../../cli.mjs`'s `declaredEdgeField` rather than assumed here: `check`
|
|
240
|
+
* knows which provider answered and this renderer does not, and the summary
|
|
241
|
+
* sentence named `implicitDependencies` on every provider — a field a Moon
|
|
242
|
+
* workspace has no counterpart for, on a finding whose file is that
|
|
243
|
+
* workspace's `moon.yml`. Absent, it falls back to the Nx/native spelling,
|
|
244
|
+
* which is the only wording this function ever produced before the field
|
|
245
|
+
* existed; a wrong noun in one sentence is prose, not a verdict, and the
|
|
246
|
+
* finding list it summarises is identical either way.
|
|
247
|
+
*
|
|
248
|
+
* @param {{findings: object[], judged: number, declaration?: string}|null|undefined} declaredEdges
|
|
249
|
+
* @param {Set<string>} [unresolvedDecisionRefs] Forwarded to `formatConstraint`.
|
|
250
|
+
* @returns {string} Empty exactly when there is no declared-edge verdict to render.
|
|
251
|
+
*/
|
|
252
|
+
export function formatDeclaredEdges(declaredEdges, unresolvedDecisionRefs) {
|
|
253
|
+
if (declaredEdges == null) return "";
|
|
254
|
+
const { findings, judged, declaration = "implicitDependencies" } = declaredEdges;
|
|
255
|
+
const label = `${judged} implicit edge${judged === 1 ? "" : "s"} judged`;
|
|
256
|
+
// The article follows the field name rather than being frozen into the
|
|
257
|
+
// sentence: "an implicitDependencies edge" is the wording every Nx and
|
|
258
|
+
// native run has always printed and must keep printing byte for byte, and
|
|
259
|
+
// "a dependsOn edge" is the one Moon needs. Spelling one of the two into
|
|
260
|
+
// the template makes the other ungrammatical on every run of that provider.
|
|
261
|
+
const article = /^[aeiou]/iu.test(declaration) ? "an" : "a";
|
|
262
|
+
if (findings.length === 0) {
|
|
263
|
+
return `✔ no declared-edge violations (${label})`;
|
|
264
|
+
}
|
|
265
|
+
const entries = findings.map((finding) => {
|
|
266
|
+
const message = finding.message
|
|
267
|
+
.split("\n")
|
|
268
|
+
.map((line) => (line === "" ? "" : `${CONTINUED}${line}`))
|
|
269
|
+
.join("\n");
|
|
270
|
+
return [
|
|
271
|
+
`${finding.file} ${finding.messageId}`,
|
|
272
|
+
message,
|
|
273
|
+
`${DETAIL}edge ${finding.source} → ${finding.target}`,
|
|
274
|
+
`${DETAIL}constraint ${formatConstraint(finding.constraint, unresolvedDecisionRefs)}`,
|
|
275
|
+
].join("\n");
|
|
276
|
+
});
|
|
277
|
+
return [
|
|
278
|
+
entries.join("\n\n"),
|
|
279
|
+
`✖ declared-edge violations: ${findings.length} ` +
|
|
280
|
+
`finding${findings.length === 1 ? "" : "s"} (${label}) — ${article} ${declaration} edge ` +
|
|
281
|
+
`crosses a boundary depConstraints forbids, with no import site to remove; the ` +
|
|
282
|
+
`dependency itself needs removing or its tags reconciled, and the run fails`,
|
|
283
|
+
].join("\n\n");
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* The tsconfig paths hygiene section — rendered only when the run HAS a paths
|
|
288
|
+
* verdict, decided nowhere here.
|
|
289
|
+
*
|
|
290
|
+
* `tsconfigPaths` is `null` (or absent) when the workspace has no tsconfig or
|
|
291
|
+
* its tsconfig declares no `paths`, and then this prints nothing: a workspace
|
|
292
|
+
* without the table pays nothing and hears nothing, the same bargain
|
|
293
|
+
* `formatGoWork` states. When the check ran, even a clean result is a line
|
|
294
|
+
* that counts the aliases judged — and separately the ones the check's rule
|
|
295
|
+
* cannot judge (`../tsconfig-paths.mjs` header) — because "no dead aliases"
|
|
296
|
+
* is a claim about coverage the reader cannot otherwise tell from silence.
|
|
297
|
+
*
|
|
298
|
+
* Findings are positionless by construction (the parsed options carry no
|
|
299
|
+
* source positions, and under `extends` the alias may be declared in another
|
|
300
|
+
* file), so every entry renders the file alone, never a fabricated line 1.
|
|
301
|
+
*
|
|
302
|
+
* @param {{tsConfig: string, findings: object[], aliases: number,
|
|
303
|
+
* unjudged: number}|null|undefined} tsconfigPaths
|
|
304
|
+
* @returns {string} Empty exactly when there is no paths verdict to render.
|
|
305
|
+
*/
|
|
306
|
+
export function formatTsconfigPaths(tsconfigPaths) {
|
|
307
|
+
if (tsconfigPaths == null) return "";
|
|
308
|
+
const { tsConfig, findings, aliases, unjudged } = tsconfigPaths;
|
|
309
|
+
const judged =
|
|
310
|
+
`${aliases} alias${aliases === 1 ? "" : "es"} judged in ${tsConfig}` +
|
|
311
|
+
(unjudged > 0 ? `, ${unjudged} outside this check's rule and not judged` : "");
|
|
312
|
+
if (findings.length === 0) {
|
|
313
|
+
return `✔ no dead tsconfig path aliases (${judged})`;
|
|
314
|
+
}
|
|
315
|
+
const entries = findings.map((finding) => {
|
|
316
|
+
const message = finding.message
|
|
317
|
+
.split("\n")
|
|
318
|
+
.map((line) => (line === "" ? "" : `${CONTINUED}${line}`))
|
|
319
|
+
.join("\n");
|
|
320
|
+
return [`${finding.file} ${finding.messageId}`, message].join("\n");
|
|
321
|
+
});
|
|
322
|
+
return [
|
|
323
|
+
entries.join("\n\n"),
|
|
324
|
+
`✖ dead tsconfig path aliases: ${findings.length} ` +
|
|
325
|
+
`finding${findings.length === 1 ? "" : "s"} (${judged}) — no import matching ` +
|
|
326
|
+
`${findings.length === 1 ? "this alias" : "these aliases"} can resolve through the ` +
|
|
327
|
+
`paths table, and the run fails`,
|
|
328
|
+
].join("\n\n");
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* The architecture-intent section — rendered only when the run HAS an intent
|
|
333
|
+
* verdict, decided nowhere here.
|
|
334
|
+
*
|
|
335
|
+
* `intent` is `null` (or absent) when the workspace has no tracked root
|
|
336
|
+
* `architecture-intent.json`, and then this prints nothing: a workspace
|
|
337
|
+
* without the declaration pays nothing and hears nothing, the same bargain
|
|
338
|
+
* `formatGoWork` and `formatTsconfigPaths` state. When the check ran, even a
|
|
339
|
+
* clean result is a line that counts the boundaries judged, because
|
|
340
|
+
* "architecture-intent agrees" is a claim about coverage the reader cannot
|
|
341
|
+
* otherwise tell apart from "nothing looked" — the same reason the go.work
|
|
342
|
+
* line counts modules.
|
|
343
|
+
*
|
|
344
|
+
* A no-verdict is a boundary (or a row side) that matched no observed project:
|
|
345
|
+
* the intent for that boundary cannot be verified, which is not a clean
|
|
346
|
+
* verdict. It renders as a warning line and the run fails (exit 3), the same
|
|
347
|
+
* posture a whole-file failure takes — an empty boundary must never read as a
|
|
348
|
+
* boundary that passed.
|
|
349
|
+
*
|
|
350
|
+
* @param {{verdict: "ok"|"findings"|"no-verdict", findings: object[],
|
|
351
|
+
* unresolved: object[], boundaries: object[],
|
|
352
|
+
* unresolvedDecisionRefs?: {kind: string, decisionRef: string}[]}|null|undefined} intent
|
|
353
|
+
* @returns {string} Empty exactly when there is no intent verdict to render.
|
|
354
|
+
*/
|
|
355
|
+
export function formatIntentSection(intent) {
|
|
356
|
+
if (intent == null) return "";
|
|
357
|
+
const { verdict, findings, unresolved, boundaries, unresolvedDecisionRefs = [] } = intent;
|
|
358
|
+
const count = boundaries.length;
|
|
359
|
+
const label = `${count} boundar${count === 1 ? "y" : "ies"}`;
|
|
360
|
+
// An intent row whose `decisionRef` names no ADR, rule, or fitness record the
|
|
361
|
+
// registry knows — a citation the workspace claims as its authority but that
|
|
362
|
+
// does not exist (`check` folds these into its no-verdict lane; `drift` and
|
|
363
|
+
// `provenance` flag the identical row loudly). Rendered as an UNRESOLVED
|
|
364
|
+
// block so the gate's text face is not the one that stays silent.
|
|
365
|
+
const decisionRefSection =
|
|
366
|
+
unresolvedDecisionRefs.length > 0
|
|
367
|
+
? unresolvedDecisionRefs
|
|
368
|
+
.map(
|
|
369
|
+
(entry) =>
|
|
370
|
+
`⚠ ${entry.kind} decisionRef [${entry.decisionRef}] (UNRESOLVED — no matching ADR, rule, or fitness record)`,
|
|
371
|
+
)
|
|
372
|
+
.join("\n")
|
|
373
|
+
: "";
|
|
374
|
+
if (verdict === "ok") {
|
|
375
|
+
return [decisionRefSection, `✔ architecture-intent agrees with the observed graph (${label})`]
|
|
376
|
+
.filter(Boolean)
|
|
377
|
+
.join("\n\n");
|
|
378
|
+
}
|
|
379
|
+
if (verdict === "no-verdict") {
|
|
380
|
+
const entries = unresolved.map((entry) => {
|
|
381
|
+
const message = entry.issue
|
|
382
|
+
.split("\n")
|
|
383
|
+
.map((line) => (line === "" ? "" : `${CONTINUED}${line}`))
|
|
384
|
+
.join("\n");
|
|
385
|
+
return `${entry.boundary} ${message}`;
|
|
386
|
+
});
|
|
387
|
+
return [
|
|
388
|
+
decisionRefSection,
|
|
389
|
+
entries.join("\n\n"),
|
|
390
|
+
`⚠ architecture-intent.json reached no verdict on ${unresolved.length} ` +
|
|
391
|
+
`boundar${unresolved.length === 1 ? "y" : "ies"} — the intent ` +
|
|
392
|
+
`${unresolved.length === 1 ? "this boundary names" : "these boundaries name"} ` +
|
|
393
|
+
`could not be verified, and the run fails`,
|
|
394
|
+
]
|
|
395
|
+
.filter(Boolean)
|
|
396
|
+
.join("\n\n");
|
|
397
|
+
}
|
|
398
|
+
const entries = findings.map((finding) => {
|
|
399
|
+
const message = finding.message
|
|
400
|
+
.split("\n")
|
|
401
|
+
.map((line) => (line === "" ? "" : `${CONTINUED}${line}`))
|
|
402
|
+
.join("\n");
|
|
403
|
+
return `${finding.rule} ${message}`;
|
|
404
|
+
});
|
|
405
|
+
return [
|
|
406
|
+
decisionRefSection,
|
|
407
|
+
entries.join("\n\n"),
|
|
408
|
+
`✖ architecture-intent findings: ${findings.length} ` +
|
|
409
|
+
`finding${findings.length === 1 ? "" : "s"} (${label}) — the intended ` +
|
|
410
|
+
`architecture and the observed one disagree, and the run fails`,
|
|
411
|
+
]
|
|
412
|
+
.filter(Boolean)
|
|
413
|
+
.join("\n\n");
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* One glyph per verdict state, shared by every verdict table below. Each
|
|
418
|
+
* section used to declare its own copy; two copies of a four-glyph table
|
|
419
|
+
* agree only until one gains a state, which is the drift the
|
|
420
|
+
* never-state-a-rule-twice bullet in `../../../../AGENTS.md` exists to end.
|
|
421
|
+
*/
|
|
422
|
+
const VERDICT_GLYPH = Object.freeze({ pass: "✔", fail: "✖", unknown: "⚠", not_applicable: "◌" });
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* The fitness section — one line per declared fitness function's verdict,
|
|
426
|
+
* rendered only when the run's policy declared any (`fitness === undefined`).
|
|
427
|
+
*
|
|
428
|
+
* This is a verdict table, not a findings list: every declared function gets
|
|
429
|
+
* its row, so "no fitness failed" always reads as a claim about the specific
|
|
430
|
+
* functions that were judged. The overall verdict is a fourth line naming the
|
|
431
|
+
* run's posture — `fail` wins, then `unknown`, then `pass`, exactly
|
|
432
|
+
* `fitnessVerdictFor`'s ordering (`../governance/fitness-registry.mjs`) — and
|
|
433
|
+
* a declared function that could not apply to this run — a `match` that
|
|
434
|
+
* selected nothing, or a `coverage-minimum` row judged from a path-scoped run
|
|
435
|
+
* (`../governance/fitness-rules.mjs`'s `coverageMinimum`) — shows
|
|
436
|
+
* `not_applicable` with its own reason: loud, never absent. The overall label
|
|
437
|
+
* below stays reason-agnostic for exactly that reason: it must read true for
|
|
438
|
+
* either cause, not just the `match`-selected-nothing one it was first written
|
|
439
|
+
* for.
|
|
440
|
+
*
|
|
441
|
+
* @param {object[]} decisions Per-function verdict records from
|
|
442
|
+
* `evaluateFitness`.
|
|
443
|
+
* @param {{verdict: string}} overall The aggregate from `fitnessVerdictFor`.
|
|
444
|
+
* @returns {string} Empty exactly when the policy declared no fitness.
|
|
445
|
+
*/
|
|
446
|
+
export function formatFitnessSection(decisions, overall) {
|
|
447
|
+
if (decisions.length === 0) return "";
|
|
448
|
+
const rows = decisions
|
|
449
|
+
.map((decision) => `${VERDICT_GLYPH[decision.verdict]} ${decision.name} ${decision.message}`)
|
|
450
|
+
.join("\n");
|
|
451
|
+
const overallLabel = {
|
|
452
|
+
pass: `✔ fitness: ${decisions.length} function${decisions.length === 1 ? "" : "s"} passed`,
|
|
453
|
+
fail: `✖ fitness: ${overall.verdict} — the build fails`,
|
|
454
|
+
unknown: `⚠ fitness: ${decisions.length} function${decisions.length === 1 ? "" : "s"} judged, some could not be determined — the run cannot claim pass`,
|
|
455
|
+
not_applicable: `◌ fitness: every declared function is not applicable to this run — nothing was judged`,
|
|
456
|
+
}[overall.verdict];
|
|
457
|
+
return `${rows}\n\n${overallLabel}`;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Where one custom-rule finding points, or `null` when it points nowhere.
|
|
462
|
+
*
|
|
463
|
+
* A rule may report a finding about the workspace as a whole, and it may
|
|
464
|
+
* report a file with no position inside it — both are legitimate, and both
|
|
465
|
+
* render without the part they do not have rather than with a fabricated line
|
|
466
|
+
* 1, the same convention `formatGoWork`'s missing-use findings and
|
|
467
|
+
* `formatTsconfigPaths` already keep. A `column` with no `line` has no
|
|
468
|
+
* clickable form at all (the file alone is what renders); the raw fields
|
|
469
|
+
* survive in the JSON envelope either way.
|
|
470
|
+
*
|
|
471
|
+
* @param {{sourceFile?: string, line?: number, column?: number}} finding
|
|
472
|
+
* @returns {string|null}
|
|
473
|
+
*/
|
|
474
|
+
function customFindingSite(finding) {
|
|
475
|
+
if (finding.sourceFile === undefined) return null;
|
|
476
|
+
if (finding.line === undefined) return finding.sourceFile;
|
|
477
|
+
if (finding.column === undefined) return `${finding.sourceFile}:${finding.line}`;
|
|
478
|
+
return `${finding.sourceFile}:${finding.line}:${finding.column}`;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* The custom-rules section — one entry per rule the policy declared, rendered
|
|
483
|
+
* only when it declared any.
|
|
484
|
+
*
|
|
485
|
+
* A verdict table like `formatFitnessSection`'s, and for the same reason:
|
|
486
|
+
* every declared rule gets its row, so "no custom rule failed" always reads as
|
|
487
|
+
* a claim about the specific rules that were judged rather than about silence.
|
|
488
|
+
* Each entry carries the rule's DECLARED reason — the workspace's answer to
|
|
489
|
+
* "why does this rule exist", which is the half a reader needs to decide
|
|
490
|
+
* whether a finding is worth fixing or the rule is worth deleting — and then
|
|
491
|
+
* its findings, each on a `file:line:column` line a terminal turns into a link
|
|
492
|
+
* (`formatViolation`'s opening argument, applied to a finding that has a
|
|
493
|
+
* position; a finding without one renders under its namespaced id alone).
|
|
494
|
+
*
|
|
495
|
+
* The summary counts all four verdicts rather than only the failing one: a
|
|
496
|
+
* rule that answered `not_applicable` did NOT look, and a reader who cannot
|
|
497
|
+
* tell that from a rule that looked and found nothing has been handed the one
|
|
498
|
+
* ambiguity this tool exists to end (`../../../../AGENTS.md`).
|
|
499
|
+
*
|
|
500
|
+
* @param {{decisions: object[], overall: {verdict: string}}|null|undefined} customRules
|
|
501
|
+
* @returns {string} Empty exactly when the policy declared no custom rules.
|
|
502
|
+
*/
|
|
503
|
+
export function formatCustomRulesSection(customRules) {
|
|
504
|
+
if (customRules == null || customRules.decisions.length === 0) return "";
|
|
505
|
+
const { decisions, overall } = customRules;
|
|
506
|
+
const entries = decisions.map((decision) => {
|
|
507
|
+
const lines = [
|
|
508
|
+
`${VERDICT_GLYPH[decision.verdict]} ${decision.name} ${decision.message}`,
|
|
509
|
+
`${DETAIL}reason ${decision.reason}`,
|
|
510
|
+
];
|
|
511
|
+
for (const finding of decision.findings ?? []) {
|
|
512
|
+
const site = customFindingSite(finding);
|
|
513
|
+
lines.push(`${DETAIL}${site === null ? finding.id : `${site} ${finding.id}`}`);
|
|
514
|
+
lines.push(
|
|
515
|
+
...finding.message.split("\n").map((line) => (line === "" ? "" : `${CONTINUED}${line}`)),
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
return lines.join("\n");
|
|
519
|
+
});
|
|
520
|
+
const count = (verdict) => decisions.filter((decision) => decision.verdict === verdict).length;
|
|
521
|
+
const summary =
|
|
522
|
+
`${VERDICT_GLYPH[overall.verdict]} custom rules: ${count("pass")} passed, ` +
|
|
523
|
+
`${count("fail")} failed, ${count("unknown")} unknown, ` +
|
|
524
|
+
`${count("not_applicable")} not applicable`;
|
|
525
|
+
return `${entries.join("\n\n")}\n\n${summary}`;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* The coverage-gap section — rendered only when this run knows of coverage it
|
|
530
|
+
* did not itself provide: polyglot edges the Nx graph is missing that the
|
|
531
|
+
* checker's own analysis did cover, or tracked analyzable files no project
|
|
532
|
+
* owns (`formatCoverageGap` below dispatches, one arm per kind).
|
|
533
|
+
*
|
|
534
|
+
* `coverageGaps` is `[]` (or absent) when there is no gap, and then this
|
|
535
|
+
* prints nothing — the same bargain `formatGoWork` and `formatTsconfigPaths`
|
|
536
|
+
* state: no fact, no claim. When the gap exists, it names the manifests and
|
|
537
|
+
* says what is missing: `nx affected` will not trace through these edges, and
|
|
538
|
+
* `@nx/enforce-module-boundaries` does not see them at all.
|
|
539
|
+
*
|
|
540
|
+
* This is not a finding (it does not change the exit code) and it is not a
|
|
541
|
+
* refusal (the checker still judged every import it found). It is a
|
|
542
|
+
* degraded-coverage note: the checker's verdict is valid, but the Nx graph
|
|
543
|
+
* those edges were meant for is incomplete, and anyone relying on `nx affected`
|
|
544
|
+
* or ESLint boundary enforcement for polyglot projects is under-covered.
|
|
545
|
+
*
|
|
546
|
+
* Every entry in `coverageGaps` renders — not only the first — so a second
|
|
547
|
+
* gap is never dropped from the report while still riding along in the JSON
|
|
548
|
+
* envelope (`cli.mjs`'s `coverage.coverageGaps`); the invariant this module is
|
|
549
|
+
* judged against (`../../../../AGENTS.md`) applies to a report section as much
|
|
550
|
+
* as to a verdict.
|
|
551
|
+
*
|
|
552
|
+
* @param {object[]} coverageGaps Each entry has a `kind` and the fields that
|
|
553
|
+
* kind carries — `manifests` for `"unregistered-plugin"`, `files`/
|
|
554
|
+
* `languages`/`provider` for `"unowned-files"`.
|
|
555
|
+
* @returns {string} Empty exactly when there is no coverage gap to render.
|
|
556
|
+
*/
|
|
557
|
+
export function formatCoverageGaps(coverageGaps) {
|
|
558
|
+
if (coverageGaps.length === 0) return "";
|
|
559
|
+
return coverageGaps.map(formatCoverageGap).join("\n");
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* How many unowned-file paths this face prints before it stops listing and
|
|
564
|
+
* says how many are left. The count is the headline and is always exact; the
|
|
565
|
+
* paths are the sample that makes it actionable. A real Nx or Moon workspace
|
|
566
|
+
* carries tens of them (this repository's own tree: 50), and a report whose
|
|
567
|
+
* every other section is a handful of lines does not survive one section
|
|
568
|
+
* fifty lines long — the reader stops reading the report, which is the same
|
|
569
|
+
* outcome as not printing it. Nothing is dropped silently: the total is
|
|
570
|
+
* stated first and the remainder is named, with the surface that holds the
|
|
571
|
+
* complete list.
|
|
572
|
+
*/
|
|
573
|
+
const UNOWNED_SAMPLE_LIMIT = 10;
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* One coverage gap entry, dispatched on `kind`.
|
|
577
|
+
*
|
|
578
|
+
* The unknown-kind arm is not decoration: a kind added on the producing side
|
|
579
|
+
* without an arm here would otherwise read a field it does not carry and
|
|
580
|
+
* throw mid-report, losing every section after it — a whole report lost to
|
|
581
|
+
* the newest, least-important line in it. Naming the kind is the loud
|
|
582
|
+
* version, and it mirrors what `./sarif.mjs`'s
|
|
583
|
+
* `sarifCoverageGapNotification` already does for the same input.
|
|
584
|
+
*
|
|
585
|
+
* @param {object} gap Has `kind`, and the fields that kind carries.
|
|
586
|
+
* @returns {string}
|
|
587
|
+
*/
|
|
588
|
+
function formatCoverageGap(gap) {
|
|
589
|
+
if (gap.kind === "unregistered-plugin") return formatUnregisteredPluginGap(gap);
|
|
590
|
+
if (gap.kind === "unowned-files") return formatUnownedFilesGap(gap);
|
|
591
|
+
return `⚠ coverage gap "${gap.kind}" — part of this workspace is outside what this run covered`;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* The unregistered-plugin gap: every manifest listed, because there are as
|
|
596
|
+
* many of them as there are polyglot projects and a reader acts on each one.
|
|
597
|
+
*
|
|
598
|
+
* @param {{manifests: string[]}} gap
|
|
599
|
+
* @returns {string}
|
|
600
|
+
*/
|
|
601
|
+
function formatUnregisteredPluginGap(gap) {
|
|
602
|
+
const count = gap.manifests.length;
|
|
603
|
+
const label = `${count} polyglot manifest${count === 1 ? "" : "s"}`;
|
|
604
|
+
const paths = gap.manifests.map((manifest) => `${CONTINUED}${manifest}`).join("\n");
|
|
605
|
+
return (
|
|
606
|
+
`⚠ nx.json does not register this plugin but ${label} ` +
|
|
607
|
+
`found under project roots — nx affected and ` +
|
|
608
|
+
`@nx/enforce-module-boundaries will not cover these edges\n${paths}\n` +
|
|
609
|
+
`${DETAIL}register the plugin: "plugins": [{ "plugin": "@ecoma-io/archkeep/nx" }]`
|
|
610
|
+
);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* The unowned-analyzable-files gap: tracked TypeScript, JavaScript or Vue
|
|
615
|
+
* files no project claims. They are skipped on purpose — Nx's own graph and
|
|
616
|
+
* `@nx/enforce-module-boundaries` already cover that language, which is why
|
|
617
|
+
* they are not failures and change no exit code
|
|
618
|
+
* (`../commands/context.mjs`'s `UNCLAIMED_CHECK_LANGUAGES`) — but "skipped"
|
|
619
|
+
* and "never mentioned" are different claims, and only the first one is
|
|
620
|
+
* true of this run.
|
|
621
|
+
*
|
|
622
|
+
* The count and the languages lead, because they are what a reader checks
|
|
623
|
+
* the clean line's own file count against; the paths follow, bounded.
|
|
624
|
+
*
|
|
625
|
+
* @param {{provider?: string, languages?: string[], files: string[]}} gap
|
|
626
|
+
* @returns {string}
|
|
627
|
+
*/
|
|
628
|
+
function formatUnownedFilesGap(gap) {
|
|
629
|
+
// `files` is read as defensively as the SARIF face reads it. `./check.mjs`
|
|
630
|
+
// contributes this entry only when the list is non-empty, but
|
|
631
|
+
// `formatCoverageGaps` is exported and a caller holding an entry with no
|
|
632
|
+
// `files` would otherwise render a section announcing zero files — a
|
|
633
|
+
// heading with nothing under it reads as a truncation, not as "none".
|
|
634
|
+
const files = gap.files ?? [];
|
|
635
|
+
const count = files.length;
|
|
636
|
+
const languages = gap.languages ?? [];
|
|
637
|
+
const spans = languages.length > 0 ? ` (${languages.join(", ")})` : "";
|
|
638
|
+
const shown = files.slice(0, UNOWNED_SAMPLE_LIMIT);
|
|
639
|
+
const remaining = count - shown.length;
|
|
640
|
+
const lines = shown.map((file) => `${CONTINUED}${file}`);
|
|
641
|
+
if (remaining > 0) {
|
|
642
|
+
lines.push(`${CONTINUED}… and ${remaining} more — the full list is in --format json`);
|
|
643
|
+
}
|
|
644
|
+
// The one place this face needs to know which project model it is
|
|
645
|
+
// describing: a reader is being told to declare a project, and the file
|
|
646
|
+
// that declares one is not the same file on the two providers.
|
|
647
|
+
const them = count === 1 ? "it" : "them";
|
|
648
|
+
const manifest = gap.provider === "moon" ? "moon.yml" : "project.json";
|
|
649
|
+
return (
|
|
650
|
+
`⚠ ${count} tracked analyzable file${count === 1 ? "" : "s"}${spans} ` +
|
|
651
|
+
`owned by no project — skipped, so no boundary verdict here covers ${them}\n` +
|
|
652
|
+
`${lines.join("\n")}\n` +
|
|
653
|
+
`${DETAIL}declare a project that owns ${them} (a ${manifest} under a directory that ` +
|
|
654
|
+
`contains ${them}), or leave ${them} outside the boundary system knowingly`
|
|
655
|
+
);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* The policy-identity line — which law this run enforced — rendered FIRST,
|
|
660
|
+
* ahead of every verdict below it: a reader has to know WHICH law produced a
|
|
661
|
+
* result before the result itself means anything. A violating tree under a
|
|
662
|
+
* weak policy and a clean tree under a strict one used to print the exact
|
|
663
|
+
* same "no boundary violations" sentence, with nothing anywhere in the report
|
|
664
|
+
* saying which law had run (P1-01, `../../../../AGENTS.md`'s empty-result
|
|
665
|
+
* invariant applied to the law itself, not only to the verdict).
|
|
666
|
+
*
|
|
667
|
+
* `policy` is `null`/absent only for a caller that has nothing to say about
|
|
668
|
+
* one — a unit test exercising some other section of this report. `cli.mjs`'s
|
|
669
|
+
* `check` always supplies it, because `check` always loads exactly one
|
|
670
|
+
* boundary law before it can judge anything; `profile` inside it is `null`,
|
|
671
|
+
* stated rather than omitted, on every run that is not profile-selected — the
|
|
672
|
+
* same "no fact, no claim" bargain `formatGoWork`/`formatTsconfigPaths` keep
|
|
673
|
+
* for a feature a workspace does not use either.
|
|
674
|
+
*
|
|
675
|
+
* @param {{profile: string|null, source: string, fingerprint: string}|null|undefined} policy
|
|
676
|
+
* @returns {string} Empty exactly when no policy identity was supplied.
|
|
677
|
+
*/
|
|
678
|
+
export function formatPolicy(policy) {
|
|
679
|
+
if (policy == null) return "";
|
|
680
|
+
const { profile, source, fingerprint } = policy;
|
|
681
|
+
const law = profile === null ? source : `profile "${profile}" from ${source}`;
|
|
682
|
+
return `policy ${law} — fingerprint ${fingerprint}`;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* The accepted-violations section: every violation an ACTIVE waiver covered,
|
|
687
|
+
* rendered with the waiver that accepts it and its expiry.
|
|
688
|
+
*
|
|
689
|
+
* A waived violation lives in `run.violations` (marked `waivedBy`) — it is
|
|
690
|
+
* still a violation, still counted, still exit-1 — and this formatter splits
|
|
691
|
+
* it out of the main list so a reader sees exactly what is being accepted and
|
|
692
|
+
* for how long. The evidence of the acceptance is the reason the waiver row
|
|
693
|
+
* carries.
|
|
694
|
+
*
|
|
695
|
+
* @param {object[]} waived Violations carrying `waivedBy`.
|
|
696
|
+
* @param {Set<string>} [unresolvedDecisionRefs] Forwarded to `formatViolation`.
|
|
697
|
+
* @returns {string}
|
|
698
|
+
*/
|
|
699
|
+
export function formatAcceptedViolations(waived, unresolvedDecisionRefs) {
|
|
700
|
+
const rows = waived.map((violation) => {
|
|
701
|
+
const waiver = violation.waivedBy;
|
|
702
|
+
return [
|
|
703
|
+
formatViolation(violation, unresolvedDecisionRefs),
|
|
704
|
+
`${DETAIL}waiver accepted until ${waiver.expiresAt}` +
|
|
705
|
+
(waiver.origin ? ` (origin: ${waiver.origin})` : ""),
|
|
706
|
+
`${DETAIL}reason ${waiver.reason}`,
|
|
707
|
+
].join("\n");
|
|
708
|
+
});
|
|
709
|
+
const count = waived.length;
|
|
710
|
+
return [
|
|
711
|
+
`⚠ accepted violations: ${count} boundary violation${count === 1 ? "" : "s"} waived until their ` +
|
|
712
|
+
`expiry — the boundary is still breached (the run stays non-zero), and each one below will ` +
|
|
713
|
+
`re-assert the moment its waiver lapses:`,
|
|
714
|
+
rows.join("\n\n"),
|
|
715
|
+
].join("\n\n");
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* The whole report, violations first.
|
|
720
|
+
*
|
|
721
|
+
* The summary states what was inspected and not only what was found, because
|
|
722
|
+
* "no violations" is a claim about coverage as much as about correctness: a run
|
|
723
|
+
* that analyzed nothing and a clean tree print the same sentence otherwise, and
|
|
724
|
+
* that indistinguishability is the defect this whole tool exists to end
|
|
725
|
+
* (`../../AGENTS.md`).
|
|
726
|
+
*
|
|
727
|
+
* Waived violations are still `violations` (the engine marks them `waivedBy`,
|
|
728
|
+
* it never removes them), so an all-waived run still renders non-zero — the
|
|
729
|
+
* "waiving must not flip exit 1 → 0" invariant, in the report layer. The
|
|
730
|
+
* summary line above only says "no boundary violations" when there is nothing
|
|
731
|
+
* a waiver is covering either.
|
|
732
|
+
*
|
|
733
|
+
* @param {{violations: object[], failures: object[], analyzed: number, projects: number, imports: number, goWork?: object|null, tsconfigPaths?: object|null, declaredEdges?: object|null, intent?: object|null, fitness?: object|null, fitnessOverall?: {verdict: string}|null, customRules?: {decisions: object[], overall: {verdict: string}}|null, coverageGaps?: object[], notes?: string[], policy?: {profile: string|null, source: string, fingerprint: string}|null, unresolvedDecisionRefs?: Set<string>}} run
|
|
734
|
+
* @returns {string}
|
|
735
|
+
*/
|
|
736
|
+
export function formatReport({
|
|
737
|
+
violations,
|
|
738
|
+
failures,
|
|
739
|
+
analyzed,
|
|
740
|
+
projects,
|
|
741
|
+
imports,
|
|
742
|
+
goWork,
|
|
743
|
+
tsconfigPaths,
|
|
744
|
+
declaredEdges,
|
|
745
|
+
intent,
|
|
746
|
+
fitness,
|
|
747
|
+
fitnessOverall,
|
|
748
|
+
customRules,
|
|
749
|
+
coverageGaps = [],
|
|
750
|
+
notes = [],
|
|
751
|
+
policy = null,
|
|
752
|
+
unresolvedDecisionRefs,
|
|
753
|
+
}) {
|
|
754
|
+
const inspected =
|
|
755
|
+
`${imports} import${imports === 1 ? "" : "s"} in ${analyzed} file${analyzed === 1 ? "" : "s"} ` +
|
|
756
|
+
`across ${projects} project${projects === 1 ? "" : "s"}` +
|
|
757
|
+
// `boundaryConfig`-dialect facts a reader needs alongside the count —
|
|
758
|
+
// today only the ESLint flat-config dialect ever populates this
|
|
759
|
+
// (`../eslint-config.mjs`'s `extractBoundaryRule`), e.g. which entry
|
|
760
|
+
// among several configuring the rule was binding.
|
|
761
|
+
(notes.length > 0 ? `; ${notes.join("; ")}` : "");
|
|
762
|
+
const sections = [];
|
|
763
|
+
|
|
764
|
+
const policySection = formatPolicy(policy);
|
|
765
|
+
if (policySection !== "") sections.push(policySection);
|
|
766
|
+
|
|
767
|
+
const waived = violations.filter((violation) => violation.waivedBy);
|
|
768
|
+
const live = violations.filter((violation) => !violation.waivedBy);
|
|
769
|
+
|
|
770
|
+
if (live.length > 0) {
|
|
771
|
+
sections.push(
|
|
772
|
+
live.map((violation) => formatViolation(violation, unresolvedDecisionRefs)).join("\n\n"),
|
|
773
|
+
);
|
|
774
|
+
const files = new Set(live.map((violation) => violation.sourceFile)).size;
|
|
775
|
+
sections.push(
|
|
776
|
+
`✖ ${live.length} boundary violation${live.length === 1 ? "" : "s"} ` +
|
|
777
|
+
`in ${files} file${files === 1 ? "" : "s"} (${inspected})`,
|
|
778
|
+
);
|
|
779
|
+
} else if (violations.length === 0) {
|
|
780
|
+
sections.push(`✔ no boundary violations (${inspected})`);
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
if (waived.length > 0) {
|
|
784
|
+
sections.push(formatAcceptedViolations(waived, unresolvedDecisionRefs));
|
|
785
|
+
// When every finding is waived the run is still NOT clean (exit 1), so the
|
|
786
|
+
// summary says so rather than letting the accepted section read as a clean
|
|
787
|
+
// tree. `waived` is the count word, deliberately not `!` — the finding is
|
|
788
|
+
// accepted, not an error a reader must chase.
|
|
789
|
+
if (live.length === 0) {
|
|
790
|
+
sections.push(
|
|
791
|
+
`✖ ${waived.length} boundary violation${waived.length === 1 ? "" : "s"} accepted (${inspected})`,
|
|
792
|
+
);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
const goWorkSection = formatGoWork(goWork);
|
|
797
|
+
if (goWorkSection !== "") sections.push(goWorkSection);
|
|
798
|
+
|
|
799
|
+
const tsconfigPathsSection = formatTsconfigPaths(tsconfigPaths);
|
|
800
|
+
if (tsconfigPathsSection !== "") sections.push(tsconfigPathsSection);
|
|
801
|
+
|
|
802
|
+
const declaredEdgesSection = formatDeclaredEdges(declaredEdges, unresolvedDecisionRefs);
|
|
803
|
+
if (declaredEdgesSection !== "") sections.push(declaredEdgesSection);
|
|
804
|
+
|
|
805
|
+
const intentSection = formatIntentSection(intent);
|
|
806
|
+
if (intentSection !== "") sections.push(intentSection);
|
|
807
|
+
|
|
808
|
+
const fitnessSection = formatFitnessSection(fitness ?? [], fitnessOverall ?? { verdict: "pass" });
|
|
809
|
+
if (fitnessSection !== "") sections.push(fitnessSection);
|
|
810
|
+
|
|
811
|
+
// After fitness, the order `../../cli.mjs`'s `check` judges them in: both
|
|
812
|
+
// are policy-declared verdict tables, and the custom rules are the ones the
|
|
813
|
+
// workspace wrote itself.
|
|
814
|
+
const customRulesSection = formatCustomRulesSection(customRules);
|
|
815
|
+
if (customRulesSection !== "") sections.push(customRulesSection);
|
|
816
|
+
|
|
817
|
+
const coverageGapsSection = formatCoverageGaps(coverageGaps);
|
|
818
|
+
if (coverageGapsSection !== "") sections.push(coverageGapsSection);
|
|
819
|
+
|
|
820
|
+
const unresolved = formatFailures(failures);
|
|
821
|
+
if (unresolved !== "") sections.push(unresolved);
|
|
822
|
+
return sections.join("\n\n");
|
|
823
|
+
}
|