@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
package/src/config.mjs
ADDED
|
@@ -0,0 +1,1308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads the workspace's module-boundary law and refuses to run on a malformed
|
|
3
|
+
* one.
|
|
4
|
+
*
|
|
5
|
+
* The law itself lives in a file at the workspace root — by default
|
|
6
|
+
* `module-boundaries.config.mjs`, which is also where ESLint reads it (that
|
|
7
|
+
* file's header says why it moved out of `eslint.config.mjs`), and otherwise
|
|
8
|
+
* wherever the plugin's `boundaryConfig` option names. This module is the second
|
|
9
|
+
* reader's side of that seam: it loads the file and checks its shape, so a typo
|
|
10
|
+
* fails here, once, naming the offending row — rather than downstream as a rule
|
|
11
|
+
* that silently matches nothing. A constraint that matches nothing does not
|
|
12
|
+
* error; it approves.
|
|
13
|
+
*
|
|
14
|
+
* **Both the path and the filename come from the caller, never from this file's
|
|
15
|
+
* own location and never from a constant here.** The path, because this tool
|
|
16
|
+
* runs in trees it is not part of: pointed at a consumer's root while its own
|
|
17
|
+
* directory sits under that consumer's pinned reference clone, a
|
|
18
|
+
* `../../../../` relative import would resolve to the tool's own copy — the
|
|
19
|
+
* wrong tree's rules, silently. The filename, because it is an Nx convention
|
|
20
|
+
* rather than a contract, and a workspace that renamed it would otherwise get
|
|
21
|
+
* "cannot load `module-boundaries.config.mjs`" — a message that reads like a
|
|
22
|
+
* missing config instead of a misconfigured tool. Same reason nothing here
|
|
23
|
+
* assumes a project name, an area, or a tag value: everything comes from the
|
|
24
|
+
* graph and the config.
|
|
25
|
+
*
|
|
26
|
+
* Validation covers shape, and one thing beyond it: whether every pattern the
|
|
27
|
+
* table contains can actually be used. Nx feeds tags, external-import globs and
|
|
28
|
+
* `allow` entries to three different matchers, each of which builds a `RegExp`,
|
|
29
|
+
* so an unbalanced bracket in a constraint row throws from inside a rule
|
|
30
|
+
* halfway through a run — and an EMPTY entry throws nothing at all, it just
|
|
31
|
+
* quietly matches nothing (or, in `allow`, everything). Both classes are caught
|
|
32
|
+
* here by asking `./rules/match.mjs` — the same matchers the rules use, so the
|
|
33
|
+
* check is the real one and not a second opinion about it. That is why a module
|
|
34
|
+
* this low imports one from `./rules/`: the alternative is a second copy of the
|
|
35
|
+
* matchers, which is the failure mode this whole file exists to prevent.
|
|
36
|
+
*
|
|
37
|
+
* What it still has no opinion on is the VALUES. Whether `layer:adapter` should
|
|
38
|
+
* be allowed to reach `layer:domain` is the workspace's decision, stated in that
|
|
39
|
+
* config with its reasoning; this module must not grow a view on it.
|
|
40
|
+
*
|
|
41
|
+
* The one shape here that upstream has no counterpart for is
|
|
42
|
+
* `boundarySuppressions`: the exemptions a workspace has decided to accept, each
|
|
43
|
+
* with the reason it was accepted. ESLint takes those as `eslint-disable`
|
|
44
|
+
* comments, which is a JavaScript convention with no equivalent in Go, Rust or
|
|
45
|
+
* Python and would give exemptions a second home besides the config this file
|
|
46
|
+
* exists to keep single. Validation is where the mandatory reason is enforced,
|
|
47
|
+
* loudly, at load — see `suppressionRowViolations`.
|
|
48
|
+
*
|
|
49
|
+
* `customRules` is the second such shape, and the fifth top-level law: each row
|
|
50
|
+
* DECLARES a rule this engine did not write — the artifact carrying it, the
|
|
51
|
+
* hash pinning that artifact's bytes, the parameters it is judged under, and
|
|
52
|
+
* the reason it exists (`../../../docs/adr/0002-custom-rules-one-contract.md`
|
|
53
|
+
* records the decision, "Declared in the policy, never discovered"). What is
|
|
54
|
+
* checked here is the DECLARATION and nothing else: this module never reads the
|
|
55
|
+
* artifact, hashes it, or runs it — it is handed a policy rather than a tree,
|
|
56
|
+
* and a loader that reached the filesystem for a row's bytes would be asking a
|
|
57
|
+
* question its callers (the Nx hook, the language server, an inline
|
|
58
|
+
* `archkeep.json` object) are not all in a position to answer. What it does
|
|
59
|
+
* refuse is a declaration that could never name a real rule: a name two rows
|
|
60
|
+
* share, a `sha256` no digest can equal, an `artifact` that leaves the
|
|
61
|
+
* workspace, `params` that cannot survive serialization into a rule's
|
|
62
|
+
* evidence — see `customRuleRowViolations`.
|
|
63
|
+
*
|
|
64
|
+
* ## Three dialects, one validator, one dispatch
|
|
65
|
+
*
|
|
66
|
+
* `boundaryConfig` may name a `.mjs`/`.js` module (`import()`ed, as above and
|
|
67
|
+
* always), a `.json` file (`JSON.parse`d — never JSONC, never `import()`ed, so
|
|
68
|
+
* a `.json` boundary law carries no more parser leniency than the language it
|
|
69
|
+
* is written in promises), or an ESLint flat config (`./eslint-config.mjs`
|
|
70
|
+
* reads the workspace's own `@nx/enforce-module-boundaries` rule entry off
|
|
71
|
+
* it, rather than a second, hand-kept copy of the same table —
|
|
72
|
+
* `docs/concepts/policies.md` is the dialect reference for what that reader
|
|
73
|
+
* can and cannot see). `loadBoundaryConfigFile` is the one place that
|
|
74
|
+
* dispatches between them, on **basename first, extension second**: a name
|
|
75
|
+
* matching `eslint.config.*` (`ESLINT_FLAT_CONFIG_BASENAME`) always reaches
|
|
76
|
+
* the ESLint dialect and a legacy `.eslintrc*` name
|
|
77
|
+
* (`LEGACY_ESLINTRC_BASENAME`) is always refused by name, before either one's
|
|
78
|
+
* extension — `.mjs`, `.js`, or none at all for a bare `.eslintrc` — ever
|
|
79
|
+
* reaches the extension dispatch below. Basename has to run first: both
|
|
80
|
+
* shapes are `.mjs`/`.js`-extensioned far more often than not, and reaching
|
|
81
|
+
* the module dialect's bare `import()` first would either half-work (a
|
|
82
|
+
* flat-config array is a valid ES module, so it would "load" and then fail on
|
|
83
|
+
* `findBoundaryConfigViolations` with a message that never mentions ESLint)
|
|
84
|
+
* or, for a `.eslintrc.json`, land in the `.json` dialect's
|
|
85
|
+
* unrecognised-top-level-key refusal — neither reads as what actually went
|
|
86
|
+
* wrong: this is an ESLint config, of the wrong dialect or shape, not a
|
|
87
|
+
* malformed archkeep policy file. Only once both basenames are ruled out does
|
|
88
|
+
* the extension decide between the `.mjs`/`.js` module dialect and the
|
|
89
|
+
* `.json` file dialect.
|
|
90
|
+
*
|
|
91
|
+
* All three dialects hand their parsed data to the same
|
|
92
|
+
* `findBoundaryConfigViolations` above (through the shared `policyFrom` tail
|
|
93
|
+
* — see there), so a constraint row is validated identically regardless of
|
|
94
|
+
* which file held it — the alternative is a second copy of these rules that
|
|
95
|
+
* drifts from this one the first time any of them changes.
|
|
96
|
+
*
|
|
97
|
+
* The `.mjs`/`.js` and `.json` dialects share the same top-level key law: a
|
|
98
|
+
* top-level export (or key) beyond `depConstraints`, `moduleBoundaryOptions`,
|
|
99
|
+
* `boundarySuppressions`, `fitness` and `customRules` is rejected by name. The
|
|
100
|
+
* `.mjs` dialect's tolerance for a helper export used to let a misspelled key
|
|
101
|
+
* (`moduleBoundaryOptions` → `moduleBoundaryOption`) disappear into silence —
|
|
102
|
+
* a typo'd law is a law that is not enforced, the exact silent direction this
|
|
103
|
+
* file exists to end — so it now carries the same refusal as the `.json`
|
|
104
|
+
* dialect, through the same `policyKeyViolations`. One carve-out remains, for
|
|
105
|
+
* the `.json` dialect only: `$schema`, which editors write into a JSON file
|
|
106
|
+
* unasked for IDE validation and which states no rule of its own (accepted
|
|
107
|
+
* and checked there and in a native workspace's inline policy; an ES module
|
|
108
|
+
* has no editor-validation hook to point it at). The ESLint dialect has no
|
|
109
|
+
* `boundarySuppressions` counterpart at all — ESLint has its own
|
|
110
|
+
* `eslint-disable` convention for that, with no equivalent this reader can
|
|
111
|
+
* read back — so it always reports an empty suppression list, and it has no
|
|
112
|
+
* home for a `customRules` declaration either: a flat config's one rule entry
|
|
113
|
+
* is a constraint table and its options, with nowhere to name a rule artifact,
|
|
114
|
+
* so a policy read through that dialect carries NO `customRules` key rather
|
|
115
|
+
* than an empty one — absent is the workspace's own statement, where an empty
|
|
116
|
+
* list would read as "custom rules are configured here, and there are none".
|
|
117
|
+
* See `loadBoundaryConfigFile`'s ESLint branch.
|
|
118
|
+
*/
|
|
119
|
+
import { basename, extname, posix, resolve } from "node:path";
|
|
120
|
+
import { existsSync } from "node:fs";
|
|
121
|
+
import { readFile as readFileFromDisk } from "node:fs/promises";
|
|
122
|
+
import { pathToFileURL } from "node:url";
|
|
123
|
+
|
|
124
|
+
import { containmentViolation, pathEscapes } from "./containment.mjs";
|
|
125
|
+
|
|
126
|
+
import { loadEslintBoundaryConfig } from "./eslint-config.mjs";
|
|
127
|
+
import { findFitnessViolations } from "./governance/fitness-registry.mjs";
|
|
128
|
+
import { declaredFitnessNames, stripRuleFitnessPrefix } from "./governance/adr-registry.mjs";
|
|
129
|
+
import { GOVERNANCE_ROW_KEYS, rowSchemaViolations } from "./governance/row-schema.mjs";
|
|
130
|
+
import {
|
|
131
|
+
GLOB_METACHARACTERS,
|
|
132
|
+
globComplexityError,
|
|
133
|
+
globPatternError,
|
|
134
|
+
importPatternError,
|
|
135
|
+
projectPatternError,
|
|
136
|
+
safeMatchesGlob,
|
|
137
|
+
tagPatternError,
|
|
138
|
+
} from "./rules/match.mjs";
|
|
139
|
+
import { MESSAGE_IDS } from "./rules/messages.mjs";
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The basename that selects the ESLint flat-config dialect
|
|
143
|
+
* (`./eslint-config.mjs`) — an explicit opt-in, matched on basename rather
|
|
144
|
+
* than extension so `eslint.config.mjs`, `.cjs`, `.js`, `.ts`, `.mts` and
|
|
145
|
+
* `.cts` all reach the dispatch below. The DISPATCH matches all of them;
|
|
146
|
+
* whether importing one of the TypeScript-extension spellings then actually
|
|
147
|
+
* loads is a separate question this dispatch has no opinion on — it is the
|
|
148
|
+
* runtime's own TS support that decides, and it disagrees across the
|
|
149
|
+
* versions this package supports: Node 24 strips types before running the
|
|
150
|
+
* file, Node 20 and 22 error on the `import()` before this module ever sees
|
|
151
|
+
* it (`.node-version` pins the version CI itself runs). Nothing shorter than
|
|
152
|
+
* this literal prefix is accepted: a workspace's own boundary law living in a
|
|
153
|
+
* file that merely CONTAINS "eslint" somewhere in its name must not be
|
|
154
|
+
* silently routed through a reader built for a different shape. Exported so
|
|
155
|
+
* `src/lsp/boundary-config.mjs` can refuse the same basenames by name rather
|
|
156
|
+
* than repeating the two patterns — `AGENTS.md`'s "never state a rule twice".
|
|
157
|
+
*/
|
|
158
|
+
export const ESLINT_FLAT_CONFIG_BASENAME = /^eslint\.config\./u;
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* A legacy (pre-flat-config) ESLint config basename. `@nx/enforce-module-boundaries`
|
|
162
|
+
* itself requires ESLint 9's flat config, so a `boundaryConfig` naming one of
|
|
163
|
+
* these can never have meant "read my ESLint rule entry" — it is refused by
|
|
164
|
+
* name, immediately, rather than falling through to the `.mjs`-module dialect
|
|
165
|
+
* and failing on an unrelated "not a module object" a reader would have no way
|
|
166
|
+
* to connect back to "this is an ESLint config, and the wrong kind of one".
|
|
167
|
+
* Exported for the same reuse reason as `ESLINT_FLAT_CONFIG_BASENAME` above.
|
|
168
|
+
*/
|
|
169
|
+
export const LEGACY_ESLINTRC_BASENAME = /^\.eslintrc(\.|$)/u;
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The filename the config has when a workspace has not said otherwise lives in
|
|
173
|
+
* `./options.mjs`, with the option that overrides it — the two are one fact, and
|
|
174
|
+
* they used to be two: this module owned a `MODULE_BOUNDARIES_CONFIG_FILE`
|
|
175
|
+
* constant, and a workspace that named its law something else had no way to say
|
|
176
|
+
* so. Nothing here spells the name at all now. The loaders below take a path or
|
|
177
|
+
* a filename from their caller, who resolved it.
|
|
178
|
+
*/
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The eight non-table options, with the type each must have, and — where the
|
|
182
|
+
* option's entries are patterns rather than plain names — the matcher they have
|
|
183
|
+
* to survive. `../rules/match.mjs` owns those matchers; asking them directly is
|
|
184
|
+
* what makes this check the real one rather than an approximation of it.
|
|
185
|
+
*
|
|
186
|
+
* `buildTargets` carries a matcher even though its entries are target names,
|
|
187
|
+
* compared with `===` (see `OPTION_ENTRY_MATCHERS`): a NAME is exact by
|
|
188
|
+
* definition, so an entry containing glob syntax can never match a target
|
|
189
|
+
* declared in any graph — it is refused at load rather than silently
|
|
190
|
+
* selecting nothing. It still gets the empty-string check every list gets.
|
|
191
|
+
*/
|
|
192
|
+
const OPTION_TYPES = {
|
|
193
|
+
allow: "string[]",
|
|
194
|
+
buildTargets: "string[]",
|
|
195
|
+
enforceBuildableLibDependency: "boolean",
|
|
196
|
+
allowCircularSelfDependency: "boolean",
|
|
197
|
+
checkDynamicDependenciesExceptions: "string[]",
|
|
198
|
+
ignoredCircularDependencies: "pair[]",
|
|
199
|
+
banTransitiveDependencies: "boolean",
|
|
200
|
+
checkNestedExternalImports: "boolean",
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const OPTION_ENTRY_MATCHERS = {
|
|
204
|
+
// Both are matched with `matchImportWithWildcard`, whose fallback branch is
|
|
205
|
+
// an unanchored `new RegExp(entry)`. `""` compiles to a regex matching every
|
|
206
|
+
// string, so one empty entry in `allow` exempts the entire workspace from all
|
|
207
|
+
// fifteen rules — silently, and reading like an empty list.
|
|
208
|
+
allow: importPatternError,
|
|
209
|
+
checkDynamicDependenciesExceptions: importPatternError,
|
|
210
|
+
// Expanded through Nx's `findMatchingProjects`; this engine reproduces only
|
|
211
|
+
// the part it can reproduce exactly, and rejects the rest here rather than
|
|
212
|
+
// suppressing cycles it half-understands.
|
|
213
|
+
ignoredCircularDependencies: projectPatternError,
|
|
214
|
+
// A target NAME is matched with `===` against a project's declared targets
|
|
215
|
+
// (`../rules/topology.mjs`'s `hasBuildExecutor`), so an entry carrying glob
|
|
216
|
+
// syntax can never match anything — Nx users conventionally write target
|
|
217
|
+
// patterns (`"build:*"`), and this engine reproduces no target patterns, so
|
|
218
|
+
// the entry is refused by name rather than silently selecting no project.
|
|
219
|
+
buildTargets: targetPatternError,
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* The keys a constraint row may carry, per `@nx/enforce-module-boundaries`'
|
|
224
|
+
* own schema, each with the matcher its entries are fed to. Two row shapes
|
|
225
|
+
* share one list of lists: a row keys on either one `sourceTag` or an
|
|
226
|
+
* `allSourceTags` array, and both then take the same four optional list fields.
|
|
227
|
+
*/
|
|
228
|
+
/**
|
|
229
|
+
* Why a `buildTargets` entry cannot name a target in this engine, or `null`.
|
|
230
|
+
*
|
|
231
|
+
* `hasBuildExecutor` compares entries to a project's declared target names
|
|
232
|
+
* with `===` — the same exact lookup upstream uses — so an entry containing
|
|
233
|
+
* glob syntax (`"build:*"`, `"*"`) can never match any target, and a workspace
|
|
234
|
+
* that wrote one believes `enforceBuildableLibDependency` is live when no
|
|
235
|
+
* project can possibly be selected.
|
|
236
|
+
*
|
|
237
|
+
* @param {string} entry
|
|
238
|
+
* @returns {string|null}
|
|
239
|
+
*/
|
|
240
|
+
function targetPatternError(entry) {
|
|
241
|
+
if (GLOB_METACHARACTERS.test(entry)) {
|
|
242
|
+
return (
|
|
243
|
+
`is a glob, but buildTargets entries are compared with === against a project's declared ` +
|
|
244
|
+
`target names — this entry can never match any target. Name targets exactly, or omit this ` +
|
|
245
|
+
`entry (buildTargets defaults to ['build'])`
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const ROW_LIST_MATCHERS = {
|
|
252
|
+
onlyDependOnLibsWithTags: tagPatternError,
|
|
253
|
+
notDependOnLibsWithTags: tagPatternError,
|
|
254
|
+
allowedExternalImports: globPatternError,
|
|
255
|
+
bannedExternalImports: globPatternError,
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
const ROW_LIST_KEYS = Object.keys(ROW_LIST_MATCHERS);
|
|
259
|
+
|
|
260
|
+
/** @type {(value: unknown) => value is string[]} */
|
|
261
|
+
const isStringArray = (value) =>
|
|
262
|
+
Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
263
|
+
|
|
264
|
+
/** @type {(value: unknown) => value is [string, string][]} */
|
|
265
|
+
const isTagPairArray = (value) =>
|
|
266
|
+
Array.isArray(value) && value.every((pair) => isStringArray(pair) && pair.length === 2);
|
|
267
|
+
|
|
268
|
+
/** @type {(value: unknown) => value is Record<string, unknown>} */
|
|
269
|
+
const isPlainObject = (value) =>
|
|
270
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* What is wrong with the entries of one string list, each message naming the
|
|
274
|
+
* entry's own index so a long list points at the offender rather than at
|
|
275
|
+
* itself.
|
|
276
|
+
*
|
|
277
|
+
* Two classes of problem, and neither would ever throw at runtime — which is
|
|
278
|
+
* why they are caught here. An **empty entry** is silent: depending on the list
|
|
279
|
+
* it lands in, it matches nothing (a rule that reads as enforced and is not),
|
|
280
|
+
* or it matches everything (`allow: [""]`). A **pattern that will not compile**
|
|
281
|
+
* throws from inside a rule, halfway through a run, with no idea which config
|
|
282
|
+
* row produced it.
|
|
283
|
+
*
|
|
284
|
+
* @param {string[]} values
|
|
285
|
+
* @param {string} at Dotted path of the list, for the message.
|
|
286
|
+
* @param {((pattern: string) => string|null)|undefined} patternError
|
|
287
|
+
* @returns {string[]}
|
|
288
|
+
*/
|
|
289
|
+
function listEntryViolations(values, at, patternError) {
|
|
290
|
+
const violations = [];
|
|
291
|
+
values.forEach((value, index) => {
|
|
292
|
+
if (value === "") {
|
|
293
|
+
violations.push(
|
|
294
|
+
`${at}[${index}]: must not be empty — an empty pattern is never what a reader ` +
|
|
295
|
+
`expects, and in 'allow' it matches every import in the workspace`,
|
|
296
|
+
);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
const problem = patternError?.(value);
|
|
300
|
+
if (problem) violations.push(`${at}[${index}]: '${value}' ${problem}`);
|
|
301
|
+
});
|
|
302
|
+
return violations;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** One row's problems, prefixed with its index so a report names the offender. */
|
|
306
|
+
function constraintRowViolations(row, index, io) {
|
|
307
|
+
const at = `depConstraints[${index}]`;
|
|
308
|
+
if (!isPlainObject(row)) return [`${at}: must be an object, got ${describe(row)}`];
|
|
309
|
+
|
|
310
|
+
const violations = [];
|
|
311
|
+
const hasSourceTag = "sourceTag" in row;
|
|
312
|
+
const hasAllSourceTags = "allSourceTags" in row;
|
|
313
|
+
if (hasSourceTag === hasAllSourceTags) {
|
|
314
|
+
violations.push(
|
|
315
|
+
`${at}: must carry exactly one of 'sourceTag' or 'allSourceTags' — ` +
|
|
316
|
+
`a row with neither matches no project and silently approves everything`,
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
if (hasSourceTag && (typeof row.sourceTag !== "string" || row.sourceTag === "")) {
|
|
320
|
+
violations.push(`${at}.sourceTag: must be a non-empty string, got ${describe(row.sourceTag)}`);
|
|
321
|
+
}
|
|
322
|
+
if (hasAllSourceTags && (!isStringArray(row.allSourceTags) || row.allSourceTags.length < 2)) {
|
|
323
|
+
violations.push(
|
|
324
|
+
`${at}.allSourceTags: must be an array of at least 2 strings, got ${describe(row.allSourceTags)}`,
|
|
325
|
+
);
|
|
326
|
+
} else if (hasAllSourceTags) {
|
|
327
|
+
// A combo row matches only projects carrying EVERY tag it names, so one
|
|
328
|
+
// unusable tag makes the whole row match nothing — and a row that matches
|
|
329
|
+
// nothing does not error, it approves.
|
|
330
|
+
violations.push(
|
|
331
|
+
...listEntryViolations(
|
|
332
|
+
/** @type {string[]} */ (row.allSourceTags),
|
|
333
|
+
`${at}.allSourceTags`,
|
|
334
|
+
tagPatternError,
|
|
335
|
+
),
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
if (hasSourceTag && typeof row.sourceTag === "string" && row.sourceTag !== "") {
|
|
339
|
+
const problem = tagPatternError(row.sourceTag);
|
|
340
|
+
if (problem) violations.push(`${at}.sourceTag: '${row.sourceTag}' ${problem}`);
|
|
341
|
+
}
|
|
342
|
+
for (const key of ROW_LIST_KEYS) {
|
|
343
|
+
if (!(key in row)) continue;
|
|
344
|
+
if (!isStringArray(row[key])) {
|
|
345
|
+
violations.push(`${at}.${key}: must be an array of strings, got ${describe(row[key])}`);
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
violations.push(...listEntryViolations(row[key], `${at}.${key}`, ROW_LIST_MATCHERS[key]));
|
|
349
|
+
}
|
|
350
|
+
// Optional informational fields — they do not change evaluation, but they
|
|
351
|
+
// give a constraint row a name and a remediation hint that reports and
|
|
352
|
+
// explanations can surface. An unnamed constraint still enforces; a named
|
|
353
|
+
// one is easier to act on.
|
|
354
|
+
if ("description" in row) {
|
|
355
|
+
if (typeof row.description !== "string" || row.description === "") {
|
|
356
|
+
violations.push(
|
|
357
|
+
`${at}.description: must be a non-empty string when present, got ${describe(row.description)}`,
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
if ("remediation" in row) {
|
|
362
|
+
if (typeof row.remediation !== "string" || row.remediation === "") {
|
|
363
|
+
violations.push(
|
|
364
|
+
`${at}.remediation: must be a non-empty string when present, got ${describe(row.remediation)}`,
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
// The governance block (Contract 2): origin/rationale/decisionRef/
|
|
369
|
+
// fitnessBindings, validated by the ONE shared schema (`./governance/row-schema.mjs`)
|
|
370
|
+
// so a constraint row and an intent row are checked identically across the
|
|
371
|
+
// wave — never a second copy of what a governance key may hold. Additive: a
|
|
372
|
+
// row without the block is a legacy row and stays valid byte-identical.
|
|
373
|
+
// Resolution of a decisionRef/fitnessBinding id is the registry capability's
|
|
374
|
+
// (`./governance/row-schema.mjs`); shape is validated here, loudly.
|
|
375
|
+
if ("origin" in row || "rationale" in row || "decisionRef" in row || "fitnessBindings" in row) {
|
|
376
|
+
violations.push(...rowSchemaViolations(row, at, io));
|
|
377
|
+
}
|
|
378
|
+
// Rejected rather than ignored: an unknown key is almost always a
|
|
379
|
+
// misspelling of one above (`bannedExternalImport`), and the rule would
|
|
380
|
+
// accept the row, enforce the half it understood, and drop the ban.
|
|
381
|
+
const ROW_SCALAR_KEYS = ["description", "remediation"];
|
|
382
|
+
for (const key of Object.keys(row)) {
|
|
383
|
+
if (
|
|
384
|
+
key === "sourceTag" ||
|
|
385
|
+
key === "allSourceTags" ||
|
|
386
|
+
ROW_LIST_KEYS.includes(key) ||
|
|
387
|
+
ROW_SCALAR_KEYS.includes(key) ||
|
|
388
|
+
GOVERNANCE_ROW_KEYS.includes(key)
|
|
389
|
+
)
|
|
390
|
+
continue;
|
|
391
|
+
violations.push(
|
|
392
|
+
`${at}.${key}: not a constraint field — expected one of ` +
|
|
393
|
+
`sourceTag, allSourceTags, ${ROW_LIST_KEYS.join(", ")}, ${ROW_SCALAR_KEYS.join(", ")}, ` +
|
|
394
|
+
GOVERNANCE_ROW_KEYS.join(", "),
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
return violations;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* The keys a suppression entry may carry. `reason` is not optional, and that is
|
|
402
|
+
* the whole point of the shape — see `suppressionRowViolations`. A row that
|
|
403
|
+
* also carries `expiresAt` is a WAIVER (temporary acceptance) rather than a
|
|
404
|
+
* suppression (permanent one): both validate through this same shape, and the
|
|
405
|
+
* waiver semantics live in `./governance/waiver.mjs`.
|
|
406
|
+
*/
|
|
407
|
+
const SUPPRESSION_KEYS = ["path", "messageId", "reason", "expiresAt", "origin"];
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Does this suppression cover a violation at `sourceFile` with `messageId`?
|
|
411
|
+
*
|
|
412
|
+
* `path` is a glob over the workspace-relative path of the importing file,
|
|
413
|
+
* matched with `node:path`'s own `matchesGlob`, through `./rules/match.mjs`'s
|
|
414
|
+
* `safeMatchesGlob` — the stdlib, deliberately: this project may import no
|
|
415
|
+
* third-party matcher (project `AGENTS.md`), and the alternative —
|
|
416
|
+
* hand-rolling an almost-minimatch — is exactly what `projectPatternError`
|
|
417
|
+
* already refuses to do for `ignoredCircularDependencies`. A pattern it
|
|
418
|
+
* cannot parse returns false rather than throwing, which for a suppression
|
|
419
|
+
* fails toward reporting; a pattern whose brace groups expand combinatorially
|
|
420
|
+
* throws instead, guarded by `safeMatchesGlob` — `suppressionRowViolations`
|
|
421
|
+
* below refuses the same pattern at config load, so this throw is a backstop
|
|
422
|
+
* for a suppression that reached matching without going through it, not the
|
|
423
|
+
* primary defence.
|
|
424
|
+
*
|
|
425
|
+
* `posix` and not the platform default: every path in an analysis record is
|
|
426
|
+
* workspace-relative and `/`-separated (`analysis/contract.md`), so there is no
|
|
427
|
+
* platform to detect and a Windows run must not read `\` as an escape.
|
|
428
|
+
*
|
|
429
|
+
* An entry with no `messageId` covers every violation type at that path. That
|
|
430
|
+
* is the broader form on purpose: the files this exists for are config files a
|
|
431
|
+
* loader cannot resolve aliases in, and which message their one import draws is
|
|
432
|
+
* a detail of the spelling rather than of the decision.
|
|
433
|
+
*
|
|
434
|
+
* @param {{path: string, messageId?: string}} suppression
|
|
435
|
+
* @param {{sourceFile: string, messageId: string}} violation
|
|
436
|
+
* @returns {boolean}
|
|
437
|
+
*/
|
|
438
|
+
export function suppressionCovers(suppression, violation) {
|
|
439
|
+
if (suppression.messageId !== undefined && suppression.messageId !== violation.messageId) {
|
|
440
|
+
return false;
|
|
441
|
+
}
|
|
442
|
+
return safeMatchesGlob(violation.sourceFile, suppression.path);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* One suppression entry's problems, prefixed with its index.
|
|
447
|
+
*
|
|
448
|
+
* **A missing `reason` is rejected, and that check is why this validator
|
|
449
|
+
* exists.** A suppression is a violation someone decided to accept; with the
|
|
450
|
+
* decision unwritten, what is left is a hole that reads as "clean" to every
|
|
451
|
+
* later reader — the state this whole tool was built to end. Defaulting the
|
|
452
|
+
* field to `""` would make it decorative, and a decorative field is one nobody
|
|
453
|
+
* fills in.
|
|
454
|
+
*
|
|
455
|
+
* `messageId` is checked against the fifteen ids the rules layer can produce,
|
|
456
|
+
* read from `messages.mjs` rather than listed here: a typo'd id suppresses
|
|
457
|
+
* nothing, which is the safe direction, but it also reads as a decision that
|
|
458
|
+
* has been taken when it has not.
|
|
459
|
+
*/
|
|
460
|
+
/**
|
|
461
|
+
* @param {object} row
|
|
462
|
+
* @param {number} index
|
|
463
|
+
*/
|
|
464
|
+
function suppressionRowViolations(row, index) {
|
|
465
|
+
const at = `boundarySuppressions[${index}]`;
|
|
466
|
+
if (!isPlainObject(row)) return [`${at}: must be an object, got ${describe(row)}`];
|
|
467
|
+
|
|
468
|
+
const violations = [];
|
|
469
|
+
if (typeof row.path !== "string" || row.path === "") {
|
|
470
|
+
violations.push(
|
|
471
|
+
`${at}.path: must be a non-empty glob over the workspace-relative path of the ` +
|
|
472
|
+
`importing file, got ${describe(row.path)}`,
|
|
473
|
+
);
|
|
474
|
+
} else {
|
|
475
|
+
const problem = globComplexityError(row.path);
|
|
476
|
+
if (problem) violations.push(`${at}.path: '${row.path}' ${problem}`);
|
|
477
|
+
}
|
|
478
|
+
if (typeof row.reason !== "string" || row.reason.trim() === "") {
|
|
479
|
+
violations.push(
|
|
480
|
+
`${at}.reason: must be a non-empty string — a suppression is a violation someone ` +
|
|
481
|
+
`decided to accept, and one with no reason written down is indistinguishable from ` +
|
|
482
|
+
`a boundary that quietly stopped being enforced`,
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
if ("messageId" in row && !MESSAGE_IDS.includes(/** @type {string} */ (row.messageId))) {
|
|
486
|
+
violations.push(
|
|
487
|
+
`${at}.messageId: ${describe(row.messageId)} is not a violation type this engine ` +
|
|
488
|
+
`reports — expected one of ${MESSAGE_IDS.join(", ")}`,
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
if ("expiresAt" in row) {
|
|
492
|
+
// A waiver's term. Must be a full ISO-8601 instant carrying an explicit
|
|
493
|
+
// UTC or offset designator — the shape `Date.prototype.toISOString()`
|
|
494
|
+
// itself produces, and the one spellings like `"2026-09-01"` or `"0"`
|
|
495
|
+
// (`Date.parse`'s epoch) or `"2026-09-01 03:00"` deliberately do not
|
|
496
|
+
// match. Those parse, but ambiguity is the bug: a date-only or TZ-less
|
|
497
|
+
// string is interpreted in the machine's local zone, so the same law
|
|
498
|
+
// yields a different term — and a different expiry verdict — under two
|
|
499
|
+
// machines' `TZ`. A waiver whose term means different things to different
|
|
500
|
+
// machines is not a term; an instant-bearing spelling means the same
|
|
501
|
+
// instant everywhere.
|
|
502
|
+
const expiryMatches = typeof row.expiresAt === "string" ? row.expiresAt : null;
|
|
503
|
+
const expiryMatch = expiryMatches
|
|
504
|
+
? /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(\.\d+)?(Z|[+-]\d{2}:\d{2})$/.exec(
|
|
505
|
+
expiryMatches,
|
|
506
|
+
)
|
|
507
|
+
: null;
|
|
508
|
+
const expiry = expiryMatch ? Date.parse(expiryMatches) : NaN;
|
|
509
|
+
// A calendar-impossible instant like `2026-02-30` passes the shape regex
|
|
510
|
+
// AND `Date.parse`, which silently normalises it to a different calendar
|
|
511
|
+
// day (`2026-03-02`) — a term that quietly extends acceptance past what
|
|
512
|
+
// its author wrote, the one direction this repository treats as poison.
|
|
513
|
+
// The written calendar fields are checked against the same fields of the
|
|
514
|
+
// instant they claim, independent of the string's timezone: `Date.UTC`
|
|
515
|
+
// normalises the impossible and the round-trip no longer matches.
|
|
516
|
+
// The capture groups destructure as `unknown` under tsc's strict JSDoc
|
|
517
|
+
// checking (the regex `.exec` return type does not name them), so each is
|
|
518
|
+
// coerced here — the calendar comparison wants numbers anyway.
|
|
519
|
+
const [, ...captures] = expiryMatch ?? [];
|
|
520
|
+
const [y, mo, d, h, mi, s] = captures.map(Number);
|
|
521
|
+
const normalized =
|
|
522
|
+
expiryMatch && !Number.isNaN(expiry) ? new Date(Date.UTC(y, mo - 1, d, h, mi, s)) : null;
|
|
523
|
+
const impossibleCalendar =
|
|
524
|
+
normalized !== null &&
|
|
525
|
+
(normalized.getUTCFullYear() !== y ||
|
|
526
|
+
normalized.getUTCMonth() !== mo - 1 ||
|
|
527
|
+
normalized.getUTCDate() !== d ||
|
|
528
|
+
normalized.getUTCHours() !== h ||
|
|
529
|
+
normalized.getUTCMinutes() !== mi ||
|
|
530
|
+
normalized.getUTCSeconds() !== s);
|
|
531
|
+
if (!expiryMatch || Number.isNaN(expiry) || impossibleCalendar) {
|
|
532
|
+
violations.push(
|
|
533
|
+
`${at}.expiresAt: must be a full ISO-8601 instant with an explicit UTC/offset, ` +
|
|
534
|
+
`like "2026-09-01T00:00:00.000Z", got ${describe(row.expiresAt)} — a term with no ` +
|
|
535
|
+
`designator is interpreted in the machine's local zone, so the same waiver would mean ` +
|
|
536
|
+
`different things under different TZ environments${
|
|
537
|
+
impossibleCalendar
|
|
538
|
+
? `; a calendar date this calendar does not contain (like ${`${y}-${mo}-${d}`}) would be ` +
|
|
539
|
+
`silently shifted to another day by the parser, extending the waiver past what was written`
|
|
540
|
+
: ""
|
|
541
|
+
}`,
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
if ("origin" in row && (typeof row.origin !== "string" || row.origin.trim() === "")) {
|
|
546
|
+
violations.push(
|
|
547
|
+
`${at}.origin: must be a non-empty string naming where the waiver came from, got ` +
|
|
548
|
+
`${describe(row.origin)}`,
|
|
549
|
+
);
|
|
550
|
+
}
|
|
551
|
+
for (const key of Object.keys(row)) {
|
|
552
|
+
if (SUPPRESSION_KEYS.includes(key)) continue;
|
|
553
|
+
violations.push(
|
|
554
|
+
`${at}.${key}: not a suppression field — expected one of ${SUPPRESSION_KEYS.join(", ")}`,
|
|
555
|
+
);
|
|
556
|
+
}
|
|
557
|
+
return violations;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* The grammar a custom rule's `name` is written in: lowercase letters and
|
|
562
|
+
* digits, single `-` separators, nothing else.
|
|
563
|
+
*
|
|
564
|
+
* A name is a SELECTOR rather than a label — it is what a row is identified by,
|
|
565
|
+
* and what the duplicate check below compares — so two names a human reads as
|
|
566
|
+
* identical must never be able to exist as two different strings. That is the
|
|
567
|
+
* same reason `./governance/profile-registry.mjs` gives for restricting a
|
|
568
|
+
* profile name, one axis narrower: no uppercase either, so a policy cannot
|
|
569
|
+
* carry both `no-cycles` and `No-Cycles` and leave a reader to work out which
|
|
570
|
+
* of them a message is about.
|
|
571
|
+
*
|
|
572
|
+
* Exported because it is the grammar of a NAME rather than of this file's row
|
|
573
|
+
* shape: anything that has to spell or check one answers from here rather than
|
|
574
|
+
* from a second copy of the pattern (`../../../AGENTS.md`, "Never state a rule
|
|
575
|
+
* twice").
|
|
576
|
+
*/
|
|
577
|
+
export const CUSTOM_RULE_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* A `sha256`, as `node:crypto`'s own `digest("hex")` spells it: 64 lowercase
|
|
581
|
+
* hex characters. Uppercase is refused rather than folded, because the hash is
|
|
582
|
+
* compared to a digest string and a comparison that lowercased one side would
|
|
583
|
+
* be a second opinion about what the declared bytes are; refusing at load names
|
|
584
|
+
* the spelling, where a mismatch later would only name the bytes.
|
|
585
|
+
*/
|
|
586
|
+
const CUSTOM_RULE_SHA256_PATTERN = /^[0-9a-f]{64}$/u;
|
|
587
|
+
|
|
588
|
+
/** The keys a custom-rule row may carry, beside the governance block. */
|
|
589
|
+
const CUSTOM_RULE_KEYS = ["name", "artifact", "sha256", "params", "reason"];
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* An absolute path in either family — a leading separator, or a Windows drive
|
|
593
|
+
* (`C:\rules\x.wasm`, `C:rules\x.wasm`). Tested against the raw spelling rather
|
|
594
|
+
* than through `node:path`'s `isAbsolute`, which answers about the platform the
|
|
595
|
+
* check happens to run on: a policy is committed once and read on every
|
|
596
|
+
* machine, so a `C:`-rooted artifact has to be refused by a POSIX CI runner
|
|
597
|
+
* too, not silently read as a relative directory named `C:`.
|
|
598
|
+
*/
|
|
599
|
+
const ABSOLUTE_ARTIFACT_PATH = /^(?:[\\/]|[A-Za-z]:)/u;
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* The root the containment question is asked against, LEXICALLY. A row names a
|
|
603
|
+
* workspace-relative path and this validator never sees a workspace root — the
|
|
604
|
+
* loaders it runs behind are handed a policy, not a tree, and the inline
|
|
605
|
+
* dialect has no file of its own to be relative to. So the half that can be
|
|
606
|
+
* answered from the string is answered here: does the path leave the directory
|
|
607
|
+
* it is relative to? `./containment.mjs`'s `pathEscapes` is asked rather than
|
|
608
|
+
* reimplemented, so there is one answer to "this path leaves the tree". The
|
|
609
|
+
* other half — a symlink that resolves out of the workspace — needs a real root
|
|
610
|
+
* and a real filesystem, and belongs to whatever reads the artifact's bytes;
|
|
611
|
+
* what this closes is a declared `../../etc/rule.wasm` failing at load instead
|
|
612
|
+
* of at read.
|
|
613
|
+
*/
|
|
614
|
+
const NOMINAL_WORKSPACE_ROOT = "/archkeep-workspace";
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* Why `artifact` cannot name a file inside the workspace, or `null`.
|
|
618
|
+
*
|
|
619
|
+
* @param {string} artifact The declared, workspace-relative path.
|
|
620
|
+
* @returns {string|null}
|
|
621
|
+
*/
|
|
622
|
+
function artifactPathProblem(artifact) {
|
|
623
|
+
if (ABSOLUTE_ARTIFACT_PATH.test(artifact)) {
|
|
624
|
+
return (
|
|
625
|
+
`is absolute — an artifact is named relative to the workspace root, so the same policy ` +
|
|
626
|
+
`resolves to the same bytes on every machine that reads it`
|
|
627
|
+
);
|
|
628
|
+
}
|
|
629
|
+
// Separators are normalised to `/` before the question is asked: a `..\`
|
|
630
|
+
// written on Windows escapes exactly as far as a `../` does, and POSIX's
|
|
631
|
+
// `normalize` would otherwise read the whole `..\rules` as one filename.
|
|
632
|
+
const resolved = posix.normalize(
|
|
633
|
+
`${NOMINAL_WORKSPACE_ROOT}/${artifact.split(/[\\/]/u).join("/")}`,
|
|
634
|
+
);
|
|
635
|
+
if (pathEscapes(NOMINAL_WORKSPACE_ROOT, resolved)) {
|
|
636
|
+
return (
|
|
637
|
+
`leaves the workspace — a custom rule's artifact is a file inside the tree its policy ` +
|
|
638
|
+
`governs, so that a reviewer reads the same bytes CI runs`
|
|
639
|
+
);
|
|
640
|
+
}
|
|
641
|
+
return null;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* Whether `value` is an object JSON carries as itself: a plain one, or a
|
|
646
|
+
* null-prototype one (`JSON.parse` builds those for a `__proto__` key). An
|
|
647
|
+
* array, a `Map`, a `Set`, a `RegExp`, a `Date` and a class instance all answer
|
|
648
|
+
* `false` — they carry a prototype of their own, and `isPlainObject` above
|
|
649
|
+
* accepts most of them, which is right for the config shapes it guards and
|
|
650
|
+
* wrong here, where the question is what survives serialization rather than
|
|
651
|
+
* what has properties.
|
|
652
|
+
*
|
|
653
|
+
* @param {unknown} value
|
|
654
|
+
* @returns {value is Record<string, unknown>}
|
|
655
|
+
*/
|
|
656
|
+
function isJsonObject(value) {
|
|
657
|
+
if (typeof value !== "object" || value === null) return false;
|
|
658
|
+
const proto = Object.getPrototypeOf(value);
|
|
659
|
+
return proto === Object.prototype || proto === null;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* Where `params` holds a value JSON cannot carry, or `null`.
|
|
664
|
+
*
|
|
665
|
+
* `params` is handed to the rule as serialized evidence, so a value with no
|
|
666
|
+
* JSON form does not fail loudly there — it DISAPPEARS. `JSON.stringify` drops
|
|
667
|
+
* a function, a symbol and an `undefined` from an object outright, renders
|
|
668
|
+
* `NaN`/`Infinity` as `null`, flattens a `Map` or a `Set` to `{}`, and turns a
|
|
669
|
+
* `Date` into a string; a rule would then be judged under parameters nobody
|
|
670
|
+
* wrote, which is this repository's poison direction. A cycle is the one shape
|
|
671
|
+
* that throws instead, and it throws from wherever the serialization happens
|
|
672
|
+
* rather than naming the row — so it is caught here too, by name.
|
|
673
|
+
*
|
|
674
|
+
* The offending value is named by KIND rather than through `describe`: a `Map`
|
|
675
|
+
* renders as `object ({})` there — precisely the thing it serializes to, which
|
|
676
|
+
* is what makes it silent — and a `BigInt` cannot be rendered at all, because
|
|
677
|
+
* `describe`'s own `JSON.stringify` throws on one.
|
|
678
|
+
*
|
|
679
|
+
* @param {unknown} value
|
|
680
|
+
* @param {string} at Dotted path of the value, for the message.
|
|
681
|
+
* @param {Set<unknown>} seen Objects on the current path, for the cycle check.
|
|
682
|
+
* @returns {string|null}
|
|
683
|
+
*/
|
|
684
|
+
function unserializableParam(value, at, seen) {
|
|
685
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") return null;
|
|
686
|
+
if (typeof value === "number") {
|
|
687
|
+
return Number.isFinite(value)
|
|
688
|
+
? null
|
|
689
|
+
: `${at}: must be a finite number — ${String(value)} serializes as null, so the rule ` +
|
|
690
|
+
`would be judged under a parameter nobody wrote`;
|
|
691
|
+
}
|
|
692
|
+
if (Array.isArray(value) || isJsonObject(value)) {
|
|
693
|
+
if (seen.has(value)) {
|
|
694
|
+
return `${at}: refers back to a value that contains it — a parameter table with a cycle has no serialization at all`;
|
|
695
|
+
}
|
|
696
|
+
seen.add(value);
|
|
697
|
+
if (Array.isArray(value)) {
|
|
698
|
+
for (const [index, item] of value.entries()) {
|
|
699
|
+
const problem = unserializableParam(item, `${at}[${index}]`, seen);
|
|
700
|
+
if (problem) return problem;
|
|
701
|
+
}
|
|
702
|
+
} else {
|
|
703
|
+
for (const [key, item] of Object.entries(value)) {
|
|
704
|
+
const problem = unserializableParam(item, `${at}.${key}`, seen);
|
|
705
|
+
if (problem) return problem;
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
seen.delete(value);
|
|
709
|
+
return null;
|
|
710
|
+
}
|
|
711
|
+
const kind = typeof value === "object" ? (value.constructor?.name ?? "object") : typeof value;
|
|
712
|
+
return (
|
|
713
|
+
`${at}: must be JSON data, got ${kind} — a value with no JSON form of its own is dropped or ` +
|
|
714
|
+
`rewritten on the way into a rule's evidence rather than refused there, so the rule would ` +
|
|
715
|
+
`run under parameters that differ from the ones written here`
|
|
716
|
+
);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* One custom-rule row's problems, prefixed with its index so a report names the
|
|
721
|
+
* offender.
|
|
722
|
+
*
|
|
723
|
+
* Every field is checked in the loud direction — the row DECLARES law this
|
|
724
|
+
* engine did not write, so a field that cannot be read is a rule that cannot be
|
|
725
|
+
* loaded, never a rule quietly skipped. `reason` is mandatory for the reason it
|
|
726
|
+
* is mandatory on a suppression and a fitness row (`suppressionRowViolations`):
|
|
727
|
+
* a rule nobody wrote a reason for is indistinguishable from a rule nobody
|
|
728
|
+
* would defend.
|
|
729
|
+
*
|
|
730
|
+
* @param {unknown} row
|
|
731
|
+
* @param {number} index
|
|
732
|
+
* @param {Set<string>} names The names earlier rows already claimed — a name is
|
|
733
|
+
* a selector, so two rows sharing one make every report about that rule
|
|
734
|
+
* ambiguous.
|
|
735
|
+
* @param {{resolve?: (key: "decisionRef"|"fitnessBindings", id: string) => boolean}} io
|
|
736
|
+
* Passed through to the shared governance schema, exactly as a constraint row
|
|
737
|
+
* passes it.
|
|
738
|
+
* @returns {string[]}
|
|
739
|
+
*/
|
|
740
|
+
function customRuleRowViolations(row, index, names, io) {
|
|
741
|
+
const at = `customRules[${index}]`;
|
|
742
|
+
if (!isPlainObject(row)) return [`${at}: must be an object, got ${describe(row)}`];
|
|
743
|
+
|
|
744
|
+
const violations = [];
|
|
745
|
+
if (typeof row.name !== "string" || !CUSTOM_RULE_NAME_PATTERN.test(row.name)) {
|
|
746
|
+
violations.push(
|
|
747
|
+
`${at}.name: must be a non-empty name of lowercase letters and digits joined by single ` +
|
|
748
|
+
`"-" separators (like "no-interface-outside-domain"), got ${describe(row.name)}`,
|
|
749
|
+
);
|
|
750
|
+
} else if (names.has(row.name)) {
|
|
751
|
+
violations.push(
|
|
752
|
+
`${at}.name: "${row.name}" is declared more than once — every custom rule name must be ` +
|
|
753
|
+
`unique, because a finding names the rule that reported it`,
|
|
754
|
+
);
|
|
755
|
+
} else {
|
|
756
|
+
names.add(row.name);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
if (typeof row.artifact !== "string" || row.artifact === "") {
|
|
760
|
+
violations.push(
|
|
761
|
+
`${at}.artifact: must be a non-empty workspace-relative path to the rule's artifact, got ` +
|
|
762
|
+
`${describe(row.artifact)}`,
|
|
763
|
+
);
|
|
764
|
+
} else {
|
|
765
|
+
const problem = artifactPathProblem(row.artifact);
|
|
766
|
+
if (problem) violations.push(`${at}.artifact: '${row.artifact}' ${problem}`);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
if (typeof row.sha256 !== "string" || !CUSTOM_RULE_SHA256_PATTERN.test(row.sha256)) {
|
|
770
|
+
violations.push(
|
|
771
|
+
`${at}.sha256: must be 64 lowercase hex characters — the hash of the artifact's own bytes, ` +
|
|
772
|
+
`which is what makes the law CI ran and the law a reviewer read the same law, got ` +
|
|
773
|
+
`${describe(row.sha256)}`,
|
|
774
|
+
);
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
if ("params" in row) {
|
|
778
|
+
if (!isPlainObject(row.params)) {
|
|
779
|
+
violations.push(
|
|
780
|
+
`${at}.params: must be an object of JSON data when present, got ${describe(row.params)}`,
|
|
781
|
+
);
|
|
782
|
+
} else {
|
|
783
|
+
const problem = unserializableParam(row.params, `${at}.params`, new Set());
|
|
784
|
+
if (problem) violations.push(problem);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
if (typeof row.reason !== "string" || row.reason.trim() === "") {
|
|
789
|
+
violations.push(
|
|
790
|
+
`${at}.reason: must be a non-empty string — a custom rule is a policy decision, and one ` +
|
|
791
|
+
`with no reason written down is indistinguishable from a rule nobody would defend`,
|
|
792
|
+
);
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
// The governance block (Contract 2), through the ONE shared schema every
|
|
796
|
+
// other row family asks (`./governance/row-schema.mjs`) — including the
|
|
797
|
+
// resolution half, so a `fitnessBindings` entry naming no declared fitness
|
|
798
|
+
// rule fails here exactly as it does on a constraint row.
|
|
799
|
+
if ("origin" in row || "rationale" in row || "decisionRef" in row || "fitnessBindings" in row) {
|
|
800
|
+
violations.push(...rowSchemaViolations(row, at, io));
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// Rejected rather than ignored, for the reason a constraint row's unknown key
|
|
804
|
+
// is: a misspelt `param`/`sha`/`artefact` would load, carry the half this
|
|
805
|
+
// reader understood, and drop the rest of the declaration.
|
|
806
|
+
for (const key of Object.keys(row)) {
|
|
807
|
+
if (CUSTOM_RULE_KEYS.includes(key) || GOVERNANCE_ROW_KEYS.includes(key)) continue;
|
|
808
|
+
violations.push(
|
|
809
|
+
`${at}.${key}: not a custom-rule field — expected one of ${CUSTOM_RULE_KEYS.join(", ")}, ` +
|
|
810
|
+
`plus the governance block keys ${GOVERNANCE_ROW_KEYS.join(", ")}`,
|
|
811
|
+
);
|
|
812
|
+
}
|
|
813
|
+
return violations;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/**
|
|
817
|
+
* Everything wrong with a `customRules` list, as messages; empty when it is
|
|
818
|
+
* well-formed.
|
|
819
|
+
*
|
|
820
|
+
* An ABSENT list means "this workspace declares no custom rules", the same
|
|
821
|
+
* decision an absent `boundarySuppressions` states. A list that is PRESENT and
|
|
822
|
+
* empty is refused, the same way `findFitnessViolations`
|
|
823
|
+
* (`./governance/fitness-registry.mjs`) refuses an empty `fitness`: both keys
|
|
824
|
+
* declare law, and a law list present but empty reads as governed while judging
|
|
825
|
+
* nothing. The difference from a suppression list — where `[]` is accepted — is
|
|
826
|
+
* that an empty exemption list exempts nothing, which is the direction that
|
|
827
|
+
* cannot hide anything.
|
|
828
|
+
*
|
|
829
|
+
* @param {unknown} list The parsed `customRules` value.
|
|
830
|
+
* @param {{resolve?: (key: "decisionRef"|"fitnessBindings", id: string) => boolean}} io
|
|
831
|
+
* @returns {string[]}
|
|
832
|
+
*/
|
|
833
|
+
function findCustomRuleViolations(list, io) {
|
|
834
|
+
if (!Array.isArray(list)) {
|
|
835
|
+
return [`customRules: must be an array of custom-rule rows, got ${describe(list)}`];
|
|
836
|
+
}
|
|
837
|
+
if (list.length === 0) {
|
|
838
|
+
return [
|
|
839
|
+
"customRules: must not be empty — a list present but empty reads as law while judging nothing",
|
|
840
|
+
];
|
|
841
|
+
}
|
|
842
|
+
const violations = [];
|
|
843
|
+
const names = new Set();
|
|
844
|
+
list.forEach((row, index) => violations.push(...customRuleRowViolations(row, index, names, io)));
|
|
845
|
+
return violations;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
/** A value's type, for an error message that shows what was actually there. */
|
|
849
|
+
function describe(value) {
|
|
850
|
+
if (Array.isArray(value)) return `an array (${JSON.stringify(value)})`;
|
|
851
|
+
if (value === null) return "null";
|
|
852
|
+
return `${typeof value} (${JSON.stringify(value) ?? String(value)})`;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
/**
|
|
856
|
+
* Everything wrong with a loaded boundary config, as messages; empty when it
|
|
857
|
+
* is well-formed. Pure, so a test drives it without a file on disk.
|
|
858
|
+
*
|
|
859
|
+
* @param {unknown} module The config module's exports.
|
|
860
|
+
* @returns {string[]}
|
|
861
|
+
*/
|
|
862
|
+
export function findBoundaryConfigViolations(module, io = {}) {
|
|
863
|
+
if (!isPlainObject(module)) return [`config: expected a module object, got ${describe(module)}`];
|
|
864
|
+
|
|
865
|
+
const violations = [];
|
|
866
|
+
const { depConstraints, moduleBoundaryOptions, boundarySuppressions, fitness, customRules } =
|
|
867
|
+
module;
|
|
868
|
+
// F05: the resolution half of the governance block (`row-schema.mjs`'s
|
|
869
|
+
// `io.resolve`) was validator-only until now — no production caller passed
|
|
870
|
+
// one, so a row bound to a fitness rule that does not exist loaded and ran
|
|
871
|
+
// as if verified. When no caller supplies one, fill it from this module's
|
|
872
|
+
// OWN `fitness` export: a row may only claim a fitness rule this very
|
|
873
|
+
// policy declares (F04's authority, `declaredFitnessNames`). An ADR id needs
|
|
874
|
+
// the registry, which no config loader holds — `unresolvedDecisionRefRows`
|
|
875
|
+
// (`./governance/adr-registry.mjs`) is the check/adr/provenance callers'
|
|
876
|
+
// game, run where the registry lives. What is resolved HERE is the half that
|
|
877
|
+
// can answer from the policy alone.
|
|
878
|
+
if (!io.resolve) {
|
|
879
|
+
const declared = declaredFitnessNames(module);
|
|
880
|
+
io = {
|
|
881
|
+
...io,
|
|
882
|
+
resolve: (key, id) =>
|
|
883
|
+
key === "fitnessBindings"
|
|
884
|
+
? declared.has(stripRuleFitnessPrefix(id))
|
|
885
|
+
: // A row's `decisionRef` has two name spaces — a `rule:`/`fitness:`-shaped
|
|
886
|
+
// citation must name a declared fitness here (the half this loader can
|
|
887
|
+
// answer), while a bare `NNN-slug` or `adr:`-prefixed one is an ADR
|
|
888
|
+
// citation the registry owns, judged where the registry lives
|
|
889
|
+
// (`unresolvedDecisionRefRows`, run by check/adr/provenance).
|
|
890
|
+
stripRuleFitnessPrefix(id) === id || declared.has(stripRuleFitnessPrefix(id)),
|
|
891
|
+
};
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
// The fitness list — declared where every other executable policy is
|
|
895
|
+
// (`../governance/fitness-registry.mjs` owns the shape). Absent means "no
|
|
896
|
+
// fitness declared", which is a workspace decision the report states; a list
|
|
897
|
+
// present but malformed is refused here, loudly, the same way a malformed
|
|
898
|
+
// suppression is.
|
|
899
|
+
if (fitness !== undefined) {
|
|
900
|
+
violations.push(...findFitnessViolations(fitness, io));
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
// The custom-rule list — the fifth top-level law, declared here and executed
|
|
904
|
+
// nowhere near here (this module's header says why). Absent means "no custom
|
|
905
|
+
// rules declared"; present and malformed is refused loudly, because a row
|
|
906
|
+
// this reader could not understand is a rule that would not run while the
|
|
907
|
+
// policy still says it does.
|
|
908
|
+
if (customRules !== undefined) {
|
|
909
|
+
violations.push(...findCustomRuleViolations(customRules, io));
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
// Absent means "nothing is suppressed", which is the only default that fails
|
|
913
|
+
// toward reporting — unlike the eight options above, where a missing value
|
|
914
|
+
// would be a second copy of something ESLint also reads and this module has
|
|
915
|
+
// no business guessing. A suppression has no second reader: ESLint uses its
|
|
916
|
+
// own directives, so there is nothing here to disagree with.
|
|
917
|
+
if (boundarySuppressions !== undefined) {
|
|
918
|
+
if (!Array.isArray(boundarySuppressions)) {
|
|
919
|
+
violations.push(
|
|
920
|
+
`boundarySuppressions: must be an exported array when present, got ` +
|
|
921
|
+
`${describe(boundarySuppressions)}`,
|
|
922
|
+
);
|
|
923
|
+
} else {
|
|
924
|
+
boundarySuppressions.forEach((row, index) =>
|
|
925
|
+
violations.push(...suppressionRowViolations(row, index)),
|
|
926
|
+
);
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
if (!Array.isArray(depConstraints)) {
|
|
931
|
+
violations.push(
|
|
932
|
+
`depConstraints: must be an exported array, got ${describe(depConstraints)} — ` +
|
|
933
|
+
`this is the constraint table both enforcers read`,
|
|
934
|
+
);
|
|
935
|
+
} else {
|
|
936
|
+
depConstraints.forEach((row, index) =>
|
|
937
|
+
violations.push(...constraintRowViolations(row, index, io)),
|
|
938
|
+
);
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
if (!isPlainObject(moduleBoundaryOptions)) {
|
|
942
|
+
violations.push(
|
|
943
|
+
`moduleBoundaryOptions: must be an exported object, got ${describe(moduleBoundaryOptions)}`,
|
|
944
|
+
);
|
|
945
|
+
return violations;
|
|
946
|
+
}
|
|
947
|
+
for (const [key, type] of Object.entries(OPTION_TYPES)) {
|
|
948
|
+
if (!(key in moduleBoundaryOptions)) {
|
|
949
|
+
// Missing is rejected rather than defaulted. A default here would be a
|
|
950
|
+
// second copy of a value the config file already states, and the two
|
|
951
|
+
// would answer differently the day one of them changed.
|
|
952
|
+
violations.push(`moduleBoundaryOptions.${key}: missing — every option is stated explicitly`);
|
|
953
|
+
continue;
|
|
954
|
+
}
|
|
955
|
+
const value = moduleBoundaryOptions[key];
|
|
956
|
+
const ok =
|
|
957
|
+
type === "boolean"
|
|
958
|
+
? typeof value === "boolean"
|
|
959
|
+
: type === "string[]"
|
|
960
|
+
? isStringArray(value)
|
|
961
|
+
: isTagPairArray(value);
|
|
962
|
+
if (!ok) {
|
|
963
|
+
violations.push(`moduleBoundaryOptions.${key}: must be ${type}, got ${describe(value)}`);
|
|
964
|
+
continue;
|
|
965
|
+
}
|
|
966
|
+
const at = `moduleBoundaryOptions.${key}`;
|
|
967
|
+
if (type === "string[]") {
|
|
968
|
+
violations.push(
|
|
969
|
+
...listEntryViolations(/** @type {string[]} */ (value), at, OPTION_ENTRY_MATCHERS[key]),
|
|
970
|
+
);
|
|
971
|
+
} else if (type === "pair[]") {
|
|
972
|
+
/** @type {[string, string][]} */ (value).forEach((pair, index) =>
|
|
973
|
+
violations.push(
|
|
974
|
+
...listEntryViolations(pair, `${at}[${index}]`, OPTION_ENTRY_MATCHERS[key]),
|
|
975
|
+
),
|
|
976
|
+
);
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
for (const key of Object.keys(moduleBoundaryOptions)) {
|
|
980
|
+
if (!(key in OPTION_TYPES)) {
|
|
981
|
+
violations.push(
|
|
982
|
+
`moduleBoundaryOptions.${key}: not an option of @nx/enforce-module-boundaries — ` +
|
|
983
|
+
`expected one of ${Object.keys(OPTION_TYPES).join(", ")}`,
|
|
984
|
+
);
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
return violations;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
/**
|
|
991
|
+
* The `.json` dialect's top-level keys, beyond the three every dialect reads.
|
|
992
|
+
* `$schema` is the one key the `.mjs` dialect has no counterpart for at all —
|
|
993
|
+
* an ES module has no analogous editor-validation hook — so the `.mjs`
|
|
994
|
+
* dialect refuses it by name like any other unknown export while the `.json`
|
|
995
|
+
* dialect carves it out by name (accepted and checked, never folded into a
|
|
996
|
+
* general "ignore unknown" rule, which is exactly the leniency this file's
|
|
997
|
+
* header argues a JSON object must not get). `fitness` is the fourth: the
|
|
998
|
+
* boundary dialect's key for the fitness-functions list, validated as an array
|
|
999
|
+
* of fitness rows. `customRules` is the fifth and newest — the declared rules
|
|
1000
|
+
* this engine did not write, validated as an array of custom-rule rows.
|
|
1001
|
+
*
|
|
1002
|
+
* The name says `.json` and the list binds both file dialects: `loadModulePolicy`
|
|
1003
|
+
* runs the same check over an ES module's exports, which is what makes a
|
|
1004
|
+
* misspelt `customRule` export a named refusal in either spelling rather than a
|
|
1005
|
+
* law that silently never loads.
|
|
1006
|
+
*/
|
|
1007
|
+
const JSON_POLICY_KEYS = [
|
|
1008
|
+
"depConstraints",
|
|
1009
|
+
"moduleBoundaryOptions",
|
|
1010
|
+
"boundarySuppressions",
|
|
1011
|
+
"fitness",
|
|
1012
|
+
"customRules",
|
|
1013
|
+
];
|
|
1014
|
+
|
|
1015
|
+
/**
|
|
1016
|
+
* The `.json` dialect's own shape check, on top of `findBoundaryConfigViolations`:
|
|
1017
|
+
* a top-level key that is neither one of the three every dialect reads nor —
|
|
1018
|
+
* when `allowSchema` is set — `$schema` is rejected by name. Run only when
|
|
1019
|
+
* `parsed` is itself a plain object — a non-object top level is already
|
|
1020
|
+
* `findBoundaryConfigViolations`' first check, and this function would have
|
|
1021
|
+
* nothing to enumerate.
|
|
1022
|
+
*
|
|
1023
|
+
* When `allowSchema` is set, `$schema` is accepted but CHECKED rather than
|
|
1024
|
+
* ignored: an editor writes it in unasked, but a `$schema` that is not a
|
|
1025
|
+
* non-empty string states nothing an editor can validate against and reads as
|
|
1026
|
+
* a false green — the same silent-direction rule this file applies to every
|
|
1027
|
+
* other key. The check lives here so the `.json` file dialect and a native
|
|
1028
|
+
* workspace's inline `archkeep.json → boundaryConfig` object share it
|
|
1029
|
+
* (`./providers/native/model.mjs`) — one validator, one key law.
|
|
1030
|
+
* (`../../../docs/reference/policy-schema.md`, "Inline policy (`archkeep.json` only)").
|
|
1031
|
+
*
|
|
1032
|
+
* @param {unknown} parsed
|
|
1033
|
+
* @param {{allowSchema: boolean}} options
|
|
1034
|
+
* @returns {string[]}
|
|
1035
|
+
*/
|
|
1036
|
+
export function policyKeyViolations(parsed, { allowSchema }) {
|
|
1037
|
+
if (!isPlainObject(parsed)) return [];
|
|
1038
|
+
const violations = [];
|
|
1039
|
+
for (const key of Object.keys(parsed)) {
|
|
1040
|
+
if (allowSchema && key === "$schema") {
|
|
1041
|
+
if (typeof parsed[key] !== "string" || parsed[key].trim() === "") {
|
|
1042
|
+
violations.push(
|
|
1043
|
+
`$schema: must be a non-empty string naming the schema the editor should validate ` +
|
|
1044
|
+
`against, got ${describe(parsed[key])}`,
|
|
1045
|
+
);
|
|
1046
|
+
}
|
|
1047
|
+
continue;
|
|
1048
|
+
}
|
|
1049
|
+
if (JSON_POLICY_KEYS.includes(key)) continue;
|
|
1050
|
+
violations.push(
|
|
1051
|
+
`${key}: not a recognised top-level key — expected one of ${JSON_POLICY_KEYS.join(", ")}` +
|
|
1052
|
+
(allowSchema ? `, plus '$schema' (for editor validation)` : "") +
|
|
1053
|
+
(key === "default" ? " — the .mjs dialect reads named exports, not a default export" : ""),
|
|
1054
|
+
);
|
|
1055
|
+
}
|
|
1056
|
+
return violations;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
/**
|
|
1060
|
+
* The validate-then-reshape tail every policy loader shares, whichever
|
|
1061
|
+
* dialect or spelling produced `parsed`: run `findBoundaryConfigViolations`
|
|
1062
|
+
* against it, alongside whatever dialect-specific violations the caller
|
|
1063
|
+
* already found (the `.json` dialect's `policyKeyViolations`, for instance),
|
|
1064
|
+
* throw one error naming every violation at once when there are any, and
|
|
1065
|
+
* otherwise reshape the three keys into the `{depConstraints, options,
|
|
1066
|
+
* suppressions}` shape every caller reads.
|
|
1067
|
+
*
|
|
1068
|
+
* Extracted because this exact sequence used to be copied, by hand, into
|
|
1069
|
+
* `loadModulePolicy` and `loadJsonPolicy` below, with a third, UN-validated
|
|
1070
|
+
* copy in `../cli.mjs`'s native inline-`boundaryConfig` branch — three places
|
|
1071
|
+
* that had to be kept in agreement across every future change to either
|
|
1072
|
+
* `findBoundaryConfigViolations` or this return shape, with nothing that
|
|
1073
|
+
* would fail if one of them drifted. `./lsp/boundary-config.mjs`'s `.json`
|
|
1074
|
+
* arm calls this too, for the identical reason — as does this module's own
|
|
1075
|
+
* ESLint-dialect branch of `loadBoundaryConfigFile` below, so a malformed
|
|
1076
|
+
* `depConstraints` row is refused identically whether it arrived through an
|
|
1077
|
+
* ES module, a JSON file, or an ESLint flat config's rule entry.
|
|
1078
|
+
*
|
|
1079
|
+
* @param {any} parsed The dialect's parsed data — a module's exports, a
|
|
1080
|
+
* parsed JSON object, or a native workspace's inline policy object. Typed
|
|
1081
|
+
* loosely on purpose: this function is the one place that reshapes it, and
|
|
1082
|
+
* every field it reads has already been through `findBoundaryConfigViolations`
|
|
1083
|
+
* by the time the return statement below is reached.
|
|
1084
|
+
* @param {string} sourceLabel What failed, named in the thrown message — an
|
|
1085
|
+
* absolute path for a file-backed dialect, a descriptive phrase for an
|
|
1086
|
+
* inline one.
|
|
1087
|
+
* @param {string[]} [extraViolations] Violations the caller already found that
|
|
1088
|
+
* `findBoundaryConfigViolations` does not check on its own.
|
|
1089
|
+
* @returns {{ depConstraints: object[], options: object, suppressions: object[], fitness?: object[], customRules?: object[] }}
|
|
1090
|
+
* `fitness` and `customRules` are present only when the config declares
|
|
1091
|
+
* them — a workspace without one carries no key, the same "absent is a
|
|
1092
|
+
* decision" posture `cli.mjs`'s `check` uses for a missing
|
|
1093
|
+
* architecture-intent file. Both ride through exactly as the file declared
|
|
1094
|
+
* them, with nothing defaulted in: a row here is the workspace's own text,
|
|
1095
|
+
* and a reader that filled a field in would be stating law the policy does
|
|
1096
|
+
* not.
|
|
1097
|
+
* @throws {Error} `archkeep: ${sourceLabel} is malformed:` followed by every
|
|
1098
|
+
* violation found, when `extraViolations` or `findBoundaryConfigViolations`
|
|
1099
|
+
* found any.
|
|
1100
|
+
*/
|
|
1101
|
+
export function policyFrom(parsed, sourceLabel, extraViolations = []) {
|
|
1102
|
+
const violations = [...extraViolations, ...findBoundaryConfigViolations(parsed)];
|
|
1103
|
+
if (violations.length > 0) {
|
|
1104
|
+
throw new Error(`archkeep: ${sourceLabel} is malformed:\n ${violations.join("\n ")}`);
|
|
1105
|
+
}
|
|
1106
|
+
return {
|
|
1107
|
+
depConstraints: parsed.depConstraints,
|
|
1108
|
+
options: parsed.moduleBoundaryOptions,
|
|
1109
|
+
suppressions: parsed.boundarySuppressions ?? [],
|
|
1110
|
+
...(parsed.fitness === undefined ? {} : { fitness: parsed.fitness }),
|
|
1111
|
+
...(parsed.customRules === undefined ? {} : { customRules: parsed.customRules }),
|
|
1112
|
+
};
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
/**
|
|
1116
|
+
* Loads and validates the `.mjs`/`.js` dialect: an ES module whose exports
|
|
1117
|
+
* `findBoundaryConfigViolations` above reads by name.
|
|
1118
|
+
*
|
|
1119
|
+
* The module's own top-level exports get the same unknown-key law the `.json`
|
|
1120
|
+
* dialect applies to its top-level keys: an export that is not one of the five
|
|
1121
|
+
* this loader reads is almost always a misspelling of one of them
|
|
1122
|
+
* (`moduleBoundaryOptions` → `moduleBoundaryOptions` silent-ignored), and
|
|
1123
|
+
* a misspelled law is a law that is not enforced. A genuine helper export is
|
|
1124
|
+
* refused by name; the header's older position that ESM exports are a
|
|
1125
|
+
* legitimate namespace to share a helper in gave a typo the same silence.
|
|
1126
|
+
*
|
|
1127
|
+
* @param {string} path Absolute path of the config file.
|
|
1128
|
+
* @returns {Promise<{ depConstraints: object[], options: object, suppressions: object[], fitness?: object[], customRules?: object[] }>}
|
|
1129
|
+
* @throws {Error} when the file is missing, unloadable, or malformed.
|
|
1130
|
+
*/
|
|
1131
|
+
async function loadModulePolicy(path) {
|
|
1132
|
+
let module;
|
|
1133
|
+
try {
|
|
1134
|
+
module = await import(pathToFileURL(path).href);
|
|
1135
|
+
} catch (cause) {
|
|
1136
|
+
throw new Error(`archkeep: cannot load ${path}: ${cause?.message ?? cause}`, {
|
|
1137
|
+
cause,
|
|
1138
|
+
});
|
|
1139
|
+
}
|
|
1140
|
+
return policyFrom(module, path, policyKeyViolations(module, { allowSchema: false }));
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
/**
|
|
1144
|
+
* Loads and validates the `.json` dialect: plain `JSON.parse`, never JSONC and
|
|
1145
|
+
* never `import()`, so the file this loader reads carries no more syntax than
|
|
1146
|
+
* the format it declares itself to be. Its three data keys go through the
|
|
1147
|
+
* exact same `findBoundaryConfigViolations` the `.mjs` dialect uses — this
|
|
1148
|
+
* module's header explains why that has to be one function rather than two.
|
|
1149
|
+
*
|
|
1150
|
+
* @param {string} path Absolute path of the config file.
|
|
1151
|
+
* @param {{readFile?: (path: string, encoding: "utf8") => Promise<string>}} [io]
|
|
1152
|
+
* Injectable read, defaulting to `node:fs/promises`' `readFile` — the only
|
|
1153
|
+
* code in this function that reaches outside the process.
|
|
1154
|
+
* @returns {Promise<{ depConstraints: object[], options: object, suppressions: object[], fitness?: object[], customRules?: object[] }>}
|
|
1155
|
+
* @throws {Error} when the file is missing, unreadable, not valid JSON, or
|
|
1156
|
+
* malformed — either by `findBoundaryConfigViolations`' rules or by carrying
|
|
1157
|
+
* a top-level key none of those rules knows about.
|
|
1158
|
+
*/
|
|
1159
|
+
async function loadJsonPolicy(path, { readFile = readFileFromDisk } = {}) {
|
|
1160
|
+
let text;
|
|
1161
|
+
try {
|
|
1162
|
+
text = await readFile(path, "utf8");
|
|
1163
|
+
} catch (cause) {
|
|
1164
|
+
throw new Error(`archkeep: cannot load ${path}: ${cause?.message ?? cause}`, {
|
|
1165
|
+
cause,
|
|
1166
|
+
});
|
|
1167
|
+
}
|
|
1168
|
+
let parsed;
|
|
1169
|
+
try {
|
|
1170
|
+
parsed = JSON.parse(text);
|
|
1171
|
+
} catch (cause) {
|
|
1172
|
+
throw new Error(`archkeep: cannot load ${path}: ${cause?.message ?? cause}`, {
|
|
1173
|
+
cause,
|
|
1174
|
+
});
|
|
1175
|
+
}
|
|
1176
|
+
return policyFrom(parsed, path, policyKeyViolations(parsed, { allowSchema: true }));
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
/**
|
|
1180
|
+
* Loads and validates one boundary config, named by its own absolute path.
|
|
1181
|
+
*
|
|
1182
|
+
* The path-taking form exists because the config's location and the tree being
|
|
1183
|
+
* judged are two facts, not one. They coincide in this repository and come
|
|
1184
|
+
* apart wherever a workspace keeps its law somewhere else, and a run may need
|
|
1185
|
+
* to say so explicitly — `cli.mjs --config` is that seam. `loadBoundaryConfig`
|
|
1186
|
+
* below is the common case expressed in terms of this one, so both forms answer
|
|
1187
|
+
* through the same validation and neither can drift into a second opinion about
|
|
1188
|
+
* a malformed row.
|
|
1189
|
+
*
|
|
1190
|
+
* Dispatches on the path's extension: `.mjs`/`.js` through `loadModulePolicy`
|
|
1191
|
+
* (an `import()`, as this loader has always done) and `.json` through
|
|
1192
|
+
* `loadJsonPolicy` (a `JSON.parse`, new — this module's header explains the
|
|
1193
|
+
* dialect). Anything else is refused by name, in a message that does not
|
|
1194
|
+
* contain the words "cannot load" — a `boundaryConfig` misspelt to a `.yaml`
|
|
1195
|
+
* or `.toml` extension is a naming mistake, not a missing or unreadable file,
|
|
1196
|
+
* and the two must read as different problems.
|
|
1197
|
+
*
|
|
1198
|
+
* @param {string} path Absolute path of the config file.
|
|
1199
|
+
* @param {{readFile?: (path: string, encoding: "utf8") => Promise<string>}} [io]
|
|
1200
|
+
* Injectable read, used only by the `.json` dialect — see `loadJsonPolicy`.
|
|
1201
|
+
* @returns {Promise<{ depConstraints: object[], options: object, suppressions: object[], fitness?: object[], customRules?: object[], notes?: string[] }>}
|
|
1202
|
+
* `suppressions` is `[]` when the config declares none. `notes` is present
|
|
1203
|
+
* only under the ESLint dialect, and only when `./eslint-config.mjs` has
|
|
1204
|
+
* something worth telling a reader about which entry it bound — see
|
|
1205
|
+
* `extractBoundaryRule`'s own `@returns`. `cli.mjs`'s `check` surfaces it on
|
|
1206
|
+
* the report's coverage line, next to what was inspected, rather than
|
|
1207
|
+
* computing it and dropping it: a fact worth noting and never shown is the
|
|
1208
|
+
* silent direction with extra steps.
|
|
1209
|
+
* @throws {Error} when the file is missing, unloadable, or malformed. Loud on
|
|
1210
|
+
* purpose: an enforcer that starts with no rules enforces nothing and says
|
|
1211
|
+
* nothing, which is the failure this whole tool exists to end.
|
|
1212
|
+
*/
|
|
1213
|
+
export async function loadBoundaryConfigFile(path, io = {}) {
|
|
1214
|
+
// Basename tests run STRICTLY BEFORE the extension dispatch below — both
|
|
1215
|
+
// `eslint.config.*` and `.eslintrc*` are `.mjs`/`.js`-extensioned far more
|
|
1216
|
+
// often than not, and reaching the module dialect's bare `import()` first
|
|
1217
|
+
// would either half-work (a flat-config array is a valid ES module, so
|
|
1218
|
+
// `.mjs` would "load" it and then fail on `findBoundaryConfigViolations`
|
|
1219
|
+
// with a message that never mentions ESLint) or fail on a `.eslintrc.json`
|
|
1220
|
+
// with the `.json` dialect's unrecognised-top-level-key refusal — neither
|
|
1221
|
+
// reads as what actually went wrong: this is an ESLint config, of the wrong
|
|
1222
|
+
// dialect or shape, not a malformed archkeep policy file.
|
|
1223
|
+
const name = basename(path);
|
|
1224
|
+
|
|
1225
|
+
if (LEGACY_ESLINTRC_BASENAME.test(name)) {
|
|
1226
|
+
throw new Error(
|
|
1227
|
+
`archkeep: ${path} names a legacy ESLint config (${name}) as boundaryConfig — ` +
|
|
1228
|
+
"@nx/enforce-module-boundaries itself only runs under ESLint's flat config, so archkeep's " +
|
|
1229
|
+
"ESLint dialect reads only a file named eslint.config.* exporting a flat-config array. " +
|
|
1230
|
+
"Point boundaryConfig at that file once the workspace has migrated, or at an .mjs " +
|
|
1231
|
+
"boundary-law module directly.",
|
|
1232
|
+
);
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
if (ESLINT_FLAT_CONFIG_BASENAME.test(name)) {
|
|
1236
|
+
const { depConstraints, options, note } = await loadEslintBoundaryConfig(path);
|
|
1237
|
+
// The ESLint dialect's assembled data goes through the same
|
|
1238
|
+
// validate-then-reshape tail every other dialect uses — `policyFrom`
|
|
1239
|
+
// reads `depConstraints`/`moduleBoundaryOptions` by the identical names
|
|
1240
|
+
// `findBoundaryConfigViolations` checks, so a malformed constraint row is
|
|
1241
|
+
// refused the same way regardless of which dialect produced it. `notes`
|
|
1242
|
+
// has no counterpart in that shared shape (no other dialect produces
|
|
1243
|
+
// one), so it is folded back in afterwards rather than taught to
|
|
1244
|
+
// `policyFrom` itself.
|
|
1245
|
+
const policy = policyFrom({ depConstraints, moduleBoundaryOptions: options }, path);
|
|
1246
|
+
return {
|
|
1247
|
+
...policy,
|
|
1248
|
+
// The ESLint dialect has no counterpart for `boundarySuppressions`: it
|
|
1249
|
+
// is this tool's own concept, with nothing for ESLint to read it back
|
|
1250
|
+
// from (see this module's header). Never populated under this dialect —
|
|
1251
|
+
// `policyFrom` already resolves that to `[]` since the object above
|
|
1252
|
+
// states no `boundarySuppressions` key, and it leaves `customRules`
|
|
1253
|
+
// absent for the same reason, the header's own distinction between an
|
|
1254
|
+
// absent law and an empty one.
|
|
1255
|
+
...(note !== undefined ? { notes: [note] } : {}),
|
|
1256
|
+
};
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
const extension = extname(path);
|
|
1260
|
+
if (extension === ".mjs" || extension === ".js") return loadModulePolicy(path);
|
|
1261
|
+
if (extension === ".json") return loadJsonPolicy(path, io);
|
|
1262
|
+
throw new Error(
|
|
1263
|
+
`archkeep: ${path} names an unsupported boundaryConfig extension '${extension || "(none)"}' — ` +
|
|
1264
|
+
`expected .mjs, .js, or .json`,
|
|
1265
|
+
);
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
/**
|
|
1269
|
+
* Loads and validates the boundary config a workspace root implies.
|
|
1270
|
+
*
|
|
1271
|
+
* @param {string} workspaceRoot Absolute path of the workspace root — the tree
|
|
1272
|
+
* being judged, which is not this module's own tree once the package is
|
|
1273
|
+
* installed into a consumer's `node_modules`.
|
|
1274
|
+
* @param {string} boundaryConfig The config's filename in THIS workspace,
|
|
1275
|
+
* resolved by the caller from the plugin's options (`./options.mjs`). Required
|
|
1276
|
+
* rather than defaulted here, so no code path can read a filename this module
|
|
1277
|
+
* guessed while the workspace uses another one — the failure that would follow
|
|
1278
|
+
* is "cannot load", which reads like a missing config rather than a
|
|
1279
|
+
* misconfigured tool.
|
|
1280
|
+
* @param {{readFile?: (path: string, encoding: "utf8") => Promise<string>}} [io]
|
|
1281
|
+
* Forwarded to `loadBoundaryConfigFile` — see there.
|
|
1282
|
+
* @returns {Promise<{ depConstraints: object[], options: object, suppressions: object[], fitness?: object[], customRules?: object[], notes?: string[] }>}
|
|
1283
|
+
* @throws {Error} as `loadBoundaryConfigFile`.
|
|
1284
|
+
*/
|
|
1285
|
+
export async function loadBoundaryConfig(workspaceRoot, boundaryConfig, io = {}) {
|
|
1286
|
+
const path = `${workspaceRoot.replace(/\/$/, "")}/${boundaryConfig}`;
|
|
1287
|
+
// Resolved ONCE, and the IDENTICAL string feeds the containment check and
|
|
1288
|
+
// the read (`loadBoundaryConfigFile`). The boundary law is the workspace's
|
|
1289
|
+
// own declared fact, and the name `boundaryConfig` is tree-derived
|
|
1290
|
+
// (`nx.json`/`archkeep.json` options) — so a tracked symlink in an
|
|
1291
|
+
// intermediate component of that name would hand outside constraint rows in
|
|
1292
|
+
// as the workspace's law, judged with zeros. The `--config` override is the
|
|
1293
|
+
// caller's explicit choice and takes the path-taking form, NOT this
|
|
1294
|
+
// function, so refusing here traps exactly the tree-derived case and leaves
|
|
1295
|
+
// the explicit override alone. Resolving first (rather than checking the
|
|
1296
|
+
// raw spelling) is the resolve-first contract `../containment.mjs`'s
|
|
1297
|
+
// `containsDotDot` refusal exists for: a `..` in the raw name would be
|
|
1298
|
+
// normalised away by `resolve` for the check while the read still followed
|
|
1299
|
+
// it (`./containment.mjs`, the read-side G-10 closure).
|
|
1300
|
+
const resolved = resolve(path);
|
|
1301
|
+
if (existsSync(workspaceRoot)) {
|
|
1302
|
+
const violation = containmentViolation(workspaceRoot, resolved);
|
|
1303
|
+
if (violation !== null) {
|
|
1304
|
+
throw new Error(`archkeep: cannot load ${path}: ${violation}`);
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
return loadBoundaryConfigFile(resolved, io);
|
|
1308
|
+
}
|