@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,989 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `check` command: every import site judged against the boundary rules,
|
|
3
|
+
* folded together with every other finding class one verdict counts.
|
|
4
|
+
*
|
|
5
|
+
* `../../cli.mjs`'s `runCheck` owns argv, the output destination and the
|
|
6
|
+
* process's exit code; this module owns the computation and hands back the
|
|
7
|
+
* report and the counts — `./README.md`'s rule applied to the one command
|
|
8
|
+
* that predates it. `../../cli.mjs` re-exports `check` under its own name, so
|
|
9
|
+
* every importer that already reads it from there keeps working.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { statSync } from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
|
|
15
|
+
import { fileFailure, isWholeFileFailure } from "../analysis/source-util.mjs";
|
|
16
|
+
import { tsconfigPathsFacts } from "../analysis/typescript.mjs";
|
|
17
|
+
import { suppressionCovers } from "../config.mjs";
|
|
18
|
+
import { referenceTime } from "../governance/clock.mjs";
|
|
19
|
+
import { suppressionFate } from "../governance/waiver.mjs";
|
|
20
|
+
import { resolveCommandContext, unownedGapWithoutRunConfiguration } from "./context.mjs";
|
|
21
|
+
import { readAdrContext } from "./adr.mjs";
|
|
22
|
+
import { declaredFitnessNames, unresolvedDecisionRefRows } from "../governance/adr-registry.mjs";
|
|
23
|
+
import { declaredEdgeViolationsForCheck } from "./edge-constraints.mjs";
|
|
24
|
+
import { customRulesForCheck, declaresCustomRules } from "./custom-rules.mjs";
|
|
25
|
+
import { driftForCheck } from "./drift.mjs";
|
|
26
|
+
import { fitnessForCheck } from "./fitness.mjs";
|
|
27
|
+
import { computePolicyFingerprint } from "./graph.mjs";
|
|
28
|
+
import { resolvePolicy } from "./policy.mjs";
|
|
29
|
+
import { resolveProvenance } from "./provenance.mjs";
|
|
30
|
+
import { INTENT_FILE, loadIntent } from "../architecture-intent/model.mjs";
|
|
31
|
+
import { compareGoWork, parseGoWorkUse } from "../go-work.mjs";
|
|
32
|
+
import { jsonEnvelope, renderJson } from "../report/json.mjs";
|
|
33
|
+
import { formatSarif } from "../report/sarif.mjs";
|
|
34
|
+
import { formatReport } from "../report/text.mjs";
|
|
35
|
+
import { ARCHKEEP_MODEL_FILE } from "../providers/native/model.mjs";
|
|
36
|
+
import { evaluateRun, exemptResolvedFile } from "../rules/index.mjs";
|
|
37
|
+
import { orphanedNotDependOnTags, unmatchedConstraintRows } from "../rules/tags.mjs";
|
|
38
|
+
import { judgeTsconfigPaths } from "../tsconfig-paths.mjs";
|
|
39
|
+
import { verdictFor } from "../verdict.mjs";
|
|
40
|
+
import { listTrackedFiles } from "../workspace.mjs";
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A total order over violations, so a report's byte sequence is an invariant
|
|
44
|
+
* rather than an accident of git's index order.
|
|
45
|
+
*
|
|
46
|
+
* `listTrackedFiles` returns `git ls-files` output verbatim, and the analysis
|
|
47
|
+
* and rule layers preserve exactly that order through `evaluate()` — which
|
|
48
|
+
* makes byte-identity hold today only because git's index happens to sort
|
|
49
|
+
* paths. A filesystem or git version that stopped guaranteeing that order
|
|
50
|
+
* would silently reorder every report while the determinism contract
|
|
51
|
+
* (`docs/reference/json-output.md`) still claimed byte-identity. Sorting here —
|
|
52
|
+
* at the boundary between the engine and its reports — makes `(sourceFile,
|
|
53
|
+
* line, column, messageId)` the one place a reorder could come from, and
|
|
54
|
+
* plain `<` comparison means the ordering never varies with the invoking
|
|
55
|
+
* environment's collation (`../../../../AGENTS.md`, the localeCompare invariant).
|
|
56
|
+
*
|
|
57
|
+
* @param {object[]} violations `Violation` records from `../rules/`.
|
|
58
|
+
* @returns {object[]} A new array, sorted; the input is not mutated.
|
|
59
|
+
*/
|
|
60
|
+
export function sortViolations(violations) {
|
|
61
|
+
return [...violations].sort((a, b) => {
|
|
62
|
+
if (a.sourceFile !== b.sourceFile) return a.sourceFile < b.sourceFile ? -1 : 1;
|
|
63
|
+
if (a.line !== b.line) return a.line < b.line ? -1 : 1;
|
|
64
|
+
if (a.column !== b.column) return a.column < b.column ? -1 : 1;
|
|
65
|
+
if (a.messageId !== b.messageId) return a.messageId < b.messageId ? -1 : 1;
|
|
66
|
+
return 0;
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
/** Renderers for the two formats whose output is a report, not an envelope. */
|
|
70
|
+
const FORMATS = Object.freeze({ text: formatReport, sarif: formatSarif });
|
|
71
|
+
/**
|
|
72
|
+
* The per-project file each provider declares an edge in, and the field it
|
|
73
|
+
* spells the declaration with — the two facts a declared-edge finding needs to
|
|
74
|
+
* point somewhere real despite having no import site.
|
|
75
|
+
*
|
|
76
|
+
* One table rather than two lookups, because the file and the field are the
|
|
77
|
+
* same provider's vocabulary and a run that got one from Moon and the other
|
|
78
|
+
* from Nx would render a `moon.yml` blamed for an `implicitDependencies` it
|
|
79
|
+
* has no field for.
|
|
80
|
+
*
|
|
81
|
+
* - **Nx** declares it per-project, in that project's own `project.json`,
|
|
82
|
+
* under `implicitDependencies`.
|
|
83
|
+
* - **Moon** declares it per-project too, in that project's own `moon.yml`,
|
|
84
|
+
* under `dependsOn`. Moon calls such a dependency `explicit` — its own
|
|
85
|
+
* inverse of this package's word — and `../providers/moon.mjs`'s
|
|
86
|
+
* `edgeTypeFromScope` is where the two vocabularies are mapped onto each
|
|
87
|
+
* other; `docs/integrations/moon.md`'s "explicit → implicit" row and
|
|
88
|
+
* `../../e2e/moon.e2e.mjs` both drive the same `moon.yml`.
|
|
89
|
+
* - **native** carries no per-project file of its own: `archkeep.json`
|
|
90
|
+
* validates the row regardless of whether it sits in that file's
|
|
91
|
+
* `projects.declared` or in a tracked `project.json`
|
|
92
|
+
* (`../providers/native/discover.mjs`'s union of the two), so
|
|
93
|
+
* `archkeep.json` is the workspace's single source of truth that opted
|
|
94
|
+
* either one in — the same reasoning `coverage.exempt`'s attribution above
|
|
95
|
+
* uses. `perProject: false` is what says so.
|
|
96
|
+
*/
|
|
97
|
+
const DECLARED_EDGE_SITE = Object.freeze({
|
|
98
|
+
nx: { file: "project.json", field: "implicitDependencies", perProject: true },
|
|
99
|
+
moon: { file: "moon.yml", field: "dependsOn", perProject: true },
|
|
100
|
+
native: { file: ARCHKEEP_MODEL_FILE, field: "implicitDependencies", perProject: false },
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Which field name the run's provider calls a declared edge, for the one
|
|
105
|
+
* sentence the report writes about it (`../report/text.mjs`'s
|
|
106
|
+
* `formatDeclaredEdges`).
|
|
107
|
+
*
|
|
108
|
+
* A provider this table does not know falls back to the native row rather
|
|
109
|
+
* than to `undefined`: every provider that exists resolves here, and a name
|
|
110
|
+
* is only ever read as prose, so an unknown one is a wrong noun rather than a
|
|
111
|
+
* missing verdict. The verdict itself — the finding list — is unaffected.
|
|
112
|
+
*
|
|
113
|
+
* @param {string} provider
|
|
114
|
+
* @returns {string}
|
|
115
|
+
*/
|
|
116
|
+
function declaredEdgeField(provider) {
|
|
117
|
+
return (DECLARED_EDGE_SITE[provider] ?? DECLARED_EDGE_SITE.native).field;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Where a declared-edge finding's declaration lives, so the finding can point
|
|
122
|
+
* somewhere real despite having no import site.
|
|
123
|
+
*
|
|
124
|
+
* **The path has to be one the reader's checkout actually contains.** A
|
|
125
|
+
* non-Nx provider used to get `archkeep.json` unconditionally, which on a Moon
|
|
126
|
+
* workspace names a file that provably cannot be there: a Moon tree carrying
|
|
127
|
+
* `archkeep.json` is refused outright, exit 3, before any rule runs
|
|
128
|
+
* (`./context.mjs`'s `refusal(moonMarker, ARCHKEEP_MODEL_FILE)`).
|
|
129
|
+
* That is not only a confusing text line — GitHub's code scanning silently
|
|
130
|
+
* DROPS a SARIF result whose `uri` is not a real repository-relative path,
|
|
131
|
+
* which is exactly the failure `../custom-rules/host.mjs`'s
|
|
132
|
+
* `isWorkspaceRelative` refuses for a wasm rule's own findings, so a
|
|
133
|
+
* declared-edge violation reported through `--format sarif` disappeared with
|
|
134
|
+
* no error anywhere. Moon reaches this function on every hand-written
|
|
135
|
+
* `dependsOn` since #262 inverted the Moon `explicit`/`implicit` mapping (the
|
|
136
|
+
* comment here previously claimed it could not reach it at all), so that path
|
|
137
|
+
* is now the common one rather than a corner.
|
|
138
|
+
*
|
|
139
|
+
* The per-project half is built from the graph node's own `root` — the
|
|
140
|
+
* finding carries a project NAME, and the node is the only place its
|
|
141
|
+
* directory is known — the same derivation the Nx arm has always used.
|
|
142
|
+
*
|
|
143
|
+
* @param {{provider: string, graph: {nodes: object}}} commandContext
|
|
144
|
+
* @param {string} sourceProject
|
|
145
|
+
* @returns {string} A workspace-relative path.
|
|
146
|
+
*/
|
|
147
|
+
function declaredEdgeManifest({ provider, graph }, sourceProject) {
|
|
148
|
+
const site = DECLARED_EDGE_SITE[provider] ?? DECLARED_EDGE_SITE.native;
|
|
149
|
+
if (!site.perProject) return site.file;
|
|
150
|
+
const root = graph.nodes[sourceProject]?.data?.root;
|
|
151
|
+
// A root of `.` (or `""`, or a trailing slash) is the workspace root
|
|
152
|
+
// itself — Moon spells a root-level project's `source` exactly that way
|
|
153
|
+
// (`../providers/moon.mjs`'s `inferWorkspaceLayout` names the same
|
|
154
|
+
// spelling) — and `./project.json` is a different string from
|
|
155
|
+
// `project.json` to every consumer that compares paths, this file's own
|
|
156
|
+
// SARIF `uri` included.
|
|
157
|
+
const scoped = typeof root === "string" ? root.replace(/\/+$/u, "") : "";
|
|
158
|
+
return scoped === "" || scoped === "." ? site.file : `${scoped}/${site.file}`;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Runs one `check`: workspace, analysis, rules, report.
|
|
162
|
+
*
|
|
163
|
+
* Returns the report and the counts rather than printing, so the caller owns
|
|
164
|
+
* both the destination and the exit code — and so a test can read the verdict
|
|
165
|
+
* without a subprocess.
|
|
166
|
+
*
|
|
167
|
+
* The workspace/provider/analysis preamble is `./context.mjs`'s
|
|
168
|
+
* `resolveCommandContext` — this function's whole body used to BE that
|
|
169
|
+
* preamble, before a second command existed to need it too. What is still
|
|
170
|
+
* this function's own: loading the boundary policy, the go.work drift check,
|
|
171
|
+
* the tsconfig paths hygiene check, judging the rules, and rendering the
|
|
172
|
+
* report in whichever format was asked for.
|
|
173
|
+
*
|
|
174
|
+
* `readGraph` and `listFiles` are the two seams that reach outside this
|
|
175
|
+
* process — Nx and git — threaded straight through to `resolveCommandContext`.
|
|
176
|
+
* Injectable for the same reason every resolver in this project takes its
|
|
177
|
+
* readers: a test drives the real analysis, the real rules and the real
|
|
178
|
+
* report over a fixture tree, and pins the exact `file:line:column` a
|
|
179
|
+
* developer would act on, without an Nx installation or a git repository.
|
|
180
|
+
*
|
|
181
|
+
* @param {{format: string, config: string|null, paths: string[],
|
|
182
|
+
* evidenceOut?: string|null}} options
|
|
183
|
+
* @param {{cwd: string, readGraph?: Function, listFiles?: Function}} context
|
|
184
|
+
* @returns {Promise<{report: string, violations: number, declaredEdgeFindings: number,
|
|
185
|
+
* goWorkDrift: number, tsconfigPathsDead: number, intentFindings: number,
|
|
186
|
+
* intentUnresolved: number, intentUnresolvedDecisionRefs: number, fitnessFail: number,
|
|
187
|
+
* fitnessUnknown: number, customRuleFail: number, customRuleUnknown: number,
|
|
188
|
+
* customRuleEvidence: {rule: string, bytes: Uint8Array}[], customRulesDeclared: boolean,
|
|
189
|
+
* analyzed: number, unchecked: number, waived?: number}>}
|
|
190
|
+
*/
|
|
191
|
+
export async function check(options, { cwd, readGraph, listFiles = listTrackedFiles }) {
|
|
192
|
+
const commandContext = resolveCommandContext(
|
|
193
|
+
{ cwd, paths: options.paths },
|
|
194
|
+
{ readGraph, listFiles },
|
|
195
|
+
);
|
|
196
|
+
const { root, graph, workspace, tracked } = commandContext;
|
|
197
|
+
const { imports, exemptedFiles } = commandContext.analysis;
|
|
198
|
+
const failures = [...commandContext.analysis.failures];
|
|
199
|
+
const analyzed = commandContext.analysis.analyzed;
|
|
200
|
+
|
|
201
|
+
// The config's location is a separate fact from the workspace root, which is
|
|
202
|
+
// why `--config` does not move the root: pointed at a consumer's tree, the
|
|
203
|
+
// tool and the law it enforces are in different trees, and the tree being
|
|
204
|
+
// judged is still the consumer's. Loaded before the three workspace-level
|
|
205
|
+
// checks below (go.work drift, dead tsconfig path aliases, and the declared
|
|
206
|
+
// `implicitDependencies` edges), the same order `check` has always used — a
|
|
207
|
+
// malformed `--config` stops the run before any of them runs at all.
|
|
208
|
+
// `resolvePolicy` owns the profile/file/inline priority — see its own doc for the order and
|
|
209
|
+
// why a `profiles` registry, when named, takes `--config`/`boundaryConfig`
|
|
210
|
+
// over as a profile NAME rather than a filename. `check` is the one caller
|
|
211
|
+
// of the eleven that also needs to know WHICH profile/file resolved it —
|
|
212
|
+
// `profile`/`source` — so its report can name the law that governed the run
|
|
213
|
+
// (P1-01): a violating tree under a weak policy and a clean tree under a
|
|
214
|
+
// strict one used to produce byte-identical output, with nothing anywhere in
|
|
215
|
+
// the report saying which law had run. `profile` is `null` on every branch
|
|
216
|
+
// but the profile one — stated, not omitted, the same "no fact, no claim"
|
|
217
|
+
// bargain `goWork`/`tsconfigPaths` keep below for a feature a workspace does
|
|
218
|
+
// not use either. `source` is always workspace-relative, the convention
|
|
219
|
+
// every other file reference in this report already keeps (`sourceFile`,
|
|
220
|
+
// `tsConfig`, intent's `file`).
|
|
221
|
+
const {
|
|
222
|
+
config,
|
|
223
|
+
profile: policyProfile,
|
|
224
|
+
source: policySource,
|
|
225
|
+
} = await resolvePolicy(options, commandContext, cwd);
|
|
226
|
+
// D-10: provenance is resolved once, up front — BEFORE any verdict — and
|
|
227
|
+
// reused in the JSON envelope, so a commitless repository (an unborn HEAD,
|
|
228
|
+
// which makes `git ls-files` report zero files) is a loud exit-3 could-not-
|
|
229
|
+
// look in BOTH report formats instead of a quiet "0 imports in 0 files"
|
|
230
|
+
// claim. `resolveProvenance` throws for that state; `null` here means "git
|
|
231
|
+
// not available or not a repository at all", a legitimate no-origin-claim.
|
|
232
|
+
const provenance = resolveProvenance(root);
|
|
233
|
+
// The policy's own fingerprint, alongside its source — the SHA-256 of the
|
|
234
|
+
// canonicalized policy (`depConstraints`/`options`/`suppressions`) that
|
|
235
|
+
// `graph`/`diff`/`history` already share (`computePolicyFingerprint`,
|
|
236
|
+
// `./graph.mjs`), reused here rather than recomputed by hand so
|
|
237
|
+
// two runs under the identical effective policy always agree, whichever of
|
|
238
|
+
// `resolvePolicy`'s branches produced it. Unlike `graph`'s own optional
|
|
239
|
+
// `result.policy` — absent when no config was given — `check` always loads
|
|
240
|
+
// exactly one policy before it can judge anything, so `policy` is `null`
|
|
241
|
+
// only on the one defensive arm `resolvePolicy` itself documents as
|
|
242
|
+
// unreachable by any current provider.
|
|
243
|
+
const policy = config
|
|
244
|
+
? {
|
|
245
|
+
profile: policyProfile,
|
|
246
|
+
source: policySource,
|
|
247
|
+
fingerprint: computePolicyFingerprint(config),
|
|
248
|
+
}
|
|
249
|
+
: null;
|
|
250
|
+
|
|
251
|
+
// The go.work drift check, keyed off the manifest's presence the way every
|
|
252
|
+
// resolver keys off its language's manifest: no tracked root go.work, no
|
|
253
|
+
// check and no mention. It ignores `options.paths` on purpose — two
|
|
254
|
+
// workspace facts are compared, not files analyzed — and a go.work the
|
|
255
|
+
// parser cannot read becomes a whole-file failure (exit 3) rather than a
|
|
256
|
+
// truncated use list, because a use list cut short at the malformed line
|
|
257
|
+
// would hide every stale entry below it while inventing missing-use
|
|
258
|
+
// findings above it — a verdict about a file that was never read
|
|
259
|
+
// (`../go-work.mjs`).
|
|
260
|
+
let goWork = null;
|
|
261
|
+
if (tracked.includes("go.work")) {
|
|
262
|
+
try {
|
|
263
|
+
const goWorkText = workspace.readFile("go.work");
|
|
264
|
+
if (goWorkText === null) throw new Error("go.work could not be read");
|
|
265
|
+
goWork = compareGoWork({
|
|
266
|
+
uses: parseGoWorkUse(goWorkText),
|
|
267
|
+
workspaceRoot: root,
|
|
268
|
+
projects: workspace.projects,
|
|
269
|
+
files: tracked,
|
|
270
|
+
});
|
|
271
|
+
} catch (cause) {
|
|
272
|
+
failures.push(
|
|
273
|
+
fileFailure(
|
|
274
|
+
"go.work",
|
|
275
|
+
`${cause?.message ?? cause} — a go.work this tool cannot read is a coverage hole, ` +
|
|
276
|
+
`not an empty use list, so the drift check reached no verdict`,
|
|
277
|
+
),
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// The tsconfig paths hygiene check, keyed the same way: no `paths` table in
|
|
283
|
+
// the workspace tsconfig — or no tsconfig at all — means no check and no
|
|
284
|
+
// mention. The table, its base and the failure posture all come from the
|
|
285
|
+
// resolver's own parsed context (`tsconfigPathsFacts`), so the file judged
|
|
286
|
+
// here is provably the file `ts.resolveModuleName` reads, and a tsconfig
|
|
287
|
+
// that failed to load is a whole-file failure (exit 3) here exactly as it is
|
|
288
|
+
// at every TypeScript import site — never an absent table. Only existence is
|
|
289
|
+
// asked of the filesystem, because the judgement is about directories on
|
|
290
|
+
// disk, the same disk the resolver probes (`../tsconfig-paths.mjs` owns the
|
|
291
|
+
// rule and its limits). Like go.work, `options.paths` is ignored on purpose:
|
|
292
|
+
// a workspace fact is judged, not files analyzed.
|
|
293
|
+
let tsconfigPaths = null;
|
|
294
|
+
{
|
|
295
|
+
const facts = tsconfigPathsFacts(workspace);
|
|
296
|
+
if (facts.configFailure !== null) {
|
|
297
|
+
failures.push(
|
|
298
|
+
fileFailure(
|
|
299
|
+
facts.tsConfig,
|
|
300
|
+
`${facts.configFailure} — and the paths hygiene check reached no verdict, because a ` +
|
|
301
|
+
`tsconfig this tool cannot load is a coverage hole, not an empty alias table`,
|
|
302
|
+
),
|
|
303
|
+
);
|
|
304
|
+
} else if (facts.paths !== undefined) {
|
|
305
|
+
tsconfigPaths = judgeTsconfigPaths({
|
|
306
|
+
paths: facts.paths,
|
|
307
|
+
base: facts.base,
|
|
308
|
+
workspaceRoot: root,
|
|
309
|
+
tsConfig: facts.tsConfig,
|
|
310
|
+
directoryExists: (dir) => {
|
|
311
|
+
try {
|
|
312
|
+
return statSync(join(root, dir)).isDirectory();
|
|
313
|
+
} catch {
|
|
314
|
+
return false;
|
|
315
|
+
}
|
|
316
|
+
},
|
|
317
|
+
});
|
|
318
|
+
for (const { reason } of tsconfigPaths.malformed) {
|
|
319
|
+
failures.push(fileFailure(facts.tsConfig, reason));
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// The architecture-intent check, keyed the same way as go.work and tsconfig:
|
|
325
|
+
// a tracked root `architecture-intent.json` means the workspace declared the
|
|
326
|
+
// architecture it intends, and the observed graph is judged against it. An
|
|
327
|
+
// intent file that fails to parse or validate is a no-verdict (exit 3) on
|
|
328
|
+
// the intent axis rather than "no intent" — a declaration this tool cannot
|
|
329
|
+
// establish must never read as one that was verified. It is NOT folded into
|
|
330
|
+
// `failures`: that bucket means "a source file the analysis could not read",
|
|
331
|
+
// and counting `architecture-intent.json` there would flip
|
|
332
|
+
// `coverage.complete` and list it among `notAnalyzed` source holes, which a
|
|
333
|
+
// reader would misread as a coverage problem with source files (the design
|
|
334
|
+
// contract `docs/reference/architecture-intent.md` states: a malformed
|
|
335
|
+
// intent renders as a distinct line, never as "N files could not be
|
|
336
|
+
// analyzed"). It rides `intentUnresolved` instead, the same no-verdict lane
|
|
337
|
+
// a zero-member boundary takes.
|
|
338
|
+
/** @type {{verdict: "ok"|"findings"|"no-verdict", findings: object[], unresolved: object[], boundaries: object[], notes: object[], unresolvedDecisionRefs?: {kind: string, decisionRef: string}[]}|null} */
|
|
339
|
+
let intent = null;
|
|
340
|
+
// The intent rows that carry a `decisionRef`, for `check`'s own citation
|
|
341
|
+
// pass — empty when no intent is tracked, and populated by the fold below
|
|
342
|
+
// (F01: an intent row citing nothing the registry knows must be surfaced
|
|
343
|
+
// the way `drift`/`provenance` surface it, not left silent).
|
|
344
|
+
let intentDecisionRefRows = [];
|
|
345
|
+
if (tracked.includes(INTENT_FILE)) {
|
|
346
|
+
try {
|
|
347
|
+
// The drift fold runs the same refuse-incomplete-graph guard the `drift`
|
|
348
|
+
// command does: an Nx workspace whose polyglot manifests are invisible to
|
|
349
|
+
// the graph must not have its intent judged against a graph that cannot
|
|
350
|
+
// see them. `driftForCheck` is what `drift` and `check` share.
|
|
351
|
+
const drift = await driftForCheck(commandContext, {
|
|
352
|
+
loadIntentOverride: (root) => loadIntent(root, { tracked }),
|
|
353
|
+
});
|
|
354
|
+
intent = {
|
|
355
|
+
verdict:
|
|
356
|
+
drift.findings.length > 0
|
|
357
|
+
? "findings"
|
|
358
|
+
: drift.unresolved.length > 0
|
|
359
|
+
? "no-verdict"
|
|
360
|
+
: "ok",
|
|
361
|
+
boundaries: drift.boundaries,
|
|
362
|
+
findings: drift.findings,
|
|
363
|
+
unresolved: drift.unresolved,
|
|
364
|
+
notes: drift.notes,
|
|
365
|
+
};
|
|
366
|
+
// The fold's intent rows carrying a `decisionRef` — the rows `drift`/
|
|
367
|
+
// `provenance` judge loudly, and which `check` must not leave silent
|
|
368
|
+
// (F01). Captured here, outside the `intent` object, so the citation
|
|
369
|
+
// pass below can judge them through the same registry as the boundary
|
|
370
|
+
// rows.
|
|
371
|
+
intentDecisionRefRows = drift.decisionRefRows;
|
|
372
|
+
} catch (cause) {
|
|
373
|
+
const reason =
|
|
374
|
+
`${cause?.message ?? cause} — architecture-intent.json could not be ` +
|
|
375
|
+
`established, so the intent check reached no verdict`;
|
|
376
|
+
intent = {
|
|
377
|
+
verdict: "no-verdict",
|
|
378
|
+
findings: [],
|
|
379
|
+
unresolved: [{ boundary: INTENT_FILE, issue: reason }],
|
|
380
|
+
boundaries: [],
|
|
381
|
+
notes: [],
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Both faces of one walk: the run's verdict, and the raw superset it was
|
|
387
|
+
// picked from — every candidate up to each site's surviving group, including
|
|
388
|
+
// the verdicts the suppression table removed to get there. `evaluate` alone
|
|
389
|
+
// cannot answer whether a row is dead, because a row that removes everything
|
|
390
|
+
// leaves no trace in the verdict; the raw side of this walk is the trace.
|
|
391
|
+
// The reference instant is fixed once here and threaded into the walk, so
|
|
392
|
+
// the gate's waiver-expiry judgement below and the engine's are the same
|
|
393
|
+
// judgement, not two reads of the clock a boundary instant could split.
|
|
394
|
+
const now = referenceTime();
|
|
395
|
+
const { violations: judged, rawViolations } = evaluateRun(imports, graph, { ...config, now });
|
|
396
|
+
const violations = sortViolations(judged);
|
|
397
|
+
// An ACTIVE waiver keeps the violation it accepts in the findings list,
|
|
398
|
+
// marked `waivedBy` — the run is still non-zero (waiving does not flip
|
|
399
|
+
// exit 1 → 0), and this count is the additive "accepted violations" number
|
|
400
|
+
// the report surfaces. Expired waivers re-assert in full (evidence
|
|
401
|
+
// "expired waiver"), so they are ordinary violations here, never waived.
|
|
402
|
+
const waived = violations.filter((violation) => violation.waivedBy).length;
|
|
403
|
+
|
|
404
|
+
// `evaluate()` above judges only import sites, by design (`../rules/README.md`:
|
|
405
|
+
// "analysis records and the loaded config, nothing else"). An `implicit`-typed
|
|
406
|
+
// graph edge — `implicitDependencies` in a `project.json`/`archkeep.json` row —
|
|
407
|
+
// has no import site behind it and so never reaches that loop, which used to
|
|
408
|
+
// mean `check` reported nothing about it while `context`/`impact` (walking
|
|
409
|
+
// `graph.dependencies` directly through the same `judgeEdge`) showed it as a
|
|
410
|
+
// tag violation: an empty result that was not actually a clean verdict, the
|
|
411
|
+
// exact defect this project's invariant forbids (`../../../../AGENTS.md`). Reusing
|
|
412
|
+
// `judgeEdge` here — rather than a second implementation — is what guarantees
|
|
413
|
+
// `check` can never disagree with what those commands already display for the
|
|
414
|
+
// identical edge.
|
|
415
|
+
const implicitEdges = Object.values(graph.dependencies ?? {})
|
|
416
|
+
.flat()
|
|
417
|
+
.filter((edge) => edge.type === "implicit").length;
|
|
418
|
+
const declaredEdgeViolations = declaredEdgeViolationsForCheck(graph, config.depConstraints).map(
|
|
419
|
+
(violation) => ({ ...violation, file: declaredEdgeManifest(commandContext, violation.source) }),
|
|
420
|
+
);
|
|
421
|
+
const declaredEdgeFindings = declaredEdgeViolations.length;
|
|
422
|
+
// `null` — not `{checked: true, findings: []}` — when the graph has no
|
|
423
|
+
// `implicit` edges at all: the same "no fact, no claim" bargain go.work and
|
|
424
|
+
// tsconfig-paths state for a workspace that never uses the feature either,
|
|
425
|
+
// so a workspace with no `implicitDependencies` anywhere pays nothing and
|
|
426
|
+
// hears nothing, rather than a near-universal "0 implicit edges" line.
|
|
427
|
+
// `declaration` names the field the RUN's provider spells a declared edge
|
|
428
|
+
// with, so the report's own sentence about it can too — a Moon workspace has
|
|
429
|
+
// no `implicitDependencies` field for the text to blame, and naming one sends
|
|
430
|
+
// a reader looking for a key `moon.yml` does not accept. Report-only: it
|
|
431
|
+
// rides beside the verdict, never into it, and the JSON envelope below still
|
|
432
|
+
// publishes exactly `judged` and `findings`.
|
|
433
|
+
const declaredEdges =
|
|
434
|
+
implicitEdges === 0
|
|
435
|
+
? null
|
|
436
|
+
: {
|
|
437
|
+
findings: declaredEdgeViolations,
|
|
438
|
+
judged: implicitEdges,
|
|
439
|
+
declaration: declaredEdgeField(commandContext.provider),
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
// A `depConstraints` row's `decisionRef` names the ADR (or rule/fitness id)
|
|
443
|
+
// that supposedly authorizes it — but nothing verified that citation before
|
|
444
|
+
// it reached a report: `rowSchemaViolations` (`../governance/row-schema.mjs`)
|
|
445
|
+
// has always had a resolution half gated on an injected `io.resolve`, and
|
|
446
|
+
// `config.mjs` has never supplied one, so `resolveDecisionRef`
|
|
447
|
+
// (`../governance/adr-registry.mjs`) had zero production callers. A rule
|
|
448
|
+
// bound to a nonexistent ADR id fired exactly as designed and the report
|
|
449
|
+
// quoted the citation as if it were confirmed. Checked only when a row
|
|
450
|
+
// actually carries one — the common case (no `docs/adr/` adopted yet) pays
|
|
451
|
+
// no extra read. Report-only: an unresolved citation is a fact about the
|
|
452
|
+
// rule's DOCUMENTATION, not about whether the boundary holds, so it changes
|
|
453
|
+
// no byte of `verdictFor`'s inputs below — the same posture `provenance`
|
|
454
|
+
// already takes for a row with no `origin` (`./provenance-command.mjs`).
|
|
455
|
+
//
|
|
456
|
+
// The intent's rows are judged through the SAME registry, in the SAME pass,
|
|
457
|
+
// so `check` cannot disagree with `drift`/`provenance` about which intent
|
|
458
|
+
// citations resolve. Unlike the boundary rows, an intent row with an
|
|
459
|
+
// unresolved `decisionRef` folds into the no-verdict lane (exit 3) WHEN THE
|
|
460
|
+
// INTENT IS APPLIED — a workspace that declared an intended architecture
|
|
461
|
+
// whose governing decision does not exist cannot claim `ok` on that axis
|
|
462
|
+
// (`drift`/`provenance` already flag the identical row loudly). That is the
|
|
463
|
+
// F01 parity the audit names: `drift` and `provenance` both surface it, and
|
|
464
|
+
// the gate CI runs must not be the one face that stays silent.
|
|
465
|
+
const unresolvedDecisionRefs = new Set();
|
|
466
|
+
const intentUnresolvedDecisionRefRows = [];
|
|
467
|
+
{
|
|
468
|
+
// Intent rows while the intent is actually tracked — the same gate the
|
|
469
|
+
// fold above uses, so a workspace with no intent pays nothing and hears
|
|
470
|
+
// nothing.
|
|
471
|
+
const decisionRefRows = [
|
|
472
|
+
...config.depConstraints
|
|
473
|
+
.map((row, index) => ({ kind: `depConstraints[${index}]`, row }))
|
|
474
|
+
.filter(({ row }) => typeof row?.decisionRef === "string" && row.decisionRef.trim() !== ""),
|
|
475
|
+
...intentDecisionRefRows,
|
|
476
|
+
];
|
|
477
|
+
if (decisionRefRows.length > 0) {
|
|
478
|
+
const adrContext = readAdrContext(root, { tracked });
|
|
479
|
+
// F04: the fitness half resolves against the ids the loaded policy
|
|
480
|
+
// DECLARES (`declaredFitnessNames(config)`), never the ADRs' own
|
|
481
|
+
// `bindings` — a citation cannot resolve itself.
|
|
482
|
+
for (const row of unresolvedDecisionRefRows(
|
|
483
|
+
decisionRefRows,
|
|
484
|
+
adrContext.byId,
|
|
485
|
+
declaredFitnessNames(config),
|
|
486
|
+
)) {
|
|
487
|
+
unresolvedDecisionRefs.add(row.decisionRef);
|
|
488
|
+
if (row.kind.startsWith("depConstraints")) continue;
|
|
489
|
+
intentUnresolvedDecisionRefRows.push({ kind: row.kind, decisionRef: row.decisionRef });
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
// The unresolved intent citations ride the intent object so the text
|
|
493
|
+
// report can render them inline (the same UNRESOLVED note the constraint
|
|
494
|
+
// rows get), and so the JSON intent block names WHICH rows cited what.
|
|
495
|
+
if (intent !== null && intentUnresolvedDecisionRefRows.length > 0) {
|
|
496
|
+
intent.unresolvedDecisionRefs = intentUnresolvedDecisionRefRows;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const goWorkDrift = goWork === null ? 0 : goWork.findings.length;
|
|
501
|
+
const tsconfigPathsDead = tsconfigPaths === null ? 0 : tsconfigPaths.findings.length;
|
|
502
|
+
const intentFindings = intent === null ? 0 : intent.findings.length;
|
|
503
|
+
const intentUnresolved = intent === null ? 0 : intent.unresolved.length;
|
|
504
|
+
|
|
505
|
+
// The fitness fold, keyed the same two ways every governance axis is: a
|
|
506
|
+
// policy that declares fitness (the `fitness` export on the boundary
|
|
507
|
+
// config) is judged against the run's own facts — the same graph, the same
|
|
508
|
+
// analysis, the same intent verdict, the same suppressions `check` already
|
|
509
|
+
// holds. Absence is a workspace decision (the key is omitted, never
|
|
510
|
+
// `null`); presence without a number of declared rows is impossible (the
|
|
511
|
+
// validator refuses an empty list). A declared function whose match selects
|
|
512
|
+
// no project is `skipped` — loud, never folded into `pass`; a function the
|
|
513
|
+
// run could not determine is `unknown`, which rides `fitnessUnknown`, the
|
|
514
|
+
// same no-verdict lane a zero-member boundary takes. `scoped` marks the
|
|
515
|
+
// path-scoped case (`check <path>`), where coverage over a matched
|
|
516
|
+
// project's whole file set is not determinable — the registry answers that
|
|
517
|
+
// `not_applicable`, never a partial-number verdict and never `unknown`
|
|
518
|
+
// either (P1-19): `coverage-minimum` cannot be judged by ANY scoped run,
|
|
519
|
+
// which is a fact about the run mode, not evidence of a coverage hole, so it
|
|
520
|
+
// joins `skipped` in NOT riding `fitnessFail`/`fitnessUnknown` below — a
|
|
521
|
+
// scoped run over an otherwise-clean subtree in a workspace that declares
|
|
522
|
+
// `coverage-minimum` no longer exits 3 for a question this run was never in
|
|
523
|
+
// a position to answer.
|
|
524
|
+
let fitness = null;
|
|
525
|
+
if (config.fitness !== undefined) {
|
|
526
|
+
const { decisions, overall } = fitnessForCheck(commandContext, {
|
|
527
|
+
rows: config.fitness,
|
|
528
|
+
intent,
|
|
529
|
+
suppressions: config.suppressions,
|
|
530
|
+
scoped: options.paths.length > 0,
|
|
531
|
+
});
|
|
532
|
+
fitness = { decisions, overall };
|
|
533
|
+
}
|
|
534
|
+
const fitnessFail = fitness === null ? 0 : fitness.overall.verdict === "fail" ? 1 : 0;
|
|
535
|
+
const fitnessUnknown = fitness === null ? 0 : fitness.overall.verdict === "unknown" ? 1 : 0;
|
|
536
|
+
|
|
537
|
+
// The custom-rules fold, keyed by presence exactly as the fitness fold above
|
|
538
|
+
// is, and judged after it over the same observed facts — the same graph, the
|
|
539
|
+
// same analysis, the same policy. A workspace that declares no `customRules`
|
|
540
|
+
// reaches nothing here and hears nothing anywhere: no section, no envelope
|
|
541
|
+
// key, no SARIF descriptor, byte-for-byte the report it already got
|
|
542
|
+
// (`../../../../AGENTS.md`, "a change to what is reported on an unchanged
|
|
543
|
+
// workspace is a breaking change").
|
|
544
|
+
//
|
|
545
|
+
// Every load-class failure THROWS out of here rather than becoming a verdict
|
|
546
|
+
// — the declared law could not be read, so the run refuses the way it
|
|
547
|
+
// refuses a malformed boundary config, and `runCheck` below turns that into
|
|
548
|
+
// the same exit 3 (`./custom-rules.mjs` argues the split). The
|
|
549
|
+
// counts below then carry only what a loaded law decided: a `fail` is a
|
|
550
|
+
// finding (exit 1) and an `unknown` is a could-not-determine (exit 3), the
|
|
551
|
+
// identical two lanes fitness rides, per RULE rather than per aggregate
|
|
552
|
+
// because each declared rule is its own law and a reader acts on the one
|
|
553
|
+
// that failed.
|
|
554
|
+
let customRules = null;
|
|
555
|
+
if (declaresCustomRules(config)) {
|
|
556
|
+
customRules = await customRulesForCheck(commandContext, {
|
|
557
|
+
rows: config.customRules,
|
|
558
|
+
policy: config,
|
|
559
|
+
scoped: options.paths.length > 0,
|
|
560
|
+
// Off unless `--evidence-out` asked: the bundle carries every import
|
|
561
|
+
// site in the tree, and a run nobody asked to inspect should not hold
|
|
562
|
+
// one per rule in memory.
|
|
563
|
+
collectEvidence: Boolean(options.evidenceOut),
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
const customRuleDecisions = customRules === null ? [] : customRules.decisions;
|
|
567
|
+
const customRuleFail = customRuleDecisions.filter((rule) => rule.verdict === "fail").length;
|
|
568
|
+
const customRuleUnknown = customRuleDecisions.filter((rule) => rule.verdict === "unknown").length;
|
|
569
|
+
|
|
570
|
+
// Files the run produced no verdict about, counted here rather than
|
|
571
|
+
// recomputed by the caller: the exit code, the text report and the JSON
|
|
572
|
+
// envelope must all agree about which failures mean "not covered", and one
|
|
573
|
+
// predicate is how they do.
|
|
574
|
+
const unchecked = new Set(
|
|
575
|
+
failures.filter(isWholeFileFailure).map((failure) => failure.sourceFile),
|
|
576
|
+
).size;
|
|
577
|
+
|
|
578
|
+
// A row of the boundary law that covers nothing is a boundary that stopped
|
|
579
|
+
// being enforced, and — unlike a missing `reason`, which only a human can
|
|
580
|
+
// judge — it is machine-detectable. Two tables can be dead, and both are
|
|
581
|
+
// refused here with the same exit and the same sentence shape the stale
|
|
582
|
+
// `coverage.exempt` row has always gotten (`../providers/native/index.mjs`):
|
|
583
|
+
//
|
|
584
|
+
// - a `boundarySuppressions` row covering no candidate violation, measured
|
|
585
|
+
// against `rawViolations` above (the candidates up to each site's
|
|
586
|
+
// surviving group, so a row covering a verdict ANOTHER row removed first
|
|
587
|
+
// still counts as alive). Waiver rows still in force are excluded: their
|
|
588
|
+
// lifecycle is the `waivers` command's informational surface, which has
|
|
589
|
+
// always reported a term-bearing row that currently covers nothing as
|
|
590
|
+
// stale rather than fatal — a fixed violation leaves its waiver idle until
|
|
591
|
+
// expiry, and that resting state is the feature working, not a defect. An
|
|
592
|
+
// EXPIRED waiver (`fate === "reassert"`) is the opposite state: its term
|
|
593
|
+
// has lapsed, it can never come back into force without an edit, and it
|
|
594
|
+
// sits in the table forever — exactly as dead as a permanent row, refused
|
|
595
|
+
// here with it, named with its lapse date.
|
|
596
|
+
// - a `depConstraints` row selecting no project as its source
|
|
597
|
+
// (`unmatchedConstraintRows`), which approves everything on its axis while
|
|
598
|
+
// reading as enforced. This half applies to law the workspace WROTE — a
|
|
599
|
+
// filename at its root, an inline policy, a registry it keeps in the tree.
|
|
600
|
+
// A profile resolved from inside a dependency install (`node_modules`) is
|
|
601
|
+
// exempt: a shipped policy pack is data adopted wholesale
|
|
602
|
+
// (`docs/usage/presets.md` — "a pack saves you the blank page"), written
|
|
603
|
+
// for trees that instantiate its style at their own pace, so holding its
|
|
604
|
+
// rows to this tree's tag vocabulary would refuse every partial adoption —
|
|
605
|
+
// the false-positive direction. A rename under an adopted pack is still
|
|
606
|
+
// loud for the reason that feature already ships: the renamed projects
|
|
607
|
+
// stop matching any row, and the no-matching-constraint-is-an-error rule
|
|
608
|
+
// reports them on their first import (`../rules/tags.mjs`'s header).
|
|
609
|
+
//
|
|
610
|
+
// Both verdicts fire only where they are KNOWABLE — a whole-workspace run
|
|
611
|
+
// over a tree this run fully analyzed. A path-scoped run judges part of the
|
|
612
|
+
// tree, and files whose analysis failed contribute no candidates at all;
|
|
613
|
+
// either way a row covering nothing in what this run saw may cover plenty in
|
|
614
|
+
// what it did not (a scoped run over a half-adopted style pack being the
|
|
615
|
+
// everyday case for the constraint half), and refusing there would be the
|
|
616
|
+
// false-positive direction. The language server's per-file path never
|
|
617
|
+
// reaches this function, so it needs no guard of its own. The constraint
|
|
618
|
+
// verdict needs only the graph, which scoping never narrows, but it rides
|
|
619
|
+
// the same gate rather than a second one — one dialect, one condition pair,
|
|
620
|
+
// stated once (`../../../../AGENTS.md`, "Never state a rule twice").
|
|
621
|
+
if (
|
|
622
|
+
config !== null &&
|
|
623
|
+
options.paths.length === 0 &&
|
|
624
|
+
failures.length === 0 &&
|
|
625
|
+
(config.suppressions.length > 0 || config.depConstraints.length > 0)
|
|
626
|
+
) {
|
|
627
|
+
const deadRows = [];
|
|
628
|
+
config.suppressions.forEach((row, index) => {
|
|
629
|
+
const fate = suppressionFate(row, now);
|
|
630
|
+
if (fate === "waive") return;
|
|
631
|
+
if (rawViolations.some((violation) => suppressionCovers(row, violation))) return;
|
|
632
|
+
const expired = fate === "reassert" ? ` (expired ${row.expiresAt})` : "";
|
|
633
|
+
deadRows.push(
|
|
634
|
+
`boundarySuppressions[${index}]: '${row.path}'${expired} matches no violation this run ` +
|
|
635
|
+
`judged — either the code it accepted is gone, or the path was never right`,
|
|
636
|
+
);
|
|
637
|
+
});
|
|
638
|
+
const authoredLaw =
|
|
639
|
+
policySource === null || !policySource.split(/[\\/]/u).includes("node_modules");
|
|
640
|
+
if (authoredLaw && Object.keys(graph.nodes).length > 0) {
|
|
641
|
+
for (const { row, index } of unmatchedConstraintRows(config.depConstraints, graph)) {
|
|
642
|
+
const selector = Array.isArray(row.allSourceTags)
|
|
643
|
+
? `allSourceTags (${row.allSourceTags.join(", ")})`
|
|
644
|
+
: `sourceTag '${row.sourceTag}'`;
|
|
645
|
+
deadRows.push(
|
|
646
|
+
`depConstraints[${index}]: ${selector} matches no project in the graph — the row ` +
|
|
647
|
+
`selects no source, and a constraint matching nothing does not error, it approves. ` +
|
|
648
|
+
`Either its tags were renamed out from under it or they were never right`,
|
|
649
|
+
);
|
|
650
|
+
}
|
|
651
|
+
for (const { index, position, tag } of orphanedNotDependOnTags(
|
|
652
|
+
config.depConstraints,
|
|
653
|
+
graph,
|
|
654
|
+
)) {
|
|
655
|
+
deadRows.push(
|
|
656
|
+
`depConstraints[${index}].notDependOnLibsWithTags[${position}]: '${tag}' is carried by ` +
|
|
657
|
+
`no project in the graph — the ban names nothing that can exist, so this axis of the ` +
|
|
658
|
+
`row forbids nothing while reading as enforced. Either the tag was renamed out from ` +
|
|
659
|
+
`under it or it was never right`,
|
|
660
|
+
);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
if (deadRows.length > 0) {
|
|
664
|
+
throw new Error(
|
|
665
|
+
`archkeep: ${policySource ?? "the boundary config"} describes a workspace that does not ` +
|
|
666
|
+
`match the tree:\n ${deadRows.join("\n ")}`,
|
|
667
|
+
);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// A `coverage.exempt` row removes a file from `unclaimed` before this run
|
|
672
|
+
// ever sees it — legitimately, for vendored or generated code — but nothing
|
|
673
|
+
// that removal produces was ever named in any report: an exempted file and
|
|
674
|
+
// a genuinely covered one read identically in every surface `check`
|
|
675
|
+
// produces. Stated as a note for the same reason the polyglot coverage gap
|
|
676
|
+
// below is: the exit code and verdict are unchanged (this is what
|
|
677
|
+
// `coverage.exempt` is FOR), but the silent direction — a reader unable to
|
|
678
|
+
// tell that coverage narrowed at all — is closed.
|
|
679
|
+
const exemptionNote =
|
|
680
|
+
exemptedFiles.length > 0
|
|
681
|
+
? `${exemptedFiles.length} file${exemptedFiles.length === 1 ? "" : "s"} exempted from ` +
|
|
682
|
+
`coverage by ${ARCHKEEP_MODEL_FILE}'s coverage.exempt`
|
|
683
|
+
: null;
|
|
684
|
+
|
|
685
|
+
// The other half of the same fact (#218): an import whose record resolved
|
|
686
|
+
// INTO one of those files is judged unconstrained — neither a project edge
|
|
687
|
+
// nor an external import (`../rules/index.mjs`'s `exemptResolvedFile`,
|
|
688
|
+
// the same predicate the rule engine decided the site with, not a second
|
|
689
|
+
// membership test that could drift from it). Without this count, "the run
|
|
690
|
+
// chose not to constrain these imports" and "these imports never existed"
|
|
691
|
+
// would render identically — the silent direction again. Stated only when
|
|
692
|
+
// nonzero, so a workspace with exempt files but no imports of them keeps
|
|
693
|
+
// byte-identical output; and always directly after `exemptionNote`, which
|
|
694
|
+
// is what "those files" below points at.
|
|
695
|
+
const exemptedSet = new Set(exemptedFiles);
|
|
696
|
+
const unconstrainedExemptImports = imports.filter(
|
|
697
|
+
(site) => exemptResolvedFile(site, exemptedSet) !== null,
|
|
698
|
+
).length;
|
|
699
|
+
const unconstrainedImportNote =
|
|
700
|
+
unconstrainedExemptImports > 0
|
|
701
|
+
? `${unconstrainedExemptImports} import${unconstrainedExemptImports === 1 ? "" : "s"} ` +
|
|
702
|
+
`resolve${unconstrainedExemptImports === 1 ? "s" : ""} into those files and ` +
|
|
703
|
+
`${unconstrainedExemptImports === 1 ? "is" : "are"} left unconstrained — neither project edges nor external imports`
|
|
704
|
+
: null;
|
|
705
|
+
|
|
706
|
+
// Ready-to-ship policy facts have always been sourced from `boundaryConfig`
|
|
707
|
+
// via `../config.mjs`'s `notes`; the intent check's own coverage notes ride
|
|
708
|
+
// the same seam so both surfaces (text and JSON) thread them identically —
|
|
709
|
+
// today only an `"optional": true` allowed row whose statement is absent.
|
|
710
|
+
const notes = [
|
|
711
|
+
...(config.notes ?? []),
|
|
712
|
+
...(intent === null ? [] : intent.notes),
|
|
713
|
+
...(exemptionNote === null ? [] : [exemptionNote]),
|
|
714
|
+
...(unconstrainedImportNote === null ? [] : [unconstrainedImportNote]),
|
|
715
|
+
];
|
|
716
|
+
|
|
717
|
+
// A polyglot coverage gap: the Nx graph carries no polyglot edges because
|
|
718
|
+
// the plugin is not registered, but polyglot manifests exist under project
|
|
719
|
+
// roots. The checker still judged every import it found — this is not a
|
|
720
|
+
// finding and not a refusal — but `nx affected` and `@nx/enforce-module-boundaries`
|
|
721
|
+
// are blind to Go, Rust and Python dependencies. Surfaced as a
|
|
722
|
+
// degraded-coverage note so the silent direction (exit 0 with no mention) is
|
|
723
|
+
// closed, without changing the exit code or verdict. The condition mirrors
|
|
724
|
+
// `./graph.mjs`'s refusal, which throws for the same state
|
|
725
|
+
// because a descriptive command's output is the graph itself — here the
|
|
726
|
+
// checker's own analysis covers what the graph does not, so a note is the
|
|
727
|
+
// right level. cf. #38
|
|
728
|
+
//
|
|
729
|
+
// The second kind rides the same channel for the same reason: tracked
|
|
730
|
+
// analyzable files no project owns, in the languages `./context.mjs`'s
|
|
731
|
+
// `UNCLAIMED_CHECK_LANGUAGES` deliberately does NOT fail on. Skipping them
|
|
732
|
+
// is the documented, unchanged decision (`../../../../docs/reference/violations.md`,
|
|
733
|
+
// "The order matters" step 2); leaving no trace of the skip anywhere in the
|
|
734
|
+
// report was not — the run printed byte-identical output whether fifty
|
|
735
|
+
// files sat outside every project or none did (#263). Appended AFTER the
|
|
736
|
+
// plugin gap, and contributed only when the list is non-empty, so a
|
|
737
|
+
// workspace with no unowned analyzable file reports exactly the bytes it
|
|
738
|
+
// reported before. Like the gap above it: no exit code, no verdict, and
|
|
739
|
+
// `coverage.complete` untouched — those belong to `unchecked`, and moving
|
|
740
|
+
// this state into them would turn `check` red on trees whose only sin is a
|
|
741
|
+
// root-level tooling script.
|
|
742
|
+
// The law that actually governed THIS run, not the one the workspace
|
|
743
|
+
// declared: `policySource` already carries the `--config` override and the
|
|
744
|
+
// resolved profile, workspace-relative. Subtracting it here rather than
|
|
745
|
+
// inside `resolveCommandContext` is forced — `resolvePolicy` takes the
|
|
746
|
+
// context as an argument, so it cannot run before it. `tsConfig` joins it
|
|
747
|
+
// because it is configuration by the same test, though every spelling of it
|
|
748
|
+
// is `.json` today and so never reaches the list.
|
|
749
|
+
const unownedGap = unownedGapWithoutRunConfiguration(commandContext.unownedGap, [
|
|
750
|
+
policySource,
|
|
751
|
+
commandContext.options.tsConfig,
|
|
752
|
+
]);
|
|
753
|
+
|
|
754
|
+
const coverageGaps = [
|
|
755
|
+
...(commandContext.provider === "nx" &&
|
|
756
|
+
!commandContext.pluginGap.registered &&
|
|
757
|
+
commandContext.pluginGap.manifests.length > 0
|
|
758
|
+
? [{ kind: "unregistered-plugin", manifests: commandContext.pluginGap.manifests }]
|
|
759
|
+
: []),
|
|
760
|
+
...(unownedGap.files.length > 0
|
|
761
|
+
? [
|
|
762
|
+
{
|
|
763
|
+
kind: "unowned-files",
|
|
764
|
+
// Which project model a reader has to declare the files in — the
|
|
765
|
+
// remediation differs by provider (`project.json` against
|
|
766
|
+
// `moon.yml`), and the faces that render this carry no other way
|
|
767
|
+
// to know which tree they are describing.
|
|
768
|
+
provider: commandContext.provider,
|
|
769
|
+
languages: unownedGap.languages,
|
|
770
|
+
files: unownedGap.files,
|
|
771
|
+
},
|
|
772
|
+
]
|
|
773
|
+
: []),
|
|
774
|
+
];
|
|
775
|
+
|
|
776
|
+
const report =
|
|
777
|
+
options.format === "json"
|
|
778
|
+
? renderJson(
|
|
779
|
+
jsonEnvelope({
|
|
780
|
+
command: "check",
|
|
781
|
+
context: {
|
|
782
|
+
root,
|
|
783
|
+
provider: commandContext.provider,
|
|
784
|
+
marker: commandContext.marker,
|
|
785
|
+
// D-10: provenance rides the SAME-envelope shape every other
|
|
786
|
+
// command resolves through the one `resolveProvenance` — a check
|
|
787
|
+
// report is byte-identifiable to the git HEAD it was run on, so
|
|
788
|
+
// a dirty or un-stamped CI run cannot present a claim about a
|
|
789
|
+
// different tree state. Resolved once at the top of `check` for
|
|
790
|
+
// the reason that comment states.
|
|
791
|
+
provenance,
|
|
792
|
+
},
|
|
793
|
+
// `verdictFor` returns `status`, `exitCode`, and the canonical
|
|
794
|
+
// `decision` — the four-state verb of the same counts — so the
|
|
795
|
+
// envelope's `decision.verdict` and its `status` are built from
|
|
796
|
+
// exactly one computation and can never disagree.
|
|
797
|
+
...verdictFor({
|
|
798
|
+
violations: violations.length,
|
|
799
|
+
declaredEdgeFindings,
|
|
800
|
+
goWorkDrift,
|
|
801
|
+
tsconfigPathsDead,
|
|
802
|
+
intentFindings,
|
|
803
|
+
intentUnresolved,
|
|
804
|
+
intentUnresolvedDecisionRefs: intentUnresolvedDecisionRefRows.length,
|
|
805
|
+
unchecked,
|
|
806
|
+
fitnessFail,
|
|
807
|
+
fitnessUnknown,
|
|
808
|
+
customRuleFail,
|
|
809
|
+
customRuleUnknown,
|
|
810
|
+
}),
|
|
811
|
+
coverage: {
|
|
812
|
+
complete: unchecked === 0,
|
|
813
|
+
projects: Object.keys(graph.nodes).length,
|
|
814
|
+
analyzedFiles: analyzed,
|
|
815
|
+
imports: imports.length,
|
|
816
|
+
notAnalyzed: failures
|
|
817
|
+
.filter(isWholeFileFailure)
|
|
818
|
+
.map(({ sourceFile, reason }) => ({ file: sourceFile, reason })),
|
|
819
|
+
blindSpots: failures
|
|
820
|
+
.filter((failure) => !isWholeFileFailure(failure))
|
|
821
|
+
.map(({ sourceFile, line, column, reason }) => ({
|
|
822
|
+
file: sourceFile,
|
|
823
|
+
line,
|
|
824
|
+
column,
|
|
825
|
+
reason,
|
|
826
|
+
})),
|
|
827
|
+
notes,
|
|
828
|
+
coverageGaps,
|
|
829
|
+
},
|
|
830
|
+
result: {
|
|
831
|
+
// Named first: which law produced everything below it (P1-01).
|
|
832
|
+
// Always present — `check` cannot judge anything without
|
|
833
|
+
// loading exactly one policy — unlike `graph`'s own `policy`,
|
|
834
|
+
// which is absent when no config was given to a purely
|
|
835
|
+
// descriptive run.
|
|
836
|
+
policy,
|
|
837
|
+
violations,
|
|
838
|
+
// Additive and optional: absent when the tree has no active
|
|
839
|
+
// waivers, so an unchanged tree's JSON is unchanged. Never `!` —
|
|
840
|
+
// the accepted count is a tracked decision, not a new error kind.
|
|
841
|
+
...(waived > 0 ? { waived } : {}),
|
|
842
|
+
// Additive and optional, the same bargain: absent when every
|
|
843
|
+
// depConstraints row's decisionRef resolves (or none carries
|
|
844
|
+
// one) — an unchanged tree's JSON is unchanged. A row that fired
|
|
845
|
+
// stays in `violations` with its raw `constraint.decisionRef`
|
|
846
|
+
// untouched (byte-identical to today); this list is the
|
|
847
|
+
// separate, resolved fact a consumer cross-checks it against,
|
|
848
|
+
// never a mutation of the row itself.
|
|
849
|
+
...(unresolvedDecisionRefs.size > 0
|
|
850
|
+
? {
|
|
851
|
+
unresolvedDecisionRefs: [...unresolvedDecisionRefs].sort((a, b) =>
|
|
852
|
+
a < b ? -1 : a > b ? 1 : 0,
|
|
853
|
+
),
|
|
854
|
+
}
|
|
855
|
+
: {}),
|
|
856
|
+
goWork: goWork === null ? null : { checked: true, findings: goWork.findings },
|
|
857
|
+
tsconfigPaths:
|
|
858
|
+
tsconfigPaths === null ? null : { checked: true, findings: tsconfigPaths.findings },
|
|
859
|
+
// `declaredEdges` is `null` under the same "no fact, no claim"
|
|
860
|
+
// bargain as goWork/tsconfigPaths above — computed once, right
|
|
861
|
+
// where `implicitEdges` is counted.
|
|
862
|
+
declaredEdges:
|
|
863
|
+
declaredEdges === null
|
|
864
|
+
? null
|
|
865
|
+
: {
|
|
866
|
+
checked: true,
|
|
867
|
+
judged: declaredEdges.judged,
|
|
868
|
+
findings: declaredEdges.findings,
|
|
869
|
+
},
|
|
870
|
+
// Intent is a governance DECLARATION, absent when the workspace
|
|
871
|
+
// chose not to make one: the key is omitted, never written as
|
|
872
|
+
// null — the design contract `docs/reference/json-output.md` will
|
|
873
|
+
// state ("never serialized as `null` — absent key, not null
|
|
874
|
+
// value"). goWork/tsconfig leave a named null because those are
|
|
875
|
+
// checks the tool can always run (they just found nothing);
|
|
876
|
+
// intent absence is a workspace decision, not a finding of zero.
|
|
877
|
+
...(intent === null
|
|
878
|
+
? {}
|
|
879
|
+
: {
|
|
880
|
+
intent: {
|
|
881
|
+
checked: true,
|
|
882
|
+
file: INTENT_FILE,
|
|
883
|
+
verdict: intent.verdict,
|
|
884
|
+
findings: intent.findings,
|
|
885
|
+
unresolved: intent.unresolved,
|
|
886
|
+
boundaries: intent.boundaries,
|
|
887
|
+
// The intent rows whose `decisionRef` does not resolve —
|
|
888
|
+
// the same citations `result.unresolvedDecisionRefs` lists
|
|
889
|
+
// values for, named here with their row so the JSON
|
|
890
|
+
// intent block is self-contained.
|
|
891
|
+
...(intent.unresolvedDecisionRefs !== undefined
|
|
892
|
+
? { unresolvedDecisionRefs: intent.unresolvedDecisionRefs }
|
|
893
|
+
: {}),
|
|
894
|
+
},
|
|
895
|
+
}),
|
|
896
|
+
// Fitness is a policy DECLARATION too, absent when the workspace
|
|
897
|
+
// chose not to declare functions — the same omitted-key-not-
|
|
898
|
+
// null discipline the intent block above states.
|
|
899
|
+
...(fitness === null
|
|
900
|
+
? {}
|
|
901
|
+
: {
|
|
902
|
+
fitness: {
|
|
903
|
+
checked: true,
|
|
904
|
+
verdict: fitness.overall.verdict,
|
|
905
|
+
functions: fitness.decisions,
|
|
906
|
+
},
|
|
907
|
+
}),
|
|
908
|
+
// Custom rules are a policy DECLARATION as well, and take the
|
|
909
|
+
// same omitted-key-not-null discipline: a workspace that
|
|
910
|
+
// declares none has no `customRules` key at all, so its envelope
|
|
911
|
+
// is byte-identical to the one it got before this section
|
|
912
|
+
// existed — the additive-only half of the stability promise
|
|
913
|
+
// `docs/reference/json-output.md` publishes. `rules` (not
|
|
914
|
+
// `functions`) because each entry is a whole declared law rather
|
|
915
|
+
// than a named gate, and it carries the rule's own `findings`,
|
|
916
|
+
// which no fitness decision has.
|
|
917
|
+
...(customRules === null
|
|
918
|
+
? {}
|
|
919
|
+
: {
|
|
920
|
+
customRules: {
|
|
921
|
+
checked: true,
|
|
922
|
+
verdict: customRules.overall.verdict,
|
|
923
|
+
rules: customRules.decisions,
|
|
924
|
+
},
|
|
925
|
+
}),
|
|
926
|
+
},
|
|
927
|
+
}),
|
|
928
|
+
)
|
|
929
|
+
: FORMATS[options.format]({
|
|
930
|
+
policy,
|
|
931
|
+
violations,
|
|
932
|
+
failures,
|
|
933
|
+
analyzed,
|
|
934
|
+
imports: imports.length,
|
|
935
|
+
projects: Object.keys(graph.nodes).length,
|
|
936
|
+
goWork,
|
|
937
|
+
tsconfigPaths,
|
|
938
|
+
declaredEdges,
|
|
939
|
+
intent,
|
|
940
|
+
fitness: fitness?.decisions,
|
|
941
|
+
fitnessOverall: fitness?.overall,
|
|
942
|
+
// One object rather than the decisions/overall pair fitness passes:
|
|
943
|
+
// the SARIF face also needs the finding CATALOGUE (its
|
|
944
|
+
// reportingDescriptor set), and splitting three fields across the
|
|
945
|
+
// two formatters would let a face be handed one without the others.
|
|
946
|
+
customRules,
|
|
947
|
+
// Only the ESLint boundaryConfig dialect ever produces one (see
|
|
948
|
+
// `../eslint-config.mjs`'s `extractBoundaryRule`) — which entry it
|
|
949
|
+
// bound when more than one configured the rule, or that the winning
|
|
950
|
+
// entry was files-scoped under the accepted TS/JS shape. Computing it
|
|
951
|
+
// and never showing it would be the silent direction with extra
|
|
952
|
+
// steps, so it rides the same coverage line every other "what was
|
|
953
|
+
// inspected" fact does (`../report/text.mjs`'s `formatReport`).
|
|
954
|
+
notes,
|
|
955
|
+
coverageGaps,
|
|
956
|
+
// `formatReport` (text) reads this to annotate an unresolved
|
|
957
|
+
// decisionRef inline; `formatSarif` files each one as a warning
|
|
958
|
+
// notification. Both faces of one run name the same citations, in
|
|
959
|
+
// the same order — the SARIF face sorts with the comparator this
|
|
960
|
+
// envelope already uses, so a reader comparing them cannot find
|
|
961
|
+
// them disagreeing.
|
|
962
|
+
unresolvedDecisionRefs,
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
return {
|
|
966
|
+
report,
|
|
967
|
+
violations: violations.length,
|
|
968
|
+
waived,
|
|
969
|
+
declaredEdgeFindings,
|
|
970
|
+
goWorkDrift,
|
|
971
|
+
tsconfigPathsDead,
|
|
972
|
+
intentFindings,
|
|
973
|
+
intentUnresolved,
|
|
974
|
+
intentUnresolvedDecisionRefs: intentUnresolvedDecisionRefRows.length,
|
|
975
|
+
fitnessFail,
|
|
976
|
+
fitnessUnknown,
|
|
977
|
+
customRuleFail,
|
|
978
|
+
customRuleUnknown,
|
|
979
|
+
// Empty on every run that did not ask for it, and — deliberately — also
|
|
980
|
+
// on a scoped run and on a policy that declares no custom rule. `runCheck`
|
|
981
|
+
// tells those three apart before it writes anything, because a
|
|
982
|
+
// `--evidence-out` that quietly produced no file would be the silent
|
|
983
|
+
// direction wearing a debugging flag's name.
|
|
984
|
+
customRuleEvidence: customRules?.evidence ?? [],
|
|
985
|
+
customRulesDeclared: customRules !== null,
|
|
986
|
+
analyzed,
|
|
987
|
+
unchecked,
|
|
988
|
+
};
|
|
989
|
+
}
|