@ecoma-io/archkeep 0.22.2 → 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.
Files changed (63) hide show
  1. package/package.json +40 -13
  2. package/src/analysis/jvm/packages.mjs +0 -17
  3. package/src/analysis/manifest-util.mjs +14 -5
  4. package/src/analysis/markdown.mjs +340 -0
  5. package/src/analysis/source-util.mjs +5 -4
  6. package/src/analysis/typescript.mjs +146 -0
  7. package/src/architecture-intent/judge.mjs +1 -1
  8. package/src/architecture-intent/model.mjs +3 -12
  9. package/src/commands/README.md +16 -7
  10. package/src/commands/change-intent.mjs +2 -11
  11. package/src/commands/check.mjs +234 -12
  12. package/src/commands/completeness.mjs +0 -32
  13. package/src/commands/context-command.mjs +13 -21
  14. package/src/commands/context.mjs +46 -47
  15. package/src/commands/coverage-verdict.mjs +12 -2
  16. package/src/commands/delta-snapshot.mjs +1 -5
  17. package/src/commands/diff.mjs +1 -1
  18. package/src/commands/discover.mjs +7 -3
  19. package/src/commands/evaluation-primitives.mjs +6 -2
  20. package/src/commands/explain.mjs +17 -20
  21. package/src/commands/graph.mjs +7 -0
  22. package/src/commands/health.mjs +4 -0
  23. package/src/commands/impact-reachability.mjs +104 -0
  24. package/src/commands/impact.mjs +9 -71
  25. package/src/commands/plan-context-command.mjs +5 -1
  26. package/src/commands/policy.mjs +4 -4
  27. package/src/commands/provenance.mjs +8 -2
  28. package/src/commands/scenario-evaluation.mjs +1 -1
  29. package/src/config.mjs +171 -17
  30. package/src/custom-rules/evidence.mjs +1 -1
  31. package/src/custom-rules/host.mjs +2 -2
  32. package/src/custom-rules/values.mjs +8 -3
  33. package/src/errors.mjs +24 -2
  34. package/src/eslint-config.mjs +2 -5
  35. package/src/fixtures/evolution-lifecycle/workspace.mjs +0 -5
  36. package/src/governance/adr-registry.mjs +33 -17
  37. package/src/governance/decision-graph.mjs +1 -1
  38. package/src/governance/evolution-store.mjs +36 -18
  39. package/src/governance/fitness-registry.mjs +1 -14
  40. package/src/governance/profile-registry.mjs +20 -23
  41. package/src/governance/provenance-record.mjs +1 -11
  42. package/src/governance/reconcile-score.mjs +0 -3
  43. package/src/governance/row-schema.mjs +1 -14
  44. package/src/governance/verdict.mjs +168 -4
  45. package/src/intent/intent-manifest.json +16 -16
  46. package/src/lsp/diagnose.mjs +2 -2
  47. package/src/lsp/server.mjs +1 -1
  48. package/src/lsp/workspace-index.mjs +3 -3
  49. package/src/options.mjs +1 -1
  50. package/src/providers/model-gate.mjs +59 -0
  51. package/src/providers/moon.mjs +6 -6
  52. package/src/providers/native/model.mjs +2 -16
  53. package/src/report/README.md +13 -7
  54. package/src/report/evidence.mjs +11 -168
  55. package/src/report/json.mjs +10 -7
  56. package/src/report/sarif.mjs +29 -4
  57. package/src/report/text.mjs +39 -0
  58. package/src/rules/README.md +18 -9
  59. package/src/{commands → rules}/edge-constraints.mjs +18 -12
  60. package/src/rules/index.mjs +30 -0
  61. package/src/values.mjs +49 -0
  62. package/src/verdict.mjs +58 -7
  63. package/src/workspace.mjs +29 -0
package/package.json CHANGED
@@ -1,22 +1,49 @@
1
1
  {
2
2
  "name": "@ecoma-io/archkeep",
3
- "version": "0.22.2",
4
- "description": "Architecture enforcement for polyglot repositories — dependency graphs and module boundaries for Go, Rust, Python, TypeScript, JavaScript, Vue, Java and Kotlin",
3
+ "version": "0.24.0",
4
+ "description": "Architecture authority for human and agentic software development deterministic, evidence-backed enforcement of declared architecture.",
5
5
  "keywords": [
6
6
  "architecture",
7
- "enforcement",
7
+ "software-architecture",
8
+ "architecture-governance",
9
+ "architecture-enforcement",
10
+ "architecture-as-code",
11
+ "architecture-drift",
12
+ "architecture-compliance",
13
+ "architecture-rules",
14
+ "dependency-analysis",
15
+ "dependency-governance",
8
16
  "dependency-graph",
9
- "monorepo",
10
- "module-boundaries",
11
- "polyglot",
12
17
  "static-analysis",
13
- "language-server",
14
- "nx",
15
- "nx-plugin",
16
- "golang",
17
- "rust",
18
- "python",
19
- "typescript"
18
+ "code-analysis",
19
+ "code-governance",
20
+ "architecture-fitness",
21
+ "fitness-functions",
22
+ "architecture-decision",
23
+ "architecture-decision-record",
24
+ "adr",
25
+ "architecture-evidence",
26
+ "evidence-based",
27
+ "deterministic-analysis",
28
+ "deterministic-verdicts",
29
+ "drift-detection",
30
+ "change-impact-analysis",
31
+ "architecture-debt",
32
+ "architecture-health",
33
+ "architecture-discovery",
34
+ "architecture-reconciliation",
35
+ "polyglot",
36
+ "monorepo",
37
+ "multi-repo",
38
+ "cross-repo",
39
+ "coding-agents",
40
+ "ai-agents",
41
+ "agentic-development",
42
+ "developer-tools",
43
+ "developer-experience",
44
+ "software-governance",
45
+ "engineering-governance",
46
+ "continuous-compliance"
20
47
  ],
21
48
  "type": "module",
22
49
  "license": "Apache-2.0",
@@ -190,20 +190,3 @@ export function resolveJvmPackagePrefix(specifier, index) {
190
190
  }
191
191
  return null;
192
192
  }
