@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 +1 -1
- package/src/analysis/markdown.mjs +340 -0
- package/src/analysis/source-util.mjs +5 -4
- package/src/analysis/typescript.mjs +146 -0
- package/src/architecture-intent/model.mjs +2 -1
- package/src/commands/check.mjs +166 -8
- package/src/commands/context-command.mjs +12 -20
- package/src/commands/coverage-verdict.mjs +12 -2
- package/src/commands/delta-snapshot.mjs +1 -5
- package/src/commands/explain.mjs +17 -20
- package/src/commands/graph.mjs +7 -0
- package/src/commands/health.mjs +4 -0
- package/src/commands/plan-context-command.mjs +5 -1
- package/src/commands/policy.mjs +4 -4
- package/src/commands/provenance.mjs +8 -2
- package/src/config.mjs +170 -2
- package/src/errors.mjs +23 -1
- package/src/eslint-config.mjs +2 -5
- package/src/governance/adr-registry.mjs +2 -1
- package/src/governance/evolution-store.mjs +3 -2
- package/src/governance/fitness-registry.mjs +0 -3
- package/src/governance/row-schema.mjs +0 -3
- package/src/intent/intent-manifest.json +6 -6
- package/src/providers/moon.mjs +5 -5
- package/src/rules/index.mjs +30 -0
- package/src/verdict.mjs +33 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ecoma-io/archkeep",
|
|
3
|
-
"version": "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
|
-
*
|
|
157
|
-
* deterministic operations instead of milliseconds —
|
|
158
|
-
* repository does not trust in a test (cf. #359, #369).
|
|
159
|
-
* lookup makes is counted: one per binary-search step,
|
|
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
|
|
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} — ` +
|