@designtools/adherence 0.0.0-stage → 0.1.1

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,1030 @@
1
+ // src/config.ts
2
+ import { existsSync, readFileSync } from "fs";
3
+ import { dirname, relative, resolve } from "path";
4
+ var CONFIG_FILE = "designtools.json";
5
+ var UsageError = class extends Error {
6
+ };
7
+ var DEFAULT_INCLUDE = ["app/**/*.{tsx,jsx,ts,js}", "src/**/*.{tsx,jsx,ts,js}"];
8
+ var DEFAULT_EXCLUDE = [
9
+ "app/design/**",
10
+ "src/app/design/**",
11
+ // the (design) route group, where the skills put the docs and every other design page
12
+ "app/(design)/**",
13
+ "src/app/(design)/**",
14
+ "**/node_modules/**",
15
+ "**/*.d.ts",
16
+ "**/*.{test,spec}.*",
17
+ "**/__tests__/**",
18
+ "**/{test,tests,e2e}/**",
19
+ "**/*.{fixtures,examples,stories}.*",
20
+ "**/{fixtures,examples}/**"
21
+ ];
22
+ function loadConfig(flags) {
23
+ const root = resolve(flags.root ?? ".");
24
+ const path = resolve(root, CONFIG_FILE);
25
+ let file = {};
26
+ if (existsSync(path)) {
27
+ try {
28
+ file = JSON.parse(readFileSync(path, "utf8"));
29
+ } catch (e) {
30
+ throw new UsageError(`${CONFIG_FILE} is not valid JSON: ${e.message}`);
31
+ }
32
+ }
33
+ let manifest;
34
+ if (flags.manifest) {
35
+ manifest = toRelative(root, resolve(flags.manifest));
36
+ } else {
37
+ const out = file.manifest?.out ?? (file.manifest?.system ? `${trim(file.manifest.system)}/manifest` : void 0);
38
+ if (!out) {
39
+ throw new UsageError(
40
+ `no manifest. Pass --manifest <components.json>, or add { "manifest": { "system": "src/ds" } } to ${CONFIG_FILE}.`
41
+ );
42
+ }
43
+ manifest = `${trim(out)}/components.json`;
44
+ }
45
+ if (!existsSync(resolve(root, manifest))) {
46
+ throw new UsageError(`${manifest} does not exist. Run designtools-manifest build first.`);
47
+ }
48
+ const tokensPath = `${dirname(manifest)}/tokens.json`;
49
+ const include = flags.include?.length ? flags.include : file.adherence?.include ?? DEFAULT_INCLUDE;
50
+ const exclude = [...DEFAULT_EXCLUDE, ...file.adherence?.exclude ?? [], ...flags.exclude ?? []];
51
+ for (const [name, list] of [["include", include], ["exclude", exclude]]) {
52
+ if (!Array.isArray(list) || list.some((g) => typeof g !== "string")) {
53
+ throw new UsageError(`adherence.${name} in ${CONFIG_FILE} must be a list of globs`);
54
+ }
55
+ }
56
+ return {
57
+ root,
58
+ manifest,
59
+ tokens: existsSync(resolve(root, tokensPath)) ? tokensPath : void 0,
60
+ tsconfig: file.manifest?.tsconfig,
61
+ include,
62
+ exclude
63
+ };
64
+ }
65
+ function trim(p) {
66
+ return p.replace(/\/+$/, "");
67
+ }
68
+ function toRelative(root, abs) {
69
+ const rel = relative(root, abs);
70
+ if (rel.startsWith("..") || resolve(root, rel) !== abs) return abs.split("\\").join("/");
71
+ return rel.split("\\").join("/");
72
+ }
73
+
74
+ // src/sort.ts
75
+ function cmp(a, b) {
76
+ return a < b ? -1 : a > b ? 1 : 0;
77
+ }
78
+
79
+ // src/markdown.ts
80
+ var LIMIT = 6e4;
81
+ var PER_FILE = 25;
82
+ function formatMarkdown(report) {
83
+ const s = report.summary;
84
+ const lines = [];
85
+ lines.push("## Design system adherence", "");
86
+ lines.push(
87
+ s.share === null ? "No system components or replaceable raw elements found." : `**${percent(s.share)}** from the system: ${s.systemUses} system component ${plural(s.systemUses, "use")} against ${s.rawElements} raw ${plural(s.rawElements, "element")} a component replaces.`
88
+ );
89
+ lines.push(
90
+ "",
91
+ `${s.offSystemValues} off-system ${plural(s.offSystemValues, "value")} (${s.offSystem.palette} palette, ${s.offSystem.arbitrary} arbitrary, ${s.offSystem["status-text"] ?? 0} status text) \xB7 ${s.overrides} ${plural(s.overrides, "override")} \xB7 ${s.files} ${plural(s.files, "file")} scanned, ${s.filesWithFindings} with findings.`
92
+ );
93
+ lines.push("", "Warnings only: nothing here blocks a merge.");
94
+ if (!s.replaceable.length) {
95
+ lines.push("", "No component declares `@replaces`, so no raw elements were counted. Tag components with the elements they stand in for, such as `@replaces button`.");
96
+ }
97
+ if (report.routes.length) {
98
+ lines.push("", "### Routes", "", "| Route | From the system | Off-system values | Overrides |", "| --- | --- | --- | --- |");
99
+ for (const r of report.routes) {
100
+ lines.push(`| \`${r.route}\` | ${shareCell(r.counts)} | ${r.counts.offSystemValues} | ${r.counts.overrides} |`);
101
+ }
102
+ }
103
+ const findings = report.files.filter((f) => f.raw.length || f.offSystem.length || f.overrides.length);
104
+ const tail = [];
105
+ const used = report.components.filter((c) => c.uses > 0);
106
+ if (used.length) {
107
+ tail.push("", "### Components", "", "| Component | Uses | Files |", "| --- | --- | --- |");
108
+ for (const c of [...used].sort((a, b) => b.uses - a.uses || cmp(a.name, b.name))) {
109
+ tail.push(`| \`${c.name}\` | ${c.uses} | ${c.files} |`);
110
+ }
111
+ }
112
+ if (s.componentsUnused.length) {
113
+ tail.push("", `Not used in the product: ${s.componentsUnused.map((n) => `\`${n}\``).join(", ")}.`);
114
+ }
115
+ if (report.skipped.length) {
116
+ tail.push("", "### Skipped", "");
117
+ for (const f of report.skipped) tail.push(`- \`${f.file}\`: ${f.reason}`);
118
+ }
119
+ tail.push("", `<sub>${report.generator} \xB7 ${report.manifest}</sub>`, "");
120
+ if (findings.length) {
121
+ lines.push("", "### Findings by file", "");
122
+ let size = lines.join("\n").length + tail.join("\n").length;
123
+ for (let i = 0; i < findings.length; i++) {
124
+ const block = fileBlock(findings[i]);
125
+ if (size + block.length > LIMIT) {
126
+ lines.push(`${findings.length - i} more ${plural(findings.length - i, "file")} with findings are in the JSON report (\`--json <file>\`).`, "");
127
+ break;
128
+ }
129
+ lines.push(block);
130
+ size += block.length + 1;
131
+ }
132
+ }
133
+ return [...lines, ...tail].join("\n").replace(/\n{3,}/g, "\n\n");
134
+ }
135
+ function fileBlock(f) {
136
+ const parts = [
137
+ f.raw.length && `${f.raw.length} raw`,
138
+ f.offSystem.length && `${f.offSystem.length} off-system`,
139
+ f.overrides.length && `${f.overrides.length} ${plural(f.overrides.length, "override")}`
140
+ ].filter(Boolean);
141
+ const items = [
142
+ ...f.raw.map((r) => [
143
+ r.line,
144
+ r.column,
145
+ `\`<${r.element}>\`${r.selector !== r.element ? ` (\`${r.selector}\`)` : ""}: use ${r.replacedBy.map((c) => `\`${c}\``).join(" or ")}`
146
+ ]),
147
+ ...f.offSystem.map((v) => [
148
+ v.line,
149
+ v.column,
150
+ `\`${v.class}\`: ${OFF_SYSTEM_ADVICE[v.kind]}`
151
+ ]),
152
+ ...f.overrides.map((o) => [
153
+ o.line,
154
+ o.column,
155
+ `\`<${o.component} className>\` overrides ${[...new Set(o.classes.map((c) => c.kind))].join(", ")} with ${o.classes.map((c) => `\`${c.class}\``).join(" ")}; use a variant`
156
+ ])
157
+ ].sort((a, b) => a[0] - b[0] || a[1] - b[1] || cmp(a[2], b[2]));
158
+ const shown = items.slice(0, PER_FILE).map(([line, , text]) => `- L${line} ${text}`);
159
+ if (items.length > PER_FILE) shown.push(`- and ${items.length - PER_FILE} more`);
160
+ const title = `\`${f.file}\`${f.route ? ` (\`${f.route}\`)` : ""}: ${parts.join(", ")}`;
161
+ return ["<details>", `<summary>${title}</summary>`, "", ...shown, "", "</details>", ""].join("\n");
162
+ }
163
+ function percent(share) {
164
+ let p = Math.round(share * 100);
165
+ if (p === 100 && share < 1) p = 99;
166
+ if (p === 0 && share > 0) p = 1;
167
+ return `${p}%`;
168
+ }
169
+ function shareCell(c) {
170
+ if (c.share === null) return "\u2013";
171
+ return `${percent(c.share)} (${c.systemUses} of ${c.systemUses + c.rawElements})`;
172
+ }
173
+ function plural(n, word) {
174
+ return n === 1 ? word : `${word}s`;
175
+ }
176
+ var OFF_SYSTEM_ADVICE = {
177
+ palette: "a palette step; use a semantic colour",
178
+ arbitrary: "an arbitrary value; use a token",
179
+ "status-text": "a status fill as text, unchecked on the page; use its `-subdued-foreground`, or neutral text with an icon for a warning"
180
+ };
181
+
182
+ // src/discipline.ts
183
+ import { existsSync as existsSync2 } from "fs";
184
+ import { createRequire } from "module";
185
+ import { dirname as dirname2, join, relative as relative2, sep } from "path";
186
+ import { fileURLToPath } from "url";
187
+ var TAILWIND_FAMILIES = [
188
+ "red",
189
+ "orange",
190
+ "amber",
191
+ "yellow",
192
+ "lime",
193
+ "green",
194
+ "emerald",
195
+ "teal",
196
+ "cyan",
197
+ "sky",
198
+ "blue",
199
+ "indigo",
200
+ "violet",
201
+ "purple",
202
+ "fuchsia",
203
+ "pink",
204
+ "rose",
205
+ "slate",
206
+ "gray",
207
+ "zinc",
208
+ "neutral",
209
+ "stone",
210
+ "mauve",
211
+ "olive",
212
+ "mist",
213
+ "taupe"
214
+ ];
215
+ var COLOUR_UTILITIES = [
216
+ "bg",
217
+ "text",
218
+ "border",
219
+ "border-x",
220
+ "border-y",
221
+ "border-t",
222
+ "border-r",
223
+ "border-b",
224
+ "border-l",
225
+ "border-s",
226
+ "border-e",
227
+ "outline",
228
+ "ring",
229
+ "ring-offset",
230
+ "inset-ring",
231
+ "shadow",
232
+ "inset-shadow",
233
+ "drop-shadow",
234
+ "text-shadow",
235
+ "divide",
236
+ "fill",
237
+ "stroke",
238
+ "from",
239
+ "via",
240
+ "to",
241
+ "decoration",
242
+ "accent",
243
+ "caret",
244
+ "placeholder"
245
+ ];
246
+ var esc = (s) => s.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
247
+ var wrap = (utility) => `^((?:\\S*:)?!?-?${utility}!?)$`;
248
+ var LAYOUT = `(?:z|w|min-w|max-w|grid-cols|grid-rows|columns|aspect)-\\[`;
249
+ function restrictionsFor(families) {
250
+ const fams = [...new Set(families)].sort((a, b) => b.length - a.length || cmp(a, b)).map(esc).join("|");
251
+ const utils = [...COLOUR_UTILITIES].sort((a, b) => b.length - a.length).map(esc).join("|");
252
+ const step = `(?:${fams})-\\d+`;
253
+ const palette = `(?:${utils})-(?:${step}|\\(--color-${step}\\)|\\[var\\(--color-${step}\\)\\])(?:\\/\\S+)?`;
254
+ return [
255
+ { pattern: wrap(palette), message: "[designtools:palette] $1" },
256
+ // a bracketed value that is not a token reference, or an arbitrary property. Layout is left
257
+ // alone: container widths, grid templates, aspect ratios and z-index are not tokens
258
+ // (mxa-tokens, "Deliberately not tokenised"), so `max-w-[40rem]` is art direction, not drift.
259
+ // a status colour's fill used as text: it is measured against its own label, not the page, and
260
+ // fails there (red text on a dark page, 2.4:1 for every brand tested). Its subdued label is the
261
+ // text tone that reads on every background; warning text is neutral text with an icon.
262
+ { pattern: wrap(`text-(?:success|warning|destructive)(?:\\/\\S+)?`), message: "[designtools:status-text] $1" },
263
+ { pattern: wrap(`(?:(?!${LAYOUT})[a-z][\\w-]*-\\[(?!var\\(--)[^\\]]+\\](?:\\/\\S+)?|\\[[a-z-]+:[^\\]]+\\])`), message: "[designtools:arbitrary] $1" }
264
+ ];
265
+ }
266
+ function familiesFromTokens(tokens) {
267
+ const out = /* @__PURE__ */ new Set();
268
+ for (const t of tokens) {
269
+ const m = /^--color-([a-z][a-z0-9-]*?)-(\d+)$/.exec(t.name);
270
+ if (m) out.add(m[1]);
271
+ }
272
+ return [...out].sort();
273
+ }
274
+ async function checkDiscipline(root, files, families) {
275
+ const values = /* @__PURE__ */ new Map();
276
+ const failed = [];
277
+ if (!files.length) return { values, failed };
278
+ const [{ ESLint }, parser, plugin] = await Promise.all([
279
+ import("eslint"),
280
+ import("@typescript-eslint/parser"),
281
+ import("eslint-plugin-better-tailwindcss")
282
+ ]);
283
+ const eslint = new ESLint({
284
+ cwd: root,
285
+ overrideConfigFile: true,
286
+ // the project's own eslint-disable comments name rules that are not loaded here
287
+ allowInlineConfig: false,
288
+ overrideConfig: [
289
+ {
290
+ files: ["**/*.{tsx,jsx,ts,js,mjs,cjs}"],
291
+ languageOptions: {
292
+ parser: parser.default ?? parser,
293
+ parserOptions: { ecmaFeatures: { jsx: true } }
294
+ },
295
+ linterOptions: { reportUnusedDisableDirectives: "off" },
296
+ plugins: { "better-tailwindcss": plugin.default ?? plugin },
297
+ settings: { "better-tailwindcss": { cwd: tailwindCwd(root) } },
298
+ rules: { "better-tailwindcss/no-restricted-classes": ["warn", { restrict: restrictionsFor([...TAILWIND_FAMILIES, ...families]) }] }
299
+ }
300
+ ]
301
+ });
302
+ const results = await eslint.lintFiles(files.map((f) => join(root, f)));
303
+ for (const result of results) {
304
+ const file = relative2(root, result.filePath).split(sep).join("/");
305
+ const found = [];
306
+ for (const m of result.messages) {
307
+ if (m.fatal) {
308
+ failed.push({ file, reason: m.message });
309
+ continue;
310
+ }
311
+ const hit = /\[designtools:(palette|arbitrary|status-text)\] (\S+)/.exec(m.message);
312
+ if (hit) found.push({ class: hit[2], kind: hit[1], line: m.line, column: m.column });
313
+ }
314
+ if (found.length) values.set(file, found.sort((a, b) => a.line - b.line || a.column - b.column || cmp(a.class, b.class)));
315
+ }
316
+ return { values, failed };
317
+ }
318
+ function tailwindCwd(root) {
319
+ try {
320
+ createRequire(join(root, "package.json")).resolve("tailwindcss/package.json");
321
+ return root;
322
+ } catch {
323
+ let dir = dirname2(fileURLToPath(import.meta.url));
324
+ while (!existsSync2(join(dir, "package.json")) && dirname2(dir) !== dir) dir = dirname2(dir);
325
+ return dir;
326
+ }
327
+ }
328
+
329
+ // src/files.ts
330
+ import { existsSync as existsSync3, readdirSync, statSync } from "fs";
331
+ import { join as join2, posix } from "path";
332
+ import picomatch from "picomatch";
333
+ var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules"]);
334
+ function listProductFiles(root, includeGlobs, excludeGlobs) {
335
+ const include = includeGlobs.map(nextSegments);
336
+ const exclude = excludeGlobs.map(nextSegments);
337
+ const isIncluded = picomatch(include, { dot: false });
338
+ const isExcluded = exclude.length ? picomatch(exclude, { dot: true }) : () => false;
339
+ const bases = new Set(include.map((g) => picomatch.scan(g).base.replace(/\\/g, "").replace(/^\.\//, "")));
340
+ const found = /* @__PURE__ */ new Set();
341
+ const walk = (rel) => {
342
+ const abs = rel ? join2(root, rel) : root;
343
+ let entries;
344
+ try {
345
+ entries = readdirSync(abs, { withFileTypes: true });
346
+ } catch {
347
+ return;
348
+ }
349
+ for (const entry of entries) {
350
+ const path = rel ? posix.join(rel, entry.name) : entry.name;
351
+ if (entry.isDirectory()) {
352
+ if (SKIP_DIRS.has(entry.name) || entry.name.startsWith(".")) continue;
353
+ if (isExcluded(`${path}/`) || isExcluded(`${path}/x`)) continue;
354
+ walk(path);
355
+ } else if (entry.isFile() && isIncluded(path) && !isExcluded(path)) {
356
+ found.add(path);
357
+ }
358
+ }
359
+ };
360
+ for (const base of [...bases].sort()) {
361
+ if (!base) {
362
+ walk("");
363
+ continue;
364
+ }
365
+ const abs = join2(root, base);
366
+ if (!existsSync3(abs)) continue;
367
+ if (statSync(abs).isFile()) {
368
+ if (isIncluded(base) && !isExcluded(base)) found.add(base);
369
+ } else {
370
+ walk(base);
371
+ }
372
+ }
373
+ return [...found].sort();
374
+ }
375
+ function nextSegments(glob) {
376
+ return glob.replace(/(^|\/)\(([^()/|]+)\)(?=\/|$)/g, "$1\\($2\\)").replace(/(^|\/)(\[\[?(?:\.\.\.)?[\w-]+\]?\])(?=\/|$)/g, (_, lead, seg) => lead + seg.replace(/[[\]]/g, "\\$&"));
377
+ }
378
+
379
+ // src/overrides.ts
380
+ var KEYWORD_COLOURS = ["black", "white", "transparent", "current", "currentColor", "inherit"];
381
+ var SPACING = /^(?:p|px|py|pt|pr|pb|pl|ps|pe|m|mx|my|mt|mr|mb|ml|ms|me|gap|gap-x|gap-y|space-x|space-y)-./;
382
+ var RADIUS = /^rounded(?:$|-)/;
383
+ var BRACKET_COLOUR = /^\[(?:#|rgba?\(|hsla?\(|oklch\(|oklab\(|lab\(|lch\(|hwb\(|color\(|color-mix\(|var\(--color-)|^\(--color-/;
384
+ function colourContext(tokens, families) {
385
+ const colours = new Set(KEYWORD_COLOURS);
386
+ for (const t of tokens) {
387
+ const m = /^--color-(.+)$/.exec(t.name);
388
+ if (m) colours.add(m[1]);
389
+ }
390
+ return { colours, families: [.../* @__PURE__ */ new Set([...TAILWIND_FAMILIES, ...families])] };
391
+ }
392
+ function findOverrides(uses, ctx) {
393
+ const out = [];
394
+ for (const use of uses) {
395
+ const value = use.props.className ?? use.props.class;
396
+ if (typeof value !== "string" || value.startsWith("(")) continue;
397
+ const classes = value.split(/\s+/).filter(Boolean).flatMap((c) => {
398
+ const kind = overrideKind(c, ctx);
399
+ return kind ? [{ class: c, kind }] : [];
400
+ });
401
+ if (classes.length) out.push({ component: use.component, classes, line: use.line, column: use.column });
402
+ }
403
+ return out;
404
+ }
405
+ function overrideKind(cls, ctx) {
406
+ const utility = stripVariants(cls).replace(/^!/, "").replace(/!$/, "").replace(/^-/, "");
407
+ if (RADIUS.test(utility)) return "radius";
408
+ if (SPACING.test(utility) && !/-auto$/.test(utility)) return "spacing";
409
+ for (const prefix of [...COLOUR_UTILITIES].sort((a, b) => b.length - a.length)) {
410
+ if (!utility.startsWith(`${prefix}-`)) continue;
411
+ const value = utility.slice(prefix.length + 1).replace(/\/[^/]+$/, "");
412
+ if (isColour(value, ctx)) return "colour";
413
+ break;
414
+ }
415
+ return void 0;
416
+ }
417
+ function isColour(value, ctx) {
418
+ if (ctx.colours.has(value)) return true;
419
+ if (BRACKET_COLOUR.test(value)) return true;
420
+ const m = /^(.+)-(\d+)$/.exec(value);
421
+ return !!m && ctx.families.includes(m[1]);
422
+ }
423
+ function stripVariants(cls) {
424
+ let depth = 0;
425
+ let last = -1;
426
+ for (let i = 0; i < cls.length; i++) {
427
+ const c = cls[i];
428
+ if (c === "[" || c === "(") depth++;
429
+ else if (c === "]" || c === ")") depth--;
430
+ else if (c === ":" && depth === 0) last = i;
431
+ }
432
+ return cls.slice(last + 1);
433
+ }
434
+
435
+ // src/resolve.ts
436
+ import { existsSync as existsSync4, readFileSync as readFileSync2, statSync as statSync2 } from "fs";
437
+ import { dirname as dirname3, posix as posix2, resolve as resolve2 } from "path";
438
+ var EXTENSIONS = [".tsx", ".ts", ".jsx", ".js", ".mjs"];
439
+ function createResolver(root, tsconfig) {
440
+ const paths = readPaths(root, tsconfig);
441
+ const cache = /* @__PURE__ */ new Map();
442
+ return (moduleName, fromFile) => {
443
+ const relativeImport = moduleName.startsWith(".");
444
+ const key = relativeImport ? `${posix2.dirname(fromFile)}\0${moduleName}` : moduleName;
445
+ if (cache.has(key)) return cache.get(key);
446
+ let candidates = [];
447
+ if (relativeImport) {
448
+ candidates = [resolve2(root, posix2.dirname(fromFile), moduleName)];
449
+ } else if (moduleName.startsWith("/")) {
450
+ candidates = [moduleName];
451
+ } else if (paths) {
452
+ candidates = aliasTargets(paths, moduleName);
453
+ }
454
+ let found;
455
+ for (const candidate of candidates) {
456
+ const file = probe(candidate);
457
+ if (file) {
458
+ found = toRelative(root, file);
459
+ break;
460
+ }
461
+ }
462
+ cache.set(key, found);
463
+ return found;
464
+ };
465
+ }
466
+ function aliasTargets(paths, moduleName) {
467
+ const matches = paths.entries.filter(
468
+ (e) => e.wildcard ? moduleName.startsWith(e.prefix) && moduleName.endsWith(e.suffix) && moduleName.length >= e.prefix.length + e.suffix.length : moduleName === e.prefix
469
+ ).sort((a, b) => b.prefix.length - a.prefix.length);
470
+ const out = [];
471
+ for (const entry of matches) {
472
+ const star = entry.wildcard ? moduleName.slice(entry.prefix.length, moduleName.length - entry.suffix.length) : "";
473
+ for (const target of entry.targets) out.push(resolve2(paths.base, target.replace("*", star)));
474
+ }
475
+ return out;
476
+ }
477
+ function probe(path) {
478
+ if (isFile(path)) return path;
479
+ for (const ext of EXTENSIONS) if (isFile(path + ext)) return path + ext;
480
+ for (const ext of EXTENSIONS) if (isFile(`${path}/index${ext}`)) return `${path}/index${ext}`;
481
+ return void 0;
482
+ }
483
+ function isFile(path) {
484
+ try {
485
+ return statSync2(path).isFile();
486
+ } catch {
487
+ return false;
488
+ }
489
+ }
490
+ function readPaths(root, tsconfig) {
491
+ const start = resolve2(root, tsconfig ?? "tsconfig.json");
492
+ const seen = /* @__PURE__ */ new Set();
493
+ let file = start;
494
+ while (file && existsSync4(file) && !seen.has(file)) {
495
+ seen.add(file);
496
+ let json;
497
+ try {
498
+ json = JSON.parse(stripJsonComments(readFileSync2(file, "utf8")));
499
+ } catch {
500
+ return void 0;
501
+ }
502
+ const options = json.compilerOptions ?? {};
503
+ if (options.paths) {
504
+ const base = resolve2(dirname3(file), options.baseUrl ?? ".");
505
+ return {
506
+ base,
507
+ entries: Object.entries(options.paths).map(([pattern, targets]) => {
508
+ const star = pattern.indexOf("*");
509
+ return {
510
+ prefix: star === -1 ? pattern : pattern.slice(0, star),
511
+ suffix: star === -1 ? "" : pattern.slice(star + 1),
512
+ wildcard: star !== -1,
513
+ targets
514
+ };
515
+ })
516
+ };
517
+ }
518
+ const ext = Array.isArray(json.extends) ? json.extends[0] : json.extends;
519
+ file = ext && ext.startsWith(".") ? resolve2(dirname3(file), ext.endsWith(".json") ? ext : `${ext}.json`) : void 0;
520
+ }
521
+ return void 0;
522
+ }
523
+ function stripJsonComments(text) {
524
+ let out = "";
525
+ let i = 0;
526
+ while (i < text.length) {
527
+ const c = text[i];
528
+ if (c === '"') {
529
+ let j = i + 1;
530
+ while (j < text.length && text[j] !== '"') j += text[j] === "\\" ? 2 : 1;
531
+ out += text.slice(i, j + 1);
532
+ i = j + 1;
533
+ } else if (c === "/" && text[i + 1] === "/") {
534
+ while (i < text.length && text[i] !== "\n") i++;
535
+ } else if (c === "/" && text[i + 1] === "*") {
536
+ const end = text.indexOf("*/", i + 2);
537
+ i = end === -1 ? text.length : end + 2;
538
+ } else {
539
+ out += c;
540
+ i++;
541
+ }
542
+ }
543
+ return out.replace(/,(\s*[}\]])/g, "$1");
544
+ }
545
+
546
+ // src/routes.ts
547
+ import { posix as posix3 } from "path";
548
+ var PAGE = /^page\.(tsx|jsx|ts|js|mdx)$/;
549
+ var APP_DIRS = ["app", "src/app"];
550
+ function mapRoutes(files) {
551
+ const pages = /* @__PURE__ */ new Map();
552
+ for (const file of files) {
553
+ const appDir = APP_DIRS.find((d) => file.startsWith(`${d}/`));
554
+ if (!appDir || !PAGE.test(posix3.basename(file))) continue;
555
+ pages.set(file, routeOf(posix3.dirname(file).slice(appDir.length)));
556
+ }
557
+ const pageIn = /* @__PURE__ */ new Map();
558
+ const leadsToPage = /* @__PURE__ */ new Set();
559
+ for (const page of pages.keys()) {
560
+ let dir = posix3.dirname(page);
561
+ pageIn.set(dir, page);
562
+ while (dir !== "." && !leadsToPage.has(dir)) {
563
+ leadsToPage.add(dir);
564
+ dir = posix3.dirname(dir);
565
+ }
566
+ }
567
+ const owner = /* @__PURE__ */ new Map();
568
+ for (const file of files) {
569
+ if (!APP_DIRS.some((d) => file.startsWith(`${d}/`))) continue;
570
+ let dir = posix3.dirname(file);
571
+ while (dir !== ".") {
572
+ const page = pageIn.get(dir);
573
+ if (page) {
574
+ owner.set(file, page);
575
+ break;
576
+ }
577
+ if (leadsToPage.has(dir)) break;
578
+ dir = posix3.dirname(dir);
579
+ }
580
+ }
581
+ return { pages, owner };
582
+ }
583
+ function routeOf(dir) {
584
+ const segments = dir.split("/").filter((s) => s && !/^\(.*\)$/.test(s) && !s.startsWith("@")).map((s) => s.replace(/^(?:\(\.{1,3}\))+/, ""));
585
+ return `/${segments.join("/")}`;
586
+ }
587
+
588
+ // src/scanner.ts
589
+ import { createRequire as createRequire2 } from "module";
590
+ import { posix as posix4, relative as relative3, sep as sep2 } from "path";
591
+ var require2 = createRequire2(import.meta.url);
592
+ async function scanJsx(root, files) {
593
+ const wanted = new Set(files);
594
+ if (!files.length) return { report: {}, instances: [], failed: [] };
595
+ const dirs = /* @__PURE__ */ new Set();
596
+ for (const f of files) {
597
+ let d = posix4.dirname(f);
598
+ while (d !== "." && !dirs.has(d)) {
599
+ dirs.add(d);
600
+ d = posix4.dirname(d);
601
+ }
602
+ }
603
+ const rel = (abs) => relative3(root, abs).split(sep2).join("/");
604
+ const scanner = require2("react-scanner");
605
+ let raw = {};
606
+ const failed = [];
607
+ const log = console.log;
608
+ const error = console.error;
609
+ console.log = () => {
610
+ };
611
+ console.error = (...args) => {
612
+ const m = /^Failed to parse: (.*)$/.exec(String(args[0]));
613
+ if (m) failed.push(rel(m[1]));
614
+ };
615
+ try {
616
+ await scanner.run(
617
+ {
618
+ crawlFrom: root,
619
+ rootDir: root,
620
+ globs: ["**/*.@(tsx|jsx|ts|js|mjs)"],
621
+ exclude: (_name, dirPath) => {
622
+ const d = rel(dirPath.replace(/[\\/]+$/, ""));
623
+ return d !== "" && !dirs.has(d);
624
+ },
625
+ includeSubComponents: true,
626
+ getPropValue: ({ node, propName, defaultGetPropValue }) => (
627
+ // a bare attribute, <Checkbox checked>, is true; react-scanner calls it null
628
+ node === null ? true : propName === "className" || propName === "class" ? classValue(node, defaultGetPropValue) : safe(defaultGetPropValue, node)
629
+ ),
630
+ processors: [
631
+ ({ report: report2 }) => {
632
+ raw = report2;
633
+ }
634
+ ]
635
+ },
636
+ root,
637
+ "programmatic"
638
+ );
639
+ } catch (e) {
640
+ if (!/No files found to scan/.test(e.message)) throw e;
641
+ } finally {
642
+ console.log = log;
643
+ console.error = error;
644
+ }
645
+ const report = narrow(raw, (abs) => {
646
+ const f = rel(abs);
647
+ return wanted.has(f) ? f : void 0;
648
+ });
649
+ return { report, instances: flatten(report), failed: failed.filter((f) => wanted.has(f)).sort() };
650
+ }
651
+ function narrow(report, keep) {
652
+ const out = {};
653
+ for (const name of Object.keys(report).sort()) {
654
+ const entry = report[name];
655
+ const instances = (entry.instances ?? []).flatMap((i) => {
656
+ const file = keep(i.location.file);
657
+ return file ? [{ ...i, location: { ...i.location, file } }] : [];
658
+ });
659
+ const components = entry.components ? narrow(entry.components, keep) : void 0;
660
+ const next = {};
661
+ if (instances.length) next.instances = instances;
662
+ if (components && Object.keys(components).length) next.components = components;
663
+ if (next.instances || next.components) out[name] = next;
664
+ }
665
+ return out;
666
+ }
667
+ function flatten(report, prefix = "") {
668
+ const out = [];
669
+ for (const [key, entry] of Object.entries(report)) {
670
+ const name = prefix + key;
671
+ for (const i of entry.instances ?? []) {
672
+ out.push({
673
+ name,
674
+ file: i.location.file,
675
+ line: i.location.start.line,
676
+ column: i.location.start.column + 1,
677
+ importInfo: i.importInfo,
678
+ props: i.props,
679
+ spread: i.propsSpread
680
+ });
681
+ }
682
+ if (entry.components) out.push(...flatten(entry.components, `${name}.`));
683
+ }
684
+ return out.sort((a, b) => cmp(a.file, b.file) || a.line - b.line || a.column - b.column || cmp(a.name, b.name));
685
+ }
686
+ function safe(get, node) {
687
+ try {
688
+ return get(node);
689
+ } catch {
690
+ return node ? `(${node.type})` : null;
691
+ }
692
+ }
693
+ function classValue(node, get) {
694
+ if (!node || node.type === "Literal") return safe(get, node);
695
+ const strings = [];
696
+ collectStrings(node, strings);
697
+ const text = strings.join(" ").split(/\s+/).filter(Boolean).join(" ");
698
+ return text || safe(get, node);
699
+ }
700
+ function collectStrings(node, out) {
701
+ if (!node || typeof node !== "object") return;
702
+ const n = node;
703
+ switch (n.type) {
704
+ case "Literal":
705
+ if (typeof n.value === "string") out.push(n.value);
706
+ return;
707
+ case "TemplateLiteral":
708
+ for (const q of n.quasis) out.push(q.value.cooked ?? "");
709
+ for (const e of n.expressions) collectStrings(e, out);
710
+ return;
711
+ case "Property":
712
+ if (n.key.type === "Literal") collectStrings(n.key, out);
713
+ else if (n.key.type === "Identifier" && !n.computed) out.push(n.key.name);
714
+ return;
715
+ case "ConditionalExpression":
716
+ collectStrings(n.consequent, out);
717
+ collectStrings(n.alternate, out);
718
+ return;
719
+ case "BinaryExpression":
720
+ if (n.operator === "+") {
721
+ collectStrings(n.left, out);
722
+ collectStrings(n.right, out);
723
+ }
724
+ return;
725
+ case "MemberExpression":
726
+ case "Identifier":
727
+ case "ThisExpression":
728
+ return;
729
+ default:
730
+ for (const [key, value] of Object.entries(n)) {
731
+ if (key === "loc" || key === "range" || key === "parent" || key === "callee") continue;
732
+ if (Array.isArray(value)) for (const v of value) collectStrings(v, out);
733
+ else if (value && typeof value === "object") collectStrings(value, out);
734
+ }
735
+ }
736
+ }
737
+
738
+ // src/types.ts
739
+ var ADHERENCE_SCHEMA = "designtools.adherence/1";
740
+
741
+ // src/usage.ts
742
+ function parseSelector(text) {
743
+ const m = /^([a-z][a-z0-9-]*)((?:\[[^\]]+\])*)$/.exec(text.trim());
744
+ if (!m) return void 0;
745
+ const attrs = [...m[2].matchAll(/\[([^\]=]+)(?:=["']?([^"'\]]*)["']?)?\]/g)].map((a) => ({
746
+ name: a[1].trim(),
747
+ ...a[2] !== void 0 && { value: a[2] }
748
+ }));
749
+ return { text: text.trim(), tag: m[1], attrs };
750
+ }
751
+ function classify(instances, manifest, resolve4) {
752
+ const system = manifest.system.replace(/\/+$/, "");
753
+ const bySource = /* @__PURE__ */ new Map();
754
+ const byExport = /* @__PURE__ */ new Map();
755
+ for (const c of [...manifest.components].sort((a, b) => cmp(a.source, b.source) || cmp(a.name, b.name))) {
756
+ if (!bySource.has(c.source)) bySource.set(c.source, /* @__PURE__ */ new Map());
757
+ bySource.get(c.source).set(c.export, c);
758
+ if (!byExport.has(c.export)) byExport.set(c.export, c);
759
+ }
760
+ const replaceable = /* @__PURE__ */ new Map();
761
+ for (const c of manifest.components) {
762
+ for (const r of c.replaces ?? []) {
763
+ const selector = parseSelector(r);
764
+ if (!selector) continue;
765
+ const entry = replaceable.get(selector.text) ?? { selector, components: [] };
766
+ if (!entry.components.includes(c.name)) entry.components.push(c.name);
767
+ entry.components.sort();
768
+ replaceable.set(selector.text, entry);
769
+ }
770
+ }
771
+ const selectors = [...replaceable.values()].sort(
772
+ (a, b) => b.selector.attrs.length - a.selector.attrs.length || cmp(a.selector.text, b.selector.text)
773
+ );
774
+ const uses = /* @__PURE__ */ new Map();
775
+ const raw = /* @__PURE__ */ new Map();
776
+ const push = (map, file, item) => {
777
+ if (!map.has(file)) map.set(file, []);
778
+ map.get(file).push(item);
779
+ };
780
+ for (const i of instances) {
781
+ const [first, ...rest] = i.name.split(".");
782
+ if (!i.importInfo) {
783
+ if (rest.length || !/^[a-z]/.test(first)) continue;
784
+ const matched = selectors.filter((s) => s.selector.tag === first && s.selector.attrs.every((a) => attrMatches(i.props, a)));
785
+ if (!matched.length) continue;
786
+ const best = matched[0];
787
+ const replacedBy = [...new Set(matched.flatMap((s) => s.components))];
788
+ push(raw, i.file, { element: first, selector: best.selector.text, replacedBy, line: i.line, column: i.column });
789
+ continue;
790
+ }
791
+ const file = resolve4(i.importInfo.moduleName, i.file);
792
+ if (!file) continue;
793
+ const inSource = bySource.get(file);
794
+ if (!inSource && !(file === system || file.startsWith(`${system}/`))) continue;
795
+ const namespace = i.importInfo.importType === "ImportNamespaceSpecifier";
796
+ const exportName = namespace ? rest[0] : i.importInfo.importType === "ImportDefaultSpecifier" ? i.importInfo.local : i.importInfo.imported ?? first;
797
+ if (!exportName) continue;
798
+ let entry = inSource?.get(exportName) ?? (inSource ? void 0 : byExport.get(exportName));
799
+ if (!entry && i.importInfo.importType === "ImportDefaultSpecifier" && inSource?.size === 1) entry = [...inSource.values()][0];
800
+ if (!entry) continue;
801
+ push(uses, i.file, {
802
+ component: entry.name,
803
+ ...i.name !== entry.name && { as: i.name },
804
+ line: i.line,
805
+ column: i.column,
806
+ props: sortProps(i.props),
807
+ spread: i.spread,
808
+ entry
809
+ });
810
+ }
811
+ return { uses, raw, replaceable };
812
+ }
813
+ function attrMatches(props, attr) {
814
+ if (!(attr.name in props)) return false;
815
+ if (attr.value === void 0) return true;
816
+ return String(props[attr.name]) === attr.value;
817
+ }
818
+ function sortProps(props) {
819
+ const out = {};
820
+ for (const key of Object.keys(props).sort()) {
821
+ const v = props[key];
822
+ out[key] = v === null || typeof v === "string" || typeof v === "number" || typeof v === "boolean" ? v : String(v);
823
+ }
824
+ return out;
825
+ }
826
+
827
+ // src/report.ts
828
+ import { readFileSync as readFileSync3 } from "fs";
829
+ import { dirname as dirname4, join as join3, resolve as resolve3 } from "path";
830
+ import { fileURLToPath as fileURLToPath2 } from "url";
831
+ var GENERATOR_NAME = "@designtools/adherence";
832
+ var VERSION = (() => {
833
+ try {
834
+ const here = dirname4(fileURLToPath2(import.meta.url));
835
+ return JSON.parse(readFileSync3(join3(here, "../package.json"), "utf8")).version;
836
+ } catch {
837
+ return "0.0.0";
838
+ }
839
+ })();
840
+ var GENERATOR = `${GENERATOR_NAME}@${VERSION}`;
841
+ async function buildReport(config) {
842
+ const t0 = performance.now();
843
+ const manifest = readJson(config.root, config.manifest);
844
+ if (!manifest || !Array.isArray(manifest.components) || typeof manifest.system !== "string") {
845
+ throw new UsageError(`${config.manifest} is not a components.json written by designtools-manifest`);
846
+ }
847
+ const tokens = config.tokens ? readJson(config.root, config.tokens)?.tokens ?? [] : [];
848
+ const system = manifest.system.replace(/\/+$/, "");
849
+ const exclude = [...config.exclude, `${system}/**`];
850
+ const files = listProductFiles(config.root, config.include, exclude);
851
+ const t1 = performance.now();
852
+ const scan = await scanJsx(config.root, files);
853
+ const classified = classify(scan.instances, manifest, createResolver(config.root, config.tsconfig));
854
+ const families = familiesFromTokens(tokens);
855
+ const colours = colourContext(tokens, families);
856
+ const t2 = performance.now();
857
+ const discipline = await checkDiscipline(config.root, files, families);
858
+ const t3 = performance.now();
859
+ const routes = mapRoutes(files);
860
+ const fileEntries = [];
861
+ for (const file of files) {
862
+ const uses = (classified.uses.get(file) ?? []).map(({ entry: _entry, ...use }) => use);
863
+ const raw = classified.raw.get(file) ?? [];
864
+ const offSystem = discipline.values.get(file) ?? [];
865
+ const overrides = findOverrides(uses, colours);
866
+ if (!uses.length && !raw.length && !offSystem.length) continue;
867
+ const owner = routes.owner.get(file);
868
+ fileEntries.push({
869
+ file,
870
+ ...owner && { route: routes.pages.get(owner) },
871
+ counts: counts(uses.length, raw.length, offSystem.length, overrides.length),
872
+ uses,
873
+ raw,
874
+ offSystem,
875
+ overrides
876
+ });
877
+ }
878
+ const byFile = new Map(fileEntries.map((f) => [f.file, f]));
879
+ const routeEntries = [...routes.pages.entries()].map(([page, route]) => {
880
+ const owned = files.filter((f) => routes.owner.get(f) === page);
881
+ return { route, page, files: owned, counts: sum(owned.map((f) => byFile.get(f)?.counts)) };
882
+ }).sort((a, b) => cmp(a.route, b.route) || cmp(a.page, b.page));
883
+ const components = componentUsage(manifest.components, classified.uses);
884
+ const elements = [...classified.replaceable.values()].map(({ selector, components: components2 }) => {
885
+ const hits = fileEntries.flatMap((f) => f.raw.filter((r) => r.selector === selector.text).map(() => f.file));
886
+ return { selector: selector.text, uses: hits.length, files: new Set(hits).size, replacedBy: components2 };
887
+ }).sort((a, b) => cmp(a.selector, b.selector));
888
+ const skipped = /* @__PURE__ */ new Map();
889
+ for (const file of scan.failed) skipped.set(file, { file, reason: "react-scanner could not parse it" });
890
+ for (const f of discipline.failed) if (!skipped.has(f.file)) skipped.set(f.file, { file: f.file, reason: f.reason });
891
+ const total = sum(fileEntries.map((f) => f.counts));
892
+ const all = fileEntries.flatMap((f) => f.offSystem);
893
+ const overrideClasses = fileEntries.flatMap((f) => f.overrides.flatMap((o) => o.classes));
894
+ const report = {
895
+ generator: GENERATOR,
896
+ schema: ADHERENCE_SCHEMA,
897
+ manifest: config.manifest,
898
+ system,
899
+ include: config.include,
900
+ exclude,
901
+ summary: {
902
+ ...total,
903
+ files: files.length,
904
+ filesWithFindings: fileEntries.filter((f) => f.raw.length || f.offSystem.length || f.overrides.length).length,
905
+ offSystem: {
906
+ palette: all.filter((v) => v.kind === "palette").length,
907
+ arbitrary: all.filter((v) => v.kind === "arbitrary").length,
908
+ "status-text": all.filter((v) => v.kind === "status-text").length
909
+ },
910
+ overrideKinds: {
911
+ colour: overrideClasses.filter((c) => c.kind === "colour").length,
912
+ spacing: overrideClasses.filter((c) => c.kind === "spacing").length,
913
+ radius: overrideClasses.filter((c) => c.kind === "radius").length
914
+ },
915
+ componentsUsed: components.filter((c) => c.uses > 0).length,
916
+ componentsUnused: components.filter((c) => c.uses === 0).map((c) => c.name),
917
+ replaceable: elements.map((e) => e.selector)
918
+ },
919
+ routes: routeEntries,
920
+ files: fileEntries,
921
+ components,
922
+ elements,
923
+ skipped: [...skipped.values()].sort((a, b) => cmp(a.file, b.file))
924
+ };
925
+ const t4 = performance.now();
926
+ return {
927
+ report,
928
+ timings: { files: t1 - t0, components: t2 - t1, discipline: t3 - t2, total: t4 - t0 }
929
+ };
930
+ }
931
+ function counts(systemUses, rawElements, offSystemValues, overrides) {
932
+ const denominator = systemUses + rawElements;
933
+ return {
934
+ systemUses,
935
+ rawElements,
936
+ share: denominator ? Math.round(systemUses / denominator * 1e4) / 1e4 : null,
937
+ offSystemValues,
938
+ overrides
939
+ };
940
+ }
941
+ function sum(list) {
942
+ let u = 0, r = 0, o = 0, v = 0;
943
+ for (const c of list) {
944
+ if (!c) continue;
945
+ u += c.systemUses;
946
+ r += c.rawElements;
947
+ v += c.offSystemValues;
948
+ o += c.overrides;
949
+ }
950
+ return counts(u, r, v, o);
951
+ }
952
+ function componentUsage(manifest, uses) {
953
+ const sorted = [...manifest].sort((a, b) => cmp(a.name, b.name) || cmp(a.source, b.source));
954
+ return sorted.map((c) => {
955
+ let count = 0;
956
+ const files = /* @__PURE__ */ new Set();
957
+ const props = {};
958
+ const axes = c.variants?.axes ?? [];
959
+ const variants = {};
960
+ for (const a of axes) variants[a.name] = {};
961
+ for (const [file, list] of uses) {
962
+ for (const use of list) {
963
+ if (use.entry !== c) continue;
964
+ count++;
965
+ files.add(file);
966
+ for (const p of Object.keys(use.props)) props[p] = (props[p] ?? 0) + 1;
967
+ for (const a of axes) {
968
+ const has = a.name in use.props;
969
+ const v = use.props[a.name];
970
+ const key = !has ? a.default ?? "(unset)" : v === null ? "true" : typeof v === "string" && !v.startsWith("(") ? v : typeof v === "boolean" || typeof v === "number" ? String(v) : "(dynamic)";
971
+ variants[a.name][key] = (variants[a.name][key] ?? 0) + 1;
972
+ }
973
+ }
974
+ }
975
+ return {
976
+ name: c.name,
977
+ source: c.source,
978
+ uses: count,
979
+ files: files.size,
980
+ props: sortKeys(props),
981
+ ...axes.length && { variants: Object.fromEntries(axes.map((a) => [a.name, sortKeys(variants[a.name])])) }
982
+ };
983
+ });
984
+ }
985
+ function sortKeys(o) {
986
+ return Object.fromEntries(Object.keys(o).sort().map((k) => [k, o[k]]));
987
+ }
988
+ function readJson(root, path) {
989
+ try {
990
+ return JSON.parse(readFileSync3(resolve3(root, path), "utf8"));
991
+ } catch {
992
+ return void 0;
993
+ }
994
+ }
995
+ function serialise(report) {
996
+ return `${JSON.stringify(report, null, 2)}
997
+ `;
998
+ }
999
+
1000
+ export {
1001
+ CONFIG_FILE,
1002
+ UsageError,
1003
+ DEFAULT_INCLUDE,
1004
+ DEFAULT_EXCLUDE,
1005
+ loadConfig,
1006
+ formatMarkdown,
1007
+ TAILWIND_FAMILIES,
1008
+ COLOUR_UTILITIES,
1009
+ restrictionsFor,
1010
+ familiesFromTokens,
1011
+ checkDiscipline,
1012
+ listProductFiles,
1013
+ colourContext,
1014
+ findOverrides,
1015
+ overrideKind,
1016
+ createResolver,
1017
+ mapRoutes,
1018
+ routeOf,
1019
+ scanJsx,
1020
+ flatten,
1021
+ ADHERENCE_SCHEMA,
1022
+ parseSelector,
1023
+ classify,
1024
+ GENERATOR_NAME,
1025
+ VERSION,
1026
+ GENERATOR,
1027
+ buildReport,
1028
+ counts,
1029
+ serialise
1030
+ };