adsa-cli 0.1.4 → 0.1.5

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/README.md CHANGED
@@ -49,7 +49,7 @@ the version checked out in front of them instead of reading files and guessing.
49
49
 
50
50
  | Score | System | Repository | Package audited |
51
51
  | :-- | :-- | :-- | :-- |
52
- | 33/45 | Astryx · Meta | `facebook/astryx` | `@astryxdesign/core` |
52
+ | 35/45 | Astryx · Meta | `facebook/astryx` | `@astryxdesign/core` |
53
53
  | 29/45 | Chakra UI | `chakra-ui/chakra-ui` | `@chakra-ui/react` |
54
54
  | 25/45 | React Spectrum · Adobe | `adobe/react-spectrum` | `@react-spectrum/s2` |
55
55
  | 25/45 | shadcn/ui | `shadcn-ui/ui` | `v4` |
@@ -60,12 +60,12 @@ the version checked out in front of them instead of reading files and guessing.
60
60
  | 19/45 | Base UI · MUI | `mui/base-ui` | `@base-ui/react` |
61
61
  | 19/45 | Carbon · IBM | `carbon-design-system/carbon` | `@carbon/react` |
62
62
  | 17/45 | Primer · GitHub | `primer/react` | `@primer/react` |
63
+ | 15/45 | Untitled UI React | `untitleduico/react` | `@untitledui/react` |
63
64
  | 13/45 | Radix Primitives · WorkOS | `radix-ui/primitives` | `radix-ui` |
64
- | 13/45 | Untitled UI React | `untitleduico/react` | `@untitledui/react` |
65
65
 
66
66
  Two things follow, and both are the point of publishing this table.
67
67
 
68
- **45 is nobody's score.** The best-documented public design system reaches 33. The
68
+ **45 is nobody's score.** The best-documented public design system reaches 35. The
69
69
  bands are anchored to that measured field rather than to a wish: **31+ agent-ready**,
70
70
  **24–30 good foundation**, **15–23 gaps to address**, **below 15 not ready**. A system
71
71
  in the low twenties is in the middle of the field, not failing.
@@ -77,6 +77,38 @@ on an excellent website scores low here and may well be the better system for pe
77
77
  Every row is reproducible: clone the repository and run `npx adsa-cli audit <clone>`.
78
78
  `npx adsa-cli reference` prints this table from the tool.
79
79
 
80
+ ## Systems that are not component libraries
81
+
82
+ Plenty of design systems are not a package of React components with a guide per
83
+ component, and that is a decision rather than an omission — a stylesheet and a class
84
+ vocabulary survives contact with engineers on four different stacks, which is why
85
+ small teams keep choosing it.
86
+
87
+ **A stylesheet is read as a surface.** When nothing is importable, the class
88
+ vocabulary is the API: the classes in the shipped stylesheet are counted as the
89
+ components, elements and modifiers fold into the block they belong to
90
+ (`card__title`, `card-footer` → `card`), utilities and state classes are left out, and
91
+ a class counts as documented when an example applies it — `.dialog` in prose, or
92
+ `class="dialog"` in an HTML block.
93
+
94
+ **Examples are read in the language they are written in.** HTML, CSS, Vue, Svelte and
95
+ Astro blocks count, alongside TypeScript, Swift and Kotlin.
96
+
97
+ **Tokens are read from the stylesheet, not from a heading.** Custom properties
98
+ declared at the root — including inside Tailwind's `@theme` — are named decisions in
99
+ the format the browser itself reads, exactly as an `.xcassets` catalog or an Android
100
+ `colors.xml` already was. A page documenting them counts whether or not its title
101
+ contains the word "token", and the hex inside `--brand: #0af` is a definition, not a
102
+ raw value to be penalised.
103
+
104
+ **A dimension that cannot apply is dropped from the maximum.** A repository with no
105
+ importable surface and no component source files at all is not asked whether every
106
+ component has a guide: `docs-coverage` and `docs-freshness` are skipped and the score
107
+ is out of 35, and the comparison against the field is recomputed on the same seven
108
+ dimensions. This only happens when the shape is unambiguous — a repository full of
109
+ `.tsx` files where the scan found no exports is this tool failing, and it keeps the
110
+ low score and says so on the `scanned:` line.
111
+
80
112
  ## The nine dimensions
81
113
 
82
114
  | | Dimension | The question |
package/bin/adsa.mjs CHANGED
@@ -21,7 +21,7 @@ import { scan } from "../lib/scan.mjs";
21
21
  import { RUBRIC, score, scoreFile } from "../lib/score.mjs";
22
22
  import { buildTodo, html, markdown } from "../lib/report.mjs";
23
23
  import { band, badgeEndpoint, badgeMarkdown } from "../lib/badge.mjs";
24
- import { REFERENCE, standing } from "../lib/reference.mjs";
24
+ import { REFERENCE, scoredIds, standing } from "../lib/reference.mjs";
25
25
  import { exists, isDir } from "../lib/fsx.mjs";
