@weatherboard/gyde-design 0.3.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 +21 -0
- package/README.md +264 -0
- package/adoption.mjs +138 -0
- package/agentdocs.mjs +246 -0
- package/boundaries.mjs +350 -0
- package/catalogue.mjs +506 -0
- package/cli.mjs +723 -0
- package/clientboundary.mjs +399 -0
- package/compound.mjs +123 -0
- package/docdrift.mjs +439 -0
- package/emit.mjs +862 -0
- package/enforcement.mjs +100 -0
- package/index.mjs +40 -0
- package/markup.mjs +177 -0
- package/migration.mjs +148 -0
- package/normalise.mjs +416 -0
- package/package.json +59 -0
- package/props.mjs +255 -0
- package/ratchet.mjs +290 -0
- package/rules.mjs +258 -0
- package/scan.mjs +291 -0
- package/stylex.mjs +178 -0
- package/tailwind.mjs +238 -0
- package/tokens.mjs +398 -0
- package/upgrade.mjs +344 -0
- package/usage.mjs +245 -0
- package/wiring.mjs +297 -0
- package/workflow.mjs +221 -0
- package/workspace.mjs +318 -0
package/docdrift.mjs
ADDED
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* G-70 — every component a doc names must resolve.
|
|
3
|
+
*
|
|
4
|
+
* CHARTER §2 promises "machine-verified component catalogs… every component
|
|
5
|
+
* reference checked against real package exports in CI, so docs cannot rot
|
|
6
|
+
* silently." contract.md ranks `docs-rot` as the highest-priority intake class
|
|
7
|
+
* because "stale docs actively misroute agents", against a measured failure of
|
|
8
|
+
* roughly seventeen stale references in one repository.
|
|
9
|
+
*
|
|
10
|
+
* The generated artefacts cannot rot: `.gyde/design-system.md` and the
|
|
11
|
+
* catalogue are built from the same metadata as the barrel, so they cannot name
|
|
12
|
+
* a component that does not exist. That was the easy half, and it is done.
|
|
13
|
+
*
|
|
14
|
+
* This is the other half. Hand-written docs — a README, a CLAUDE.md, an
|
|
15
|
+
* onboarding page, an architecture note — are where the rot actually
|
|
16
|
+
* accumulates, because nothing regenerates them when a component is renamed or
|
|
17
|
+
* deleted. They are also the ones an agent reads first.
|
|
18
|
+
*
|
|
19
|
+
* WHY IT CHECKS IMPORTS RATHER THAN EVERY CAPITALISED WORD.
|
|
20
|
+
*
|
|
21
|
+
* The tempting rule is "every `<Thing />` in a doc must be a real component".
|
|
22
|
+
* It is wrong in both directions. A doc legitimately shows `<Image>` from a
|
|
23
|
+
* framework, `<Suspense>` from React, or a component belonging to a library it
|
|
24
|
+
* is comparing against — and flagging those makes the check noise, which is how
|
|
25
|
+
* a docs check gets switched off in the week it is added.
|
|
26
|
+
*
|
|
27
|
+
* So the signal is an import FROM THE SYSTEM PACKAGE. That is the one place a
|
|
28
|
+
* doc states, unambiguously, "this name comes from your design system" — and
|
|
29
|
+
* the barrel states, unambiguously, whether it does. Both halves are facts, so
|
|
30
|
+
* the comparison has no judgment in it.
|
|
31
|
+
*
|
|
32
|
+
* A name imported from the system and then used as JSX is checked once, at the
|
|
33
|
+
* import. Usage without an import is not reported: it is ambiguous, and this
|
|
34
|
+
* check earns its place by being believed.
|
|
35
|
+
*
|
|
36
|
+
* AN UNPARSEABLE BARREL IS NOT AN EMPTY ONE.
|
|
37
|
+
*
|
|
38
|
+
* `exportedComponents` returns null when it cannot read the barrel, and this
|
|
39
|
+
* returns `unknown: true` rather than declaring every reference in the
|
|
40
|
+
* repository broken. wiring.mjs established that discipline and it matters more
|
|
41
|
+
* here than anywhere: a docs check that reports every doc as rotten on the day
|
|
42
|
+
* somebody changes their barrel format is a check nobody will ever trust again.
|
|
43
|
+
*
|
|
44
|
+
* @gyde-emits-source-for-another-repo — the fixtures beside this file are
|
|
45
|
+
* markdown and TypeScript written as data for a repository that is not this one.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
import { readFileSync, readdirSync, statSync, existsSync } from "node:fs";
|
|
49
|
+
import { join, relative, extname, sep } from "node:path";
|
|
50
|
+
|
|
51
|
+
export const RULE = "doc-drift";
|
|
52
|
+
|
|
53
|
+
const SKIP = new Set([
|
|
54
|
+
"node_modules", "dist", "build", ".next", ".turbo", ".git", "coverage",
|
|
55
|
+
"CHANGELOG.md",
|
|
56
|
+
]);
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Dot-directories a declared contract-doc glob reaches into.
|
|
60
|
+
*
|
|
61
|
+
* The walker skips dotted directories, which is right for `.git` and `.next`
|
|
62
|
+
* and wrong for the one that matters: a product whose component contracts live
|
|
63
|
+
* in `.claude/*.md` had those documents skipped entirely, so the prose check
|
|
64
|
+
* would have reported a confident zero over a directory it never opened. A
|
|
65
|
+
* declaration is an instruction, and a walker that silently ignores it turns
|
|
66
|
+
* "you told us where to look" into "we did not look".
|
|
67
|
+
*/
|
|
68
|
+
function dottedRoots(globs) {
|
|
69
|
+
const out = new Set([".gyde"]);
|
|
70
|
+
for (const g of globs || []) {
|
|
71
|
+
const first = g.split("/")[0];
|
|
72
|
+
if (first.startsWith(".") && !first.includes("*")) out.add(first);
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function walk(dir, root, out, allowedDots = new Set([".gyde"])) {
|
|
78
|
+
let entries;
|
|
79
|
+
try { entries = readdirSync(dir); } catch { return; }
|
|
80
|
+
for (const name of entries) {
|
|
81
|
+
if (SKIP.has(name)) continue;
|
|
82
|
+
if (name.startsWith(".") && !allowedDots.has(name)) continue;
|
|
83
|
+
const full = join(dir, name);
|
|
84
|
+
let st; try { st = statSync(full); } catch { continue; }
|
|
85
|
+
if (st.isDirectory()) walk(full, root, out, allowedDots);
|
|
86
|
+
else if (extname(name) === ".md") out.push(relative(root, full).split(sep).join("/"));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Named imports from a given package, wherever they appear in a markdown file.
|
|
92
|
+
*
|
|
93
|
+
* Fenced or not: a stale name misroutes a reader identically whether or not it
|
|
94
|
+
* is inside triple backticks, and the fence is a rendering decision.
|
|
95
|
+
*/
|
|
96
|
+
export function importedFrom(text, packageName) {
|
|
97
|
+
const found = [];
|
|
98
|
+
const esc = packageName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
99
|
+
const re = new RegExp(`import\\s+(?:type\\s+)?\\{([^}]*)\\}\\s*from\\s*["']${esc}(?:/[^"']*)?["']`, "g");
|
|
100
|
+
|
|
101
|
+
const lines = text.split("\n");
|
|
102
|
+
for (let i = 0; i < lines.length; i++) {
|
|
103
|
+
for (const m of lines[i].matchAll(re)) {
|
|
104
|
+
// `import type { X }` is a type reference, not a component that renders.
|
|
105
|
+
if (/import\s+type\s*\{/.test(m[0])) continue;
|
|
106
|
+
for (const raw of m[1].split(",")) {
|
|
107
|
+
const part = raw.trim();
|
|
108
|
+
if (!part || /^type\s/.test(part)) continue;
|
|
109
|
+
const name = part.split(/\s+as\s+/)[0].trim();
|
|
110
|
+
if (/^[A-Z]\w*$/.test(name)) found.push({ name, line: i + 1, source: lines[i].trim() });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return found;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Check every markdown file in a tree for components that no longer exist.
|
|
119
|
+
*
|
|
120
|
+
* `systemPackage` is the npm name a doc would import from. Without it there is
|
|
121
|
+
* no unambiguous signal and the check declines to guess — reported as
|
|
122
|
+
* `unknown`, never as clean.
|
|
123
|
+
*/
|
|
124
|
+
export function checkDocDrift(root, { systemPackage = null, exports: exported = null, contractDocs = [] } = {}) {
|
|
125
|
+
if (!systemPackage) {
|
|
126
|
+
return { unknown: true, why: "no system package name configured, so a doc's imports cannot be attributed", drift: [], docsChecked: 0 };
|
|
127
|
+
}
|
|
128
|
+
if (exported === null) {
|
|
129
|
+
return { unknown: true, why: "the design system's barrel could not be read, and an unparseable barrel is not an empty one", drift: [], docsChecked: 0 };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const known = new Set(exported);
|
|
133
|
+
const docs = [];
|
|
134
|
+
walk(root, root, docs, dottedRoots(contractDocs));
|
|
135
|
+
|
|
136
|
+
const drift = [];
|
|
137
|
+
let referencing = 0;
|
|
138
|
+
|
|
139
|
+
let contractDocsChecked = 0;
|
|
140
|
+
let namesChecked = 0;
|
|
141
|
+
|
|
142
|
+
for (const doc of docs.sort()) {
|
|
143
|
+
let text; try { text = readFileSync(join(root, doc), "utf8"); } catch { continue; }
|
|
144
|
+
|
|
145
|
+
const imports = importedFrom(text, systemPackage);
|
|
146
|
+
if (imports.length > 0) {
|
|
147
|
+
referencing++;
|
|
148
|
+
for (const imp of imports) {
|
|
149
|
+
if (known.has(imp.name)) continue;
|
|
150
|
+
drift.push({
|
|
151
|
+
file: doc, rule: RULE, line: imp.line, name: imp.name,
|
|
152
|
+
source: imp.source, from: systemPackage,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// G-96. Only inside a doc the product declared a component contract. An
|
|
158
|
+
// import says where a name came from; prose does not, so the scope has to
|
|
159
|
+
// be declared rather than guessed.
|
|
160
|
+
if (!isContractDoc(doc, contractDocs)) continue;
|
|
161
|
+
contractDocsChecked++;
|
|
162
|
+
|
|
163
|
+
for (const claim of namedInProse(text)) {
|
|
164
|
+
namesChecked++;
|
|
165
|
+
if (known.has(claim.name.split(".")[0])) continue;
|
|
166
|
+
drift.push({
|
|
167
|
+
file: doc, rule: PROSE_RULE, line: claim.line, name: claim.name,
|
|
168
|
+
source: claim.source, from: "prose in a declared contract doc",
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
unknown: false, drift,
|
|
175
|
+
docsChecked: docs.length, docsReferencing: referencing,
|
|
176
|
+
contractDocsChecked, namesChecked, known: known.size,
|
|
177
|
+
// Stated so a repository that declared nothing cannot read "no drift" as
|
|
178
|
+
// "checked and clean". The prose assertion did not run.
|
|
179
|
+
proseScoped: (contractDocs || []).length > 0,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export const PROSE_RULE = "doc-drift-prose";
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* G-96 — a component named in prose, in a doc the product declared a contract.
|
|
187
|
+
*
|
|
188
|
+
* The import-based check above was promoted from a product gate and is
|
|
189
|
+
* narrower than it, which nobody noticed until it was run against the
|
|
190
|
+
* repository the original came from: 94 docs, **zero** of them carrying an
|
|
191
|
+
* import statement, 185 component names written in backticks. The check was
|
|
192
|
+
* wired and dormant. Their `verify-doc-components.mjs` had been holding that
|
|
193
|
+
* ground the whole time.
|
|
194
|
+
*
|
|
195
|
+
* That is the assertion gap contract.md §7 now requires a promotion to state.
|
|
196
|
+
* This closes it.
|
|
197
|
+
*
|
|
198
|
+
* WHY THIS NEEDS A DECLARED SCOPE WHERE THE IMPORT CHECK DID NOT.
|
|
199
|
+
*
|
|
200
|
+
* An import says where a name came from. Prose does not: a backticked `Button`
|
|
201
|
+
* might be this system's Button, React's, a competitor's, or a sentence about
|
|
202
|
+
* buttons in general. Checking every backticked capitalised word across a
|
|
203
|
+
* repository would flag `Image`, `Suspense`, `Promise` and every type name in
|
|
204
|
+
* every architecture note — the noise that gets a docs check switched off.
|
|
205
|
+
*
|
|
206
|
+
* So the product declares which documents are component contracts, exactly as
|
|
207
|
+
* the source gate did by scoping itself to `.claude/*.md`. Inside a declared
|
|
208
|
+
* contract doc, a backticked PascalCase name is a claim about the component
|
|
209
|
+
* set and is checked. Outside one, nothing changes.
|
|
210
|
+
*
|
|
211
|
+
* The declaration is the point rather than an implementation detail: it is a
|
|
212
|
+
* product saying "these documents describe my components, hold me to them",
|
|
213
|
+
* which is a different and stronger statement than a tool guessing.
|
|
214
|
+
*/
|
|
215
|
+
const BACKTICKED = /`([A-Z][A-Za-z0-9]*(?:\.[A-Z][A-Za-z0-9]*)*)`/g;
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Names that are never a claim about the design system.
|
|
219
|
+
*
|
|
220
|
+
* Deliberately short. A long list is a rule being tuned to one repository,
|
|
221
|
+
* which is the leak CHARTER §7 forbids — these are language and framework
|
|
222
|
+
* built-ins that any repository's prose may reasonably name.
|
|
223
|
+
*/
|
|
224
|
+
const NOT_COMPONENTS = new Set([
|
|
225
|
+
"React", "Promise", "Array", "Object", "String", "Number", "Boolean", "Map", "Set",
|
|
226
|
+
"Date", "Error", "JSON", "Math", "TypeScript", "JavaScript", "Node", "CSS", "HTML",
|
|
227
|
+
"Fragment", "Suspense", "StrictMode", "Props", "Partial", "Record", "Pick", "Omit",
|
|
228
|
+
]);
|
|
229
|
+
|
|
230
|
+
/** Does this path match one of the declared contract-doc globs? */
|
|
231
|
+
export function isContractDoc(rel, globs) {
|
|
232
|
+
return (globs || []).some((g) => {
|
|
233
|
+
if (g.endsWith("/**")) return rel.startsWith(g.slice(0, -3) + "/") || rel === g.slice(0, -3);
|
|
234
|
+
if (g.includes("*")) {
|
|
235
|
+
const re = new RegExp("^" + g.split("*").map((s) => s.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join("[^/]*") + "$");
|
|
236
|
+
return re.test(rel);
|
|
237
|
+
}
|
|
238
|
+
return rel === g || rel.startsWith(g + "/");
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Component names claimed in prose, in a declared contract doc. */
|
|
243
|
+
export function namedInProse(text) {
|
|
244
|
+
const found = [];
|
|
245
|
+
text.split("\n").forEach((line, i) => {
|
|
246
|
+
for (const m of line.matchAll(BACKTICKED)) {
|
|
247
|
+
const name = m[1];
|
|
248
|
+
if (NOT_COMPONENTS.has(name.split(".")[0])) continue;
|
|
249
|
+
found.push({ name, line: i + 1, source: line.trim() });
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
return found;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export const PATH_RULE = "stale-source-path";
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* G-70, second half — a string that names a source file which is not there.
|
|
259
|
+
*
|
|
260
|
+
* The first half checks docs written in markdown. A consumer found the other
|
|
261
|
+
* shape the hard way: a component registry, written in TypeScript, whose
|
|
262
|
+
* `sourcePath` fields were rendered verbatim on every component page. Six of
|
|
263
|
+
* twenty pointed at a directory deleted during a migration. Their own doc gate
|
|
264
|
+
* checked markdown against real exports and never looked at the registry,
|
|
265
|
+
* because the registry is a `.ts` file — "the same failure the doc gate exists
|
|
266
|
+
* to prevent, one file over".
|
|
267
|
+
*
|
|
268
|
+
* That is the argument for this being a rule rather than a lint on markdown. A
|
|
269
|
+
* path rendered to a reader is documentation regardless of the extension it
|
|
270
|
+
* lives in, and the file type is exactly the wrong thing to scope the check by.
|
|
271
|
+
*
|
|
272
|
+
* WHAT MAKES A STRING A CLAIM ABOUT THIS REPOSITORY.
|
|
273
|
+
*
|
|
274
|
+
* Not every path-shaped string is one. A URL, a glob, a template with an
|
|
275
|
+
* interpolation, a relative import (which the build already resolves), a
|
|
276
|
+
* package specifier — none of them are checkable here, and guessing at them is
|
|
277
|
+
* how a rule earns a reputation for noise.
|
|
278
|
+
*
|
|
279
|
+
* The signal used is narrow and deliberate: **the first segment is a directory
|
|
280
|
+
* that exists at the repository root.** `packages/ui/src/Button.tsx` is a claim
|
|
281
|
+
* about this repository, because `packages/` is right there. `src/Button.tsx`
|
|
282
|
+
* inside some package is not checkable from here and is skipped rather than
|
|
283
|
+
* guessed at — reported in `skipped`, never silently treated as fine.
|
|
284
|
+
*/
|
|
285
|
+
const PATH_LIKE = /["'`]([A-Za-z0-9_][\w.-]*(?:\/[\w.-]+)+\.(?:tsx?|jsx?|mjs|cjs|css|scss|md|json))["'`]/g;
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* G-94 — a path declared as fixture data is not a claim that the path exists.
|
|
289
|
+
*
|
|
290
|
+
* All fifteen false positives this rule produced against its first real
|
|
291
|
+
* consumer were in one file, and that file is the one G-71 was promoted FROM.
|
|
292
|
+
* It declares ESLint fixtures — code fed to `lintText` to prove a boundary rule
|
|
293
|
+
* rejects what it claims — and every fixture carries a `file:` naming a path
|
|
294
|
+
* that deliberately does not exist:
|
|
295
|
+
*
|
|
296
|
+
* { name: "Base UI imported in app code",
|
|
297
|
+
* file: "apps/pubs/src/__boundary_fixture__.tsx", // never written to disk
|
|
298
|
+
* code: `import { Dialog } from "@base-ui/react/dialog";`,
|
|
299
|
+
* mustFail: true }
|
|
300
|
+
*
|
|
301
|
+
* Its own header explains why nothing is written: "a committed file that must
|
|
302
|
+
* fail lint is a file that breaks every other lint run." The path is not stale.
|
|
303
|
+
* It is an address for code that exists only in memory.
|
|
304
|
+
*
|
|
305
|
+
* WHY NOT A FILENAME CONVENTION.
|
|
306
|
+
*
|
|
307
|
+
* The obvious fix is to skip files named like fixtures. The consumer's own
|
|
308
|
+
* argument against it is the right one: the convention is exactly what failed
|
|
309
|
+
* here. `verify-boundaries.ts` is not named `*.test.*`, so the `isTest` skip
|
|
310
|
+
* did not catch it, and widening that pattern only moves the guess.
|
|
311
|
+
*
|
|
312
|
+
* The structural signal is the shape of the declaration. A path that sits in an
|
|
313
|
+
* object literal beside a key holding SOURCE CODE is an address for that code,
|
|
314
|
+
* not a reference to a file. That is true of any fixture table in any
|
|
315
|
+
* repository, whatever the file is called, and it is false of a registry entry
|
|
316
|
+
* — `{ name: "Button", sourcePath: "…" }` has no code sibling, which is why the
|
|
317
|
+
* registry case G-70 exists for still fires.
|
|
318
|
+
*/
|
|
319
|
+
const CODE_SIBLING = /(^|[\s{,])(code|source|contents|snippet|template|body)\s*:/;
|
|
320
|
+
|
|
321
|
+
/** The object literal enclosing a line, bounded so a malformed file cannot walk the whole tree. */
|
|
322
|
+
function enclosingObject(lines, at, span = 40) {
|
|
323
|
+
let depth = 0, start = at;
|
|
324
|
+
for (let i = at; i >= Math.max(0, at - span); i--) {
|
|
325
|
+
for (const ch of [...lines[i]].reverse()) {
|
|
326
|
+
if (ch === "}") depth++;
|
|
327
|
+
else if (ch === "{") { if (depth === 0) { start = i; i = -1; break; } depth--; }
|
|
328
|
+
}
|
|
329
|
+
if (i === -1) break;
|
|
330
|
+
start = i;
|
|
331
|
+
}
|
|
332
|
+
const end = Math.min(lines.length - 1, at + span);
|
|
333
|
+
return lines.slice(start, end + 1).join("\n");
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export function isFixtureDeclaration(lines, at) {
|
|
337
|
+
return CODE_SIBLING.test(enclosingObject(lines, at));
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export function stalePaths(root, rel, text, { rootDirs }) {
|
|
341
|
+
const found = [];
|
|
342
|
+
const skipped = [];
|
|
343
|
+
|
|
344
|
+
// A path named in prose explaining a rule is not a claim that the path
|
|
345
|
+
// exists. `upgrade.mjs` illustrates the adoption case with
|
|
346
|
+
// `packages/design-system/src/Card.tsx` — a file that deliberately does not
|
|
347
|
+
// exist, in a comment describing what Gyde must not write. Reporting it is
|
|
348
|
+
// the same mistake boundaries.mjs guards against: a rule firing on the
|
|
349
|
+
// sentence that explains it. Newlines are preserved so line numbers survive.
|
|
350
|
+
const src = text
|
|
351
|
+
.replace(/\/\*[\s\S]*?\*\//g, (s) => s.replace(/[^\n]/g, " "))
|
|
352
|
+
.replace(/^\s*\/\/[^\n]*/gm, "");
|
|
353
|
+
|
|
354
|
+
const lines = src.split("\n");
|
|
355
|
+
lines.forEach((line, i) => {
|
|
356
|
+
for (const m of line.matchAll(PATH_LIKE)) {
|
|
357
|
+
const p = m[1];
|
|
358
|
+
if (p.includes("*") || p.includes("${") || p.includes("://")) continue;
|
|
359
|
+
|
|
360
|
+
const first = p.split("/")[0];
|
|
361
|
+
if (!rootDirs.has(first)) { skipped.push({ file: rel, line: i + 1, path: p }); continue; }
|
|
362
|
+
|
|
363
|
+
if (existsSync(join(root, p))) continue;
|
|
364
|
+
if (isFixtureDeclaration(lines, i)) { skipped.push({ file: rel, line: i + 1, path: p, why: "fixture data" }); continue; }
|
|
365
|
+
|
|
366
|
+
found.push({
|
|
367
|
+
file: rel, rule: PATH_RULE, line: i + 1, path: p,
|
|
368
|
+
// `raw` as well as `path`, because every other finding in this engine
|
|
369
|
+
// carries `raw` and a caller printing findings uniformly rendered these
|
|
370
|
+
// as `verify-boundaries.ts:134 undefined` — unactionable, and
|
|
371
|
+
// unmatchable against source even where the finding was genuine.
|
|
372
|
+
raw: p,
|
|
373
|
+
source: line.trim(),
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
return { found, skipped };
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Directory names directly under the repository root — the anchor for "is this ours?". */
|
|
382
|
+
export function rootDirectories(root) {
|
|
383
|
+
const dirs = new Set();
|
|
384
|
+
let entries;
|
|
385
|
+
try { entries = readdirSync(root); } catch { return dirs; }
|
|
386
|
+
for (const name of entries) {
|
|
387
|
+
if (SKIP.has(name) || name.startsWith(".")) continue;
|
|
388
|
+
try { if (statSync(join(root, name)).isDirectory()) dirs.add(name); } catch { /* unreadable */ }
|
|
389
|
+
}
|
|
390
|
+
return dirs;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
export function formatStalePaths(findings, { skipped = 0 } = {}) {
|
|
394
|
+
if (findings.length === 0) {
|
|
395
|
+
return `no string names a source file that is missing` +
|
|
396
|
+
(skipped ? ` (${skipped} path-shaped string(s) were not repository-relative and could not be checked)` : "");
|
|
397
|
+
}
|
|
398
|
+
const L = [`${findings.length} string(s) naming a file that does not exist:`];
|
|
399
|
+
for (const f of findings) {
|
|
400
|
+
L.push(` ${f.file}:${f.line} ${f.path}`);
|
|
401
|
+
L.push(` ${f.source}`);
|
|
402
|
+
}
|
|
403
|
+
L.push("");
|
|
404
|
+
L.push(" A path rendered to a reader is documentation whatever file it lives in. This");
|
|
405
|
+
L.push(" is the registry case: a component page printing a source location that was");
|
|
406
|
+
L.push(" deleted in a migration, with no gate reading it because it is not markdown.");
|
|
407
|
+
if (skipped) L.push(` ${skipped} further path-shaped string(s) were not repository-relative and were not judged.`);
|
|
408
|
+
return L.join("\n");
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
export function formatDocDrift(result) {
|
|
412
|
+
if (result.unknown) return `doc drift: could not tell — ${result.why}`;
|
|
413
|
+
if (result.drift.length === 0) {
|
|
414
|
+
const L = [`no doc names a component that does not exist ` +
|
|
415
|
+
`(${result.docsReferencing} of ${result.docsChecked} doc(s) import the system, ${result.known} export(s))`];
|
|
416
|
+
// Which assertions actually ran. A repository that declared no contract
|
|
417
|
+
// docs has not been checked for prose, and must not read this as clean —
|
|
418
|
+
// the state that left 185 named components unguarded across 94 documents.
|
|
419
|
+
L.push(result.proseScoped
|
|
420
|
+
? ` prose: ${result.namesChecked} name(s) across ${result.contractDocsChecked} declared contract doc(s)`
|
|
421
|
+
: ` prose: NOT CHECKED — no \`contractDocs\` declared, so names written in backticks are unguarded`);
|
|
422
|
+
return L.join("\n");
|
|
423
|
+
}
|
|
424
|
+
const L = [`${result.drift.length} stale component reference(s) in docs:`];
|
|
425
|
+
for (const d of result.drift) {
|
|
426
|
+
// The two assertions are worded differently because they are different
|
|
427
|
+
// claims. An import says where a name came from; prose in a doc the product
|
|
428
|
+
// declared a contract says the product stands behind the name.
|
|
429
|
+
L.push(d.rule === PROSE_RULE
|
|
430
|
+
? ` ${d.file}:${d.line} ${d.name} is named in a declared contract doc and is not exported`
|
|
431
|
+
: ` ${d.file}:${d.line} ${d.name} is imported from ${d.from} and is not exported`);
|
|
432
|
+
L.push(` ${d.source}`);
|
|
433
|
+
}
|
|
434
|
+
L.push("");
|
|
435
|
+
L.push(" A doc naming a component that does not exist routes a reader — or an agent —");
|
|
436
|
+
L.push(" to build against something that was renamed or deleted. It is worse than a");
|
|
437
|
+
L.push(" missing doc, because it still looks authoritative.");
|
|
438
|
+
return L.join("\n");
|
|
439
|
+
}
|