@ecoma-io/archkeep 0.23.0 → 0.24.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ecoma-io/archkeep",
3
- "version": "0.23.0",
3
+ "version": "0.24.0",
4
4
  "description": "Architecture authority for human and agentic software development — deterministic, evidence-backed enforcement of declared architecture.",
5
5
  "keywords": [
6
6
  "architecture",
@@ -0,0 +1,340 @@
1
+ /**
2
+ * The markdown document track: machine-readable markers inside tracked
3
+ * documents, resolved to graph edges the existing tag rows judge.
4
+ *
5
+ * A boundary law can declare a `markdown` block (`../config.mjs`'s
6
+ * `findMarkdownViolations` owns the shape): a set of document globs and a set
7
+ * of marker rows, each a regular expression whose first capture group names an
8
+ * exported symbol — the `<!-- @api Button -->` an architecture-intent
9
+ * document pairs with the component it documents. This module turns those
10
+ * markers into `{source, target, type}` edges: source is the project that owns
11
+ * the document, target is the project that exports the named symbol, and the
12
+ * type is the row's declared edge kind (`resolvedExportOwner`, the one kind
13
+ * today). Everything downstream of the fold is machinery that already existed:
14
+ * `../providers/native/graph.mjs`'s `mergeDeclaredEdges` folds the edges into
15
+ * the graph the way it folds the declared manifest track, and
16
+ * `../rules/edge-constraints.mjs`'s `judgeEdge` — the same function
17
+ * `declaredEdgeViolationsForCheck` runs `implicit` edges through — decides
18
+ * each one against `depConstraints`. No rule knows this track exists, which is
19
+ * the point: a document pairing is a project-to-project claim, and the claims
20
+ * a workspace already wrote are the ones that should judge it.
21
+ *
22
+ * ## What this module deliberately is not
23
+ *
24
+ * It does not render markdown, lint prose, or index free text. The only bytes
25
+ * read past the extension are lines matched by a configured marker row —
26
+ * declared, machine-readable claims, the same contract the declared manifest
27
+ * track reads a pom or a csproj under. A document whose every line matches no
28
+ * row contributes nothing, and a workspace that declares no `markdown` block
29
+ * pays nothing at all: the fold is unreachable without the block, so a
30
+ * config-absent run is byte-identical to one this module never existed for.
31
+ *
32
+ * ## The export index, and why re-exports cannot own a symbol
33
+ *
34
+ * Resolution asks "which project publishes `Button`", and the engine has no
35
+ * export table to ask — so the fold builds one, scanning every project-owned
36
+ * TypeScript-language file's top-level exports once (`./typescript.mjs`'s
37
+ * `exportedNamesOf`; `.vue` single-file components are not scanned, because
38
+ * their public surface is the TypeScript barrel that re-exports it, and the
39
+ * barrel is scanned). Names are kept in two tiers: a name a file DECLARES is
40
+ * its project's, and wins over any number of projects that merely RE-EXPORT
41
+ * it — an umbrella barrel re-exporting a library must not turn that library's
42
+ * every symbol into an ambiguous claim. A name in neither tier is a document
43
+ * claim the graph cannot honor, and the marker's file fails whole: a pairing
44
+ * the tree cannot establish must never read as a clean one (`../../../AGENTS.md`,
45
+ * "an empty result is a claim, not a shrug") — the same refusal posture the
46
+ * declared manifest track holds for a pom it cannot read, and for the same
47
+ * reason: the run would otherwise report a verdict computed over a track that
48
+ * silently dropped a declared edge.
49
+ */
50
+
51
+ import { safeMatchesGlob } from "../rules/match.mjs";
52
+ import { exportedNamesOf } from "./typescript.mjs";
53
+ import { languageOf } from "./registry.mjs";
54
+ import { fileFailure } from "./source-util.mjs";
55
+
56
+ /** The extension a file must carry to be a candidate document. */
57
+ const MARKDOWN_EXTENSION = ".md";
58
+
59
+ /**
60
+ * The document track's fold, over an already-built context: every marker the
61
+ * law's rows match in every document the law's globs include, resolved against
62
+ * the workspace's exports.
63
+ *
64
+ * Edges are returned deduped by `(source, target, type)` — two markers in two
65
+ * documents naming the same symbol are one dependency at project grain, the
66
+ * same grain every other track reports at — and the caller folds them into the
67
+ * graph with `../providers/native/graph.mjs`'s `mergeDeclaredEdges`, which
68
+ * enforces the same key against the edges already there.
69
+ *
70
+ * Failures are WHOLE-FILE failures on the document that earned them, never
71
+ * positioned rows: a marker the tree cannot resolve means the document's
72
+ * pairing claim went unjudged, so the file has no verdict to claim and the
73
+ * run's coverage says so (`unchecked`), with the reason naming the line and
74
+ * the name. A positioned row would read as a resolved no — a verdict the run
75
+ * does not hold.
76
+ *
77
+ * @param {{ tracked: string[], owned: {file: string, project: string}[],
78
+ * readFile: (path: string) => string|null, workspace: object,
79
+ * markdown: {include: string[], markers: {pattern: string, edge: string}[]} }} input
80
+ * `tracked` in the caller's file order (the run's determinism basis),
81
+ * `owned` the context's file→project map, `readFile` the workspace's own
82
+ * reader, `markdown` the loaded policy's markdown block.
83
+ * @returns {{ edges: {source: string, target: string, type: string}[],
84
+ * claims: {source: string, target: string, type: string, file: string,
85
+ * line: number, column: number, name: string}[],
86
+ * failures: {sourceFile: string, reason: string}[], documents: number,
87
+ * judged: number, resolved: number, selfPaired: number,
88
+ * includeCounts: number[], rowMatches: number[] }}
89
+ * `edges` is the deduped graph fold; `claims` is the same resolution at
90
+ * marker grain — one record per resolved marker, carrying the document
91
+ * position the caller's verdicts must point at, which the deduped list
92
+ * deliberately does not. `documents` counts the files the include globs
93
+ * selected and read. `judged` counts markers extracted, `resolved` the
94
+ * ones that became an edge, `selfPaired` the ones whose document and symbol
95
+ * live in the same project (a legal claim that draws no edge — a project
96
+ * cannot depend on itself, the rule every track holds), `includeCounts` the
97
+ * per-pattern document counts and `rowMatches` the per-row match counts the
98
+ * caller's dead-law gate reads.
99
+ */
100
+ export function foldMarkdownTrack({ tracked, owned, readFile, workspace, markdown }) {
101
+ const documents = markdownIncludedFiles({ include: markdown.include, tracked });
102
+ const includeCounts = markdown.include.map(
103
+ (pattern) => documents.filter((file) => matchesInclude(file, pattern)).length,
104
+ );
105
+ /** @type {{ row: number, file: string, line: number, column: number, name: string }[]} */
106
+ const markers = [];
107
+ /** @type {{sourceFile: string, reason: string}[]} */
108
+ const failures = [];
109
+ const rowMatches = markdown.markers.map(() => 0);
110
+ /** @type {(RegExp|null)[]} */
111
+ const compiled = markdown.markers.map((row) => {
112
+ try {
113
+ return new RegExp(row.pattern, "u");
114
+ } catch {
115
+ // Load-time validation refuses an uncompilable pattern; this arm exists
116
+ // so a hand-built config in a test degrades to "this row matches
117
+ // nothing" instead of throwing past every caller that guards.
118
+ return null;
119
+ }
120
+ });
121
+
122
+ for (const file of documents) {
123
+ const text = readFile(file);
124
+ if (text === null) {
125
+ failures.push(
126
+ fileFailure(
127
+ file,
128
+ "cannot be read — the markdown track matched it, so its markers cannot be extracted " +
129
+ "and its document claims cannot be judged",
130
+ ),
131
+ );
132
+ continue;
133
+ }
134
+ const lines = text.split("\n");
135
+ for (const [rowIndex, regex] of compiled.entries()) {
136
+ if (regex === null) continue;
137
+ for (const [at, line] of lines.entries()) {
138
+ const match = regex.exec(line);
139
+ if (match === null) continue;
140
+ rowMatches[rowIndex] += 1;
141
+ const name = match[1] ?? "";
142
+ if (name.trim() === "") {
143
+ failures.push(
144
+ fileFailure(
145
+ file,
146
+ `line ${at + 1}: the marker matches markdown.markers[${rowIndex}] but captures an ` +
147
+ `empty name — the row's first capture group must carry the exported symbol the ` +
148
+ `document claims`,
149
+ ),
150
+ );
151
+ continue;
152
+ }
153
+ markers.push({ row: rowIndex, file, line: at + 1, column: (match.index ?? 0) + 1, name });
154
+ }
155
+ }
156
+ }
157
+
158
+ const edges = [];
159
+ /** @type {{source: string, target: string, type: string, file: string,
160
+ * line: number, column: number, name: string}[]} */
161
+ const claims = [];
162
+ let resolved = 0;
163
+ let selfPaired = 0;
164
+ if (markers.length > 0) {
165
+ const projectOfFile = new Map(owned.map(({ file, project }) => [file, project]));
166
+ const index = exportIndexOf({ owned, readFile, workspace });
167
+ for (const marker of markers) {
168
+ // Declared beats re-exported: see this file's header. Candidates are
169
+ // sorted because a Set's insertion order is file order, and a message
170
+ // that names two projects must not name them in a different order on a
171
+ // different checkout.
172
+ const declaredOwners = index.declared.get(marker.name);
173
+ const candidates = declaredOwners ?? index.reexported.get(marker.name) ?? new Set();
174
+ if (candidates.size === 0) {
175
+ failures.push(
176
+ fileFailure(
177
+ marker.file,
178
+ `line ${marker.line}: the marker names '${marker.name}', which no tracked project ` +
179
+ `exports — the pairing this document claims cannot be resolved to a project, so ` +
180
+ `its edge was not drawn. Exports are scanned from TypeScript-language project ` +
181
+ `files; either the symbol does not exist, is not exported from a project file, or ` +
182
+ `its name is misspelt here`,
183
+ ),
184
+ );
185
+ continue;
186
+ }
187
+ if (candidates.size > 1) {
188
+ failures.push(
189
+ fileFailure(
190
+ marker.file,
191
+ `line ${marker.line}: the marker names '${marker.name}', which more than one project ` +
192
+ `exports — ${[...candidates]
193
+ .sort()
194
+ .map((name) => `'${name}'`)
195
+ .join(", ")} — and a ` +
196
+ `claim this tree cannot read one way must not be read as kept. Qualify the marker ` +
197
+ `or narrow the export surface so the name resolves to one project`,
198
+ ),
199
+ );
200
+ continue;
201
+ }
202
+ const source = projectOfFile.get(marker.file);
203
+ const [target] = candidates;
204
+ if (source === undefined) {
205
+ failures.push(
206
+ fileFailure(
207
+ marker.file,
208
+ `line ${marker.line}: the document is owned by no project, so the edge its marker ` +
209
+ `claims has no source — include the document's directory in a project, or narrow ` +
210
+ `markdown.include to documents that live inside one`,
211
+ ),
212
+ );
213
+ continue;
214
+ }
215
+ if (source === target) {
216
+ // A document pairing its own project's symbol: a legal claim that
217
+ // carries no boundary weight — no project depends on itself, the rule
218
+ // `buildDependencies` holds for every track — but a claim the row DID
219
+ // match and resolve, so it counts as judged rather than vanishing.
220
+ selfPaired += 1;
221
+ continue;
222
+ }
223
+ resolved += 1;
224
+ const edge = { source, target, type: markdown.markers[marker.row].edge };
225
+ edges.push(edge);
226
+ claims.push({
227
+ ...edge,
228
+ file: marker.file,
229
+ line: marker.line,
230
+ column: marker.column,
231
+ name: marker.name,
232
+ });
233
+ }
234
+ }
235
+
236
+ return {
237
+ edges: dedupeEdges(edges),
238
+ claims,
239
+ failures,
240
+ documents: documents.length,
241
+ judged: markers.length,
242
+ resolved,
243
+ selfPaired,
244
+ includeCounts,
245
+ rowMatches,
246
+ };
247
+ }
248
+
249
+ /**
250
+ * The tracked documents the law's globs select — tracked order preserved, and
251
+ * restricted to markdown files: the track reads documents, and a glob whose
252
+ * every match is some other kind of file selects nothing (loudly — the
253
+ * caller's dead-law gate counts what each pattern actually matched).
254
+ *
255
+ * Exported for the dead-law gate and its tests, which need the same selection
256
+ * the fold makes without re-deriving it a second way.
257
+ *
258
+ * @param {{include: string[], tracked: string[]}} input
259
+ * @returns {string[]}
260
+ */
261
+ export function markdownIncludedFiles({ include, tracked }) {
262
+ return tracked.filter(
263
+ (file) =>
264
+ file.endsWith(MARKDOWN_EXTENSION) && include.some((pattern) => matchesInclude(file, pattern)),
265
+ );
266
+ }
267
+
268
+ /**
269
+ * Whether a tracked file matches one include pattern — `./rules/match.mjs`'s
270
+ * `safeMatchesGlob`, the one matcher `boundarySuppressions` and
271
+ * `coverage.exempt` rows use, so a glob spells the same language here it does
272
+ * everywhere else in the policy.
273
+ *
274
+ * @param {string} file Workspace-relative path.
275
+ * @param {string} pattern Workspace-relative glob.
276
+ * @returns {boolean}
277
+ */
278
+ function matchesInclude(file, pattern) {
279
+ return safeMatchesGlob(file, pattern);
280
+ }
281
+
282
+ /**
283
+ * The workspace's export index, built once per fold: every project-owned
284
+ * TypeScript-language file's exported names, keyed by name to the set of
285
+ * projects that declare or re-export them.
286
+ *
287
+ * A file whose read fails contributes nothing — and no failure of its own:
288
+ * that file's analysis already reports the read to the caller's own funnel,
289
+ * and a second row naming the same bytes would count one hole twice. A file
290
+ * whose parse fails contributes what TypeScript could read, the posture
291
+ * `exportedNamesOf` itself holds.
292
+ *
293
+ * @param {{ owned: {file: string, project: string}[], readFile: (path: string) => string|null,
294
+ * workspace: object }} input
295
+ * @returns {{ declared: Map<string, Set<string>>, reexported: Map<string, Set<string>> }}
296
+ */
297
+ function exportIndexOf({ owned, readFile, workspace }) {
298
+ /** @type {Map<string, Set<string>>} */
299
+ const declared = new Map();
300
+ /** @type {Map<string, Set<string>>} */
301
+ const reexported = new Map();
302
+ const add = (map, names, project) => {
303
+ for (const name of names) {
304
+ if (name === "") continue;
305
+ const holders = map.get(name) ?? new Set();
306
+ holders.add(project);
307
+ map.set(name, holders);
308
+ }
309
+ };
310
+ for (const { file, project } of owned) {
311
+ // `.vue` single-file components are TypeScript too, but their script
312
+ // blocks live behind the SFC parser (`./vue.mjs`), and this index needs
313
+ // only what a barrel already re-exports — see this file's header.
314
+ if (languageOf(file) !== "typescript") continue;
315
+ const text = readFile(file);
316
+ if (text === null) continue;
317
+ const names = exportedNamesOf({ sourceFile: file, text, workspace });
318
+ add(declared, names.declared, project);
319
+ add(reexported, names.reexported, project);
320
+ }
321
+ return { declared, reexported };
322
+ }
323
+
324
+ /**
325
+ * One edge per `(source, target, type)` — the same canonical key
326
+ * `buildDependencies` reduces import sites by, applied here so the fold's own
327
+ * answer is canonical before the merge adds its dedup on top.
328
+ *
329
+ * @param {{source: string, target: string, type: string}[]} edges
330
+ * @returns {{source: string, target: string, type: string}[]}
331
+ */
332
+ function dedupeEdges(edges) {
333
+ const seen = new Set();
334
+ return edges.filter((edge) => {
335
+ const key = JSON.stringify([edge.source, edge.target, edge.type]);
336
+ if (seen.has(key)) return false;
337
+ seen.add(key);
338
+ return true;
339
+ });
340
+ }
@@ -153,10 +153,11 @@ function ownershipIndexOf(projects) {
153
153
  /**
154
154
  * Root comparisons `projectOwning` has performed since the module loaded.
155
155
  *
156
- * Nothing in production reads it. It exists so the complexity test counts
157
- * deterministic operations instead of milliseconds — the wall-clock this
158
- * repository does not trust in a test (cf. #359, #369). Every comparison the
159
- * lookup makes is counted: one per binary-search step, one per equality probe.
156
+ * A test-support export: nothing in production reads it. It exists so the
157
+ * complexity test counts deterministic operations instead of milliseconds —
158
+ * the wall-clock this repository does not trust in a test (cf. #359, #369).
159
+ * Every comparison the lookup makes is counted: one per binary-search step,
160
+ * one per equality probe.
160
161
  */
161
162
  let rootComparisons = 0;
162
163
  export const ownershipRootComparisons = () => rootComparisons;
@@ -1121,3 +1121,149 @@ export function analyzeTypeScript({ sourceFile, text, workspace, lang }) {
1121
1121
  }
1122
1122
  return result;
1123
1123
  }
1124
+
1125
+ /**
1126
+ * The names one TypeScript-language file EXPORTS, split by how the export was
1127
+ * written — the two facts `../analysis/markdown.mjs`'s resolution needs when a
1128
+ * document marker names a symbol and the engine must answer "which project
1129
+ * publishes this".
1130
+ *
1131
+ * The split is load-bearing rather than bookkeeping. A name a file DECLARES
1132
+ * here (`export const Button`, `export { Button }` over a local binding,
1133
+ * `export default`) is a symbol whose home this file's project is: a marker
1134
+ * naming it resolves to that project even when five other projects re-export
1135
+ * it, because the re-exporters are downstream of the declaration, not
1136
+ * alternative homes for it. A name a file RE-EXPORTS from another module
1137
+ * (`export { Button } from "@scope/ui-button"`, `export * as ui from …`) is a
1138
+ * name this module passes through, and the project that declares it owns the
1139
+ * resolution. Resolution prefers the declared tier for exactly this reason —
1140
+ * an umbrella barrel that re-exports a whole library must not turn every one
1141
+ * of its symbols into an ambiguous claim.
1142
+ *
1143
+ * Only top-level statements are read, and `export * from "…"` is deliberately
1144
+ * absent from both tiers: a star names no symbol, and enumerating one would
1145
+ * mean resolving the starred module — a module-resolution walk this function's
1146
+ * caller never needs, because a star's targets are themselves scanned as the
1147
+ * files they are. A project whose public surface is star-re-exported from
1148
+ * another project's files resolves those markers through the declaring files'
1149
+ * own projects, which is the honest answer at project grain.
1150
+ *
1151
+ * Never throws: a malformed file yields whatever TypeScript could parse plus a
1152
+ * failure per syntax error, the same posture `analyzeTypeScript` above holds —
1153
+ * one unreadable file must not blank the index every marker resolves against.
1154
+ *
1155
+ * @param {{ sourceFile: string, text: string, workspace: object, lang?: string }} request
1156
+ * The same request shape `analyzeTypeScript` takes; `lang` is a Vue block's
1157
+ * `<script lang>` and is omitted for a real file.
1158
+ * @returns {{ declared: string[], reexported: string[], failures: object[] }}
1159
+ */
1160
+ export function exportedNamesOf({ sourceFile, text, workspace, lang }) {
1161
+ /** @type {string[]} */
1162
+ const declared = [];
1163
+ /** @type {string[]} */
1164
+ const reexported = [];
1165
+ /** @type {object[]} */
1166
+ const failures = [];
1167
+ try {
1168
+ const parsed = ts.createSourceFile(
1169
+ `${workspace.root}/${sourceFile}`,
1170
+ text,
1171
+ ts.ScriptTarget.Latest,
1172
+ false,
1173
+ scriptKindFor(sourceFile, lang),
1174
+ );
1175
+ failures.push(...parseFailures(parsed, sourceFile));
1176
+
1177
+ const hasModifier = (node, kind) =>
1178
+ (node.modifiers ?? []).some((modifier) => modifier.kind === kind);
1179
+
1180
+ for (const statement of parsed.statements) {
1181
+ // `export { a, b as c }` with no `from` — a local binding list. The
1182
+ // EXPORTED name is the alias side: plain `a` exports `a`, `b as c`
1183
+ // exports `c`. Element name text is read defensively so a
1184
+ // string-literal alias (`export { a as "x y" }`) is carried as written
1185
+ // rather than undefined.
1186
+ if (ts.isExportDeclaration(statement) && statement.exportClause) {
1187
+ const clause = statement.exportClause;
1188
+ if (ts.isNamedExports(clause)) {
1189
+ const target = statement.moduleSpecifier ? reexported : declared;
1190
+ for (const element of clause.elements) {
1191
+ target.push(element.name?.text ?? "");
1192
+ }
1193
+ } else if (clause.name) {
1194
+ // `export * as ns from "…"` — a re-export wearing a new name.
1195
+ reexported.push(clause.name.text);
1196
+ }
1197
+ continue;
1198
+ }
1199
+ if (ts.isExportAssignment(statement)) {
1200
+ // `export default <expression>` and `export = <identifier>` — the
1201
+ // module's own default/exports binding, declared here whatever it
1202
+ // wraps. The `export =` form carries the identifier it aliases; a
1203
+ // default is the name every consumer writes, not the expression's.
1204
+ declared.push(
1205
+ statement.isExportEquals && ts.isIdentifier(statement.expression)
1206
+ ? statement.expression.text
1207
+ : "default",
1208
+ );
1209
+ continue;
1210
+ }
1211
+ const exported = hasModifier(statement, ts.SyntaxKind.ExportKeyword);
1212
+ if (!exported) continue;
1213
+ if (hasModifier(statement, ts.SyntaxKind.DefaultKeyword)) {
1214
+ declared.push("default");
1215
+ continue;
1216
+ }
1217
+ // The one-name statements — function, class, enum, namespace, type,
1218
+ // interface — all carry the exported identifier as `.name`. Enumerated
1219
+ // kind by kind so the type checker's statement union narrows to the
1220
+ // members that actually have one; a future statement kind with a name
1221
+ // is a new arm here, which is the point.
1222
+ const named =
1223
+ ts.isFunctionDeclaration(statement) ||
1224
+ ts.isClassDeclaration(statement) ||
1225
+ ts.isEnumDeclaration(statement) ||
1226
+ ts.isTypeAliasDeclaration(statement) ||
1227
+ ts.isInterfaceDeclaration(statement) ||
1228
+ ts.isModuleDeclaration(statement);
1229
+ if (named) {
1230
+ const name = statement.name?.text ?? "";
1231
+ if (name !== "") {
1232
+ declared.push(name);
1233
+ continue;
1234
+ }
1235
+ }
1236
+ if (ts.isVariableStatement(statement)) {
1237
+ for (const declaration of statement.declarationList.declarations) {
1238
+ bindingNames(declaration.name, declared);
1239
+ }
1240
+ }
1241
+ }
1242
+ } catch (cause) {
1243
+ failures.push(fileFailure(sourceFile, `export scan failed: ${cause?.message ?? cause}`));
1244
+ }
1245
+ return { declared, reexported, failures };
1246
+ }
1247
+
1248
+ /**
1249
+ * Every identifier a binding pattern introduces, in source order — `a`,
1250
+ * `{ a, b: c }`'s `a` and `c`, `[x, ...rest]`'s `x` and `rest`. Computed
1251
+ * properties (`{ [key]: value }`) introduce nothing nameable and are skipped,
1252
+ * the same call a minifier would make: a name no source text carries is a name
1253
+ * no marker can claim.
1254
+ *
1255
+ * @param {ts.Node} node A binding name or pattern.
1256
+ * @param {string[]} out Accumulator, mutated in place.
1257
+ */
1258
+ function bindingNames(node, out) {
1259
+ if (ts.isIdentifier(node) || ts.isStringLiteral(node)) {
1260
+ out.push(node.text);
1261
+ return;
1262
+ }
1263
+ if (ts.isObjectBindingPattern(node) || ts.isArrayBindingPattern(node)) {
1264
+ for (const element of node.elements) {
1265
+ if (ts.isOmittedExpression(element)) continue;
1266
+ bindingNames(element.name ?? element, out);
1267
+ }
1268
+ }
1269
+ }
@@ -41,6 +41,7 @@ import { readFile as readFileFromDisk } from "node:fs/promises";
41
41
  import { resolve } from "node:path";
42
42
 
43
43
  import { containmentViolation } from "../containment.mjs";
44
+ import { isEnoent } from "../errors.mjs";
44
45
 
45
46
  import { isValidSelector, splitSelector } from "./selectors.mjs";
46
47
  import { describe, isPlainObject } from "../values.mjs";
@@ -665,7 +666,7 @@ export async function loadIntent(root, { read = readFileFromDisk, tracked } = {}
665
666
  // neighbours on the identical tree are both loud: an escaping symlink
666
667
  // throws at the containment check above, and EACCES throws below. Only
667
668
  // this one was silent (`../../../../AGENTS.md`).
668
- if (cause?.code === "ENOENT") {
669
+ if (isEnoent(cause)) {
669
670
  if (tracked === undefined) return undefined;
670
671
  throw new Error(
671
672
  `${INTENT_FILE}: is tracked but could not be read: ${cause?.message ?? cause} — ` +