26
26
  import { DIR, appendHistory, compare, readHistory, write } from "../lib/history.mjs";
27
27
  import { FIXES, applyFix } from "../lib/fix.mjs";
@@ -189,7 +189,11 @@ async function dispatch(command, args, flags, io, out, json) {
189
189
  */
190
190
  function scannedLine(facts) {
191
191
  const s = facts.scanned;
192
- const parts = [`guides ${s.guides.length ? s.guides.join(", ") : "none found"}`, `source ${s.source.length ? s.source.join(", ") : "none found"}`];
192
+ // A stylesheet system has no source directory and is not supposed to: naming the
193
+ // file the classes were read from is the honest version of "source none found".
194
+ const source = s.shape === "classes" ? `classes from ${s.stylesheets.join(", ")}` : `source ${s.source.length ? s.source.join(", ") : "none found"}`;
195
+ const parts = [`guides ${s.guides.length ? s.guides.join(", ") : "none found"}`, source];
196
+ if (s.shape === "docs-only") parts.push("no component surface: two dimensions do not apply");
193
197
  if (s.colocatedGuides) parts.push(`${s.colocatedGuides} colocated`);
194
198
  if (facts.monorepo) parts.push(`repo-level files from ${basename(facts.repoRoot)}/`);
195
199
  return ` scanned: ${parts.join(" · ")}`;
@@ -286,7 +290,7 @@ function cmdAudit(dir, flags, out, json) {
286
290
  out(delta.delta > 0 ? green(move) : delta.delta < 0 ? red(move) : dim(move));
287
291
  }
288
292
  // A score out of 45 answers nothing until you know what 45 is worth.
289
- out(dim(standing(scored.total).sentence));
293
+ out(dim(standing(scored.total, scoredIds(scored)).sentence));
290
294
  }
291
295
  }
292
296
 
package/lib/reference.mjs CHANGED
@@ -14,19 +14,36 @@ import { dirname, join } from "node:path";
14
14
  const here = dirname(fileURLToPath(import.meta.url));
15
15
  export const REFERENCE = JSON.parse(readFileSync(join(here, "..", "rubric", "reference.json"), "utf8"));
16
16
 
