@ecoma-io/archkeep 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +262 -0
- package/cli.mjs +2792 -0
- package/index.mjs +85 -0
- package/lsp.mjs +81 -0
- package/nx.mjs +24 -0
- package/package.json +81 -0
- package/presets/clean-architecture.json +78 -0
- package/presets/ddd-bounded-contexts.json +88 -0
- package/presets/hexagonal.json +68 -0
- package/presets/layered.json +92 -0
- package/presets/modular-monolith.json +85 -0
- package/presets/vertical-slice.json +68 -0
- package/src/analysis/analyze.mjs +218 -0
- package/src/analysis/contract.md +259 -0
- package/src/analysis/go.mjs +414 -0
- package/src/analysis/manifest-util.mjs +68 -0
- package/src/analysis/python.mjs +1266 -0
- package/src/analysis/registry.mjs +74 -0
- package/src/analysis/rust.mjs +674 -0
- package/src/analysis/source-util.mjs +230 -0
- package/src/analysis/typescript.mjs +1034 -0
- package/src/analysis/vue.mjs +156 -0
- package/src/architecture-intent/intent-fingerprint.mjs +29 -0
- package/src/architecture-intent/judge.mjs +539 -0
- package/src/architecture-intent/model.mjs +703 -0
- package/src/architecture-intent/selectors.mjs +170 -0
- package/src/canonical.mjs +48 -0
- package/src/commands/README.md +266 -0
- package/src/commands/adr.mjs +248 -0
- package/src/commands/check.mjs +989 -0
- package/src/commands/context-command.mjs +212 -0
- package/src/commands/context.mjs +790 -0
- package/src/commands/custom-rules.mjs +428 -0
- package/src/commands/debt.mjs +218 -0
- package/src/commands/diff.mjs +523 -0
- package/src/commands/discover.mjs +159 -0
- package/src/commands/drift.mjs +473 -0
- package/src/commands/edge-constraints.mjs +355 -0
- package/src/commands/explain.mjs +359 -0
- package/src/commands/fitness.mjs +226 -0
- package/src/commands/graph.mjs +297 -0
- package/src/commands/health.mjs +213 -0
- package/src/commands/history.mjs +614 -0
- package/src/commands/impact.mjs +226 -0
- package/src/commands/plan-context-command.mjs +496 -0
- package/src/commands/policy.mjs +138 -0
- package/src/commands/provenance-command.mjs +352 -0
- package/src/commands/provenance.mjs +159 -0
- package/src/commands/reconcile.mjs +219 -0
- package/src/commands/report.mjs +553 -0
- package/src/commands/snapshot-meta.mjs +107 -0
- package/src/commands/waivers.mjs +240 -0
- package/src/config.mjs +1308 -0
- package/src/containment.mjs +234 -0
- package/src/custom-rules/evidence.mjs +340 -0
- package/src/custom-rules/host.mjs +1023 -0
- package/src/custom-rules/values.mjs +43 -0
- package/src/entry-point.mjs +55 -0
- package/src/errors.mjs +36 -0
- package/src/eslint-config.mjs +542 -0
- package/src/go-work.mjs +394 -0
- package/src/governance/adr-registry.mjs +539 -0
- package/src/governance/clock.mjs +69 -0
- package/src/governance/debt-ledger.mjs +274 -0
- package/src/governance/discovery-proposal.mjs +423 -0
- package/src/governance/fitness-registry.mjs +504 -0
- package/src/governance/fitness-rules.mjs +668 -0
- package/src/governance/metrics.mjs +392 -0
- package/src/governance/preset-fingerprints.json +16 -0
- package/src/governance/profile-registry.mjs +366 -0
- package/src/governance/provenance-record.mjs +177 -0
- package/src/governance/reconcile-candidates.mjs +301 -0
- package/src/governance/reconcile-score.mjs +503 -0
- package/src/governance/row-schema.mjs +208 -0
- package/src/governance/verdict.mjs +127 -0
- package/src/governance/waiver.mjs +105 -0
- package/src/graph/create-dependencies.mjs +96 -0
- package/src/intent/intent-manifest.json +347 -0
- package/src/intent/mask-non-code.mjs +640 -0
- package/src/lsp/boundary-config.mjs +225 -0
- package/src/lsp/diagnose.mjs +202 -0
- package/src/lsp/diagnostics.mjs +241 -0
- package/src/lsp/protocol.mjs +215 -0
- package/src/lsp/server.mjs +922 -0
- package/src/lsp/workspace-index.mjs +891 -0
- package/src/nx-json.mjs +95 -0
- package/src/options.mjs +611 -0
- package/src/process.mjs +91 -0
- package/src/providers/moon.mjs +733 -0
- package/src/providers/native/README.md +204 -0
- package/src/providers/native/coverage.mjs +74 -0
- package/src/providers/native/differential.fixtures.mjs +1277 -0
- package/src/providers/native/discover.mjs +431 -0
- package/src/providers/native/graph.mjs +234 -0
- package/src/providers/native/index.mjs +152 -0
- package/src/providers/native/model.mjs +755 -0
- package/src/providers/nx.mjs +178 -0
- package/src/report/README.md +89 -0
- package/src/report/adr-text.mjs +129 -0
- package/src/report/context-text.mjs +109 -0
- package/src/report/debt-text.mjs +105 -0
- package/src/report/diff-text.mjs +219 -0
- package/src/report/discover-text.mjs +186 -0
- package/src/report/drift-text.mjs +194 -0
- package/src/report/envelope-shape.mjs +161 -0
- package/src/report/evidence.mjs +157 -0
- package/src/report/explain-text.mjs +159 -0
- package/src/report/graph-text.mjs +116 -0
- package/src/report/health-text.mjs +123 -0
- package/src/report/history-text.mjs +204 -0
- package/src/report/impact-text.mjs +128 -0
- package/src/report/json.mjs +173 -0
- package/src/report/plan-context-text.mjs +159 -0
- package/src/report/provenance-text.mjs +78 -0
- package/src/report/reconcile-text.mjs +159 -0
- package/src/report/report-text.mjs +264 -0
- package/src/report/sarif.mjs +953 -0
- package/src/report/text.mjs +823 -0
- package/src/report/waivers-text.mjs +100 -0
- package/src/rules/README.md +123 -0
- package/src/rules/index.mjs +962 -0
- package/src/rules/match.mjs +1708 -0
- package/src/rules/messages.mjs +73 -0
- package/src/rules/reachability.mjs +224 -0
- package/src/rules/specifiers.mjs +300 -0
- package/src/rules/tags.mjs +238 -0
- package/src/rules/topology.mjs +333 -0
- package/src/tsconfig-paths.mjs +237 -0
- package/src/verdict.mjs +145 -0
- package/src/workspace.mjs +580 -0
|
@@ -0,0 +1,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;
|