@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/upgrade.mjs ADDED
@@ -0,0 +1,344 @@
1
+ /**
2
+ * G-54 — how a scaffolded project takes a new Gyde version without forking.
3
+ *
4
+ * THE PROBLEM, WHICH IS THE PRODUCT.
5
+ *
6
+ * Gyde emits a component set. The product edits it, because editing it is the
7
+ * point (G-47). Six months later Gyde improves the template — a fixed
8
+ * accessibility bug, a primitive-library API change, a new token category. How
9
+ * does the improvement reach a file somebody has been working in?
10
+ *
11
+ * The prior-art survey found this is shadcn's openly unsolved problem: three
12
+ * multi-year issues, and its own documentation calls re-syncing "an ongoing
13
+ * responsibility rather than a solved problem." System C shows the cost
14
+ * arriving with no tool involved at all — twelve primitive packages at two
15
+ * different versions between its shared UI package and the app consuming it,
16
+ * which nobody decided.
17
+ *
18
+ * So this is not a nice-to-have. **If Gyde's answer here is no better than
19
+ * shadcn's, Gyde is shadcn with a Linear team.**
20
+ *
21
+ * THE MECHANISM: PROVENANCE, THEN A THREE-WAY CLASSIFICATION.
22
+ *
23
+ * `init` records what it wrote — the Gyde version and a hash of each file's
24
+ * exact emitted bytes. That record is the missing third point. With it, every
25
+ * file is one of four cases, and they need completely different handling:
26
+ *
27
+ * neither changed nothing to do
28
+ * Gyde changed only safe to apply — the product never touched it
29
+ * product changed only nothing to do; their edit is the current truth
30
+ * both changed a CONFLICT, reported for a human, never merged
31
+ *
32
+ * WHAT THIS DELIBERATELY DOES NOT DO.
33
+ *
34
+ * It never auto-merges. A generator that resolves a conflict in somebody's
35
+ * component is a generator that silently reverts a decision it cannot see the
36
+ * reason for — and CHARTER §3 says the components are theirs. A conflict is
37
+ * information, and the honest output is both versions and the diff between
38
+ * them.
39
+ *
40
+ * It also never rewrites a file it has no provenance for. An untracked file is
41
+ * one Gyde did not write, or wrote before provenance existed; overwriting it
42
+ * would be exactly the clobbering `init` refuses to do.
43
+ */
44
+
45
+ import { createHash } from "node:crypto";
46
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
47
+ import { join, dirname } from "node:path";
48
+
49
+ export const MANIFEST = "gyde-emitted.json";
50
+
51
+ /**
52
+ * The hash is of the exact bytes emitted.
53
+ *
54
+ * Not a normalised or whitespace-insensitive hash: a reformat IS an edit as far
55
+ * as this is concerned, because a formatter run over a file is a change the
56
+ * product made and Gyde must not silently discard it.
57
+ */
58
+ export const hash = (contents) => createHash("sha256").update(contents, "utf8").digest("hex").slice(0, 16);
59
+
60
+ export function buildManifest(files, { version }) {
61
+ return {
62
+ "//": "What Gyde emitted, and when. This is the third point a three-way " +
63
+ "comparison needs: without it, an upgrade cannot tell YOUR edit from " +
64
+ "OUR change and has to choose between clobbering and doing nothing. " +
65
+ "Committed on purpose. Do not edit by hand.",
66
+ version,
67
+ files: Object.fromEntries(Object.keys(files).sort().map((p) => [p, hash(files[p])])),
68
+ };
69
+ }
70
+
71
+ export function readManifest(root) {
72
+ const path = join(root, MANIFEST);
73
+ if (!existsSync(path)) return null;
74
+ try { return JSON.parse(readFileSync(path, "utf8")); } catch { return null; }
75
+ }
76
+
77
+ export function writeManifest(root, manifest) {
78
+ const path = join(root, MANIFEST);
79
+ mkdirSync(dirname(path), { recursive: true });
80
+ writeFileSync(path, JSON.stringify(manifest, null, 2) + "\n");
81
+ }
82
+
83
+ export const STATUS = {
84
+ UNCHANGED: "unchanged", // neither side moved
85
+ GYDE_ONLY: "gyde-only", // safe to apply
86
+ PRODUCT_ONLY: "product-only", // their edit stands
87
+ CONFLICT: "conflict", // both moved — a human decides
88
+ MISSING: "missing", // emitted once, deleted since
89
+ UNTRACKED: "untracked", // no provenance; Gyde will not touch it
90
+ NEW: "new", // this version emits a file the last one did not
91
+ FOREIGN: "foreign", // this version emits it, into a directory Gyde has never written to
92
+ };
93
+
94
+ /**
95
+ * Directories Gyde has actually written into, from the manifest (G-66).
96
+ *
97
+ * The discriminator between two cases that are otherwise identical — a file
98
+ * this version emits, which is not on disk, and which has no provenance:
99
+ *
100
+ * scaffolded repo Gyde wrote `src/Button.tsx`; now it also emits
101
+ * `src/Badge.tsx`. Adding it is the feature.
102
+ * adoption repo Gyde wrote a workflow and nothing else; now it emits
103
+ * `packages/design-system/src/Card.tsx` into a package
104
+ * the product wrote. Adding it is the thing CHARTER §3
105
+ * and G-47 forbid.
106
+ *
107
+ * A foothold is deliberately the file's OWN directory, not any ancestor. Using
108
+ * an ancestor would let a repository that took Gyde's tokens — provenance in
109
+ * `packages/design-tokens/` — become grounds to write into
110
+ * `packages/design-system/`, which is a different package with a different
111
+ * owner. The root directory is never a foothold for the same reason: it would
112
+ * make every path in the repository fair game.
113
+ *
114
+ * This is conservative, and knowingly so. A version that emits into a NEW
115
+ * subdirectory of a repository Gyde did scaffold is refused too, and reported
116
+ * rather than written. `init` is the way in — it refuses to overwrite, so the
117
+ * conservative answer costs a command rather than a file.
118
+ */
119
+ function footholds(recorded) {
120
+ return new Set(Object.keys(recorded).map((p) => dirname(p)));
121
+ }
122
+
123
+ /**
124
+ * Classify every file a new emission would touch.
125
+ *
126
+ * `nextFiles` is what the current Gyde version would emit. `manifest` is what a
127
+ * previous one did emit. The on-disk file is the third point.
128
+ */
129
+ export function classify(root, nextFiles, manifest) {
130
+ const recorded = manifest?.files ?? {};
131
+ const ours = footholds(recorded);
132
+ const out = [];
133
+
134
+ for (const [path, nextContents] of Object.entries(nextFiles)) {
135
+ const full = join(root, path);
136
+ const onDisk = existsSync(full) ? readFileSync(full, "utf8") : null;
137
+ const emitted = recorded[path] ?? null;
138
+ const next = hash(nextContents);
139
+
140
+ if (emitted === null) {
141
+ // Either Gyde has never written this path, or something else created it.
142
+ // Both mean the same thing when the file EXISTS: no provenance, no
143
+ // rewrite. When it does not exist, whether creating it is an upgrade or a
144
+ // land grab depends on whether Gyde has ever written into that directory.
145
+ out.push({
146
+ path,
147
+ status: onDisk !== null ? STATUS.UNTRACKED
148
+ : ours.has(dirname(path)) ? STATUS.NEW
149
+ : STATUS.FOREIGN,
150
+ });
151
+ continue;
152
+ }
153
+ if (onDisk === null) { out.push({ path, status: STATUS.MISSING }); continue; }
154
+
155
+ const current = hash(onDisk);
156
+ const productMoved = current !== emitted;
157
+ const gydeMoved = next !== emitted;
158
+
159
+ if (!productMoved && !gydeMoved) out.push({ path, status: STATUS.UNCHANGED });
160
+ else if (gydeMoved && !productMoved) out.push({ path, status: STATUS.GYDE_ONLY });
161
+ else if (productMoved && !gydeMoved) out.push({ path, status: STATUS.PRODUCT_ONLY });
162
+ else out.push({ path, status: STATUS.CONFLICT });
163
+ }
164
+
165
+ // A path the manifest records and this version no longer emits. Reported, not
166
+ // deleted: Gyde does not remove a file the product may now depend on.
167
+ for (const path of Object.keys(recorded)) {
168
+ if (!(path in nextFiles)) out.push({ path, status: STATUS.MISSING, retired: true });
169
+ }
170
+
171
+ return out.sort((a, b) => a.path.localeCompare(b.path));
172
+ }
173
+
174
+ /**
175
+ * Apply an upgrade.
176
+ *
177
+ * Writes only `gyde-only` files: a path the manifest records, whose on-disk
178
+ * bytes still match what Gyde emitted. Everything else is reported and left
179
+ * alone, including — especially — conflicts. `dryRun` returns the same
180
+ * decisions without touching anything, and `upgrade` and its dry run share this
181
+ * function for the same reason `plan` and `init` share theirs.
182
+ *
183
+ * WHY `FOREIGN` EXISTS, AND WHY `NEW` SURVIVED (G-66).
184
+ *
185
+ * Found against System B: at the same template version in and out,
186
+ * `upgrade` proposed sixteen writes into `packages/design-system/` and
187
+ * `apps/design/` — a design system that repository wrote itself, in a
188
+ * repository whose manifest records exactly two files. It reported them as
189
+ * "Gyde changed and you had not touched", which is false on both halves.
190
+ *
191
+ * The cause is upstream and is NOT a mistake. `upgrade` deliberately builds the
192
+ * emission with `hasTokens: false, hasSystem: false`, because an upgrade has to
193
+ * compare against the templates rather than against a skip decision — otherwise
194
+ * a scaffolded repository, which by definition has a design-system package,
195
+ * would never receive a component template fix at all. That reasoning is right
196
+ * and stays.
197
+ *
198
+ * What was wrong is that two different situations produced the same status. A
199
+ * template absent from disk with no provenance is either:
200
+ *
201
+ * an addition Gyde wrote this directory; the new version adds a file to it
202
+ * a land grab Gyde has never written here, and the product owns it
203
+ *
204
+ * The first is the feature — a scaffolded repository should pick up a new
205
+ * component template without being asked. The second is the one thing CHARTER
206
+ * §3 and G-47 forbid. `footholds()` tells them apart using the only evidence
207
+ * that means anything: whether Gyde has ever written a file into that exact
208
+ * directory.
209
+ *
210
+ * So `NEW` is still written, and `FOREIGN` is reported and never written.
211
+ * `init` is the way to take a foreign one deliberately — it refuses to
212
+ * overwrite anything, so running it again is safe.
213
+ */
214
+ export function applyUpgrade(root, nextFiles, manifest, { version, dryRun = false } = {}) {
215
+ const decisions = classify(root, nextFiles, manifest);
216
+ const applied = [];
217
+ const skipped = [];
218
+
219
+ for (const d of decisions) {
220
+ if (d.status === STATUS.GYDE_ONLY || d.status === STATUS.NEW) {
221
+ if (!dryRun) {
222
+ const full = join(root, d.path);
223
+ mkdirSync(dirname(full), { recursive: true });
224
+ writeFileSync(full, nextFiles[d.path]);
225
+ }
226
+ applied.push(d);
227
+ } else {
228
+ skipped.push(d);
229
+ }
230
+ }
231
+
232
+ /**
233
+ * The manifest records what Gyde LAST EMITTED, not what is on disk.
234
+ *
235
+ * So a file that was applied advances to the new hash, and one that was not
236
+ * keeps its old baseline — otherwise the next upgrade would read an unapplied
237
+ * change as a product edit, and a conflict would quietly become
238
+ * `product-only` and never be offered again.
239
+ *
240
+ * A FOREIGN path gets no record (G-66). Recording a hash for a file Gyde
241
+ * never wrote would be provenance asserting something false, and the next run
242
+ * would read it as "Gyde emitted this and the product deleted it" — a
243
+ * fabricated history that then justifies writing the file for real.
244
+ */
245
+ const recorded = manifest?.files ?? {};
246
+ const nextManifest = {
247
+ ...buildManifest(nextFiles, { version }),
248
+ files: Object.fromEntries(
249
+ decisions
250
+ .filter((d) => d.path in nextFiles)
251
+ .filter((d) => applied.includes(d) || d.path in recorded)
252
+ .map((d) => [d.path, applied.includes(d) ? hash(nextFiles[d.path]) : recorded[d.path]]),
253
+ ),
254
+ };
255
+
256
+ return { decisions, applied, skipped, nextManifest, conflicts: skipped.filter((d) => d.status === STATUS.CONFLICT) };
257
+ }
258
+
259
+ /**
260
+ * A line-level diff, for a human reading a conflict.
261
+ *
262
+ * Deliberately naive — this is for reading, not for merging. Anything cleverer
263
+ * would invite the auto-merge this module exists to refuse.
264
+ */
265
+ export function diff(before, after, { context = 2 } = {}) {
266
+ const a = before.split("\n");
267
+ const b = after.split("\n");
268
+ const out = [];
269
+ const max = Math.max(a.length, b.length);
270
+ let lastPrinted = -1;
271
+
272
+ for (let i = 0; i < max; i++) {
273
+ if (a[i] === b[i]) continue;
274
+ const from = Math.max(lastPrinted + 1, i - context);
275
+ if (from > lastPrinted + 1) out.push(` … ${from - lastPrinted - 1} unchanged line(s)`);
276
+ for (let j = from; j <= Math.min(max - 1, i + context); j++) {
277
+ if (j <= lastPrinted) continue;
278
+ if (a[j] === b[j]) out.push(` ${a[j] ?? ""}`);
279
+ else {
280
+ if (a[j] !== undefined) out.push(` -${a[j]}`);
281
+ if (b[j] !== undefined) out.push(` +${b[j]}`);
282
+ }
283
+ lastPrinted = j;
284
+ }
285
+ }
286
+ return out.join("\n");
287
+ }
288
+
289
+ export function formatUpgrade(result, { manifest, version }) {
290
+ const L = [];
291
+ const by = (s) => result.decisions.filter((d) => d.status === s);
292
+
293
+ L.push(`from ${manifest?.version ?? "(no manifest — nothing to upgrade from)"}`);
294
+ L.push(`to ${version}`);
295
+ L.push("");
296
+ L.push(`applied ${result.applied.length} file(s) Gyde changed and you had not touched`);
297
+ for (const d of result.applied.slice(0, 12)) L.push(` ${d.path}`);
298
+
299
+ const conflicts = by(STATUS.CONFLICT);
300
+ if (conflicts.length) {
301
+ L.push("");
302
+ L.push(`CONFLICTS ${conflicts.length} file(s) changed on BOTH sides. Nothing was written to these.`);
303
+ L.push(" Gyde does not merge: resolving a conflict in your component means");
304
+ L.push(" reverting a decision it cannot see the reason for.");
305
+ for (const d of conflicts) L.push(` ${d.path}`);
306
+ }
307
+
308
+ const held = by(STATUS.PRODUCT_ONLY);
309
+ if (held.length) L.push(`\nyours ${held.length} file(s) you changed and Gyde did not — left alone`);
310
+
311
+ const untracked = by(STATUS.UNTRACKED);
312
+ if (untracked.length) {
313
+ L.push(`\nuntracked ${untracked.length} file(s) with no provenance — Gyde will not touch them`);
314
+ for (const d of untracked.slice(0, 6)) L.push(` ${d.path}`);
315
+ }
316
+
317
+ /**
318
+ * Reported loudly, and never written (G-66).
319
+ *
320
+ * Silence here would be the worse bug in the other direction: a version that
321
+ * genuinely adds a template would say nothing, and the repository would sit
322
+ * one template behind with no signal. So the count is always shown, and the
323
+ * next step is named rather than implied.
324
+ */
325
+ const foreign = by(STATUS.FOREIGN);
326
+ if (foreign.length) {
327
+ L.push(`\nnot Gyde's ${foreign.length} file(s) this version emits, in directories Gyde has`);
328
+ L.push(" never written to.");
329
+ L.push(" NOT written. Creating a file inside a package the product owns is");
330
+ L.push(" the one thing a scaffolder must not do (CHARTER §3, G-47).");
331
+ for (const d of foreign.slice(0, 8)) L.push(` ${d.path}`);
332
+ if (foreign.length > 8) L.push(` … and ${foreign.length - 8} more`);
333
+ L.push(" `gyde-design init .` adds them. It never overwrites, so it is safe");
334
+ L.push(" to run here, and it will skip anything you already have.");
335
+ }
336
+
337
+ const retired = result.decisions.filter((d) => d.retired);
338
+ if (retired.length) {
339
+ L.push(`\nretired ${retired.length} file(s) this version no longer emits — reported, never deleted`);
340
+ for (const d of retired) L.push(` ${d.path}`);
341
+ }
342
+
343
+ return L.join("\n");
344
+ }
package/usage.mjs ADDED
@@ -0,0 +1,245 @@
1
+ /**
2
+ * G-59 / G-50 — which component is used where, and what is left over.
3
+ *
4
+ * WHY A COUNT OF VIOLATIONS IS NOT ENOUGH.
5
+ *
6
+ * The audit answers "is this value tokenised". It cannot answer the two
7
+ * questions a team actually asks while the library grows:
8
+ *
9
+ * - I need a thing. Does the set already compose it?
10
+ * - We shipped a component. Is anybody using it?
11
+ *
12
+ * One project states the principle for the first — *"A page that needs something the
13
+ * set cannot compose has found a gap in the set. That is a finding to file, not
14
+ * a licence to write bespoke CSS."* — and **nothing in either source repository
15
+ * implements the check behind that sentence.** This module is the evidence that
16
+ * check needs.
17
+ *
18
+ * THE SECOND QUESTION IS THE ONE THAT DECAYS QUIETLY. A component nobody renders
19
+ * is not free: it is code that drifts from the set it claims to belong to, and
20
+ * it is the reason System A deleted a finished `Disclosure` rather than
21
+ * ship it. An unused component and a component used once look identical
22
+ * in a diff and completely different in a design system.
23
+ *
24
+ * TWO SCOPE TRAPS, BOTH OBSERVED.
25
+ *
26
+ * 1. The design system's own source composes its components without adopting
27
+ * them. Counting it inflates every number.
28
+ * 2. A catalogue renders everything by definition. System A's usage
29
+ * index reported `Choice` as unused **while it sat at the top of the
30
+ * catalogue demonstrating itself** — the index could not see the file it was
31
+ * rendered in. So catalogue *entries* are excluded and catalogue *chrome* is
32
+ * not, because a catalogue using a component for its own theme switcher is a
33
+ * real use.
34
+ *
35
+ * Both are configuration, and both are reported, because a scope that moved
36
+ * silently is how a metric stops describing anything.
37
+ */
38
+
39
+ import { readFileSync, readdirSync, statSync } from "node:fs";
40
+ import { join, extname, relative, sep } from "node:path";
41
+
42
+ const SKIP = new Set(["node_modules", "dist", "build", ".next", ".turbo", ".git", "coverage"]);
43
+ const SOURCE = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs"]);
44
+ const isTest = (p) => /\.(test|spec)\.[jt]sx?$/.test(p) || /(^|\/)__tests__\//.test(p);
45
+
46
+ function walk(dir, root, out) {
47
+ let entries;
48
+ try { entries = readdirSync(dir); } catch { return; }
49
+ for (const name of entries) {
50
+ if (SKIP.has(name) || name.startsWith(".")) continue;
51
+ const full = join(dir, name);
52
+ let st; try { st = statSync(full); } catch { continue; }
53
+ if (st.isDirectory()) walk(full, root, out);
54
+ else if (SOURCE.has(extname(name)) && !isTest(full)) out.push(relative(root, full).split(sep).join("/"));
55
+ }
56
+ }
57
+
58
+ /** Class tokens that belong to a declared vocabulary rather than being bespoke. */
59
+ const declared = (cls, prefixes) => prefixes.some((p) => cls.startsWith(p));
60
+
61
+ /**
62
+ * Roots of the utility idiom.
63
+ *
64
+ * A utility class is a styling idiom the audit already judges; a bespoke class
65
+ * name is a vocabulary the product invented. Counting the first as the second
66
+ * buries the signal this index exists to surface — one page of Tailwind would
67
+ * out-shout every real finding.
68
+ *
69
+ * The test is the ROOT word, with or without a hyphen: the first version
70
+ * required one and let `flex`, `grid` and `hidden` through as bespoke
71
+ * vocabulary.
72
+ *
73
+ * THE TRADEOFF, STATED. A genuinely bespoke class whose first word happens to
74
+ * be a utility root — `text-heavy`, `flex-container` — is skipped. That is the
75
+ * safe direction to be wrong in: a missed gap is a gap somebody files later, an
76
+ * index drowned in utilities is one nobody reads at all.
77
+ */
78
+ const UTILITY_ROOTS = new Set([
79
+ "p", "m", "px", "py", "mx", "my", "pt", "pb", "pl", "pr", "mt", "mb", "ml", "mr",
80
+ "w", "h", "min", "max", "gap", "space", "inset", "top", "left", "right", "bottom",
81
+ "text", "bg", "border", "rounded", "shadow", "ring", "outline", "opacity", "divide",
82
+ "flex", "grid", "col", "row", "order", "items", "justify", "self", "place", "content",
83
+ "block", "inline", "hidden", "absolute", "relative", "fixed", "sticky", "static",
84
+ "font", "leading", "tracking", "whitespace", "break", "truncate", "list", "align",
85
+ "overflow", "object", "cursor", "select", "pointer", "z", "transition", "duration",
86
+ "ease", "translate", "scale", "rotate", "transform", "filter", "blur", "backdrop",
87
+ "from", "via", "to", "sr", "not", "aspect", "container", "antialiased", "underline",
88
+ // Added after a real run: System C's top five "invented vocabularies"
89
+ // were animate-spin, shrink-0, capitalize, line-clamp-2 and animate-pulse —
90
+ // every one a stock utility. A gap list made entirely of Tailwind is a gap
91
+ // list nobody reads, and the roots below are the ones that produced it.
92
+ "animate", "shrink", "grow", "basis", "capitalize", "uppercase", "lowercase",
93
+ "line", "decoration", "indent", "resize", "appearance", "caret", "accent",
94
+ "snap", "touch", "will", "isolate", "mix", "bt", "visible", "invisible",
95
+ "collapse", "table", "float", "clear", "box", "columns", "gradient", "rounded",
96
+ ]);
97
+
98
+ const isUtility = (cls) => UTILITY_ROOTS.has(cls.split("-")[0]);
99
+
100
+ /**
101
+ * Build the index.
102
+ *
103
+ * `excludePaths` are places whose usage does not count — the design system's own
104
+ * source, and a catalogue's entry file. Both are echoed back in the result so
105
+ * the exclusion is visible next to the number it changed.
106
+ */
107
+ export function buildUsage(root, {
108
+ components = [],
109
+ excludePaths = [],
110
+ classPrefixes = ["ds-"],
111
+ scanPaths = null,
112
+ } = {}) {
113
+ const files = [];
114
+ for (const base of scanPaths ?? [""]) walk(join(root, base), root, files);
115
+
116
+ const excluded = (rel) => excludePaths.some((p) => rel === p || rel.startsWith(p.replace(/\/$/, "") + "/"));
117
+
118
+ const used = Object.fromEntries(components.map((c) => [c, []]));
119
+ const leftovers = {};
120
+ let filesCounted = 0;
121
+ let filesExcluded = 0;
122
+
123
+ for (const rel of files.sort()) {
124
+ if (excluded(rel)) { filesExcluded++; continue; }
125
+ let text; try { text = readFileSync(join(root, rel), "utf8"); } catch { continue; }
126
+ filesCounted++;
127
+
128
+ for (const name of components) {
129
+ if (new RegExp(`<${name}[\\s/>]`).test(text)) used[name].push(rel);
130
+ }
131
+
132
+ // Bespoke class names: the vocabulary a product invented instead of using
133
+ // the set. This is the leading indicator — a cluster of them in one area is
134
+ // a gap in the set showing up before anybody files it.
135
+ /**
136
+ * Only STRING LITERALS inside a className, never the whole brace body.
137
+ *
138
+ * The first version captured everything after `{`, so
139
+ * `className={cond ? "a" : "b"}` yielded `cond` and `?` as bespoke class
140
+ * names. Run against a real repository it reported `?`, `===` and `selected`
141
+ * as the product's top three invented vocabularies — an output that tells
142
+ * you nothing and costs the whole feature its credibility.
143
+ */
144
+ const literals = [];
145
+ for (const m of text.matchAll(/(?:className|class)\s*=\s*"([^"]*)"/g)) literals.push(m[1]);
146
+ for (const m of text.matchAll(/(?:className|class)\s*=\s*'([^']*)'/g)) literals.push(m[1]);
147
+ for (const m of text.matchAll(/(?:className|class)\s*=\s*\{([\s\S]{0,400}?)\}/g)) {
148
+ // Interpolations are stripped BEFORE quoted runs are extracted. Doing it
149
+ // the other way round, a template literal like
150
+ // `` `row ${active ? "on" : "off"}` `` yields the text between the
151
+ // backtick and the first quote — so `${active`, `?` and `:` arrive as
152
+ // class names.
153
+ const body = m[1].replace(/\$\{[^}]*\}?/g, " ");
154
+ for (const lit of body.matchAll(/["'`]([^"'`]*)["'`]/g)) literals.push(lit[1]);
155
+ // A bare template with no quotes inside: `` `row gap` ``.
156
+ for (const lit of body.matchAll(/`([^`]*)`/g)) literals.push(lit[1]);
157
+ }
158
+
159
+ for (const literal of literals) {
160
+ for (const cls of literal.replace(/\$\{[^}]*\}/g, " ").split(/\s+/)) {
161
+ if (!cls || declared(cls, classPrefixes)) continue;
162
+ // Strip variant prefixes first: `hover:flex` is the same idiom as `flex`.
163
+ const bare = cls.replace(/^(?:[\w-]+:)+/, "");
164
+ if (!bare || isUtility(bare)) continue;
165
+ (leftovers[cls] ??= []).push(rel);
166
+ }
167
+ }
168
+ }
169
+
170
+ const unused = components.filter((c) => used[c].length === 0);
171
+
172
+ return {
173
+ scope: { filesCounted, filesExcluded, excludePaths, scanPaths: scanPaths ?? ["(whole tree)"] },
174
+ used,
175
+ unused,
176
+ // Sorted by how often the product reached for something the set did not have.
177
+ leftovers: Object.fromEntries(
178
+ Object.entries(leftovers)
179
+ .map(([cls, files]) => [cls, [...new Set(files)]])
180
+ .sort((a, b) => b[1].length - a[1].length),
181
+ ),
182
+ };
183
+ }
184
+
185
+ /**
186
+ * Turn the index into the two questions a team asks.
187
+ *
188
+ * This is deliberately advisory. A cluster of bespoke classes is EVIDENCE of a
189
+ * gap, not proof of one, and reporting it as a violation would be the mistake
190
+ * G-60 §1 records: the closed system that had no runtime theming did not have a
191
+ * rule problem, it had a capability problem, and a gate that could not tell the
192
+ * difference just made the workaround harder to find.
193
+ */
194
+ export function guidance(usage, { gapThreshold = 3 } = {}) {
195
+ const findings = [];
196
+
197
+ for (const name of usage.unused) {
198
+ findings.push({
199
+ kind: "unused-component",
200
+ subject: name,
201
+ severity: "advisory",
202
+ why: `${name} is exported and rendered nowhere. An unused component drifts from the set it claims to belong to — either use it, or delete it and file the gap it was speculating about.`,
203
+ });
204
+ }
205
+
206
+ for (const [cls, files] of Object.entries(usage.leftovers)) {
207
+ if (files.length < gapThreshold) continue;
208
+ findings.push({
209
+ kind: "possible-gap",
210
+ subject: cls,
211
+ severity: "advisory",
212
+ occurrences: files.length,
213
+ examples: files.slice(0, 4),
214
+ why: `"${cls}" is spelled in ${files.length} files. A vocabulary the product keeps reinventing is usually a component the set is missing — file it as a gap before writing it a sixth time.`,
215
+ });
216
+ }
217
+
218
+ const single = Object.entries(usage.used).filter(([, f]) => f.length === 1);
219
+ for (const [name, files] of single) {
220
+ findings.push({
221
+ kind: "single-use-component",
222
+ subject: name,
223
+ severity: "advisory",
224
+ examples: files,
225
+ why: `${name} is rendered in exactly one place. That is fine for a genuinely singular surface and a warning sign otherwise — a component with one caller is often a page fragment that was promoted too early.`,
226
+ });
227
+ }
228
+
229
+ return findings.sort((a, b) => (b.occurrences ?? 0) - (a.occurrences ?? 0));
230
+ }
231
+
232
+ export function formatUsage(usage, findings) {
233
+ const L = [];
234
+ const total = Object.keys(usage.used).length;
235
+ L.push(`usage ${total - usage.unused.length} of ${total} component(s) rendered somewhere`);
236
+ L.push(` ${usage.scope.filesCounted} file(s) counted, ${usage.scope.filesExcluded} excluded${usage.scope.excludePaths.length ? ` (${usage.scope.excludePaths.join(", ")})` : ""}`);
237
+ if (usage.unused.length) L.push(`unused ${usage.unused.join(", ")}`);
238
+
239
+ const gaps = findings.filter((f) => f.kind === "possible-gap");
240
+ if (gaps.length) {
241
+ L.push(`possible gaps ${gaps.length} vocabulary(ies) the product keeps reinventing:`);
242
+ for (const g of gaps.slice(0, 8)) L.push(` ${String(g.occurrences).padStart(4)}× ${g.subject}`);
243
+ }
244
+ return L.join("\n");
245
+ }