@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.
Files changed (131) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +262 -0
  3. package/cli.mjs +2792 -0
  4. package/index.mjs +85 -0
  5. package/lsp.mjs +81 -0
  6. package/nx.mjs +24 -0
  7. package/package.json +81 -0
  8. package/presets/clean-architecture.json +78 -0
  9. package/presets/ddd-bounded-contexts.json +88 -0
  10. package/presets/hexagonal.json +68 -0
  11. package/presets/layered.json +92 -0
  12. package/presets/modular-monolith.json +85 -0
  13. package/presets/vertical-slice.json +68 -0
  14. package/src/analysis/analyze.mjs +218 -0
  15. package/src/analysis/contract.md +259 -0
  16. package/src/analysis/go.mjs +414 -0
  17. package/src/analysis/manifest-util.mjs +68 -0
  18. package/src/analysis/python.mjs +1266 -0
  19. package/src/analysis/registry.mjs +74 -0
  20. package/src/analysis/rust.mjs +674 -0
  21. package/src/analysis/source-util.mjs +230 -0
  22. package/src/analysis/typescript.mjs +1034 -0
  23. package/src/analysis/vue.mjs +156 -0
  24. package/src/architecture-intent/intent-fingerprint.mjs +29 -0
  25. package/src/architecture-intent/judge.mjs +539 -0
  26. package/src/architecture-intent/model.mjs +703 -0
  27. package/src/architecture-intent/selectors.mjs +170 -0
  28. package/src/canonical.mjs +48 -0
  29. package/src/commands/README.md +266 -0
  30. package/src/commands/adr.mjs +248 -0
  31. package/src/commands/check.mjs +989 -0
  32. package/src/commands/context-command.mjs +212 -0
  33. package/src/commands/context.mjs +790 -0
  34. package/src/commands/custom-rules.mjs +428 -0
  35. package/src/commands/debt.mjs +218 -0
  36. package/src/commands/diff.mjs +523 -0
  37. package/src/commands/discover.mjs +159 -0
  38. package/src/commands/drift.mjs +473 -0
  39. package/src/commands/edge-constraints.mjs +355 -0
  40. package/src/commands/explain.mjs +359 -0
  41. package/src/commands/fitness.mjs +226 -0
  42. package/src/commands/graph.mjs +297 -0
  43. package/src/commands/health.mjs +213 -0
  44. package/src/commands/history.mjs +614 -0
  45. package/src/commands/impact.mjs +226 -0
  46. package/src/commands/plan-context-command.mjs +496 -0
  47. package/src/commands/policy.mjs +138 -0
  48. package/src/commands/provenance-command.mjs +352 -0
  49. package/src/commands/provenance.mjs +159 -0
  50. package/src/commands/reconcile.mjs +219 -0
  51. package/src/commands/report.mjs +553 -0
  52. package/src/commands/snapshot-meta.mjs +107 -0
  53. package/src/commands/waivers.mjs +240 -0
  54. package/src/config.mjs +1308 -0
  55. package/src/containment.mjs +234 -0
  56. package/src/custom-rules/evidence.mjs +340 -0
  57. package/src/custom-rules/host.mjs +1023 -0
  58. package/src/custom-rules/values.mjs +43 -0
  59. package/src/entry-point.mjs +55 -0
  60. package/src/errors.mjs +36 -0
  61. package/src/eslint-config.mjs +542 -0
  62. package/src/go-work.mjs +394 -0
  63. package/src/governance/adr-registry.mjs +539 -0
  64. package/src/governance/clock.mjs +69 -0
  65. package/src/governance/debt-ledger.mjs +274 -0
  66. package/src/governance/discovery-proposal.mjs +423 -0
  67. package/src/governance/fitness-registry.mjs +504 -0
  68. package/src/governance/fitness-rules.mjs +668 -0
  69. package/src/governance/metrics.mjs +392 -0
  70. package/src/governance/preset-fingerprints.json +16 -0
  71. package/src/governance/profile-registry.mjs +366 -0
  72. package/src/governance/provenance-record.mjs +177 -0
  73. package/src/governance/reconcile-candidates.mjs +301 -0
  74. package/src/governance/reconcile-score.mjs +503 -0
  75. package/src/governance/row-schema.mjs +208 -0
  76. package/src/governance/verdict.mjs +127 -0
  77. package/src/governance/waiver.mjs +105 -0
  78. package/src/graph/create-dependencies.mjs +96 -0
  79. package/src/intent/intent-manifest.json +347 -0
  80. package/src/intent/mask-non-code.mjs +640 -0
  81. package/src/lsp/boundary-config.mjs +225 -0
  82. package/src/lsp/diagnose.mjs +202 -0
  83. package/src/lsp/diagnostics.mjs +241 -0
  84. package/src/lsp/protocol.mjs +215 -0
  85. package/src/lsp/server.mjs +922 -0
  86. package/src/lsp/workspace-index.mjs +891 -0
  87. package/src/nx-json.mjs +95 -0
  88. package/src/options.mjs +611 -0
  89. package/src/process.mjs +91 -0
  90. package/src/providers/moon.mjs +733 -0
  91. package/src/providers/native/README.md +204 -0
  92. package/src/providers/native/coverage.mjs +74 -0
  93. package/src/providers/native/differential.fixtures.mjs +1277 -0
  94. package/src/providers/native/discover.mjs +431 -0
  95. package/src/providers/native/graph.mjs +234 -0
  96. package/src/providers/native/index.mjs +152 -0
  97. package/src/providers/native/model.mjs +755 -0
  98. package/src/providers/nx.mjs +178 -0
  99. package/src/report/README.md +89 -0
  100. package/src/report/adr-text.mjs +129 -0
  101. package/src/report/context-text.mjs +109 -0
  102. package/src/report/debt-text.mjs +105 -0
  103. package/src/report/diff-text.mjs +219 -0
  104. package/src/report/discover-text.mjs +186 -0
  105. package/src/report/drift-text.mjs +194 -0
  106. package/src/report/envelope-shape.mjs +161 -0
  107. package/src/report/evidence.mjs +157 -0
  108. package/src/report/explain-text.mjs +159 -0
  109. package/src/report/graph-text.mjs +116 -0
  110. package/src/report/health-text.mjs +123 -0
  111. package/src/report/history-text.mjs +204 -0
  112. package/src/report/impact-text.mjs +128 -0
  113. package/src/report/json.mjs +173 -0
  114. package/src/report/plan-context-text.mjs +159 -0
  115. package/src/report/provenance-text.mjs +78 -0
  116. package/src/report/reconcile-text.mjs +159 -0
  117. package/src/report/report-text.mjs +264 -0
  118. package/src/report/sarif.mjs +953 -0
  119. package/src/report/text.mjs +823 -0
  120. package/src/report/waivers-text.mjs +100 -0
  121. package/src/rules/README.md +123 -0
  122. package/src/rules/index.mjs +962 -0
  123. package/src/rules/match.mjs +1708 -0
  124. package/src/rules/messages.mjs +73 -0
  125. package/src/rules/reachability.mjs +224 -0
  126. package/src/rules/specifiers.mjs +300 -0
  127. package/src/rules/tags.mjs +238 -0
  128. package/src/rules/topology.mjs +333 -0
  129. package/src/tsconfig-paths.mjs +237 -0
  130. package/src/verdict.mjs +145 -0
  131. 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
+ }