17
- /** Where a score sits in the field: how many of the reference systems it beats. */
18
- export function standing(total) {
19
- const systems = REFERENCE.systems;
20
- const below = systems.filter((s) => s.total < total).length;
17
+ /**
18
+ * Where a score sits in the field: how many of the reference systems it beats.
19
+ *
20
+ * When dimensions do not apply — a system with no importable surface is not asked
21
+ * whether every component has a guide — the comparison is recomputed over exactly the
22
+ * dimensions that were scored. Otherwise a 9 out of 35 gets measured against thirteen
23
+ * systems scored out of 45, and the sentence is arithmetic about two different things.
24
+ */
25
+ export function standing(total, applicable = null) {
26
+ const all = Object.keys(REFERENCE.systems[0].dimensions).length;
27
+ const ids = applicable && applicable.length && applicable.length < all ? applicable : null;
28
+ const comparable = (s) => (ids ? ids.reduce((n, id) => n + (s.dimensions[id] ?? 0), 0) : s.total);
29
+ const systems = [...REFERENCE.systems].map((s) => ({ ...s, comparable: comparable(s) })).sort((a, b) => b.comparable - a.comparable);
30
+ const max = ids ? ids.length * 5 : 45;
31
+ const below = systems.filter((s) => s.comparable < total).length;
21
32
  const best = systems[0];
33
+ const scale = ids ? ` on the ${ids.length} dimensions that apply here` : "";
22
34
  return {
23
35
  count: systems.length,
24
36
  below,
25
37
  best,
26
- median: systems[Math.floor(systems.length / 2)].total,
38
+ median: systems[Math.floor(systems.length / 2)].comparable,
27
39
  sentence:
28
- total >= best.total
29
- ? `${total}/45 is at or above ${best.name} (${best.total}), the highest of the ${systems.length} public design systems measured with this rubric.`
30
- : `${total}/45 sits above ${below} of the ${systems.length} public design systems measured with this rubric; ${best.name} leads at ${best.total}.`,
40
+ total >= best.comparable
41
+ ? `${total}/${max} is at or above ${best.name} (${best.comparable}), the highest of the ${systems.length} public design systems measured with this rubric${scale}.`
42
+ : `${total}/${max} sits above ${below} of the ${systems.length} public design systems measured with this rubric${scale}; ${best.name} leads at ${best.comparable}.`,
31
43
  };
32
44
  }
45
+
46
+ /** The dimensions a run actually scored, in rubric order — the basis for a fair comparison. */
47
+ export function scoredIds(scored) {
48
+ return scored.dimensions.filter((d) => !d.skipped).map((d) => d.id);
49
+ }
package/lib/report.mjs CHANGED
@@ -11,7 +11,7 @@
11
11
  */
12
12
  import { RUBRIC } from "./score.mjs";
13
13
  import { band as scoreBand, badgeColor, badgeMarkdown } from "./badge.mjs";
14
- import { standing } from "./reference.mjs";
14
+ import { scoredIds, standing } from "./reference.mjs";
15
15
 
16
16
  /** What the accessibility dimension is called on each platform. */
17
17
  const A11Y_STAT_LABEL = {
@@ -66,7 +66,7 @@ export function html(facts, scored, history = []) {
66
66
  const verdict = scoreBand(scored.total, scored.max);
67
67
  const band = BAND_CLASS[verdict.id];
68
68
  const [verdictTitle, verdictLine] = VERDICT[verdict.id];
69
- const field = standing(scored.total);
69
+ const field = standing(scored.total, scoredIds(scored));
70
70
  const dims = scored.dimensions;
71
71
  const live = dims.filter((d) => !d.skipped);
72
72
  const weakest = [...live].sort((a, b) => a.score - b.score).slice(0, 3);
@@ -698,7 +698,7 @@ export function markdown(facts, scored) {
698
698
  "",
699
699
  VERDICT[band][1],
700
700
  "",
701
- standing(scored.total).sentence,
701
+ standing(scored.total, scoredIds(scored)).sentence,
702
702
  "",
703
703
  `| Dimension | Score | Evidence |`,
704
704
  `| :-- | :-- | :-- |`,
package/lib/scan.mjs CHANGED
@@ -7,7 +7,19 @@ import { basename, dirname, join } from "node:path";
7
7
  import { exists, isDir, read, readJson, rel, walk, walkDirs } from "./fsx.mjs";
8
8
  import { COLOCATED_GUIDE, defaultGuideDirs, loadConfig, workspaceDirs } from "./config.mjs";
9
9
 
10
- const CODE_FENCE = /```(tsx|jsx|ts|js|typescript|javascript|swift|kotlin|kt)\s*\n([\s\S]*?)```/g;
10
+ /**
11
+ * The languages a guide writes its examples in. HTML and CSS belong here for the same
12
+ * reason Swift does: a system distributed as a stylesheet and a class vocabulary
13
+ * documents itself in HTML, and reading only TypeScript reported its every example as
14
+ * absent — the tool then said "no examples" about a repository full of them.
15
+ */
16
+ const CODE_FENCE = /```(tsx|jsx|ts|js|typescript|javascript|swift|kotlin|kt|html|vue|svelte|astro|css|scss)\s*\n([\s\S]*?)```/g;
17
+ /** `--brand: #0af` is where a colour is *given* a name. The hex in it is the definition, not a raw value used in place of one. */
18
+ const TOKEN_DEFINITION = /--[A-Za-z0-9_-]+\s*:[^;\n}]*/g;
19
+ /** `--brand: #0af`, `--space-2: 4px` — a named decision in the format the browser itself reads. */
20
+ const CUSTOM_PROPERTY = /(--[A-Za-z0-9_-]+)\s*:\s*([^;\n}]+)/g;
21
+ /** Where a token is declared when it belongs to the system rather than to one component. */
22
+ const GLOBAL_SCOPE = /(?:^|,|\s)(?::root|html|@theme|:host)\b|\[data-theme/;
11
23
  const PALETTE = "slate|gray|grey|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose";
12
24
  const RAW_PALETTE = new RegExp(`\\b(?:bg|text|border|ring|fill|stroke|from|to|via|divide|outline|shadow|decoration)-(?:${PALETTE})-\\d{2,3}\\b`, "g");
13
25
  const RAW_NEUTRAL = /\b(?:bg|text|border)-(?:white|black)\b/g;
@@ -76,7 +88,9 @@ export function scan(root, config, repoRoot = root) {
76
88
  facts.ci = readCi(repoRoot, pkg, repoPkg, platform.primary);
77
89
  facts.coverage = matchCoverage(facts.components, facts.guides);
78
90
  facts.freshness = checkFreshness(facts.guides, facts.symbols, facts.name, facts.ci);
79
- facts.tokens = checkTokens(facts.guides, facts.ci, root, platform.primary);
91
+ facts.stylesheets = platform.primary === "web" ? findStylesheets(root, pkg, config) : [];
92
+ facts.shape = describeShape(root, config, platform.primary, facts.components);
93
+ facts.tokens = checkTokens(facts.guides, facts.ci, root, platform.primary, facts.stylesheets);
80
94
  facts.patterns = checkPatterns(facts.guides);
81
95
  facts.a11y = checkA11y(facts.guides, facts.ci, root, platform.primary);
82
96
  facts.verification = checkVerification(root, pkg, repoPkg, facts.ci, platform.primary);
@@ -156,6 +170,8 @@ function describeScan(facts, config, guideDirs) {
156
170
  repo: facts.repoRoot,
157
171
  guides: guideDirs.map((d) => rel(facts.root, d) || "."),
158
172
  source: [...config.source],
173
+ shape: facts.shape.kind,
174
+ stylesheets: (facts.stylesheets || []).slice(0, 3).map((f) => rel(facts.root, f)),
159
175
  colocatedGuides: facts.guides.filter((g) => g.colocated).length,
160
176
  };
161
177
  }
@@ -314,9 +330,134 @@ function findComponents(root, pkg, config, platform) {
314
330
  }
315
331
  }
316
332
  }
333
+ // Nothing importable anywhere. Before concluding "not a design system", ask the
334
+ // other question: is the surface a class vocabulary rather than an import list?
335
+ if (!out.size) return findClassComponents(root, pkg, config);
317
336
  return [...out.values()].sort((a, b) => a.slug.localeCompare(b.slug));
318
337
  }
