@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/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
|
+
}
|