193
-
194
- /**
195
- * The owning project of a resolved prefix, when exactly one project claims
196
- * the matched name.
197
- *
198
- * @param {{ owners: { project: string }[], prefix: string }} resolution
199
- * @returns {{ target: string, ambiguous?: undefined } |
200
- * { target: null, ambiguous: true, projects: string[] }} A single
201
- * target when the owners agree; otherwise every distinct claimant, for the
202
- * caller's failure record.
203
- */
204
- export function projectOfResolution(resolution) {
205
- const projects = [...new Set(resolution.owners.map((owner) => owner.project))];
206
- return projects.length === 1
207
- ? { target: projects[0] }
208
- : { target: null, ambiguous: true, projects };
209
- }
@@ -67,8 +67,12 @@ export function resolveWithinWorkspace(baseDir, relative) {
67
67
  return segments.join("/");
68
68
  }
69
69
 
70
- /** A pattern carrying any of these is a glob; anything else is a literal. */
71
- const GLOB_METACHARACTERS = /[*?[{\\]/;
70
+ /**
71
+ * A pattern carrying any of these is routed to the glob matcher; everything
72
+ * else compares equal. `(` rides for extglob — `+(x).txt` matches `x.txt`
73
+ * (measured on Node v24) — the character #671 proved the table was missing.
74
+ */
75
+ const GLOB_METACHARACTERS = /[*?[{(\\]/;
72
76
 
73
77
  /**
74
78
  * Does a file's basename match any of a manifest-name pattern list — the
@@ -82,9 +86,14 @@ const GLOB_METACHARACTERS = /[*?[{\\]/;
82
86
  * the equality scan it replaced was nanoseconds, because each call compiles
83
87
  * its pattern again. The matcher is injected so `../../providers/native/
84
88
  * model.mjs`'s validated one and raw `path.posix.matchesGlob` ride the same
85
- * fast path without this module reaching for either; semantics are
86
- * unchanged, because a metacharacter-free pattern answers identically
87
- * either way and every other pattern still reaches the glob.
89
+ * fast path without this module reaching for either. The rule the table has
90
+ * to hold: every pattern carrying a character that can alter the glob's
91
+ * answer is routed to it, and everything else compares equal a pattern
92
+ * the table does not carry is literal outside constructs the table already
93
+ * routes, so the two answers agree, and routing a literal anyway (an
94
+ * unbalanced `a(b`) costs only the matcher call. #671: `(` was missing
95
+ * from the table, so an extglob manifest pattern like `+(x).csproj` was
96
+ * compared by equality and missed every file the pattern named.
88
97
  *
89
98
  * @param {string} base The basename under test.
90
99
  * @param {readonly string[]} patterns
@@ -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
+ }
@@ -129,7 +129,7 @@ function directEdges(graph) {
129
129
  * three copies of the same filter is how one of them drifts.
130
130
  *
131
131
  * `../../src/rules/reachability.mjs`'s `buildReachability` itself stays
132
- * type-agnostic on purpose: `../../src/commands/edge-constraints.mjs`'s
132
+ * type-agnostic on purpose: `../rules/edge-constraints.mjs`'s
133
133
  * `declaredEdgeViolationsForCheck` and `../../src/rules/index.mjs`'s
134
134
  * `evaluate()` both deliberately hand it the UNFILTERED graph, because
135
135
  * `depConstraints`' `notDependOnLibsWithTags` is a different, tag-based
@@ -41,8 +41,10 @@ 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";
47
+ import { describe, isPlainObject } from "../values.mjs";
46
48
  import { GOVERNANCE_ROW_KEYS, rowSchemaViolations } from "../governance/row-schema.mjs";
47
49
 
48
50
  /** The base name of the root file this module reads. */
@@ -121,17 +123,6 @@ const ROW_KEYS = Object.freeze(["from", "to", "reason", "optional", "decisionRef
121
123
  /** A boundary `name`, matched exactly by the loaders — names can never contain `:`, so a name can never collide with a `name:`-prefixed selector. */
122
124
  const NAME_PATTERN = /^[a-zA-Z0-9_-]+$/u;
123
125
 
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
126
  /** `key` on `obj` that is not one of `allowed` — for the reject-by-name rule. */
136
127
  function unknownKeys(obj, allowed) {
137
128
  return Object.keys(obj).filter((key) => !allowed.includes(key));
@@ -675,7 +666,7 @@ export async function loadIntent(root, { read = readFileFromDisk, tracked } = {}
675
666
  // neighbours on the identical tree are both loud: an escaping symlink
676
667
  // throws at the containment check above, and EACCES throws below. Only
677
668
  // this one was silent (`../../../../AGENTS.md`).
678
- if (cause?.code === "ENOENT") {
669
+ if (isEnoent(cause)) {
679
670
  if (tracked === undefined) return undefined;
680
671
  throw new Error(
681
672
  `${INTENT_FILE}: is tracked but could not be read: ${cause?.message ?? cause} — ` +