@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.
@@ -0,0 +1,399 @@
1
+ /**
2
+ * G-67 — values must not cross the "use client" boundary.
3
+ *
4
+ * WHY THIS EXISTS.
5
+ *
6
+ * `emit.mjs` already knows this failure. It writes the defaults module without a
7
+ * directive, and puts a shouting comment on top telling the reader not to add
8
+ * one. That comment is knowledge addressed to ourselves: it protects the file
9
+ * Gyde emitted, on the day it was emitted, and it protects nothing afterwards.
10
+ * The moment a product adds a fourth component and puts its defaults beside it —
11
+ * the natural thing to do, which is exactly why the comment predicts it — Gyde
12
+ * has no way to notice. It emits code shaped to avoid a failure it cannot
13
+ * detect.
14
+ *
15
+ * THE FAILURE, PRECISELY.
16
+ *
17
+ * A module carrying "use client" does not export values to a server component.
18
+ * It exports *client references*: opaque objects with no enumerable properties.
19
+ * So `<Card {...CARD_DEFAULTS}>` on a server page spreads `{}`, every prop
20
+ * reverts to undefined, and React reports "Element type is invalid … got:
21
+ * undefined" — naming neither the element nor the prop that vanished.
22
+ *
23
+ * Nothing throws at the boundary. The build passes. The styling rules pass,
24
+ * because the source says `{...CARD_DEFAULTS}` and that is a perfectly
25
+ * tokenised thing to write. This is the shape of defect the whole product
26
+ * exists for: correct-looking code, silent at every gate, wrong at runtime.
27
+ *
28
+ * WHAT IS AND IS NOT A CROSSING.
29
+ *
30
+ * Importing a client COMPONENT into a server component is the React Server
31
+ * Components model working as designed, and this rule must never flag it — a
32
+ * rule that fires on the normal case is one people switch off in a week. React
33
+ * knows how to render a client reference. It does not know how to spread one.
34
+ *
35
+ * So the rule is narrow on purpose: a value-position use — an object spread or
36
+ * a property read — of a binding that came across the boundary. `<Button />`
37
+ * is fine. `{...BUTTON_DEFAULTS}` is not. `BUTTON_DEFAULTS.tone` is not.
38
+ *
39
+ * Type-only imports are erased before they reach a runtime and are skipped.
40
+ *
41
+ * WHAT IT CANNOT SEE, AND WHY THAT IS REPORTED RATHER THAN ASSUMED.
42
+ *
43
+ * Resolution is source-level: relative paths, and workspace packages resolved
44
+ * through their own barrel. It follows `export { X } from "./y"` and `export *
45
+ * from "./y"` one package deep. It does not run a bundler, so an import it
46
+ * cannot resolve is returned in `unresolved` rather than being counted as
47
+ * clean. `wiring.mjs` established that discipline — "could not tell" is its own
48
+ * outcome, never folded into "fine" — and the same reason applies here: this
49
+ * rule's whole value is catching a silence, so it must not produce one.
50
+ */
51
+
52
+ import { readFileSync, readdirSync, statSync } from "node:fs";
53
+ // G-101. The schedule lives in one place; a second copy here would be free to
54
+ // disagree with the table a consumer reads to plan their upgrade.
55
+ import { SCHEDULE, blocksAt } from "./enforcement.mjs";
56
+ import { join, relative, extname, sep, dirname, resolve as resolvePath } from "node:path";
57
+
58
+ const SOURCE = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs"]);
59
+ const SKIP = new Set(["node_modules", "dist", "build", ".next", "coverage", "out"]);
60
+
61
+ /** Extension order for a bare specifier. `.tsx` first: components outnumber modules. */
62
+ const RESOLVE_ORDER = [".tsx", ".ts", ".jsx", ".js", ".mjs"];
63
+
64
+ function walk(dir, root, out) {
65
+ let entries;
66
+ try { entries = readdirSync(dir); } catch { return; }
67
+ for (const name of entries) {
68
+ if (SKIP.has(name) || name.startsWith(".")) continue;
69
+ const full = join(dir, name);
70
+ let st; try { st = statSync(full); } catch { continue; }
71
+ if (st.isDirectory()) walk(full, root, out);
72
+ else if (SOURCE.has(extname(name))) out.push(relative(root, full).split(sep).join("/"));
73
+ }
74
+ }
75
+
76
+ /** A test file discusses the boundary; it does not cross it. Same exclusion as boundaries.mjs. */
77
+ const isTest = (p) => /\.(test|spec)\.[jt]sx?$/.test(p) || /(^|\/)__tests__\//.test(p);
78
+
79
+ /**
80
+ * A file whose string literals are source for a DIFFERENT repository.
81
+ *
82
+ * A generator holds `import { Card } from "./card"` as data. Reading that as an
83
+ * import of this repo's `./card` produced fifteen phantom "could not tell"
84
+ * entries the first time this ran against Gyde itself — a checker whose whole
85
+ * value is refusing to say "clean" without evidence, manufacturing doubt out of
86
+ * a template. The marker is the repo's existing convention (paths.test.mjs);
87
+ * honoured here so one declaration covers both checks.
88
+ */
89
+ const TEMPLATE_MARKER = "@gyde-emits-source-for-another-repo";
90
+
91
+ const stripComments = (text) =>
92
+ text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
93
+
94
+ /**
95
+ * Is this module a client module?
96
+ *
97
+ * The directive is only a directive when it leads the file. A "use client"
98
+ * further down is an ordinary string expression and React ignores it — so this
99
+ * checks position, not presence. The emitted defaults module states the rule in
100
+ * a comment containing the exact characters, which is why comments come off
101
+ * first: a file explaining the boundary must not read as one that declares it.
102
+ */
103
+ export function isClientModule(text) {
104
+ const head = stripComments(text).trim();
105
+ return /^["']use client["']\s*;?/.test(head);
106
+ }
107
+
108
+ /**
109
+ * Imports, with their bindings. Type-only imports and bindings are dropped:
110
+ * they do not exist at runtime and cannot be spread.
111
+ */
112
+ export function parseImports(text) {
113
+ const src = stripComments(text);
114
+ const out = [];
115
+ const re = /import\s+(type\s+)?([^;'"]*?)\s*from\s*["']([^"']+)["']/g;
116
+ let m;
117
+ while ((m = re.exec(src))) {
118
+ const [, typeOnly, clause, spec] = m;
119
+ if (typeOnly) continue;
120
+ const line = src.slice(0, m.index).split("\n").length;
121
+ const bindings = [];
122
+
123
+ const named = clause.match(/\{([^}]*)\}/);
124
+ if (named) {
125
+ for (const part of named[1].split(",")) {
126
+ const p = part.trim();
127
+ if (!p || /^type\s/.test(p)) continue;
128
+ const [imported, local] = p.split(/\s+as\s+/).map((s) => s.trim());
129
+ bindings.push({ imported, local: local || imported });
130
+ }
131
+ }
132
+ // A default or namespace binding is whatever sits outside the braces.
133
+ const outside = clause.replace(/\{[^}]*\}/, "").replace(/^\s*,|,\s*$/g, "").trim();
134
+ if (outside && !outside.startsWith("*")) {
135
+ bindings.push({ imported: "default", local: outside });
136
+ }
137
+ if (bindings.length) out.push({ spec, bindings, line });
138
+ }
139
+ return out;
140
+ }
141
+
142
+ /** Re-exports, for following a barrel to the module that actually declares a name. */
143
+ export function parseReexports(text) {
144
+ const src = stripComments(text);
145
+ const out = [];
146
+ const re = /export\s+(type\s+)?(\*|\{[^}]*\})\s*from\s*["']([^"']+)["']/g;
147
+ let m;
148
+ while ((m = re.exec(src))) {
149
+ const [, typeOnly, clause, spec] = m;
150
+ if (typeOnly) continue;
151
+ if (clause === "*") { out.push({ spec, star: true, names: [] }); continue; }
152
+ const names = clause.replace(/[{}]/g, "").split(",")
153
+ .map((s) => s.trim()).filter(Boolean).filter((s) => !/^type\s/.test(s))
154
+ .map((s) => { const [i, a] = s.split(/\s+as\s+/).map((x) => x.trim()); return { imported: i, exported: a || i }; });
155
+ out.push({ spec, star: false, names });
156
+ }
157
+ return out;
158
+ }
159
+
160
+ /** Does this module declare (rather than re-export) the name? */
161
+ function declaresName(text, name) {
162
+ const src = stripComments(text);
163
+ if (name === "default") return /export\s+default\b/.test(src);
164
+ return new RegExp(`export\\s+(?:async\\s+)?(?:const|let|var|function|class)\\s+${name}\\b`).test(src)
165
+ || new RegExp(`export\\s*\\{[^}]*\\b${name}\\b[^}]*\\}\\s*(?!from)`).test(src);
166
+ }
167
+
168
+ function readFile(root, rel) {
169
+ try { return readFileSync(join(root, rel), "utf8"); } catch { return null; }
170
+ }
171
+
172
+ /** Resolve a source-relative specifier to a repo-relative file, or null. */
173
+ function resolveRelative(root, fromFile, spec) {
174
+ const base = resolvePath("/" + dirname(fromFile), spec).slice(1);
175
+ const candidates = [
176
+ ...RESOLVE_ORDER.map((e) => base + e),
177
+ ...RESOLVE_ORDER.map((e) => base + "/index" + e),
178
+ base,
179
+ ];
180
+ for (const c of candidates) if (readFile(root, c) !== null) return c;
181
+ return null;
182
+ }
183
+
184
+ /**
185
+ * Resolve a workspace package specifier to its barrel.
186
+ *
187
+ * Deliberately does not read `exports` maps in full. It takes the package's
188
+ * `main`/`module`/`exports["."]` if that points at source, and otherwise tries
189
+ * the conventional source barrel. A build output path is useless here — the
190
+ * directive lives in source, and a compiled bundle has already erased the
191
+ * question.
192
+ */
193
+ function resolvePackage(root, spec, packages) {
194
+ const pkg = packages.find((p) => p.name === spec || spec.startsWith(p.name + "/"));
195
+ if (!pkg) return null;
196
+
197
+ if (spec !== pkg.name) {
198
+ const sub = spec.slice(pkg.name.length + 1);
199
+ return resolveRelative(root, pkg.path + "/x", "./" + sub);
200
+ }
201
+ for (const guess of ["src/index", "index", "src/main"]) {
202
+ const hit = resolveRelative(root, pkg.path + "/x", "./" + guess);
203
+ if (hit) return hit;
204
+ }
205
+ return null;
206
+ }
207
+
208
+ function resolveSpec(root, fromFile, spec, packages) {
209
+ if (spec.startsWith(".")) return resolveRelative(root, fromFile, spec);
210
+ return resolvePackage(root, spec, packages);
211
+ }
212
+
213
+ /**
214
+ * Follow `name` from `file` to the module that declares it.
215
+ *
216
+ * Depth-limited and cycle-guarded. A barrel that re-exports a barrel is normal;
217
+ * a barrel that eventually re-exports itself is not, and must not hang a gate.
218
+ */
219
+ function originOf(root, file, name, packages, seen = new Set(), depth = 0) {
220
+ if (depth > 8 || seen.has(file + "#" + name)) return null;
221
+ seen.add(file + "#" + name);
222
+
223
+ const text = readFile(root, file);
224
+ if (text === null) return null;
225
+ if (declaresName(text, name)) return { file, text };
226
+
227
+ for (const rx of parseReexports(text)) {
228
+ const target = resolveSpec(root, file, rx.spec, packages);
229
+ if (!target) continue;
230
+ if (rx.star) {
231
+ const hit = originOf(root, target, name, packages, seen, depth + 1);
232
+ if (hit) return hit;
233
+ } else {
234
+ const match = rx.names.find((n) => n.exported === name);
235
+ if (match) {
236
+ const hit = originOf(root, target, match.imported, packages, seen, depth + 1);
237
+ if (hit) return hit;
238
+ }
239
+ }
240
+ }
241
+ return null;
242
+ }
243
+
244
+ /**
245
+ * Value-position uses of a local binding.
246
+ *
247
+ * Two forms, both unambiguous and both the documented failure:
248
+ * {...X} the spread that produced the original defect
249
+ * X.prop a property read, which yields undefined for the same reason
250
+ *
251
+ * JSX use (`<X`, `</X`) is explicitly not a crossing. Anything subtler than
252
+ * these two forms is left alone rather than guessed at: a false positive here
253
+ * costs more than a miss, because the miss is still caught by the next person
254
+ * to spread the thing.
255
+ */
256
+ export function valueUses(text, local) {
257
+ const src = stripComments(text);
258
+ const uses = [];
259
+ const spread = new RegExp(`\\{\\s*\\.\\.\\.\\s*${local}\\s*\\}`, "g");
260
+ const member = new RegExp(`(?<![\\w$.<])${local}\\s*\\.\\s*[A-Za-z_$]`, "g");
261
+
262
+ src.split("\n").forEach((line, i) => {
263
+ if (spread.test(line)) uses.push({ line: i + 1, form: "spread", source: line.trim() });
264
+ else if (member.test(line)) uses.push({ line: i + 1, form: "member", source: line.trim() });
265
+ spread.lastIndex = 0; member.lastIndex = 0;
266
+ });
267
+ return uses;
268
+ }
269
+
270
+ /**
271
+ * Check a tree for values crossing the client boundary.
272
+ *
273
+ * `packages` is the workspace listing from `discover()` — `{ name, path }` — and
274
+ * without it only relative imports resolve. That is a real reduction in reach
275
+ * and is reported: `unresolved` is the count of imports this could not follow,
276
+ * and a caller that prints findings without printing that number is publishing
277
+ * a clean bill it did not earn.
278
+ */
279
+ export function checkClientBoundary(root, { packages = [] } = {}) {
280
+ const files = [];
281
+ walk(root, root, files);
282
+
283
+ const crossings = [];
284
+ const unresolved = [];
285
+ let serverFilesChecked = 0;
286
+
287
+ for (const file of files.sort()) {
288
+ if (isTest(file)) continue;
289
+ const text = readFile(root, file);
290
+ if (text === null) continue;
291
+ if (text.includes(TEMPLATE_MARKER)) continue;
292
+ if (isClientModule(text)) continue; // a client file may spread freely
293
+ serverFilesChecked++;
294
+
295
+ for (const imp of parseImports(text)) {
296
+ const target = resolveSpec(root, file, imp.spec, packages);
297
+ if (!target) {
298
+ if (imp.spec.startsWith(".") || packages.some((p) => imp.spec.startsWith(p.name))) {
299
+ unresolved.push({ file, line: imp.line, spec: imp.spec });
300
+ }
301
+ continue; // third-party: not our boundary to police
302
+ }
303
+
304
+ for (const b of imp.bindings) {
305
+ const uses = valueUses(text, b.local);
306
+ if (!uses.length) continue; // imported but never used as a value
307
+
308
+ const origin = originOf(root, target, b.imported, packages);
309
+ if (!origin) { unresolved.push({ file, line: imp.line, spec: imp.spec, name: b.imported }); continue; }
310
+ if (!isClientModule(origin.text)) continue;
311
+
312
+ for (const u of uses) {
313
+ crossings.push({
314
+ file, line: u.line, form: u.form, source: u.source,
315
+ binding: b.local, imported: b.imported, from: imp.spec, declaredIn: origin.file,
316
+ });
317
+ }
318
+ }
319
+ }
320
+ }
321
+
322
+ return { crossings, unresolved, serverFilesChecked, filesWalked: files.length };
323
+ }
324
+
325
+ /**
326
+ * The template version from which a crossing blocks the gate.
327
+ *
328
+ * WHY THIS IS KEYED TO THE PRODUCT'S TEMPLATE VERSION, NOT GYDE'S.
329
+ *
330
+ * Every existing consumer has a committed ledger. A new finding class that
331
+ * blocks on upgrade turns their gate red for something they have not been told
332
+ * about yet — and "amnesty never" (CHARTER §4) means it cannot be quietly
333
+ * baselined away either, so there would be no good move available to them.
334
+ *
335
+ * Keying to Gyde's own version would be worse: the day Gyde reaches this
336
+ * number, every consumer's gate changes behaviour at once without anyone
337
+ * deciding to change it. That is a time bomb wearing a version number.
338
+ *
339
+ * So it reads the version in the product's OWN `gyde-emitted.json`, which only
340
+ * moves when they run `upgrade` and take the change. Blocking becomes something
341
+ * a product opts into, visible in a diff, at a moment of their choosing.
342
+ *
343
+ * A repository with no manifest — an adoption repo where `init` found an
344
+ * existing system and refused to write over it — is advisory forever by this
345
+ * rule, because it never opted into anything. That is correct, and it is also
346
+ * a gap: those are exactly the repositories most likely to have the defect.
347
+ * Closing it needs a different mechanism than a template version.
348
+ */
349
+ export const BLOCKS_FROM = SCHEDULE["client-boundary"].version;
350
+
351
+ const parts = (v) => String(v).split(/[.-]/).slice(0, 3).map((n) => parseInt(n, 10) || 0);
352
+
353
+ /** Is `a` at or beyond `b`? Numeric compare; a malformed version reads as 0.0.0 and does not block. */
354
+ export function atLeast(a, b) {
355
+ const [x, y, z] = parts(a), [p, q, r] = parts(b);
356
+ return x !== p ? x > p : y !== q ? y > q : z >= r;
357
+ }
358
+
359
+ /** Does a crossing block the gate for a product at this template version? */
360
+ export function blocks(templateVersion) {
361
+ return blocksAt("client-boundary", templateVersion);
362
+ }
363
+
364
+ export function formatClientBoundary(result, { blocking = null } = {}) {
365
+ const lines = [];
366
+ if (result.crossings.length === 0) {
367
+ lines.push(`no values cross the "use client" boundary (${result.serverFilesChecked} server file(s) checked)`);
368
+ } else {
369
+ lines.push(`${result.crossings.length} value(s) crossing the "use client" boundary:`);
370
+ for (const c of result.crossings) {
371
+ lines.push(` ${c.file}:${c.line} ${c.form} of \`${c.binding}\``);
372
+ lines.push(` declared in ${c.declaredIn}, which is "use client"`);
373
+ lines.push(` ${c.source}`);
374
+ }
375
+ lines.push("");
376
+ lines.push(" A server component importing a value from a \"use client\" module gets an");
377
+ lines.push(" opaque client reference, not the value. Spreading one yields {} and every");
378
+ lines.push(" prop silently reverts to undefined. Move the value to a module with no");
379
+ lines.push(" directive, importing only types.");
380
+
381
+ // Said in words, every run. An advisory finding that reports quietly is
382
+ // indistinguishable from one that found nothing — the same reason
383
+ // `fail-on: none` is loud in action.yml.
384
+ if (blocking === false) {
385
+ lines.push("");
386
+ lines.push(` ADVISORY — this did not fail the gate. It blocks from template v${BLOCKS_FROM},`);
387
+ lines.push(" which you take by running `upgrade`. Fixing them now costs nothing later.");
388
+ } else if (blocking === true) {
389
+ lines.push("");
390
+ lines.push(` BLOCKING — your template version is at or beyond v${BLOCKS_FROM}.`);
391
+ }
392
+ }
393
+ if (result.unresolved.length) {
394
+ lines.push("");
395
+ lines.push(` could not tell for ${result.unresolved.length} import(s) — not counted as clean:`);
396
+ for (const u of result.unresolved.slice(0, 5)) lines.push(` ${u.file}:${u.line} ${u.spec}`);
397
+ }
398
+ return lines.join("\n");
399
+ }
package/compound.mjs ADDED
@@ -0,0 +1,123 @@
1
+ /**
2
+ * G-72 — a compound part with no root of its own above it.
3
+ *
4
+ * `<Select.Trigger>` outside a `<Select>` reads its root's context and gets
5
+ * nothing. What happens next depends on the library: a thrown error if it
6
+ * checks, `undefined` spreading through the part's props if it does not, and
7
+ * most often a control that renders, looks approximately right, and does not
8
+ * work. The compiler has no opinion — `Select.Trigger` is a real export and the
9
+ * JSX is valid.
10
+ *
11
+ * It is the same family as G-67. The type system is satisfied, every styling
12
+ * rule passes, and the defect is a relationship between two places in a tree
13
+ * that nothing in the toolchain looks at.
14
+ *
15
+ * THE FALSE POSITIVE THAT SHAPES THIS RULE.
16
+ *
17
+ * The obvious implementation walks the JSX tree and reports any part without an
18
+ * enclosing root. It is wrong, and commonly wrong:
19
+ *
20
+ * function Header() { return <Card.Header>…</Card.Header>; } // no root above it
21
+ * function Page() { return <Card><Header /></Card>; } // …and it is fine
22
+ *
23
+ * Extracting a part into its own component is normal, good, and indistinguishable
24
+ * from the defect without following the call graph across files. A rule that
25
+ * fires on that is a rule that fires on refactoring, and it would be switched
26
+ * off the first week.
27
+ *
28
+ * So the rule is narrower than the title and says so: a part is reported when
29
+ * its root appears NOWHERE in the file. That is the case no composition can
30
+ * explain away locally — the file uses `Card.Header` and has no idea `Card`
31
+ * exists. It catches the orphan left behind when a root is deleted or renamed,
32
+ * which is the shape that actually ships.
33
+ *
34
+ * The stricter version needs a resolved component graph, which is G-73's
35
+ * territory. Recording the limit here so the next person does not "fix" this by
36
+ * widening it into the refactoring case.
37
+ *
38
+ * @gyde-emits-source-for-another-repo — the fixtures beside this file are JSX
39
+ * written as data for a repository that is not this one.
40
+ */
41
+
42
+ export const RULE = "compound-part-without-root";
43
+
44
+ /** `Root.Part`, and the nested `Root.Group.Item` form some libraries use. */
45
+ const PART = /^([A-Z]\w*)((?:\.[A-Z]\w*)+)$/;
46
+
47
+ /**
48
+ * Every JSX element opened in a source file.
49
+ *
50
+ * Attributes are allowed to span lines, because in real JSX they almost always
51
+ * do. Matching `[^>]*` on a single line finds the opening `<Card` of a
52
+ * multi-line element and never its name, so the root would appear absent in
53
+ * exactly the files most likely to be real.
54
+ */
55
+ function elements(text) {
56
+ const out = [];
57
+ const re = /<\s*([A-Z][\w.]*)(\s[\s\S]*?)?(\/?)>/g;
58
+ let m;
59
+ while ((m = re.exec(text))) {
60
+ out.push({ name: m[1], index: m.index });
61
+ }
62
+ return out;
63
+ }
64
+
65
+ const stripComments = (text) =>
66
+ text.replace(/\/\*[\s\S]*?\*\//g, (s) => s.replace(/[^\n]/g, " "))
67
+ .replace(/^\s*\/\/[^\n]*/gm, "");
68
+
69
+ const lineOf = (text, index) => text.slice(0, index).split("\n").length;
70
+
71
+ /**
72
+ * Compound parts used in a file whose root is not used anywhere in that file.
73
+ *
74
+ * `roots` narrows the check to a known set — the design system's own exports —
75
+ * so `Foo.Bar` from an unrelated namespace object is not mistaken for a
76
+ * compound component. Without it every `Object.keys` shaped identifier in JSX
77
+ * position would be a candidate, and the rule would be guessing.
78
+ */
79
+ export function orphanedParts(text, { filename = "", roots = null } = {}) {
80
+ const src = stripComments(text);
81
+ const els = elements(src);
82
+ if (els.length === 0) return [];
83
+
84
+ const opened = new Set(els.map((e) => e.name));
85
+ const found = [];
86
+ const reported = new Set();
87
+
88
+ for (const el of els) {
89
+ const m = el.name.match(PART);
90
+ if (!m) continue;
91
+ const [, root, rest] = m;
92
+
93
+ if (roots && !roots.has(root)) continue; // not one of ours to judge
94
+
95
+ // The root itself, or the explicit `Root.Root` some libraries require.
96
+ if (opened.has(root) || opened.has(`${root}.Root`)) continue;
97
+
98
+ const key = `${el.name}@${el.index}`;
99
+ if (reported.has(key)) continue;
100
+ reported.add(key);
101
+
102
+ found.push({
103
+ file: filename, rule: RULE, line: lineOf(src, el.index),
104
+ part: el.name, root, member: rest.slice(1),
105
+ });
106
+ }
107
+
108
+ return found;
109
+ }
110
+
111
+ export function formatOrphanedParts(findings) {
112
+ if (findings.length === 0) return "no compound part is used without its root";
113
+ const L = [`${findings.length} compound part(s) used with no root in the file:`];
114
+ for (const f of findings) {
115
+ L.push(` ${f.file}:${f.line} <${f.part}> — <${f.root}> is not used anywhere in this file`);
116
+ }
117
+ L.push("");
118
+ L.push(" A part reads its root's context. Without one it gets nothing, and most");
119
+ L.push(" libraries render something that looks approximately right and does not work.");
120
+ L.push(" Either wrap it in its root, or the root was renamed and this is what was");
121
+ L.push(" left behind.");
122
+ return L.join("\n");
123
+ }