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 +35 -3
- package/bin/adsa.mjs +7 -3
- package/lib/reference.mjs +25 -8
- package/lib/report.mjs +3 -3
- package/lib/scan.mjs +227 -11
- package/lib/score.mjs +25 -4
- package/package.json +1 -1
- package/rubric/reference.json +18 -18
- package/skills/ds-audit/SKILL.md +8 -1
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
|
-
|
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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)].
|
|
38
|
+
median: systems[Math.floor(systems.length / 2)].comparable,
|
|
27
39
|
sentence:
|
|
28
|
-
total >= best.
|
|
29
|
-
? `${total}
|
|
30
|
-
: `${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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
844
|
-
spacing:
|
|
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
|
|
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: [
|
|
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
|
|
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
|
-
|
|
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
package/rubric/reference.json
CHANGED
|
@@ -11,14 +11,14 @@
|
|
|
11
11
|
"package": "@astryxdesign/core",
|
|
12
12
|
"version": "0.5.4",
|
|
13
13
|
"packageDir": "packages/core",
|
|
14
|
-
"total":
|
|
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":
|
|
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": "
|
|
250
|
-
"org": "
|
|
251
|
-
"repo": "
|
|
252
|
-
"commit": "
|
|
253
|
-
"package": "
|
|
254
|
-
"version": "
|
|
255
|
-
"packageDir": "
|
|
256
|
-
"total":
|
|
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":
|
|
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": "
|
|
272
|
-
"org": "",
|
|
273
|
-
"repo": "
|
|
274
|
-
"commit": "
|
|
275
|
-
"package": "
|
|
276
|
-
"version": "
|
|
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": {
|
package/skills/ds-audit/SKILL.md
CHANGED
|
@@ -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
|
|
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
|