319
338
 
339
+ /**
340
+ * What kind of thing this repository is, which decides which questions apply to it.
341
+ *
342
+ * "No components found" has two completely different meanings, and conflating them is
343
+ * how a stylesheet-and-conventions system got judged against a rubric written for an
344
+ * import list. If there are component source files and none of them parsed, that is
345
+ * this tool failing and the score should say so. If there are none at all, the repo is
346
+ * a different shape, and the dimensions that assume an importable API are not questions
347
+ * it can answer — so they are dropped from the maximum instead of scored as failures.
348
+ */
349
+ function describeShape(root, config, platform, components) {
350
+ if (components.some((c) => c.kind === "class")) {
351
+ return { kind: "classes", surface: "CSS classes", componentFiles: 0 };
352
+ }
353
+ if (components.length) return { kind: "components", surface: "importable components", componentFiles: 0 };
354
+
355
+ const exts = platform === "swift" ? [".swift"] : platform === "android" ? [".kt"] : [".tsx", ".jsx", ".vue", ".svelte"];
356
+ const dirs = config.source.length ? config.source : ["."];
357
+ let componentFiles = 0;
358
+ for (const dir of dirs) {
359
+ componentFiles += walk(join(root, dir), exts, 6).filter((f) => !isNoiseFile(rel(root, f))).length;
360
+ if (componentFiles) break;
361
+ }
362
+ return componentFiles
363
+ ? { kind: "undetected", surface: null, componentFiles }
364
+ : { kind: "docs-only", surface: null, componentFiles: 0 };
365
+ }
366
+
367
+ /* ------------------------------------------------------- class-based systems */
368
+
369
+ const CLASS_SELECTOR = /\.(-?[A-Za-z_][A-Za-z0-9_-]*)/g;
370
+ /** State and layout classes are how a component behaves, not another component. */
371
+ const CLASS_STATE = /^(?:is|has|js|no)-|^(?:active|open|closed|disabled|hidden|visible|selected|current|loading|error|success|warning|sr-only|visually-hidden|clearfix|container|wrapper|row|col|grid|flex|left|right|center)$/i;
372
+ /**
373
+ * `mt-4`, `text-sm`, `w-full` — a utility names a value, not a thing. Deliberately
374
+ * narrow: the suffix has to be a scale step, so `text-field` and `border-box` survive
375
+ * as the components they are.
376
+ */
377
+ const UTILITY_CLASS = /^[a-z]{1,6}(?:-[a-z]+)?-(?:\d{1,3}|xs|sm|md|lg|xl|\d?xl|full|auto|none|px)$/i;
378
+ const PALETTE_CLASS = new RegExp(`^(?:bg|text|border|ring|fill|stroke)-(?:${PALETTE})-\\d{2,3}$`, "i");
379
+
380
+ /**
381
+ * A design system distributed as a stylesheet, where what a consumer reaches for is a
382
+ * class name and not an import. It is a deliberate shape, not an unfinished one: it
383
+ * survives contact with engineers on four different stacks, which is exactly why teams
384
+ * without a designer keep choosing it.
385
+ *
386
+ * Reading only `exports` and JSX reported such a repository as having no components at
387
+ * all, and then judged its documentation against that zero. The class vocabulary *is*
388
+ * the API, so it is counted as one.
389
+ */
390
+ function findClassComponents(root, pkg, config) {
391
+ const files = findStylesheets(root, pkg, config);
392
+ if (!files.length) return [];
393
+ const names = new Set();
394
+ for (const file of files) {
395
+ for (const m of selectorText(read(file) || "").matchAll(CLASS_SELECTOR)) names.add(m[1]);
396
+ }
397
+ const blocks = classBlocks([...names]);
398
+ // Under five and this is a page's own stylesheet; over three hundred and it is a
399
+ // utility framework, whose classes are values rather than components.
400
+ if (blocks.length < 5 || blocks.length > 300) return [];
401
+ const from = rel(root, files[0]);
402
+ return blocks
403
+ .map((slug) => ({ slug, name: pascal(slug), from, kind: "class", selector: `.${slug}` }))
404
+ .sort((a, b) => a.slug.localeCompare(b.slug));
405
+ }
406
+
407
+ /** Selectors only: declarations are where `1.5rem` and `url(...)` live, and neither is a class. */
408
+ function selectorText(css) {
409
+ return css.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\{[^{}]*\}/g, "{}");
410
+ }
411
+
412
+ /**
413
+ * The blocks in a class vocabulary. `card__title--large` and `card-header` are parts
414
+ * of the card, not two more components, so both fold into it — the count answers
415
+ * "how many things does a consumer choose between", which is the question the
416
+ * coverage dimension goes on to ask a guide for.
417
+ */
418
+ function classBlocks(names) {
419
+ const kept = unique(
420
+ names
421
+ .map((n) => n.replace(/__.*$/, "").replace(/--.*$/, "").replace(/^-+/, ""))
422
+ .filter((n) => n.length >= 3 && !CLASS_STATE.test(n) && !UTILITY_CLASS.test(n) && !PALETTE_CLASS.test(n)),
423
+ ).sort((a, b) => a.length - b.length || a.localeCompare(b));
424
+
425
+ const blocks = [];
426
+ for (const name of kept) {
427
+ if (blocks.some((b) => name.startsWith(`${b}-`))) continue;
428
+ blocks.push(name);
429
+ }
430
+ return blocks.sort();
431
+ }
432
+
433
+ /**
434
+ * The stylesheets this package actually ships, preferring what it declares over what
435
+ * happens to be lying around: an `exports` entry or `style` field is the file a
436
+ * consumer links, and a repository's own site CSS is not.
437
+ */
438
+ function findStylesheets(root, pkg, config) {
439
+ const out = new Set();
440
+ const add = (value) => {
441
+ if (typeof value !== "string" || !/\.(css|scss)$/.test(value)) return;
442
+ const path = join(root, value.replace(/^\.\//, ""));
443
+ if (exists(path)) out.add(path);
444
+ };
445
+ for (const [key, value] of Object.entries(pkg.exports || {})) {
446
+ add(key);
447
+ add(typeof value === "string" ? value : value?.default || value?.style);
448
+ }
449
+ add(pkg.style);
450
+ add(pkg.main);
451
+ add(pkg.unpkg);
452
+ if (out.size) return [...out];
453
+ for (const dir of unique([...config.source, "styles", "css", "src", "."])) {
454
+ for (const file of walk(join(root, dir), [".css", ".scss"], dir === "." ? 0 : 4)) {
455
+ if (!/\.min\.css$/.test(file)) out.add(file);
456
+ }
457
+ }
458
+ return [...out].slice(0, 40);
459
+ }
460
+
320
461
  /**
321
462
  * A registry ships components as source rather than as an import, and `registry.json`
322
463
  * declares which of its items are components: a block, an example and a chart are all
@@ -559,7 +700,10 @@ function guideFacts(root, file, text, colocated = false) {
559
700
  generated: GENERATED.test(text) || PROP_COMPONENT.test(text),
560
701
  hasA11y: A11Y_HEADING.test(text),
561
702
  rawPalette: unique([...(code.match(RAW_PALETTE) || []), ...(code.match(RAW_NEUTRAL) || [])]),
562
- hex: unique(code.match(HEX) || []),
703
+ // Hex inside `--brand: #0af` is the token being defined. Counting it as a raw
704
+ // value punished the guide that does the exact thing this dimension asks for.
705
+ hex: unique(code.replace(TOKEN_DEFINITION, "").match(HEX) || []),
706
+ customProperties: unique([...code.matchAll(CUSTOM_PROPERTY)].map((m) => m[1])),
563
707
  body: text,
564
708
  };
565
709
  }
@@ -791,12 +935,15 @@ function matchCoverage(components, guides) {
791
935
  const documented = [];
792
936
  const missing = [];
793
937
  for (const c of components) {
794
- const guide = bySlug.get(c.slug) || bySlug.get(depluralize(c.slug)) || bySlug.get(c.slug + "s") || byPrefix(guides, c.slug) || byMention(guides, c.name);
938
+ const mentioned = c.kind === "class" ? byClass(guides, c.slug) : byMention(guides, c.name);
939
+ const guide = bySlug.get(c.slug) || bySlug.get(depluralize(c.slug)) || bySlug.get(c.slug + "s") || byPrefix(guides, c.slug) || mentioned;
795
940
  if (guide) documented.push({ ...c, guide: guide.path });
796
941
  else missing.push(c);
797
942
  }
798
943
  const total = components.length;
799
- return { total, documented: documented.length, missing: missing.map((c) => c.name), ratio: total ? documented.length / total : 0 };
944
+ // A class system's reader looks for `.dialog`, not `Dialog`: name the missing thing
945
+ // the way the person would have to type it.
946
+ return { total, documented: documented.length, missing: missing.map((c) => c.selector || c.name), ratio: total ? documented.length / total : 0 };
800
947
  }
801
948
 
802
949
  function depluralize(slug) {
@@ -807,6 +954,12 @@ function byPrefix(guides, slug) {
807
954
  return guides.find((g) => g.slug.length >= 4 && (slug.startsWith(g.slug + "-") || slug.startsWith(depluralize(g.slug) + "-")));
808
955
  }
809
956
 
957
+ /** A class is documented where an example applies it: `.btn` in prose, `class="btn"` in HTML. */
958
+ function byClass(guides, slug) {
959
+ const re = new RegExp(`\\.${slug}\\b|class(?:Name)?=["'\`][^"'\`]*\\b${slug}\\b`);
960
+ return guides.find((g) => re.test(g.body));
961
+ }
962
+
810
963
  function byMention(guides, name) {
811
964
  const re = new RegExp(`(<|\\b)${name}\\b`);
812
965
  return guides.find((g) => re.test(g.body));
@@ -834,20 +987,29 @@ function checkFreshness(guides, symbols, packageName, ci) {
834
987
  };
835
988
  }
836
989
 
837
- function checkTokens(guides, ci, root, platform) {
838
- const docs = guides.filter((g) => /token|colour|color|typography|spacing|motion|elevation|shadow|radius|theme|palette/i.test(g.slug + " " + g.title));
990
+ function checkTokens(guides, ci, root, platform, stylesheets = []) {
991
+ // Two ways a guide can be about tokens. The name is the cheap one, and it fails on
992
+ // the system whose page is headed "Values" — so a guide that actually carries named
993
+ // custom properties counts as token documentation whatever it is called.
994
+ const named = /token|colour|color|typography|spacing|motion|elevation|shadow|radius|theme|palette|variable|value/i;
995
+ const docs = guides.filter((g) => named.test(g.slug + " " + g.title) || (g.customProperties || []).length >= 3);
839
996
  const rawHits = guides.flatMap((g) => g.rawPalette.map((c) => ({ guide: g.path, value: c })));
840
997
  const hexHits = guides.flatMap((g) => g.hex.map((c) => ({ guide: g.path, value: c })));
998
+ const source = findSourceTokens(root, platform, stylesheets);
999
+ // A `--duration-fast` in the stylesheet is a motion decision with a name, whether or
1000
+ // not a heading anywhere spells the word "motion".
1001
+ const documented = (re, headings, flag) =>
1002
+ docs.some((g) => re.test(g.slug + g.title) || headings.test(g.body) || (g.customProperties || []).some((n) => re.test(n))) || Boolean(source && source[flag]);
841
1003
  return {
842
1004
  docs: docs.map((g) => g.path),
843
- motion: docs.some((g) => /motion|duration|easing|transition/i.test(g.slug + g.title) || /^#{1,4}\s.*(motion|easing|duration)/im.test(g.body)),
844
- spacing: docs.some((g) => /spacing|space|layout|grid/i.test(g.slug + g.title) || /^#{1,4}\s.*(spacing|space scale)/im.test(g.body)),
1005
+ motion: documented(/motion|duration|easing|transition/i, /^#{1,4}\s.*(motion|easing|duration)/im, "motion"),
1006
+ spacing: documented(/spacing|space|layout|grid/i, /^#{1,4}\s.*(spacing|space scale)/im, "spacing"),
845
1007
  rawPalette: rawHits.slice(0, 30),
846
1008
  rawPaletteCount: rawHits.length,
847
1009
  hex: hexHits.slice(0, 20),
848
1010
  hexCount: hexHits.length,
849
1011
  lintEnforced: /(eslint|biome).*(token|palette)|no-restricted-syntax/i.test(Object.values(ci.scripts).join(" ")),
850
- source: findSourceTokens(root, platform),
1012
+ source,
851
1013
  };
852
1014
  }
853
1015
 
@@ -857,7 +1019,26 @@ function checkTokens(guides, ci, root, platform) {
857
1019
  * `colors.xml`/`themes.xml`. Real files, real counts — an agent can open either and
858
1020
  * see the same semantic names a guide would otherwise have to spell out.
859
1021
  */
860
- function findSourceTokens(root, platform) {
1022
+ function findSourceTokens(root, platform, stylesheets = []) {
1023
+ // The web had no branch here at all, which meant the one file holding the answer —
1024
+ // `:root { --colour-brand: … }` — was the only resource format this tool refused to
1025
+ // open, while it happily read an asset catalog and an Android colors.xml.
1026
+ if (platform === "web" && stylesheets.length) {
1027
+ const names = new Set();
1028
+ for (const file of stylesheets) {
1029
+ for (const name of globalCustomProperties(read(file) || "")) names.add(name);
1030
+ }
1031
+ if (names.size < 3) return null;
1032
+ const all = [...names];
1033
+ return {
1034
+ kind: "CSS custom properties",
1035
+ files: stylesheets.slice(0, 4).map((f) => rel(root, f)),
1036
+ count: all.length,
1037
+ names: all.slice(0, 6),
1038
+ motion: all.some((n) => /duration|easing|transition|motion|delay/i.test(n)),
1039
+ spacing: all.some((n) => /space|spacing|gap|size|radius|inset/i.test(n)),
1040
+ };
1041
+ }
861
1042
  if (platform === "swift") {
862
1043
  const catalogs = walkDirs(root, [".xcassets"], 6);
863
1044
  const colorSets = catalogs.flatMap((c) => walkDirs(c, [".colorset"], 3));
@@ -885,6 +1066,41 @@ function findSourceTokens(root, platform) {
885
1066
  return null;
886
1067
  }
887
1068
 
1069
+ /**
1070
+ * Custom properties declared at the root, where they are a decision the whole system
1071
+ * shares. `--actionbar-height` inside `.ActionBar` is a local variable: counting it as
1072
+ * a design token would let any CSS-modules repo claim a token system it does not have.
1073
+ */
1074
+ function globalCustomProperties(css) {
1075
+ const clean = css.replace(/\/\*[\s\S]*?\*\//g, "");
1076
+ const names = new Set();
1077
+ const stack = [];
1078
+ let buf = "";
1079
+ // A hand-rolled walk rather than a regex, because the block that matters nests:
1080
+ // Tailwind's `@theme` holds `@keyframes`, and a pattern that stops at the first
1081
+ // brace reads the largest token file in the repository as empty.
1082
+ const global = () => stack.some((sel) => GLOBAL_SCOPE.test(sel));
1083
+ const collect = (text) => {
1084
+ if (!global()) return;
1085
+ for (const prop of text.matchAll(CUSTOM_PROPERTY)) names.add(prop[1]);
1086
+ };
1087
+ for (const ch of clean) {
1088
+ if (ch === "{") {
1089
+ const cut = buf.lastIndexOf(";");
1090
+ collect(buf.slice(0, cut + 1));
1091
+ stack.push(buf.slice(cut + 1).trim());
1092
+ buf = "";
1093
+ } else if (ch === "}") {
1094
+ collect(buf);
1095
+ stack.pop();
1096
+ buf = "";
1097
+ } else {
1098
+ buf += ch;
1099
+ }
1100
+ }
1101
+ return names;
1102
+ }
1103
+
888
1104
  function checkPatterns(guides) {
889
1105
  const files = guides.filter((g) => /pattern|template|recipe|layout|page|blueprint|composition/i.test(g.slug + " " + g.title));
890
1106
  // A pattern is worth 5 only if it carries structure: states, traps, a skeleton.
package/lib/score.mjs CHANGED
@@ -71,9 +71,10 @@ const SCORERS = {
71
71
 
72
72
  "docs-coverage"(f) {
73
73
  const c = f.coverage;
74
- if (!c.total) return { score: 1, evidence: ["No importable components detected — check `source` in adsa.config.json."] };
74
+ if (!c.total) return { score: 1, evidence: [`${f.shape.componentFiles} component source files were read and none exported a component — check \`source\` in adsa.config.json.`] };
75
75
  const gate = f.verification.docChecks || f.verification.scoreGate;
76
- const ev = [`${c.documented} of ${c.total} importable components have a guide (${pct(c.ratio)}).`];
76
+ const noun = f.shape.kind === "classes" ? "component classes" : "importable components";
77
+ const ev = [`${c.documented} of ${c.total} ${noun} have a guide (${pct(c.ratio)}).`];
77
78
  if (f.icons) ev.push(`${f.icons} icon or logo exports were set aside; nobody writes a guide per icon.`);
78
79
  if (c.missing.length) ev.push(`Undocumented, first few: ${c.missing.slice(0, 8).join(", ")}.`);
79
80
  ev.push(gate ? "CI runs a documentation check." : "Nothing in CI stops a new export from shipping without a guide.");
@@ -158,12 +159,32 @@ const SCORERS = {
158
159
  },
159
160
  };
160
161
 
162
+ /**
163
+ * Dimensions this repository cannot answer, because of what it is rather than what it
164
+ * is missing. A system with no importable surface at all — a stylesheet, a token
165
+ * package, a documentation repo — has nothing for "does every component have a guide"
166
+ * to be about, and scoring that 1 out of 5 twice reports a shape as a failure.
167
+ *
168
+ * Only the unambiguous case skips. A repo full of `.tsx` files where the scan found no
169
+ * exports is this tool failing, and it keeps its low score and says so.
170
+ */
171
+ function autoSkipped(facts, config) {
172
+ if (config.autoSkip === false || facts.shape.kind !== "docs-only") return new Map();
173
+ const why = `No importable components and no component source files in ${facts.scanned.package}: this dimension does not apply, so it is out of the maximum rather than scored as a failure.`;
174
+ return new Map([
175
+ ["docs-coverage", why],
176
+ ["docs-freshness", why],
177
+ ]);
178
+ }
179
+
161
180
  /** @returns {{ total:number, max:number, dimensions:Array }} */
162
181
  export function score(facts, config = {}) {
163
182
  const skip = new Set(config.skip || []);
183
+ const auto = autoSkipped(facts, config);
164
184
  const dimensions = RUBRIC.dimensions.map((dim) => {
165
- if (skip.has(dim.id)) {
166
- return { id: dim.id, title: dim.title, score: null, max: 5, skipped: true, evidence: ["Skipped by configuration."], fixes: [] };
185
+ if (skip.has(dim.id) || auto.has(dim.id)) {
186
+ const why = skip.has(dim.id) ? "Skipped by configuration." : auto.get(dim.id);
187
+ return { id: dim.id, title: dim.title, score: null, max: 5, skipped: true, evidence: [why], fixes: [] };
167
188
  }
168
189
  const result = SCORERS[dim.id](facts);
169
190
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "adsa-cli",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "Score a design system on how well coding agents can use it, then fix what is missing.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -11,14 +11,14 @@
11
11
  "package": "@astryxdesign/core",
12
12
  "version": "0.5.4",
13
13
  "packageDir": "packages/core",
14
- "total": 33,
14
+ "total": 35,
15
15
  "max": 45,
16
16
  "dimensions": {
17
17
  "agent-instructions": 3,
18
18
  "machine-surface": 5,
19
19
  "docs-coverage": 3,
20
20
  "docs-freshness": 3,
21
- "tokens": 3,
21
+ "tokens": 5,
22
22
  "patterns": 5,
23
23
  "a11y": 3,
24
24
  "verification": 5,
@@ -246,21 +246,21 @@
246
246
  }
247
247
  },
248
248
  {
249
- "name": "Radix Primitives",
250
- "org": "WorkOS",
251
- "repo": "radix-ui/primitives",
252
- "commit": "f7ecd5ab16",
253
- "package": "radix-ui",
254
- "version": "1.6.7",
255
- "packageDir": "packages/react/radix-ui",
256
- "total": 13,
249
+ "name": "Untitled UI React",
250
+ "org": "",
251
+ "repo": "untitleduico/react",
252
+ "commit": "c981a73bcd",
253
+ "package": "@untitledui/react",
254
+ "version": "0.0.0",
255
+ "packageDir": ".",
256
+ "total": 15,
257
257
  "max": 45,
258
258
  "dimensions": {
259
259
  "agent-instructions": 3,
260
260
  "machine-surface": 1,
261
261
  "docs-coverage": 1,
262
262
  "docs-freshness": 1,
263
- "tokens": 1,
263
+ "tokens": 3,
264
264
  "patterns": 1,
265
265
  "a11y": 1,
266
266
  "verification": 3,
@@ -268,13 +268,13 @@
268
268
  }
269
269
  },
270
270
  {
271
- "name": "Untitled UI React",
272
- "org": "",
273
- "repo": "untitleduico/react",
274
- "commit": "c981a73bcd",
275
- "package": "@untitledui/react",
276
- "version": "0.0.0",
277
- "packageDir": ".",
271
+ "name": "Radix Primitives",
272
+ "org": "WorkOS",
273
+ "repo": "radix-ui/primitives",
274
+ "commit": "f7ecd5ab16",
275
+ "package": "radix-ui",
276
+ "version": "1.6.7",
277
+ "packageDir": "packages/react/radix-ui",
278
278
  "total": 13,
279
279
  "max": 45,
280
280
  "dimensions": {
@@ -104,7 +104,14 @@ reproduce any figure you are unsure of with `npx adsa-cli audit <clone>`.
104
104
 
105
105
  Two things to say out loud, because a reader will otherwise assume the opposite:
106
106
 
107
- - **45 is nobody's score.** The best-documented public system measured reaches 33.
107
+ - **45 is nobody's score.** The best-documented public system measured reaches 35.
108
+ - **Not every system is a component library.** A stylesheet with a class vocabulary is
109
+ a deliberate shape: the tool reads its classes as the surface and its HTML examples
110
+ as examples. If the `scanned:` line says `classes from <file>`, that is what happened,
111
+ and the coverage number is about classes.
112
+ - **A score out of 35 is not a broken run.** When a repository has no importable
113
+ surface at all, the two dimensions that assume one are skipped and the maximum drops.
114
+ Say which dimensions were skipped and why; do not report it as 35/45.
108
115
  A number in the twenties is the middle of the field, not a failing grade.
109
116
  - **This is not a ranking of design systems.** It measures what a coding agent can
110
117
  find in the repository. A system whose documentation lives on an excellent website