@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
@@ -0,0 +1,703 @@
1
+ /**
2
+ * Architecture Intent — grammar, validation, and loading.
3
+ *
4
+ * `architecture-intent.json` at a workspace root declares the architecture the
5
+ * team intends to preserve — which groups of projects share a role (boundaries)
6
+ * and, per boundary pair, which dependencies are allowed and which are
7
+ * forbidden. It also declares the architecture's *existence* facts: which
8
+ * projects must and must not exist, which tags a required project must carry,
9
+ * which dependencies are permitted or forbidden by exact name, and which
10
+ * tag→tag dependencies are forbidden — the drift contract #86 consumes. The
11
+ * observed architecture stays the graph Archkeep derives from source; governance
12
+ * is a deterministic comparison (`./judge.mjs`). NO LLM/AI anywhere in the core.
13
+ *
14
+ * This module mirrors `../../config.mjs`'s and
15
+ * `../../providers/native/model.mjs`'s split: a pure `(raw) -> string[]`
16
+ * validator and a thin loader that reads, parses, validates and throws — one
17
+ * Error naming every violation at once.
18
+ *
19
+ * Three non-obvious contracts, each the honest side of the empty-result
20
+ * invariant (`../../../../AGENTS.md` — an empty list must mean "no violation",
21
+ * and nothing else):
22
+ *
23
+ * - **It is strict JSON, never JSONC.** This is this tool's own file that Nx
24
+ * never opens, so the leniency argument `native/model.mjs`'s header makes
25
+ * for `archkeep.json` does not apply; a comment or trailing comma is a load
26
+ * error (exit 3), loud. The human need for prose is served by `reason`, not
27
+ * comments.
28
+ * - **An unknown key or selector label is rejected by name** — never folded
29
+ * into a general "ignore unknown" rule. A `tagz:x` typo for `tag:x`
30
+ * surfaces red, because a selector nobody understands is how a boundary
31
+ * silently matches nothing.
32
+ * - **Validation is nodes-free.** Whether a boundary selects zero projects is
33
+ * a semantic question that needs the observed graph, so it belongs to the
34
+ * judge (`./judge.mjs`) as a *no-verdict*, not here. A workspace that has
35
+ * not scaffolded a project yet must still load and be told its boundary is
36
+ * empty — loudly — rather than fail to load at all.
37
+ */
38
+
39
+ import { existsSync } from "node:fs";
40
+ import { readFile as readFileFromDisk } from "node:fs/promises";
41
+ import { resolve } from "node:path";
42
+
43
+ import { containmentViolation } from "../containment.mjs";
44
+
45
+ import { isValidSelector, splitSelector } from "./selectors.mjs";
46
+ import { GOVERNANCE_ROW_KEYS, rowSchemaViolations } from "../governance/row-schema.mjs";
47
+
48
+ /** The base name of the root file this module reads. */
49
+ export const INTENT_FILE = "architecture-intent.json";
50
+
51
+ /** The one supported `version`. A different value is a load error. */
52
+ export const INTENT_VERSION = "1";
53
+
54
+ /**
55
+ * The only keys a valid intent file may carry at the top level.
56
+ *
57
+ * The first four ship in v1. The last three — the drift sections — are part of
58
+ * the same version-1 contract: an intent file that uses none of them is
59
+ * byte-identical to a file written before drift existed, and a file that uses
60
+ * them declares the additional existence facts the judge enforces. Keeping
61
+ * them one version means a file cannot silently carry a section the judge does
62
+ * not understand; adding them to the same version is safe because they are
63
+ * additive — a consumer that cannot judge them must not receive them, and this
64
+ * engine judges them.
65
+ */
66
+ export const TOP_LEVEL_KEYS = Object.freeze([
67
+ "version",
68
+ "boundaries",
69
+ "allowed",
70
+ "forbidden",
71
+ "projects",
72
+ "dependencies",
73
+ "forbiddenTags",
74
+ ]);
75
+
76
+ /** The sub-keys a `projects` section may carry. */
77
+ export const PROJECT_SECTION_KEYS = Object.freeze(["required", "forbidden"]);
78
+ /** The sub-keys a `dependencies` section may carry. */
79
+ export const DEPENDENCY_SECTION_KEYS = Object.freeze(["allowed", "forbidden"]);
80
+ /** The keys a `projects.required[]` row may carry. */
81
+ export const REQUIRED_PROJECT_KEYS = Object.freeze(["name", "tags", "decisionRef"]);
82
+ /** The keys a `projects.forbidden[]` row may carry. */
83
+ export const FORBIDDEN_PROJECT_KEYS = Object.freeze(["name", "decisionRef"]);
84
+ /** The keys a `dependencies.allowed[]` / `dependencies.forbidden[]` row may carry. */
85
+ export const DEPENDENCY_ROW_KEYS = Object.freeze(["source", "target", "decisionRef"]);
86
+ /** The keys a `forbiddenTags[]` row may carry. */
87
+ export const TAG_ROW_KEYS = Object.freeze(["from", "to", "decisionRef"]);
88
+
89
+ /**
90
+ * The shared governance-block check for any intent row (Contract 2): when the
91
+ * row carries at least one of `origin`/`rationale`/`decisionRef`/`fitnessBindings`,
92
+ * its shape is validated by the ONE schema `../governance/row-schema.mjs` —
93
+ * the same validator a `depConstraints` row uses, so no capability in this
94
+ * wave owns a second copy of what a governance key may hold. Additive: a row
95
+ * without the block is a legacy row and stays valid. Resolution of a
96
+ * `decisionRef`/`fitnessBinding` id is the registry capability's, injected
97
+ * there; shape is checked here, loudly.
98
+ *
99
+ * @param {object} row
100
+ * @param {string} at Dotted path of the row, for messages.
101
+ * @returns {string[]}
102
+ */
103
+ function governanceRowViolations(row, at) {
104
+ if (
105
+ !("origin" in row) &&
106
+ !("rationale" in row) &&
107
+ !("decisionRef" in row) &&
108
+ !("fitnessBindings" in row)
109
+ ) {
110
+ return [];
111
+ }
112
+ return rowSchemaViolations(row, at);
113
+ }
114
+
115
+ /** The keys a boundary entry may carry. */
116
+ const BOUNDARY_KEYS = Object.freeze(["name", "match"]);
117
+
118
+ /** The keys an allowed/forbidden row may carry. */
119
+ const ROW_KEYS = Object.freeze(["from", "to", "reason", "optional", "decisionRef"]);
120
+
121
+ /** A boundary `name`, matched exactly by the loaders — names can never contain `:`, so a name can never collide with a `name:`-prefixed selector. */
122
+ const NAME_PATTERN = /^[a-zA-Z0-9_-]+$/u;
123
+
124
+ /** A value's type, for an error message that shows what was actually there. */
125
+ function describe(value) {
126
+ if (Array.isArray(value)) return `an array (${JSON.stringify(value)})`;
127
+ if (value === null) return "null";
128
+ return `${typeof value} (${JSON.stringify(value) ?? String(value)})`;
129
+ }
130
+
131
+ /** @type {(value: unknown) => value is Record<string, unknown>} */
132
+ const isPlainObject = (value) =>
133
+ value !== null && typeof value === "object" && !Array.isArray(value);
134
+
135
+ /** `key` on `obj` that is not one of `allowed` — for the reject-by-name rule. */
136
+ function unknownKeys(obj, allowed) {
137
+ return Object.keys(obj).filter((key) => !allowed.includes(key));
138
+ }
139
+
140
+ /**
141
+ * A `from`/`to` side is either a declared boundary name (which wins,
142
+ * deterministically) or an inline selector — so it must be one of those two,
143
+ * never an arbitrary string. A side that is neither is a typo that would
144
+ * silently match nothing at judge time; it is a load error instead.
145
+ */
146
+ function rowSideValid(side, names) {
147
+ return typeof side === "string" && side.length > 0 && (names.has(side) || isValidSelector(side));
148
+ }
149
+
150
+ /**
151
+ * Whether a selector can match at most one project on every graph — true for
152
+ * `name:x` and a bare `x` (both exact project-name matches), false for
153
+ * `tag:`, `directory:` and `*`. Used to tell a single-project self-ban from a
154
+ * same-tag ban that legitimately has cross-pairs.
155
+ * @param {string} selector
156
+ * @returns {boolean}
157
+ */
158
+ function isSingleProjectSelector(selector) {
159
+ if (!isValidSelector(selector)) return false;
160
+ const { label, value } = splitSelector(selector);
161
+ return value !== "*" && label !== "tag" && label !== "directory";
162
+ }
163
+
164
+ /**
165
+ * The declared boundary names in a validated file, in order — what `from`/`to`
166
+ * resolve against.
167
+ */
168
+ export function boundaryNames(intent) {
169
+ return (intent.boundaries ?? []).map((b) => b.name);
170
+ }
171
+
172
+ /**
173
+ * Everything wrong with a raw intent file, as messages; empty when it is
174
+ * well-formed. Pure and nodes-free — membership is the judge's question.
175
+ *
176
+ * @param {unknown} raw The parsed JSON value.
177
+ * @returns {string[]}
178
+ */
179
+ export function findIntentViolations(raw) {
180
+ const violations = [];
181
+
182
+ if (!isPlainObject(raw)) {
183
+ return [`top level: must be an object, got ${describe(raw)}`];
184
+ }
185
+ const unknownTop = unknownKeys(raw, TOP_LEVEL_KEYS);
186
+ for (const key of unknownTop) {
187
+ violations.push(
188
+ `unknown key "${key}" — architecture-intent.json may carry only ${TOP_LEVEL_KEYS.join(", ")}`,
189
+ );
190
+ }
191
+
192
+ if (raw.version !== INTENT_VERSION) {
193
+ violations.push(`version: must be exactly "${INTENT_VERSION}", got ${describe(raw.version)}`);
194
+ }
195
+
196
+ const boundaries = raw.boundaries;
197
+ if (boundaries === undefined) {
198
+ violations.push("boundaries: is required");
199
+ } else if (!Array.isArray(boundaries)) {
200
+ violations.push(`boundaries: must be an array, got ${describe(boundaries)}`);
201
+ } else if (boundaries.length === 0) {
202
+ violations.push(
203
+ "boundaries: must not be empty — a file that reads as protection while matching nothing is the silent direction",
204
+ );
205
+ } else {
206
+ const names = new Set();
207
+ boundaries.forEach((boundary, index) => {
208
+ const at = `boundaries[${index}]`;
209
+ if (!isPlainObject(boundary)) {
210
+ violations.push(`${at}: must be an object, got ${describe(boundary)}`);
211
+ return;
212
+ }
213
+ for (const key of unknownKeys(boundary, BOUNDARY_KEYS)) {
214
+ violations.push(
215
+ `${at}.${key}: unknown key — a boundary may carry only ${BOUNDARY_KEYS.join(", ")}`,
216
+ );
217
+ }
218
+ if (typeof boundary.name !== "string" || !NAME_PATTERN.test(boundary.name)) {
219
+ violations.push(
220
+ `${at}.name: must be a non-empty string of letters, digits, "-" or "_" (no ":"), got ${describe(boundary.name)}`,
221
+ );
222
+ } else {
223
+ if (names.has(boundary.name)) {
224
+ violations.push(
225
+ `${at}.name: "${boundary.name}" is declared more than once — every boundary name must be unique`,
226
+ );
227
+ }
228
+ names.add(boundary.name);
229
+ }
230
+ const match = boundary.match;
231
+ if (!Array.isArray(match)) {
232
+ violations.push(`${at}.match: must be an array of selectors, got ${describe(match)}`);
233
+ } else if (match.length === 0) {
234
+ violations.push(
235
+ `${at}.match: must not be empty — a boundary with no selectors matches nothing`,
236
+ );
237
+ } else {
238
+ match.forEach((selector, selIndex) => {
239
+ if (!isValidSelector(selector)) {
240
+ const st = splitSelector(selector);
241
+ const why =
242
+ st.label !== null && !["name", "tag", "directory"].includes(st.label)
243
+ ? `"${st.label}:" is not a selector label (name:, tag:, directory:, "*" or a bare project name)`
244
+ : `selectors may be "name:x", "tag:x", "directory:x", "*" or a bare project name, optionally "!"-prefixed, with no glob or regular expression`;
245
+ violations.push(
246
+ `${at}.match[${selIndex}]: invalid selector ${JSON.stringify(selector)} — ${why}`,
247
+ );
248
+ }
249
+ });
250
+ }
251
+ });
252
+ }
253
+
254
+ for (const listName of ["allowed", "forbidden"]) {
255
+ const list = raw[listName];
256
+ if (list === undefined) {
257
+ continue;
258
+ }
259
+ if (!Array.isArray(list)) {
260
+ violations.push(`${listName}: must be an array, got ${describe(list)}`);
261
+ continue;
262
+ }
263
+ if (list.length === 0) {
264
+ violations.push(
265
+ `${listName}: must not be empty — a list present but empty reads as policy while deciding nothing`,
266
+ );
267
+ continue;
268
+ }
269
+ // The declared boundary names a side may reference, collected after the
270
+ // boundary block above so a row naming a boundary never precedes it.
271
+ const names = new Set(
272
+ (Array.isArray(boundaries) ? boundaries : [])
273
+ .filter(isPlainObject)
274
+ .map((b) => (typeof b.name === "string" ? b.name : "")),
275
+ );
276
+ list.forEach((row, index) => {
277
+ const at = `${listName}[${index}]`;
278
+ if (!isPlainObject(row)) {
279
+ violations.push(`${at}: must be an object, got ${describe(row)}`);
280
+ return;
281
+ }
282
+ for (const key of unknownKeys(row, [...ROW_KEYS, ...GOVERNANCE_ROW_KEYS])) {
283
+ violations.push(`${at}.${key}: unknown key — a row may carry only ${ROW_KEYS.join(", ")}`);
284
+ }
285
+ violations.push(...governanceRowViolations(row, at));
286
+ if (!rowSideValid(row.from, names)) {
287
+ violations.push(
288
+ `${at}.from: must reference a declared boundary (${names.size > 0 ? [...names].join(", ") : "none declared"}) or a valid selector, got ${describe(row.from)}`,
289
+ );
290
+ }
291
+ if (!rowSideValid(row.to, names)) {
292
+ violations.push(
293
+ `${at}.to: must reference a declared boundary (${names.size > 0 ? [...names].join(", ") : "none declared"}) or a valid selector, got ${describe(row.to)}`,
294
+ );
295
+ }
296
+ if (listName === "forbidden") {
297
+ if (typeof row.reason !== "string" || row.reason.length === 0) {
298
+ violations.push(
299
+ `${at}.reason: is required on a forbidden row — a ban no one can explain is how a ban is deleted`,
300
+ );
301
+ }
302
+ if (row.optional !== undefined && typeof row.optional !== "boolean") {
303
+ violations.push(`${at}.optional: must be a boolean, got ${describe(row.optional)}`);
304
+ }
305
+ if (row.optional === true) {
306
+ violations.push(
307
+ `${at}.optional: is not allowed on a forbidden row — a conditional ban is a different concept; state it as two rows`,
308
+ );
309
+ }
310
+ // A self-ban that can never fire: both sides resolve to ONE same
311
+ // project, so the row forbids nothing and reading it as "clean — the
312
+ // ban holds" would be the silent direction. The load-provable
313
+ // spellings are rejected here, nodes-free; the ones only the graph can
314
+ // prove (two different selectors landing on one project) are the
315
+ // judge's call (`../judge.mjs` renders them a no-verdict, never
316
+ // clean). Two spellings prove a single project with no graph in hand:
317
+ // - both sides name the same declared boundary;
318
+ // - both sides name the same `name:`/bare selector, which resolves
319
+ // to that one project by exact match (`../selectors.mjs`).
320
+ // A same `tag:` or `*` is NOT this case: the set can hold many
321
+ // projects, the row has real cross-pairs ("no tag:X may reach another
322
+ // tag:X"), and it judges normally — a ban is still a ban.
323
+ if (
324
+ typeof row.from === "string" &&
325
+ typeof row.to === "string" &&
326
+ row.from === row.to &&
327
+ (names.has(row.from) || isSingleProjectSelector(row.from))
328
+ ) {
329
+ const which = names.has(row.from)
330
+ ? `name the same declared boundary "${row.from}"`
331
+ : `name the same selector "${row.from}", which selects a single project`;
332
+ violations.push(
333
+ `${at}: from and to ${which} — a self-ban on one boundary is a cycle; say it as a depConstraints row instead`,
334
+ );
335
+ }
336
+ } else {
337
+ // The allowed side. Self-reference is fine here — a boundary may
338
+ // legitimately reach itself — so there is no self-ban check, but the
339
+ // optional/reason TYPE checks still apply.
340
+ if (row.reason !== undefined && typeof row.reason !== "string") {
341
+ violations.push(
342
+ `${at}.reason: must be a string when present, got ${describe(row.reason)}`,
343
+ );
344
+ }
345
+ if (row.optional !== undefined && typeof row.optional !== "boolean") {
346
+ violations.push(`${at}.optional: must be a boolean, got ${describe(row.optional)}`);
347
+ }
348
+ }
349
+ });
350
+ }
351
+
352
+ // ── projects (drift) ──────────────────────────────────────────────────────
353
+ // Optional, but when present must be an object holding at most `required` and
354
+ // `forbidden`. Absence of a `projects` section is a workspace choice, not a
355
+ // verdict; a section present but empty would read as policy while deciding
356
+ // nothing, the same "many enforce" rule below that governs dependencies.
357
+ if (raw.projects !== undefined) {
358
+ if (!isPlainObject(raw.projects)) {
359
+ violations.push(`projects: must be an object, got ${describe(raw.projects)}`);
360
+ } else {
361
+ for (const key of unknownKeys(raw.projects, PROJECT_SECTION_KEYS)) {
362
+ violations.push(
363
+ `projects: unknown key "${key}" — a projects section may carry only ${PROJECT_SECTION_KEYS.join(", ")}`,
364
+ );
365
+ }
366
+ // A `projects` section that names neither list decides nothing while
367
+ // reading as existence policy — the state the comment above already
368
+ // claimed was refused and was not. Deleting the last row from
369
+ // `projects.required` leaves exactly this file, and `drift`/`check`
370
+ // both report clean over it.
371
+ if (PROJECT_SECTION_KEYS.every((key) => raw.projects[key] === undefined)) {
372
+ violations.push(
373
+ `projects: must state ${PROJECT_SECTION_KEYS.join(" or ")} — a section present but ` +
374
+ `empty reads as policy while deciding nothing`,
375
+ );
376
+ }
377
+ const requiredList = raw.projects.required;
378
+ if (requiredList !== undefined) {
379
+ if (!Array.isArray(requiredList)) {
380
+ violations.push(`projects.required: must be an array, got ${describe(requiredList)}`);
381
+ } else if (requiredList.length === 0) {
382
+ // The same refusal `allowed`/`forbidden`/`dependencies.*`/
383
+ // `forbiddenTags` already make, in their words.
384
+ violations.push(
385
+ `projects.required: must not be empty — a list present but empty reads as policy while deciding nothing`,
386
+ );
387
+ } else {
388
+ requiredList.forEach((row, index) => {
389
+ const at = `projects.required[${index}]`;
390
+ if (!isPlainObject(row)) {
391
+ violations.push(`${at}: must be an object, got ${describe(row)}`);
392
+ return;
393
+ }
394
+ for (const key of unknownKeys(row, [
395
+ ...REQUIRED_PROJECT_KEYS,
396
+ ...GOVERNANCE_ROW_KEYS,
397
+ ])) {
398
+ violations.push(
399
+ `${at}.${key}: unknown key — a required project may carry only ${REQUIRED_PROJECT_KEYS.join(", ")}`,
400
+ );
401
+ }
402
+ violations.push(...governanceRowViolations(row, at));
403
+ if (typeof row.name !== "string" || row.name.trim() === "") {
404
+ violations.push(`${at}.name: must be a non-empty string, got ${describe(row.name)}`);
405
+ }
406
+ if (row.tags !== undefined) {
407
+ if (!Array.isArray(row.tags)) {
408
+ violations.push(
409
+ `${at}.tags: must be an array of non-empty strings, got ${describe(row.tags)}`,
410
+ );
411
+ } else {
412
+ row.tags.forEach((tag, tagIndex) => {
413
+ if (typeof tag !== "string" || tag.trim() === "") {
414
+ violations.push(
415
+ `${at}.tags[${tagIndex}]: must be a non-empty string, got ${describe(tag)}`,
416
+ );
417
+ }
418
+ });
419
+ }
420
+ }
421
+ });
422
+ }
423
+ }
424
+ const forbiddenList = raw.projects.forbidden;
425
+ if (forbiddenList !== undefined) {
426
+ if (!Array.isArray(forbiddenList)) {
427
+ violations.push(`projects.forbidden: must be an array, got ${describe(forbiddenList)}`);
428
+ } else if (forbiddenList.length === 0) {
429
+ violations.push(
430
+ `projects.forbidden: must not be empty — a list present but empty reads as policy while deciding nothing`,
431
+ );
432
+ } else {
433
+ forbiddenList.forEach((row, index) => {
434
+ const at = `projects.forbidden[${index}]`;
435
+ if (!isPlainObject(row)) {
436
+ violations.push(`${at}: must be an object, got ${describe(row)}`);
437
+ return;
438
+ }
439
+ for (const key of unknownKeys(row, [
440
+ ...FORBIDDEN_PROJECT_KEYS,
441
+ ...GOVERNANCE_ROW_KEYS,
442
+ ])) {
443
+ violations.push(
444
+ `${at}.${key}: unknown key — a forbidden project may carry only ${FORBIDDEN_PROJECT_KEYS.join(", ")}`,
445
+ );
446
+ }
447
+ violations.push(...governanceRowViolations(row, at));
448
+ if (typeof row.name !== "string" || row.name.trim() === "") {
449
+ violations.push(`${at}.name: must be a non-empty string, got ${describe(row.name)}`);
450
+ }
451
+ });
452
+ }
453
+ }
454
+ }
455
+ }
456
+
457
+ // ── dependencies (drift) ──────────────────────────────────────────────────
458
+ // Optional, but when present must be an object holding at most `allowed` and
459
+ // `forbidden`. When `allowed` is present and non-empty, it is an exhaustive
460
+ // whitelist: every observed edge outside it is drift. When `allowed` is
461
+ // omitted entirely, only the forbidden rules can fire.
462
+ if (raw.dependencies !== undefined) {
463
+ if (!isPlainObject(raw.dependencies)) {
464
+ violations.push(`dependencies: must be an object, got ${describe(raw.dependencies)}`);
465
+ } else {
466
+ for (const key of unknownKeys(raw.dependencies, DEPENDENCY_SECTION_KEYS)) {
467
+ violations.push(
468
+ `dependencies: unknown key "${key}" — a dependencies section may carry only ${DEPENDENCY_SECTION_KEYS.join(", ")}`,
469
+ );
470
+ }
471
+ // The section itself, on the same rule its two lists already carry
472
+ // below: `dependencies: {}` named neither list, so nothing could fire
473
+ // and the file still read as dependency policy.
474
+ if (DEPENDENCY_SECTION_KEYS.every((key) => raw.dependencies[key] === undefined)) {
475
+ violations.push(
476
+ `dependencies: must state ${DEPENDENCY_SECTION_KEYS.join(" or ")} — a section present ` +
477
+ `but empty reads as policy while deciding nothing`,
478
+ );
479
+ }
480
+ for (const listName of DEPENDENCY_SECTION_KEYS) {
481
+ const list = raw.dependencies[listName];
482
+ if (list === undefined) continue;
483
+ if (!Array.isArray(list)) {
484
+ violations.push(`dependencies.${listName}: must be an array, got ${describe(list)}`);
485
+ continue;
486
+ }
487
+ if (list.length === 0) {
488
+ violations.push(
489
+ `dependencies.${listName}: must not be empty — a list present but empty reads as policy while deciding nothing`,
490
+ );
491
+ continue;
492
+ }
493
+ list.forEach((row, index) => {
494
+ const at = `dependencies.${listName}[${index}]`;
495
+ if (!isPlainObject(row)) {
496
+ violations.push(`${at}: must be an object, got ${describe(row)}`);
497
+ return;
498
+ }
499
+ for (const key of unknownKeys(row, [...DEPENDENCY_ROW_KEYS, ...GOVERNANCE_ROW_KEYS])) {
500
+ violations.push(
501
+ `${at}.${key}: unknown key — a dependency row may carry only ${DEPENDENCY_ROW_KEYS.join(", ")}`,
502
+ );
503
+ }
504
+ violations.push(...governanceRowViolations(row, at));
505
+ for (const side of ["source", "target"]) {
506
+ if (typeof row[side] !== "string" || row[side].trim() === "") {
507
+ violations.push(
508
+ `${at}.${side}: must be a non-empty string, got ${describe(row[side])}`,
509
+ );
510
+ }
511
+ }
512
+ // A forbidden row banning a project from itself: `../judge.mjs`
513
+ // skips it (`source !== target` guards the `pathExists` walk there,
514
+ // because every project reaches itself), so the row is COUNTED as
515
+ // an intent row — `drift` prints "3 rows" — and then decides
516
+ // nothing. The same concept is refused twice already in this file:
517
+ // the boundary self-ban above, whose comment says "reading it as
518
+ // holding would be the silent direction", and `forbiddenTags`'
519
+ // `from === to` below. A dependency row is the third spelling and
520
+ // it got neither. `allowed` is deliberately not refused here: a
521
+ // dependency allow-list is exhaustive, so a self-pair in it states
522
+ // which edges are permitted rather than banning nothing.
523
+ if (
524
+ listName === "forbidden" &&
525
+ typeof row.source === "string" &&
526
+ typeof row.target === "string" &&
527
+ row.source === row.target
528
+ ) {
529
+ violations.push(
530
+ `${at}: source and target must differ — a project depending on itself is a no-op and should not be phrased as a rule`,
531
+ );
532
+ }
533
+ });
534
+ }
535
+ }
536
+ }
537
+
538
+ // ── forbiddenTags (drift) ─────────────────────────────────────────────────
539
+ // Optional, but when present must be a non-empty array of from/to rows. A
540
+ // row whose `from` equals its `to` forbids a tag from depending on itself —
541
+ // a no-op the author should not phrase as a rule; it is a load error.
542
+ if (raw.forbiddenTags !== undefined) {
543
+ if (!Array.isArray(raw.forbiddenTags)) {
544
+ violations.push(`forbiddenTags: must be an array, got ${describe(raw.forbiddenTags)}`);
545
+ } else if (raw.forbiddenTags.length === 0) {
546
+ violations.push(
547
+ "forbiddenTags: must not be empty — a list present but empty reads as policy while deciding nothing",
548
+ );
549
+ } else {
550
+ raw.forbiddenTags.forEach((row, index) => {
551
+ const at = `forbiddenTags[${index}]`;
552
+ if (!isPlainObject(row)) {
553
+ violations.push(`${at}: must be an object, got ${describe(row)}`);
554
+ return;
555
+ }
556
+ for (const key of unknownKeys(row, [...TAG_ROW_KEYS, ...GOVERNANCE_ROW_KEYS])) {
557
+ violations.push(
558
+ `${at}.${key}: unknown key — a tag row may carry only ${TAG_ROW_KEYS.join(", ")}`,
559
+ );
560
+ }
561
+ violations.push(...governanceRowViolations(row, at));
562
+ for (const side of ["from", "to"]) {
563
+ if (typeof row[side] !== "string" || row[side].trim() === "") {
564
+ violations.push(
565
+ `${at}.${side}: must be a non-empty string, got ${describe(row[side])}`,
566
+ );
567
+ }
568
+ }
569
+ if (typeof row.from === "string" && typeof row.to === "string" && row.from === row.to) {
570
+ violations.push(
571
+ `${at}: from and to must differ — a tag depending on itself is a no-op and should not be phrased as a rule`,
572
+ );
573
+ }
574
+ });
575
+ }
576
+ }
577
+
578
+ // Rule 7: allowed ⊕ forbidden overlap. A from/to pair in both lists is
579
+ // ambiguous — the same dependency cannot be both explicitly allowed and
580
+ // explicitly forbidden. State the allowed pair minus the forbidden one as
581
+ // two rows instead.
582
+ /**
583
+ * The from→to pairs one list states, in its own order.
584
+ * @param {unknown} list
585
+ * @returns {string[]}
586
+ */
587
+ const statedPairs = (list) =>
588
+ (Array.isArray(list) ? list : [])
589
+ .filter(isPlainObject)
590
+ .filter((row) => typeof row.from === "string" && typeof row.to === "string")
591
+ .map((row) => `${row.from}→${row.to}`);
592
+ const allowedPairs = statedPairs(raw.allowed);
593
+ const forbiddenPairs = statedPairs(raw.forbidden);
594
+ for (const pair of forbiddenPairs) {
595
+ if (allowedPairs.includes(pair)) {
596
+ violations.push(
597
+ `allowed and forbidden both state "${pair}" — the same pair must be stated once; forbid the exceptions and allow the rest as two rows`,
598
+ );
599
+ }
600
+ }
601
+
602
+ return violations;
603
+ }
604
+
605
+ /**
606
+ * The intent file as a validated, ready-to-judge model. `loadIntent` throws ONE
607
+ * Error naming every violation when the file is malformed — the caller turns
608
+ * that into a whole-file failure (exit 3), the same posture `../../cli.mjs`
609
+ * takes for a `go.work` it cannot read (`../../go-work.mjs`).
610
+ *
611
+ * @param {object} intent The validated intent object.
612
+ * @returns {object} The normalized model: `{version, boundaries: {name, match, members?}[], allowed, forbidden, projects?, dependencies?, forbiddenTags?}`.
613
+ */
614
+ export function normalizeIntent(intent) {
615
+ return {
616
+ version: intent.version,
617
+ boundaries: intent.boundaries.map((b) => ({ name: b.name, match: [...b.match] })),
618
+ allowed: intent.allowed ?? [],
619
+ forbidden: intent.forbidden ?? [],
620
+ projects: intent.projects,
621
+ dependencies: intent.dependencies,
622
+ forbiddenTags: intent.forbiddenTags ?? [],
623
+ };
624
+ }
625
+
626
+ /**
627
+ * Read, parse and validate `architecture-intent.json` at `root`.
628
+ *
629
+ * @param {string} root Absolute workspace root.
630
+ * @param {{read?: (path: string, encoding: "utf8") => Promise<string>, tracked?: string[]}} [io]
631
+ * Injectable read, defaulting to `node:fs/promises`' `readFile` — the only
632
+ * code in this function that reaches outside the process; `tracked` is the
633
+ * `git ls-files` list, and when provided and the file is not in it, the
634
+ * loader treats the file as absent (an untracked intent file is not the
635
+ * reviewed repository state, the same edge `../../go-work.mjs` documents).
636
+ * @returns {Promise<object>} The normalized, validated model, or `undefined` when the file is absent.
637
+ * @throws {Error} naming every validation violation at once, and — when
638
+ * `tracked` is provided and lists the file — when the tracked path cannot be
639
+ * read at all. Tracked-but-unreadable is a no-verdict, never absence; see
640
+ * the ENOENT branch below.
641
+ */
642
+ export async function loadIntent(root, { read = readFileFromDisk, tracked } = {}) {
643
+ const path = `${root}/${INTENT_FILE}`;
644
+ if (tracked !== undefined && !tracked.includes(INTENT_FILE)) {
645
+ return undefined;
646
+ }
647
+ // `architecture-intent.json` is the workspace's own declared fact, and the
648
+ // name is fixed — so a TRACKED symlink at that path (`git ls-files` lists
649
+ // it, the `tracked` filter passes) resolving outside the tree hands outside
650
+ // intent bytes in as the workspace's, an "intent: ok" verdict from bytes
651
+ // this tree never committed (`../containment.mjs`, the read-side G-10
652
+ // closure). The real-fs default reader only: an injected in-memory reader a
653
+ // test drives is keyed by a fixture path that does not exist on disk.
654
+ if (existsSync(root)) {
655
+ const violation = containmentViolation(root, resolve(path));
656
+ if (violation !== null) {
657
+ throw new Error(`archkeep: ${INTENT_FILE}: ${violation}`);
658
+ }
659
+ }
660
+ let text;
661
+ try {
662
+ text = await read(path, "utf8");
663
+ } catch (cause) {
664
+ // ENOENT means two different things, and only one of them is "absent".
665
+ // With no `tracked` list the caller knows nothing about the file, so a
666
+ // missing path is a workspace that never declared an intent — undefined,
667
+ // the documented absent answer. With a `tracked` list the gate above has
668
+ // ALREADY established that `git ls-files` lists this name: the workspace
669
+ // declared the file, and the bytes are not there — a dangling symlink, a
670
+ // sparse checkout, an uninitialised submodule. That is a file this run
671
+ // could not read, not a declaration that was never made, and folding it
672
+ // into "absent" makes `../commands/drift.mjs`'s `driftForCheck` return
673
+ // empty findings, which `../../cli.mjs` folds to `verdict: "ok"` with
674
+ // `checked: true` — a verified claim about bytes nobody read. Its two
675
+ // neighbours on the identical tree are both loud: an escaping symlink
676
+ // throws at the containment check above, and EACCES throws below. Only
677
+ // this one was silent (`../../../../AGENTS.md`).
678
+ if (cause?.code === "ENOENT") {
679
+ if (tracked === undefined) return undefined;
680
+ throw new Error(
681
+ `${INTENT_FILE}: is tracked but could not be read: ${cause?.message ?? cause} — ` +
682
+ `a declared intent whose bytes are absent is a no-verdict, never a workspace that ` +
683
+ `declared none`,
684
+ { cause },
685
+ );
686
+ }
687
+ throw new Error(`${INTENT_FILE}: could not be read: ${cause?.message ?? cause}`, { cause });
688
+ }
689
+ let raw;
690
+ try {
691
+ // Strict JSON only — see the header.
692
+ raw = JSON.parse(text);
693
+ } catch (cause) {
694
+ throw new Error(`${INTENT_FILE}: is not valid strict JSON: ${cause?.message ?? cause}`, {
695
+ cause,
696
+ });
697
+ }
698
+ const violations = findIntentViolations(raw);
699
+ if (violations.length > 0) {
700
+ throw new Error(`${INTENT_FILE}: ${violations.join("; ")}`);
701
+ }
702
+ return normalizeIntent(raw);
703
+ }