@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,755 @@
1
+ /**
2
+ * `archkeep.json`: the native project-and-tag model, for a workspace with no
3
+ * Nx at all.
4
+ *
5
+ * JSONC-tolerant, like every other config in this package, but NOT read
6
+ * through `../../nx-json.mjs`: that module's whole reason to reach for Nx's
7
+ * own parser is to give a config Nx ALSO reads (`nx.json`, `project.json`)
8
+ * the exact JSONC leniency Nx gives it — there is no such thing to match
9
+ * here, because Nx never opens `archkeep.json` at all; a workspace that has
10
+ * one has no `nx.json` (`../../../../../docs/reference/configuration.md`, "This
11
+ * page is for a workspace with no Nx at all"). Routing it through `parseNxJson`
12
+ * anyway meant its JSONC tolerance — the trailing comma or `//` comment this
13
+ * very module's header used to advertise as accepted — silently depended on
14
+ * `nx` being resolvable from `node_modules`, which is exactly the dependency
15
+ * a native, no-Nx workspace is promised it does not need
16
+ * (`../../../AGENTS.md`, "the engine and the CLI run without it"). A
17
+ * workspace with no `nx` installed and a commented `archkeep.json` — its OWN
18
+ * root marker, the one file this provider cannot proceed without — would
19
+ * fail before discovery ever ran, on a form this module's own docs call
20
+ * valid. `stripJsonComments`/`stripTrailingCommas` below are the minimal,
21
+ * in-repo, dependency-free fix: they handle only the two JSONC forms
22
+ * documented here (`//` and `/* *\/` comments, one trailing comma before a
23
+ * closing `}`/`]`), the same restraint `../../rules/match.mjs` argues for
24
+ * not reimplementing minimatch — a stripper that guessed at more of JSON5
25
+ * would silently mis-parse rather than visibly refuse an input it does not
26
+ * actually understand.
27
+ *
28
+ * Never `import()`ed — unlike `module-boundaries.config.mjs`, this file
29
+ * describes data rather than code, so there is nothing here that needs a
30
+ * module loader and nothing here that could carry a side effect.
31
+ *
32
+ * This module validates SHAPE only: is `projects.declared[3].root` a string,
33
+ * does a `coverage.exempt` row carry a `reason`. Whether a declared root
34
+ * exists in the tree, whether two projects collide on one name, whether a
35
+ * `projectRules` row matches anything — those are questions about the tree
36
+ * this file describes, not about this file's own shape, and they are
37
+ * `./discover.mjs`'s to answer, the same split `../../config.mjs` draws
38
+ * between a malformed config and a config whose values do not hold up.
39
+ *
40
+ * `findNativeModelViolations`/`loadNativeModel` copy `../../config.mjs`'s own
41
+ * split: a pure `(raw) -> string[]` validator, and a thin loader that reads,
42
+ * parses, validates and throws — one error naming every violation at once,
43
+ * because a reader fixing one typo at a time against a tool that only ever
44
+ * shows the first is the slower way to get to a working config.
45
+ */
46
+ import { globComplexityError, projectPatternError, safeMatchesGlob } from "../../rules/match.mjs";
47
+ import { resolveOptions } from "../../options.mjs";
48
+ import { findBoundaryConfigViolations, policyKeyViolations } from "../../config.mjs";
49
+
50
+ /** The file this provider treats as a workspace root marker and its model. */
51
+ export const ARCHKEEP_MODEL_FILE = "archkeep.json";
52
+
53
+ /**
54
+ * Copies every character of `text` to `out` up to and including the closing
55
+ * quote of the string literal starting at `text[start]`, honouring `\"` so a
56
+ * quote escaped inside the string does not end it early.
57
+ *
58
+ * Shared by `stripJsonComments` and `stripTrailingCommas` below so both scans
59
+ * treat a `//`, `/*`, or `,` sitting inside a JSON string value as ordinary
60
+ * text rather than syntax — a `archkeep.json` string that happens to contain
61
+ * `"see a//b"` or `"waived, }"` must survive unmodified.
62
+ *
63
+ * @param {string} text
64
+ * @param {number} start Index of the opening `"`.
65
+ * @returns {{out: string, next: number}} The copied text and the index just
66
+ * past the closing quote (`text.length` if the string never closes).
67
+ */
68
+ function copyStringLiteral(text, start) {
69
+ let out = text[start];
70
+ let i = start + 1;
71
+ while (i < text.length) {
72
+ const c = text[i];
73
+ out += c;
74
+ i++;
75
+ if (c === "\\" && i < text.length) {
76
+ out += text[i];
77
+ i++;
78
+ continue;
79
+ }
80
+ if (c === '"') break;
81
+ }
82
+ return { out, next: i };
83
+ }
84
+
85
+ /**
86
+ * Strips `//` line comments and `/* *\/` block comments from `text`, leaving
87
+ * every string literal untouched.
88
+ *
89
+ * @param {string} text
90
+ * @returns {string}
91
+ */
92
+ function stripJsonComments(text) {
93
+ let out = "";
94
+ let i = 0;
95
+ while (i < text.length) {
96
+ const ch = text[i];
97
+ if (ch === '"') {
98
+ const copied = copyStringLiteral(text, i);
99
+ out += copied.out;
100
+ i = copied.next;
101
+ continue;
102
+ }
103
+ if (ch === "/" && text[i + 1] === "/") {
104
+ while (i < text.length && text[i] !== "\n") i++;
105
+ continue;
106
+ }
107
+ if (ch === "/" && text[i + 1] === "*") {
108
+ i += 2;
109
+ while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) i++;
110
+ i += 2;
111
+ continue;
112
+ }
113
+ out += ch;
114
+ i++;
115
+ }
116
+ return out;
117
+ }
118
+
119
+ /**
120
+ * Drops one trailing comma before a closing `}` or `]` — the comma, and only
121
+ * the comma, so `{"a": 1,}` and `[1, 2,]` read as strict JSON afterward.
122
+ * String literals are copied verbatim, so a comma inside one is never
123
+ * mistaken for JSON structure (unlike a `,(\s*[}\]])` regex run AFTER
124
+ * comments are stripped, which cannot tell a real trailing comma from the
125
+ * same two characters inside a string value).
126
+ *
127
+ * @param {string} text
128
+ * @returns {string}
129
+ */
130
+ function stripTrailingCommas(text) {
131
+ let out = "";
132
+ let i = 0;
133
+ while (i < text.length) {
134
+ const ch = text[i];
135
+ if (ch === '"') {
136
+ const copied = copyStringLiteral(text, i);
137
+ out += copied.out;
138
+ i = copied.next;
139
+ continue;
140
+ }
141
+ if (ch === ",") {
142
+ let j = i + 1;
143
+ while (j < text.length && /\s/.test(text[j])) j++;
144
+ if (text[j] === "}" || text[j] === "]") {
145
+ i++;
146
+ continue;
147
+ }
148
+ }
149
+ out += ch;
150
+ i++;
151
+ }
152
+ return out;
153
+ }
154
+
155
+ /**
156
+ * Parses `archkeep.json` itself — JSONC-tolerant on its own, with no
157
+ * dependency on `nx` being installed. See this module's header for why
158
+ * `../../nx-json.mjs`'s Nx-reaching parser is the wrong reader for this one
159
+ * file: `readFile`'s JSON.parse runs first, exactly as `../../nx-json.mjs`
160
+ * runs it first, so the common case (strict JSON, no comment, no trailing
161
+ * comma) pays for none of the stripping below; only a file that failed the
162
+ * first parse pays for a second pass.
163
+ *
164
+ * A file that still fails to parse after stripping throws the ORIGINAL
165
+ * `JSON.parse` error, not one from the stripped text — the stripped text's
166
+ * character offsets have shifted from the file on disk, so an error pointing
167
+ * into it would send a reader to the wrong line.
168
+ *
169
+ * @param {string} text
170
+ * @returns {object} Whatever the JSON describes.
171
+ * @throws {Error} when `text` is not valid JSON even once comments and one
172
+ * trailing comma per closing bracket are stripped.
173
+ */
174
+ function parseArchkeepJson(text) {
175
+ try {
176
+ return JSON.parse(text);
177
+ } catch (plain) {
178
+ try {
179
+ return JSON.parse(stripTrailingCommas(stripJsonComments(text)));
180
+ } catch {
181
+ throw plain;
182
+ }
183
+ }
184
+ }
185
+
186
+ /**
187
+ * The manifest basenames `projects.infer` looks for when a workspace does not
188
+ * say otherwise — one per language this package's analyzers cover
189
+ * (`../../analysis/analyze.mjs`'s `LANGUAGE_BY_EXTENSION`), plus `project.json`
190
+ * for a tree that kept Nx-shaped project boundaries without keeping Nx.
191
+ */
192
+ export const DEFAULT_MANIFEST_NAMES = Object.freeze([
193
+ "project.json",
194
+ "package.json",
195
+ "go.mod",
196
+ "Cargo.toml",
197
+ "pyproject.toml",
198
+ ]);
199
+
200
+ const PROJECT_TYPES = ["app", "lib", "e2e"];
201
+
202
+ /** @type {(value: unknown) => value is Record<string, unknown>} */
203
+ const isPlainObject = (value) =>
204
+ typeof value === "object" && value !== null && !Array.isArray(value);
205
+
206
+ /** @type {(value: unknown) => value is string[]} */
207
+ const isStringArray = (value) =>
208
+ Array.isArray(value) && value.every((item) => typeof item === "string");
209
+
210
+ /** A value's type, for an error message that shows what was actually there. */
211
+ function describe(value) {
212
+ if (Array.isArray(value)) return `an array (${JSON.stringify(value)})`;
213
+ if (value === null) return "null";
214
+ return `${typeof value} (${JSON.stringify(value) ?? String(value)})`;
215
+ }
216
+
217
+ /**
218
+ * A non-empty-string list's problems, each message naming the entry's own
219
+ * index. `patternError`, when given, is the same-shaped check
220
+ * `../../config.mjs`'s `listEntryViolations` runs — an entry that will not
221
+ * compile against the matcher it is fed to throws later, mid-run, with no
222
+ * idea which row of `archkeep.json` produced it.
223
+ *
224
+ * @param {unknown} value
225
+ * @param {string} at
226
+ * @param {((pattern: string) => string|null)|undefined} [patternError]
227
+ * @returns {string[]}
228
+ */
229
+ function stringListViolations(value, at, patternError) {
230
+ if (value === undefined) return [];
231
+ if (!isStringArray(value)) return [`${at}: must be an array of strings, got ${describe(value)}`];
232
+ const violations = [];
233
+ value.forEach((entry, index) => {
234
+ if (entry === "") {
235
+ violations.push(`${at}[${index}]: must not be empty`);
236
+ return;
237
+ }
238
+ const problem = patternError?.(entry);
239
+ if (problem) violations.push(`${at}[${index}]: '${entry}' ${problem}`);
240
+ });
241
+ return violations;
242
+ }
243
+
244
+ const DECLARED_KEYS = ["name", "root", "type", "tags", "implicitDependencies", "targets"];
245
+
246
+ /** One `projects.declared` row's problems, prefixed with its index. */
247
+ function declaredProjectViolations(row, index) {
248
+ const at = `projects.declared[${index}]`;
249
+ if (!isPlainObject(row)) return [`${at}: must be an object, got ${describe(row)}`];
250
+
251
+ const violations = [];
252
+ // Canonical form only, checked in full before anything downstream ever
253
+ // compares this string against a tracked file: `./discover.mjs`'s `hasFile`
254
+ // matches by exact prefix (`${root}/`), so any of these forms would either
255
+ // match nothing a reader expects or match by coincidence, and the
256
+ // leading/trailing-slash message below is not the right explanation for any
257
+ // of them — a `'.'` root needs to be told to write `''` instead, not told
258
+ // about slashes it does not have.
259
+ if (typeof row.root !== "string") {
260
+ violations.push(`${at}.root: must be a string, got ${describe(row.root)}`);
261
+ } else if (row.root === ".") {
262
+ violations.push(`${at}.root: '.' is not valid — write '' to name the workspace root itself`);
263
+ } else if (row.root !== "") {
264
+ if (row.root.includes("\\")) {
265
+ violations.push(
266
+ `${at}.root: '${row.root}' must use forward slashes — this tool matches paths ` +
267
+ `posix-style regardless of the platform the tree is checked out on`,
268
+ );
269
+ } else if (row.root.startsWith("/") || row.root.endsWith("/")) {
270
+ violations.push(
271
+ `${at}.root: '${row.root}' must be workspace-relative with no leading or trailing ` +
272
+ `slash — '' names the workspace root itself`,
273
+ );
274
+ } else if (row.root.split("/").some((segment) => segment === "." || segment === "..")) {
275
+ violations.push(
276
+ `${at}.root: '${row.root}' must be a canonical path — no '.' or '..' segment`,
277
+ );
278
+ }
279
+ }
280
+ if ("name" in row && (typeof row.name !== "string" || row.name === "")) {
281
+ violations.push(
282
+ `${at}.name: must be a non-empty string when present, got ${describe(row.name)}`,
283
+ );
284
+ }
285
+ if ("type" in row && !PROJECT_TYPES.includes(/** @type {string} */ (row.type))) {
286
+ violations.push(
287
+ `${at}.type: must be one of ${PROJECT_TYPES.join(", ")}, got ${describe(row.type)}`,
288
+ );
289
+ }
290
+ // Tag VALUES are not matched against anything — a project either carries a
291
+ // tag or it does not — so the only requirement is the one every string list
292
+ // here already enforces: non-empty (spec M8). `tagPatternError` belongs to
293
+ // `depConstraints.sourceTag`/`onlyDependOnLibsWithTags`, a different
294
+ // vocabulary this validator must not borrow.
295
+ violations.push(...stringListViolations(row.tags, `${at}.tags`));
296
+ // `implicitDependencies` is expanded by `../../rules/match.mjs`'s
297
+ // `findMatchingProjects` — the exact function `./graph.mjs`'s promoted
298
+ // `buildDependencies` already calls — so an entry it cannot resolve is
299
+ // rejected here with the matcher's own reason (`projectPatternError`,
300
+ // the validator `findMatchingProjects` itself is never given a chance to
301
+ // run without) rather than at graph-build time, where it would name no row
302
+ // of this file at all and `buildDependencies`'s own catch would just drop
303
+ // the edge in silence.
304
+ violations.push(
305
+ ...stringListViolations(
306
+ row.implicitDependencies,
307
+ `${at}.implicitDependencies`,
308
+ projectPatternError,
309
+ ),
310
+ );
311
+ violations.push(...stringListViolations(row.targets, `${at}.targets`));
312
+ for (const key of Object.keys(row)) {
313
+ if (!DECLARED_KEYS.includes(key)) {
314
+ violations.push(
315
+ `${at}.${key}: not a declared-project field — expected one of ${DECLARED_KEYS.join(", ")}`,
316
+ );
317
+ }
318
+ }
319
+ return violations;
320
+ }
321
+
322
+ const INFER_KEYS = ["manifests", "include", "exclude"];
323
+
324
+ /** `projects.infer`'s problems, or `[]` when the key is absent — absent means "use the defaults", not "malformed". */
325
+ function inferViolations(value) {
326
+ if (value === undefined) return [];
327
+ if (!isPlainObject(value)) return [`projects.infer: must be an object, got ${describe(value)}`];
328
+ const violations = [
329
+ ...stringListViolations(value.manifests, "projects.infer.manifests"),
330
+ ...stringListViolations(value.include, "projects.infer.include", globComplexityError),
331
+ ...stringListViolations(value.exclude, "projects.infer.exclude", globComplexityError),
332
+ ];
333
+ // `[]` and "omit the key" both validate against `stringListViolations` above
334
+ // — a list is still a list at length zero — but they must not mean the same
335
+ // thing. Omitting `projects.infer` means "use the defaults" (see this
336
+ // function's own doc comment); an explicit `[]` for `manifests` or
337
+ // `include` matches no manifest and no path, which silently claims zero
338
+ // projects by inference rather than the all/defaults an author reaching for
339
+ // `[]` almost certainly wants. `exclude: []` is exempt: it already means
340
+ // "exclude nothing," which is a real, useful, non-silent setting.
341
+ for (const key of /** @type {const} */ (["manifests", "include"])) {
342
+ if (Array.isArray(value[key]) && value[key].length === 0) {
343
+ violations.push(
344
+ `projects.infer.${key}: must not be empty — an empty list matches nothing and silently ` +
345
+ `disables inference; omit 'projects.infer' entirely to disable it instead`,
346
+ );
347
+ }
348
+ }
349
+ for (const key of Object.keys(value)) {
350
+ if (!INFER_KEYS.includes(key)) {
351
+ violations.push(
352
+ `projects.infer.${key}: not an infer field — expected one of ${INFER_KEYS.join(", ")}`,
353
+ );
354
+ }
355
+ }
356
+ return violations;
357
+ }
358
+
359
+ const PROJECT_RULE_KEYS = ["match", "tags", "type"];
360
+
361
+ /** One `projectRules` row's problems, prefixed with its index. */
362
+ function projectRuleViolations(row, index) {
363
+ const at = `projectRules[${index}]`;
364
+ if (!isPlainObject(row)) return [`${at}: must be an object, got ${describe(row)}`];
365
+
366
+ const violations = [];
367
+ if (typeof row.match !== "string" || row.match === "") {
368
+ violations.push(
369
+ `${at}.match: must be a non-empty glob over a project's root — matched with ` +
370
+ `\`path.posix.matchesGlob\`, got ${describe(row.match)}`,
371
+ );
372
+ } else {
373
+ const problem = globComplexityError(row.match);
374
+ if (problem) violations.push(`${at}.match: '${row.match}' ${problem}`);
375
+ }
376
+ if (!("tags" in row) && !("type" in row)) {
377
+ violations.push(
378
+ `${at}: must set 'tags', 'type', or both — a row that sets neither matches ` +
379
+ `projects and changes nothing about them`,
380
+ );
381
+ }
382
+ // Same non-empty-string-only bar as `declaredProjectViolations` applies to
383
+ // tag values — see the comment there.
384
+ violations.push(...stringListViolations(row.tags, `${at}.tags`));
385
+ if ("type" in row && !PROJECT_TYPES.includes(/** @type {string} */ (row.type))) {
386
+ violations.push(
387
+ `${at}.type: must be one of ${PROJECT_TYPES.join(", ")}, got ${describe(row.type)}`,
388
+ );
389
+ }
390
+ for (const key of Object.keys(row)) {
391
+ if (!PROJECT_RULE_KEYS.includes(key)) {
392
+ violations.push(
393
+ `${at}.${key}: not a projectRules field — expected one of ${PROJECT_RULE_KEYS.join(", ")}`,
394
+ );
395
+ }
396
+ }
397
+ return violations;
398
+ }
399
+
400
+ const EXEMPT_KEYS = ["path", "reason"];
401
+
402
+ /**
403
+ * One `coverage.exempt` row's problems, prefixed with its index.
404
+ *
405
+ * The mandatory `reason` copies `../../config.mjs`'s `suppressionRowViolations`
406
+ * exactly: a waiver with no reason written down is indistinguishable from
407
+ * coverage that quietly stopped being enforced, which is the one state this
408
+ * whole tool exists to make loud instead of silent.
409
+ */
410
+ function exemptRowViolations(row, index) {
411
+ const at = `coverage.exempt[${index}]`;
412
+ if (!isPlainObject(row)) return [`${at}: must be an object, got ${describe(row)}`];
413
+
414
+ const violations = [];
415
+ if (typeof row.path !== "string" || row.path === "") {
416
+ violations.push(
417
+ `${at}.path: must be a non-empty glob over a workspace-relative path, matched with ` +
418
+ `\`path.posix.matchesGlob\`, got ${describe(row.path)}`,
419
+ );
420
+ } else {
421
+ const problem = globComplexityError(row.path);
422
+ if (problem) violations.push(`${at}.path: '${row.path}' ${problem}`);
423
+ }
424
+ if (typeof row.reason !== "string" || row.reason.trim() === "") {
425
+ violations.push(
426
+ `${at}.reason: must be a non-empty string — a waiver is a coverage hole someone decided ` +
427
+ `to accept, and one with no reason written down reads as coverage that is still enforced`,
428
+ );
429
+ }
430
+ for (const key of Object.keys(row)) {
431
+ if (!EXEMPT_KEYS.includes(key)) {
432
+ violations.push(
433
+ `${at}.${key}: not an exempt field — expected one of ${EXEMPT_KEYS.join(", ")}`,
434
+ );
435
+ }
436
+ }
437
+ return violations;
438
+ }
439
+
440
+ const WORKSPACE_LAYOUT_KEYS = ["appsDir", "libsDir"];
441
+
442
+ /** `workspaceLayout`'s problems, or `[]` when the key is absent — absent, never inferred. */
443
+ function workspaceLayoutViolations(value) {
444
+ if (value === undefined) return [];
445
+ if (!isPlainObject(value)) return [`workspaceLayout: must be an object, got ${describe(value)}`];
446
+ const violations = [];
447
+ for (const key of WORKSPACE_LAYOUT_KEYS) {
448
+ if (typeof value[key] !== "string" || value[key] === "") {
449
+ violations.push(
450
+ `workspaceLayout.${key}: must be a non-empty string, got ${describe(value[key])}`,
451
+ );
452
+ }
453
+ }
454
+ for (const key of Object.keys(value)) {
455
+ if (!WORKSPACE_LAYOUT_KEYS.includes(key)) {
456
+ violations.push(
457
+ `workspaceLayout.${key}: not a workspaceLayout field — expected one of ${WORKSPACE_LAYOUT_KEYS.join(", ")}`,
458
+ );
459
+ }
460
+ }
461
+ return violations;
462
+ }
463
+
464
+ const TOP_LEVEL_KEYS = [
465
+ "projects",
466
+ "projectRules",
467
+ "coverage",
468
+ "workspaceLayout",
469
+ "boundaryConfig",
470
+ "tsConfig",
471
+ ];
472
+
473
+ /**
474
+ * Everything wrong with the SHAPE of a loaded `archkeep.json`, as messages;
475
+ * empty when it is well-formed. Pure, so a test drives it without a file on
476
+ * disk — the same contract `../../config.mjs`'s `findBoundaryConfigViolations`
477
+ * keeps.
478
+ *
479
+ * `boundaryConfig` and `tsConfig` are deliberately NOT checked here when
480
+ * `boundaryConfig` is a string: both are then validated by `resolveOptions`
481
+ * (`../../options.mjs`) inside `loadNativeModel` below, so there is one
482
+ * validator and one default table for that pair rather than a second copy of
483
+ * `DEFAULT_OPTIONS`'s rules. A `boundaryConfig` that is an OBJECT instead of a
484
+ * string — the inline policy this file's own doc names as a second accepted
485
+ * shape (`../../../../../docs/reference/configuration.md`, "boundaryConfig") —
486
+ * bypasses `resolveOptions` entirely, since that function's whole contract is
487
+ * "every value is a non-empty string", so its check is inline below, and it
488
+ * reuses `../../config.mjs`'s own `findBoundaryConfigViolations` AND
489
+ * `policyKeyViolations` rather than a second copy of what a policy object may
490
+ * hold: the same pair `../../config.mjs`'s own `.json` dialect calls, with
491
+ * `allowSchema: true` — the same key law a `.json` policy file has, so the
492
+ * same JSON text that loads as a file loads inline (an inline `$schema` is
493
+ * accepted and checked the same way, never rejected for living in this
494
+ * object rather than its own file; `../../../../../docs/reference/policy-schema.md`,
495
+ * "Inline policy (`archkeep.json` only)").
496
+ *
497
+ * @param {unknown} raw The parsed `archkeep.json`.
498
+ * @returns {string[]}
499
+ */
500
+ export function findNativeModelViolations(raw) {
501
+ if (!isPlainObject(raw)) return [`archkeep.json: expected an object, got ${describe(raw)}`];
502
+
503
+ const violations = [];
504
+ const { projects, projectRules, coverage, workspaceLayout } = raw;
505
+
506
+ // An inline policy is validated by the exact function every other route to
507
+ // a boundary law runs through, so a malformed inline `depConstraints` row
508
+ // is caught here rather than surfacing later as a rule that matches
509
+ // nothing. A `boundaryConfig` that is a string is left alone here — see
510
+ // this function's own doc comment for where that string is validated
511
+ // instead — and one that is neither a string nor an object gets its own
512
+ // message rather than silently falling through to "not checked here".
513
+ if ("boundaryConfig" in raw) {
514
+ if (isPlainObject(raw.boundaryConfig)) {
515
+ violations.push(
516
+ ...findBoundaryConfigViolations(raw.boundaryConfig).map(
517
+ (message) => `boundaryConfig.${message}`,
518
+ ),
519
+ ...policyKeyViolations(raw.boundaryConfig, { allowSchema: true }).map(
520
+ (message) => `boundaryConfig.${message}`,
521
+ ),
522
+ );
523
+ } else if (typeof raw.boundaryConfig !== "string") {
524
+ violations.push(
525
+ `boundaryConfig: must be a string (a filename) or an object (an inline policy), got ` +
526
+ `${describe(raw.boundaryConfig)}`,
527
+ );
528
+ }
529
+ }
530
+
531
+ // `projects` is optional the same way every other top-level key here is —
532
+ // `docs/reference/configuration.md`'s "The shape, field by field" names a bare
533
+ // `{}` a valid `archkeep.json`, and an absent `projects` means exactly what
534
+ // `projects: {}` already validated as: zero declared rows, no inference.
535
+ // Rejecting `undefined` here (as this used to) contradicted that documented
536
+ // minimal form outright — the loader could not read the one example the
537
+ // page opens with. `discoverNativeProjects` (`./discover.mjs`) is still the
538
+ // one that turns "zero projects" into a loud failure once the tree is
539
+ // known; this validator's job stops at shape.
540
+ if (projects !== undefined && !isPlainObject(projects)) {
541
+ violations.push(`projects: must be an object, got ${describe(projects)}`);
542
+ } else {
543
+ const declaredProjects = /** @type {Record<string, unknown>} */ (projects ?? {});
544
+ if ("declared" in declaredProjects && !Array.isArray(declaredProjects.declared)) {
545
+ violations.push(
546
+ `projects.declared: must be an array when present, got ${describe(declaredProjects.declared)}`,
547
+ );
548
+ } else {
549
+ /** @type {unknown[]} */ (declaredProjects.declared ?? []).forEach((row, index) =>
550
+ violations.push(...declaredProjectViolations(row, index)),
551
+ );
552
+ }
553
+ violations.push(...inferViolations(declaredProjects.infer));
554
+ for (const key of Object.keys(declaredProjects)) {
555
+ if (key !== "declared" && key !== "infer") {
556
+ violations.push(`projects.${key}: not a projects field — expected one of declared, infer`);
557
+ }
558
+ }
559
+ }
560
+
561
+ if (projectRules !== undefined) {
562
+ if (!Array.isArray(projectRules)) {
563
+ violations.push(`projectRules: must be an array when present, got ${describe(projectRules)}`);
564
+ } else {
565
+ projectRules.forEach((row, index) => violations.push(...projectRuleViolations(row, index)));
566
+ }
567
+ }
568
+
569
+ if (coverage !== undefined) {
570
+ if (!isPlainObject(coverage)) {
571
+ violations.push(`coverage: must be an object when present, got ${describe(coverage)}`);
572
+ } else {
573
+ if ("exempt" in coverage && !Array.isArray(coverage.exempt)) {
574
+ violations.push(
575
+ `coverage.exempt: must be an array when present, got ${describe(coverage.exempt)}`,
576
+ );
577
+ } else {
578
+ /** @type {unknown[]} */ (coverage.exempt ?? []).forEach((row, index) =>
579
+ violations.push(...exemptRowViolations(row, index)),
580
+ );
581
+ }
582
+ for (const key of Object.keys(coverage)) {
583
+ if (key !== "exempt")
584
+ violations.push(`coverage.${key}: not a coverage field — expected 'exempt'`);
585
+ }
586
+ }
587
+ }
588
+
589
+ violations.push(...workspaceLayoutViolations(workspaceLayout));
590
+
591
+ for (const key of Object.keys(raw)) {
592
+ if (!TOP_LEVEL_KEYS.includes(key)) {
593
+ violations.push(
594
+ `${key}: not a archkeep.json field — expected one of ${TOP_LEVEL_KEYS.join(", ")}`,
595
+ );
596
+ }
597
+ }
598
+ return violations;
599
+ }
600
+
601
+ /**
602
+ * A validated `archkeep.json`, with every default applied so nothing downstream
603
+ * re-derives one: `projects.infer`'s three lists, `coverage.exempt`'s empty
604
+ * array, `boundaryConfig`/`tsConfig` through `resolveOptions`.
605
+ *
606
+ * `workspaceLayout` is the one field intentionally NOT defaulted here — it
607
+ * stays `undefined` when `archkeep.json` does not declare it, exactly as an
608
+ * absent `nx.json` `workspaceLayout` leaves `graph.workspaceLayout` unset for
609
+ * the Nx provider, so `../../rules/index.mjs`'s own
610
+ * `graph.workspaceLayout ?? DEFAULT_WORKSPACE_LAYOUT` fallback is the only
611
+ * place that ever applies the default. A second default here could disagree
612
+ * with that one the day either changes.
613
+ *
614
+ * `boundaryConfig` rides through untouched when `raw.boundaryConfig` is an
615
+ * inline object — `resolveOptions` never sees it, since its whole contract is
616
+ * "every value is a non-empty string" and an inline policy is neither. It has
617
+ * already passed `findNativeModelViolations`' own check by the time this runs
618
+ * (`loadNativeModel` validates before normalizing), so nothing here re-checks
619
+ * its shape.
620
+ *
621
+ * `boundaryConfigDeclared` is the one field on the model that `archkeep.json`
622
+ * cannot write and this function computes: whether the file named a boundary
623
+ * law at all, as against taking `../../options.mjs`'s `DEFAULT_OPTIONS`
624
+ * filename by convention. Both string and inline-object spellings count as
625
+ * declared — the workspace named its law either way, and the fact a reader
626
+ * downstream needs is exactly "did somebody name one", never "in which of the
627
+ * four spellings". That module's header owns the argument for why the bit has
628
+ * to survive the merge at all.
629
+ *
630
+ * @param {Record<string, unknown>} raw Already validated by `findNativeModelViolations`.
631
+ * @returns {object} `NativeModel` — see `./index.mjs`.
632
+ */
633
+ export function normalizeNativeModel(raw) {
634
+ // `raw.projects` may be absent — `findNativeModelViolations` above validates
635
+ // that shape the same way it validates `projects: {}`, so this has to read
636
+ // it back the same way rather than assume the key is always present.
637
+ const projects = /** @type {Record<string, unknown>} */ (raw.projects ?? {});
638
+ const declared = /** @type {unknown[]} */ (projects.declared) ?? [];
639
+ const rawInfer = /** @type {Record<string, unknown>|undefined} */ (projects.infer);
640
+
641
+ // The provenance of `boundaryConfig`, computed once and used three ways:
642
+ // twice to decide what `resolveOptions` is even asked, and once as the
643
+ // model's own `boundaryConfigDeclared`. It is deliberately NOT read back
644
+ // off `resolvedOptions`, which would be wrong for the inline spelling —
645
+ // an inline policy object never reaches `resolveOptions` at all, so that
646
+ // object's own bit says `false` for the one shape where the workspace was
647
+ // most explicit about naming its law.
648
+ const declaresBoundaryConfig = "boundaryConfig" in raw;
649
+ const inlineBoundaryConfig = isPlainObject(raw.boundaryConfig) ? raw.boundaryConfig : undefined;
650
+ const resolvedOptions = resolveOptions(
651
+ (declaresBoundaryConfig && inlineBoundaryConfig === undefined) || "tsConfig" in raw
652
+ ? {
653
+ ...(declaresBoundaryConfig && inlineBoundaryConfig === undefined
654
+ ? { boundaryConfig: raw.boundaryConfig }
655
+ : {}),
656
+ ...("tsConfig" in raw ? { tsConfig: raw.tsConfig } : {}),
657
+ }
658
+ : undefined,
659
+ );
660
+
661
+ return {
662
+ projects: {
663
+ declared: declared.map((row) => {
664
+ const r = /** @type {Record<string, unknown>} */ (row);
665
+ return {
666
+ name: typeof r.name === "string" ? r.name : undefined,
667
+ root: /** @type {string} */ (r.root),
668
+ type: typeof r.type === "string" ? r.type : undefined,
669
+ tags: /** @type {string[]} */ (r.tags) ?? [],
670
+ implicitDependencies: /** @type {string[]} */ (r.implicitDependencies) ?? [],
671
+ targets: /** @type {string[]} */ (r.targets) ?? [],
672
+ };
673
+ }),
674
+ // Spec §3.1: an absent `projects.infer` key means the declared list is
675
+ // exhaustive — no inference at all — not "infer with every default."
676
+ // `./discover.mjs`'s `model.projects.infer ? inferProjectRoots(...) : []`
677
+ // already reads it that way; filling this in regardless (as this object
678
+ // used to) made that ternary always truthy and inference always ran,
679
+ // silently claiming a vendored `package.json` as a project no
680
+ // `archkeep.json` author asked for. Only a *present* `projects.infer` gets
681
+ // its own three lists defaulted, key by key.
682
+ infer:
683
+ rawInfer === undefined
684
+ ? undefined
685
+ : {
686
+ manifests: rawInfer.manifests ?? DEFAULT_MANIFEST_NAMES,
687
+ include: rawInfer.include ?? ["**"],
688
+ exclude: rawInfer.exclude ?? [],
689
+ },
690
+ },
691
+ projectRules: /** @type {unknown[]} */ (raw.projectRules ?? []).map((row) => {
692
+ const r = /** @type {Record<string, unknown>} */ (row);
693
+ return {
694
+ match: /** @type {string} */ (r.match),
695
+ tags: /** @type {string[]} */ (r.tags) ?? [],
696
+ type: typeof r.type === "string" ? r.type : undefined,
697
+ };
698
+ }),
699
+ coverage: {
700
+ exempt: /** @type {unknown[]} */ (
701
+ /** @type {Record<string, unknown>|undefined} */ (raw.coverage)?.exempt ?? []
702
+ ).map((row) => /** @type {{path: string, reason: string}} */ (row)),
703
+ },
704
+ workspaceLayout: /** @type {{appsDir: string, libsDir: string}|undefined} */ (
705
+ raw.workspaceLayout
706
+ ),
707
+ boundaryConfig: inlineBoundaryConfig ?? resolvedOptions.boundaryConfig,
708
+ boundaryConfigDeclared: declaresBoundaryConfig,
709
+ tsConfig: resolvedOptions.tsConfig,
710
+ };
711
+ }
712
+
713
+ /**
714
+ * Loads and validates the `archkeep.json` at a workspace root.
715
+ *
716
+ * The path-taking, throw-on-malformed shape mirrors `../../config.mjs`'s
717
+ * `loadBoundaryConfigFile` exactly, down to the two message shapes: every
718
+ * shape violation reported at once, or a wrapped read failure. That parity is
719
+ * deliberate — a defect in the project's own model is a "could not reach a
720
+ * verdict" failure exactly like a defect in the boundary law is, and both
721
+ * exit the same way (`../../../cli.mjs`, `EXIT.error`).
722
+ *
723
+ * @param {string} root Absolute workspace root.
724
+ * @param {{readFile: (path: string) => string|null}} io
725
+ * @returns {object} `NativeModel` — see `./index.mjs`.
726
+ * @throws {Error} when the file is missing, unreadable, or malformed.
727
+ */
728
+ export function loadNativeModel(root, { readFile }) {
729
+ const text = readFile(ARCHKEEP_MODEL_FILE);
730
+ const path = `${root}/${ARCHKEEP_MODEL_FILE}`;
731
+ if (text === null) {
732
+ throw new Error(`archkeep: cannot load ${path}: no such file`);
733
+ }
734
+ let raw;
735
+ try {
736
+ raw = parseArchkeepJson(text);
737
+ } catch (cause) {
738
+ throw new Error(`archkeep: cannot load ${path}: ${cause?.message ?? cause}`, { cause });
739
+ }
740
+ const violations = findNativeModelViolations(raw);
741
+ if (violations.length > 0) {
742
+ throw new Error(`archkeep: ${path} is malformed:\n ${violations.join("\n ")}`);
743
+ }
744
+ return normalizeNativeModel(raw);
745
+ }
746
+
747
+ // Re-exported so a caller matching a project root against a glob (discovery,
748
+ // coverage) reaches for the one matcher `archkeep.json` uses throughout,
749
+ // rather than importing `../../rules/match.mjs` a second time for the same
750
+ // job. `safeMatchesGlob`, not a bare `path.posix.matchesGlob`: `projectRules`
751
+ // and `coverage.exempt` are validated against `globComplexityError` above at
752
+ // config load (`projectRuleViolations`, `exemptRowViolations`), and this
753
+ // export is the backstop for any pattern that reaches matching without going
754
+ // through that validation — see `../../rules/match.mjs`'s own doc comment.
755
+ export const matchesGlob = safeMatchesGlob;