@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,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
|
+
}
|