adsa-cli 0.1.2 → 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/lib/scan.mjs CHANGED
@@ -3,10 +3,23 @@
3
3
  * every field is something observed in the repo, so a report can quote its evidence
4
4
  * and a maintainer can disagree with the reading rather than the number.
5
5
  */
6
- import { basename, join } from "node:path";
6
+ import { basename, dirname, join } from "node:path";
7
7
  import { exists, isDir, read, readJson, rel, walk, walkDirs } from "./fsx.mjs";
8
+ import { COLOCATED_GUIDE, defaultGuideDirs, loadConfig, workspaceDirs } from "./config.mjs";
8
9
 
9
- 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/;
10
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";
11
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");
12
25
  const RAW_NEUTRAL = /\b(?:bg|text|border)-(?:white|black)\b/g;
@@ -14,20 +27,44 @@ const HEX = /#[0-9a-fA-F]{3,8}\b/g;
14
27
  const A11Y_HEADING = /^#{2,4}.*(keyboard|accessib|a11y|screen reader|aria|voiceover|talkback|dynamic type|touch target|contentdescription)/im;
15
28
  const GENERATED = /<!--\s*(?:generated|auto-?generated|do not edit|prop-table:start|props:start)/i;
16
29
  const PROP_TABLE = /^\|[^\n]*\bprops?\b[^\n]*\|[^\n]*\btype\b[^\n]*\|/im;
30
+ /**
31
+ * A documentation site that renders its props from the types puts a component in the
32
+ * page — `<PropTable component="Button" />` — and never writes the table itself. That
33
+ * is the thing this dimension is looking for, and reading only markdown pipes counted
34
+ * the systems doing it properly as having no prop tables and hand-maintaining them.
35
+ */
36
+ const PROP_COMPONENT = /<(Props?Table|PropsTable|PropTable|ComponentProps|ArgTypes|ApiTable|TypeTable|AutoTypeTable|PropertyTable|ApiReference|PropDefs)\b/;
17
37
  const PASCAL_EXPORT = /export\s+(?:default\s+)?(?:const|function|class)\s+([A-Z][A-Za-z0-9_]*)/g;
18
38
  const TYPE_EXPORT = /export\s+(?:type|interface)\s+([A-Z][A-Za-z0-9_]*)/g;
19
39
  /** `struct Foo: View`, `public class Foo: UIView` — captures the whole conformance list so a multi-protocol clause still matches. */
20
40
  const SWIFT_TYPE = /\b(?:@[\w:()]+\s+)*(?:public\s+|open\s+|internal\s+)?(?:final\s+)?(struct|class)\s+([A-Z][A-Za-z0-9_]*)\s*(?:<[^>{]*>)?\s*:\s*([^{]+?)\{/g;
21
41
  const SWIFT_SYMBOL = /\b(?:public\s+|open\s+)?(?:static\s+)?(?:struct|class|enum|func)\s+([A-Za-z][A-Za-z0-9_]*)/g;
22
42
  /** `@Composable` followed, within a short window (annotations, modifiers, newlines), by `fun Name(`. */
43
+ /**
44
+ * Subpaths every second package exports and nobody imports as a component. Without
45
+ * this, a CLI that ships `./registry`, `./schema`, `./mcp` and `./utils` reads as a
46
+ * five-component design system and wins the workspace over the component library.
47
+ */
48
+ const NOT_A_COMPONENT_SUBPATH = new Set([
49
+ "styles", "theme", "themes", "tokens", "eslint", "prose", "package.json", "registry", "schema", "mcp", "cli",
50
+ "utils", "utilities", "helpers", "hooks", "preset", "presets", "config", "plugin", "plugins", "types", "server",
51
+ "test", "testing", "next", "vite", "webpack", "babel", "codemod", "migrate", "internal", "unstable", "experimental",
52
+ ]);
53
+
54
+ const NAMESPACE_EXPORT = /export\s+\*\s+as\s+([A-Z][A-Za-z0-9_]*)\s+from/g;
23
55
  const COMPOSABLE = /@Composable[\s\S]{0,120}?\bfun\s+([A-Z][A-Za-z0-9_]*)\s*\(/g;
24
56
  const KOTLIN_SYMBOL = /\b(?:public\s+|internal\s+)?(?:class|object|interface|enum class)\s+([A-Z][A-Za-z0-9_]*)/g;
25
57
 
26
- export function scan(root, config) {
58
+ export function scan(root, config, repoRoot = root) {
27
59
  const pkg = readJson(join(root, "package.json")) || {};
60
+ const repoPkg = repoRoot === root ? pkg : readJson(join(repoRoot, "package.json")) || {};
28
61
  const platform = detectPlatform(root, pkg);
29
62
  const facts = {
30
63
  root,
64
+ repoRoot,
65
+ // What a package inherits from the repository around it: agent instructions,
66
+ // CI, the agent surface. Null when the package is the repository.
67
+ monorepo: repoRoot === root ? null : { root: repoRoot, package: rel(repoRoot, root) },
31
68
  name: detectName(root, pkg),
32
69
  version: pkg.version || null,
33
70
  packageJson: Boolean(pkg.name),
@@ -39,20 +76,106 @@ export function scan(root, config) {
39
76
  facts.components = findComponents(root, pkg, config, platform.primary);
40
77
  facts.icons = countIconExports(pkg);
41
78
  facts.symbols = findSymbols(root, config, platform.primary);
42
- facts.guides = findGuides(root, config);
43
- facts.agentFiles = readAgentFiles(root, config, facts.name);
44
- facts.machine = findMachineSurface(root, pkg, config);
45
- facts.ci = readCi(root, pkg, platform.primary);
79
+ let guideDirs = resolveGuideDirs(root, config, repoRoot);
80
+ facts.guides = findGuides(root, config, guideDirs);
81
+ const site = config.guidesExplicit ? null : findDocsSite(repoRoot, facts.components, facts.guides);
82
+ if (site) {
83
+ guideDirs = [...guideDirs, site];
84
+ facts.guides = findGuides(root, config, guideDirs);
85
+ }
86
+ facts.agentFiles = readAgentFiles(root, repoRoot, config, facts.name);
87
+ facts.machine = findMachineSurface(root, repoRoot, pkg, config);
88
+ facts.ci = readCi(repoRoot, pkg, repoPkg, platform.primary);
46
89
  facts.coverage = matchCoverage(facts.components, facts.guides);
47
90
  facts.freshness = checkFreshness(facts.guides, facts.symbols, facts.name, facts.ci);
48
- 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);
49
94
  facts.patterns = checkPatterns(facts.guides);
50
95
  facts.a11y = checkA11y(facts.guides, facts.ci, root, platform.primary);
51
- facts.verification = checkVerification(root, pkg, facts.ci, platform.primary);
52
- facts.gaps = checkGaps(root, facts.guides, facts.agentFiles, pkg);
96
+ facts.verification = checkVerification(root, pkg, repoPkg, facts.ci, platform.primary);
97
+ facts.gaps = checkGaps(root, repoRoot, facts.guides, facts.agentFiles, pkg, repoPkg);
98
+ facts.scanned = describeScan(facts, config, guideDirs);
53
99
  return facts;
54
100
  }
55
101
 
102
+ /**
103
+ * Which directories this run actually read. Printed by `audit`, because "not found"
104
+ * and "not looked for" are the same sentence otherwise — the exact confusion this
105
+ * tool exists to stop an agent from making.
106
+ */
107
+ /**
108
+ * The directory where this repository documents its components, found by asking which
109
+ * directory's markdown is *named after the components* — `accordion.mdx`, or
110
+ * `accordion/page.mdx`.
111
+ *
112
+ * No list of directory names finds these. A monorepo keeps its guides in whatever its
113
+ * documentation site happens to be called — `apps/www/content/docs`, `apps/v4/content`,
114
+ * `docs/src/app`, `apps/mantine.dev/src/pages` — and every system whose docs the list
115
+ * missed was reported as a system with no documentation at all. Matching against the
116
+ * component names is what makes the answer checkable: a folder of blog posts scores
117
+ * zero, and a folder of component guides cannot.
118
+ *
119
+ * Returns a directory only when it beats what has already been found.
120
+ */
121
+ function findDocsSite(repoRoot, components, found) {
122
+ const slugs = new Set(components.map((c) => c.slug));
123
+ if (slugs.size < 3) return null;
124
+ const matched = (files) => new Set(files.filter((f) => slugs.has(guideSlug(f))).map((f) => guideSlug(f))).size;
125
+ const already = new Set(found.filter((g) => slugs.has(g.slug)).map((g) => g.slug)).size;
126
+
127
+ const byDir = new Map();
128
+ for (const file of walk(repoRoot, [".md", ".mdx"], 9)) {
129
+ if (/^(README|CHANGELOG|CONTRIBUTING|LICENSE|CODE_OF_CONDUCT|SECURITY)\./i.test(basename(file))) continue;
130
+ // A folder-routed site gives every guide a folder of its own, so the section
131
+ // that holds them all is one level further up: `components/button/page.mdx`
132
+ // groups with `components/card/page.mdx`, not alone.
133
+ const dir = ROUTE_FILE.test(basename(file)) ? dirname(dirname(file)) : dirname(file);
134
+ if (!byDir.has(dir)) byDir.set(dir, []);
135
+ byDir.get(dir).push(file);
136
+ }
137
+
138
+ let best = null;
139
+ for (const [dir, files] of byDir) {
140
+ const score = matched(files);
141
+ if (score >= 3 && score > already && (!best || score > best.score)) best = { dir, score };
142
+ }
143
+ return best ? docsRoot(best.dir) : null;
144
+ }
145
+
146
+ /**
147
+ * The documentation tree a component folder belongs to, so patterns and token pages
148
+ * beside the component pages are read too. Climbing stops at the first folder a
149
+ * documentation site is ever called, and after three levels, so a site nested in a
150
+ * repository never widens into the repository.
151
+ */
152
+ const ROUTE_FILE = /^(page|index|content|_index|readme)\.mdx?$/i;
153
+
154
+ const DOCS_ROOT_NAMES = new Set(["docs", "doc", "content", "pages", "guides", "documentation"]);
155
+
156
+ function docsRoot(dir) {
157
+ let at = dir;
158
+ for (let i = 0; i < 3; i++) {
159
+ if (DOCS_ROOT_NAMES.has(basename(at).toLowerCase())) return at;
160
+ const parent = dirname(at);
161
+ if (parent === at) break;
162
+ at = parent;
163
+ }
164
+ return dir;
165
+ }
166
+
167
+ function describeScan(facts, config, guideDirs) {
168
+ return {
169
+ package: facts.monorepo ? facts.monorepo.package : ".",
170
+ repo: facts.repoRoot,
171
+ guides: guideDirs.map((d) => rel(facts.root, d) || "."),
172
+ source: [...config.source],
173
+ shape: facts.shape.kind,
174
+ stylesheets: (facts.stylesheets || []).slice(0, 3).map((f) => rel(facts.root, f)),
175
+ colocatedGuides: facts.guides.filter((g) => g.colocated).length,
176
+ };
177
+ }
178
+
56
179
  /* ------------------------------------------------------------- platform */
57
180
 
58
181
  /**
@@ -151,6 +274,17 @@ function detectStack(root, pkg, platform) {
151
274
 
152
275
  /* ----------------------------------------------------------- components */
153
276
 
277
+ /**
278
+ * How many importable components a directory would report if it were audited. This
279
+ * is how the right package is picked out of a monorepo, so it runs on packages that
280
+ * will never be audited — cheap enough to run on all of them, and exactly the same
281
+ * rule as the audit, so the count the reader is shown is the count that was ranked.
282
+ */
283
+ export function countComponents(dir) {
284
+ const pkg = readJson(join(dir, "package.json")) || {};
285
+ return findComponents(dir, pkg, loadConfig(dir), detectPlatform(dir, pkg).primary).length;
286
+ }
287
+
154
288
  /**
155
289
  * What a consumer can import. Package `exports` subpaths are the most reliable
156
290
  * answer, and when a package ships them they *are* the surface, so the source scan
@@ -161,10 +295,13 @@ function findComponents(root, pkg, config, platform) {
161
295
  if (platform === "swift") return findSwiftComponents(root, config);
162
296
  if (platform === "android") return findKotlinComponents(root, config);
163
297
 
298
+ const registry = readRegistry(root);
299
+ if (registry) return registry;
300
+
164
301
  const subpaths = Object.keys(pkg.exports || {})
165
302
  .filter((k) => k.startsWith("./") && !k.includes("*") && !/\.(css|json|js|mjs|cjs)$/.test(k))
166
303
  .map((k) => k.replace(/^\.\//, ""))
167
- .filter((slug) => !["styles", "theme", "tokens", "eslint", "prose", "package.json"].includes(slug));
304
+ .filter((slug) => !NOT_A_COMPONENT_SUBPATH.has(slug));
168
305
  if (subpaths.length >= 5) {
169
306
  return subpaths
170
307
  .filter((slug) => !isIconSlug(slug))
@@ -173,6 +310,15 @@ function findComponents(root, pkg, config, platform) {
173
310
  }
174
311
  const out = new Map();
175
312
  for (const dir of config.source) {
313
+ // `export * as Dialog from "@acme/react-dialog"`: the aggregate package a
314
+ // monorepo publishes, whose own source is one line per component. Reading only
315
+ // JSX files reports the package a consumer actually installs as empty.
316
+ for (const file of walk(join(root, dir), [".ts"], 3)) {
317
+ for (const m of (read(file) || "").matchAll(NAMESPACE_EXPORT)) {
318
+ const slug = kebab(m[1]);
319
+ if (!out.has(slug)) out.set(slug, { slug, name: m[1], from: rel(root, file) });
320
+ }
321
+ }
176
322
  for (const file of walk(join(root, dir), [".tsx", ".jsx", ".vue", ".svelte"], 6)) {
177
323
  if (isNoiseFile(rel(root, file))) continue;
178
324
  const text = read(file) || "";
@@ -184,9 +330,147 @@ function findComponents(root, pkg, config, platform) {
184
330
  }
185
331
  }
186
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);
187
336
  return [...out.values()].sort((a, b) => a.slug.localeCompare(b.slug));
188
337
  }
189
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
+
461
+ /**
462
+ * A registry ships components as source rather than as an import, and `registry.json`
463
+ * declares which of its items are components: a block, an example and a chart are all
464
+ * in there too, and counting them makes a system of forty components look like one of
465
+ * four hundred, none of them documented.
466
+ */
467
+ function readRegistry(root) {
468
+ const file = readJson(join(root, "registry.json"));
469
+ const items = Array.isArray(file?.items) ? file.items.filter((i) => i.type === "registry:ui" && i.name) : [];
470
+ if (items.length < 5) return null;
471
+ return [...new Map(items.map((i) => [kebab(i.name), { slug: kebab(i.name), name: pascal(i.name), from: "registry.json" }])).values()].sort((a, b) => a.slug.localeCompare(b.slug));
472
+ }
473
+
190
474
  /** `public struct Foo: View` / `open class Foo: UIView` / `struct Foo: UIViewController`. */
191
475
  function findSwiftComponents(root, config) {
192
476
  const out = new Map();
@@ -252,12 +536,20 @@ function findSymbols(root, config, platform) {
252
536
  if (platform === "android") return findKotlinSymbols(root, config);
253
537
 
254
538
  const symbols = new Set();
255
- const dirs = [...config.source, "dist"];
539
+ // Wider than the component scan on purpose: `config.source` is where components
540
+ // live, and a provider, a hook or a type exported from `src/styled-system` is
541
+ // still a symbol a guide may legitimately import. Reading only the component
542
+ // folder reported real exports as imports that do not exist.
543
+ const dirs = [...new Set([...config.source, "src", "lib", "dist"])];
256
544
  for (const dir of dirs) {
257
545
  for (const file of walk(join(root, dir), [".tsx", ".ts", ".jsx", ".vue", ".svelte"], 7)) {
258
546
  const text = read(file) || "";
259
547
  for (const m of text.matchAll(PASCAL_EXPORT)) symbols.add(m[1]);
260
548
  for (const m of text.matchAll(TYPE_EXPORT)) symbols.add(m[1]);
549
+ // `export * as Accordion from "./accordion"` is how a namespace API is
550
+ // published, and a guide importing `Accordion` was being reported as
551
+ // importing something that does not exist.
552
+ for (const m of text.matchAll(NAMESPACE_EXPORT)) symbols.add(m[1]);
261
553
  for (const m of text.matchAll(/export\s*\{([^}]+)\}/g)) {
262
554
  for (const part of m[1].split(",")) {
263
555
  const name = part.trim().split(/\s+as\s+/).pop().trim();
@@ -304,41 +596,114 @@ function isNotAComponent(symbol, text) {
304
596
  /* --------------------------------------------------------------- guides */
305
597
 
306
598
  /**
307
- * Markdown guides from `config.guides`, plus any DocC bundle (`*.docc`) found
308
- * anywhere in the repo that is where a Swift package's guides usually live, and
309
- * it is not a directory a maintainer would think to list in `adsa.config.json`.
599
+ * Pages a documentation site keeps beside its guides that are not guides: a blog, a
600
+ * changelog, release notes, a migration write-up. They quote APIs that were removed
601
+ * on purpose, and counting them made a well-documented system look stale a hundred
602
+ * "imports that do not exist", every one of them a symbol a release note is explaining
603
+ * the removal of.
310
604
  */
311
- function findGuides(root, config) {
312
- const dirs = new Set(config.guides);
313
- for (const doccDir of walkDirs(root, [".docc"], 6)) dirs.add(rel(root, doccDir));
605
+ const NOT_A_GUIDE = /(^|\/)(blog|posts?|changelogs?|releases?|release-notes|news|migrations?|migrating[^/]*|upgrade(-guide)?|archive)(\/|\.mdx?$|$)/i;
606
+
607
+ /** Every markdown guide in `guideDirs`, plus the ones colocated with the source. */
608
+ function findGuides(root, config, guideDirs) {
314
609
  const guides = [];
315
- for (const dir of dirs) {
316
- for (const file of walk(join(root, dir), [".md", ".mdx"], 6)) {
610
+ const seen = new Set();
611
+ for (const dir of guideDirs) {
612
+ for (const file of walk(dir, [".md", ".mdx"], 6)) {
317
613
  const name = basename(file);
318
614
  if (/^(README|CHANGELOG|CONTRIBUTING|LICENSE|CODE_OF_CONDUCT|SECURITY)\./i.test(name)) continue;
319
- const text = read(file) || "";
320
- guides.push(guideFacts(root, file, text));
615
+ if (NOT_A_GUIDE.test(file)) continue;
616
+ if (seen.has(file)) continue;
617
+ seen.add(file);
618
+ guides.push(guideFacts(root, file, read(file) || "", false));
321
619
  }
322
620
  }
621
+ for (const file of colocatedGuides(root, config)) {
622
+ if (seen.has(file)) continue;
623
+ seen.add(file);
624
+ guides.push(guideFacts(root, file, read(file) || "", true));
625
+ }
323
626
  return guides;
324
627
  }
325
628
 
326
- function guideFacts(root, file, text) {
629
+ /**
630
+ * Absolute directories to read guides from.
631
+ *
632
+ * `config.guides` first; then any DocC bundle (`*.docc`) found anywhere in the
633
+ * package, because that is where a Swift package's guides live and it is not a
634
+ * directory a maintainer would think to list; then — unless the config named the
635
+ * directories by hand — the repository's own, because a monorepo commonly documents
636
+ * every package centrally in one `docs/` at the root.
637
+ */
638
+ export function resolveGuideDirs(root, config, repoRoot = root) {
639
+ const dirs = new Set(config.guides.map((d) => join(root, d)));
640
+ for (const doccDir of walkDirs(root, [".docc"], 6)) dirs.add(doccDir);
641
+ if (repoRoot !== root && !config.guidesExplicit) {
642
+ for (const d of defaultGuideDirs(repoRoot)) dirs.add(join(repoRoot, d));
643
+ }
644
+ // A skill's references are documentation — written for an agent, which is the
645
+ // reader this whole audit is about. A system that ships its catalog, its patterns
646
+ // and its motion rules as a skill has documented itself, and reading only `docs/`
647
+ // reported the most agent-native shape there is as having no documentation at all.
648
+ for (const base of unique([root, repoRoot])) {
649
+ for (const d of SKILL_DIRS) if (isDir(join(base, d))) dirs.add(join(base, d));
650
+ }
651
+ return [...dirs];
652
+ }
653
+
654
+ const SKILL_DIRS = [".claude/skills", ".cursor/skills", "skills"];
655
+
656
+ /**
657
+ * A guide can live next to the component it documents — `Button/Button.spec.md` —
658
+ * rather than in a documentation folder. That is a deliberate layout, not an absent
659
+ * one, and a scanner that only reads `docs/` reports a fully documented system as
660
+ * having no guides at all.
661
+ */
662
+ function colocatedGuides(root, config) {
663
+ const out = [];
664
+ for (const dir of config.source) {
665
+ for (const file of walk(join(root, dir), [".md", ".mdx"], 6)) {
666
+ if (!COLOCATED_GUIDE.test(basename(file))) continue;
667
+ if (/^(README|CHANGELOG)\./i.test(basename(file))) continue;
668
+ out.push(file);
669
+ }
670
+ }
671
+ return out;
672
+ }
673
+
674
+ /**
675
+ * The component a guide is about. `Button.spec.md`, `ButtonGroup.md` and
676
+ * `button-group.md` all name a component the same way once the documentation
677
+ * suffix is dropped and the casing is normalised.
678
+ */
679
+ function guideSlug(file) {
680
+ const stem = basename(file).replace(/\.mdx?$/, "").replace(/\.(spec|docs?|guide)$/i, "");
681
+ // A docs site routes by folder: `components/accordion/page.mdx` is the accordion
682
+ // guide, and every one of them would otherwise be a guide called "page".
683
+ if (/^(page|index|content|_index|readme)$/i.test(stem)) return kebab(basename(dirname(file)));
684
+ return kebab(stem);
685
+ }
686
+
687
+ function guideFacts(root, file, text, colocated = false) {
327
688
  const blocks = [...text.matchAll(CODE_FENCE)].map((m) => m[2]);
328
689
  const code = blocks.join("\n");
329
690
  return {
330
691
  path: rel(root, file),
331
- slug: basename(file).replace(/\.mdx?$/, "").toLowerCase(),
692
+ colocated,
693
+ slug: guideSlug(file),
332
694
  title: (text.match(/^#\s+(.+)$/m) || [, ""])[1].trim(),
333
695
  lines: text.split("\n").length,
334
696
  blocks: blocks.length,
335
697
  imports: importedNames(code),
336
698
  tags: jsxTags(code),
337
- hasPropTable: PROP_TABLE.test(text),
338
- generated: GENERATED.test(text),
699
+ hasPropTable: PROP_TABLE.test(text) || PROP_COMPONENT.test(text),
700
+ generated: GENERATED.test(text) || PROP_COMPONENT.test(text),
339
701
  hasA11y: A11Y_HEADING.test(text),
340
702
  rawPalette: unique([...(code.match(RAW_PALETTE) || []), ...(code.match(RAW_NEUTRAL) || [])]),
341
- 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])),
342
707
  body: text,
343
708
  };
344
709
  }
@@ -361,62 +726,190 @@ function jsxTags(code) {
361
726
 
362
727
  /* --------------------------------------------------------- agent instructions */
363
728
 
364
- function readAgentFiles(root, config, packageName) {
729
+ /**
730
+ * Agent instructions, read from the package and from the repository that declares it.
731
+ * A monorepo writes AGENTS.md once, at the root, and it governs every package under
732
+ * it — reading only the package directory reports a documented repo as undocumented.
733
+ */
734
+ function readAgentFiles(root, repoRoot, config, packageName) {
365
735
  const out = [];
366
- for (const candidate of config.agentFiles) {
367
- const path = join(root, candidate);
368
- const text = read(path);
369
- if (text === null) continue;
370
- out.push({
371
- file: candidate,
372
- lines: text.split("\n").length,
373
- mentionsPackage: packageName ? text.includes(packageName) : false,
374
- hasImportRule: /import\s*\{|from ["'][^"']+["']|import path|subpath|^import\s+\w/im.test(text),
375
- hasLookupCommand: /```|npx |yarn |pnpm |run `|swift |gradlew/i.test(text),
376
- hasTokenRule: /token|bg-primary|text-secondary|semantic|colorset|colors\.xml|theme/i.test(text),
377
- hasForbidden: /forbidden|never|do not|don't|avoid/i.test(text),
378
- hasFinishCheck: /before you (?:finish|call the work done)|before finishing|definition of done|must pass|checklist/i.test(text),
379
- hasStopAndAsk: /stop and ask|ask (?:the |a )?(?:human|person|maintainer)|do not invent|don't invent|never invent/i.test(text),
380
- });
736
+ const seen = new Set();
737
+ for (const base of [root, repoRoot]) {
738
+ for (const candidate of config.agentFiles) {
739
+ const path = join(base, candidate);
740
+ const text = read(path);
741
+ if (text === null || seen.has(path)) continue;
742
+ seen.add(path);
743
+ const fromRepoRoot = base !== root;
744
+ out.push({
745
+ file: candidate,
746
+ scope: fromRepoRoot ? "repo" : "package",
747
+ label: fromRepoRoot ? `${candidate} at the repository root` : candidate,
748
+ lines: text.split("\n").length,
749
+ mentionsPackage: packageName ? text.includes(packageName) : false,
750
+ hasImportRule: /import\s*\{|from ["'][^"']+["']|import path|subpath|^import\s+\w/im.test(text),
751
+ hasLookupCommand: /```|npx |yarn |pnpm |run `|swift |gradlew/i.test(text),
752
+ hasTokenRule: /token|bg-primary|text-secondary|semantic|colorset|colors\.xml|theme/i.test(text),
753
+ hasForbidden: /forbidden|never|do not|don't|avoid/i.test(text),
754
+ hasFinishCheck: /before you (?:finish|call the work done)|before finishing|definition of done|must pass|checklist/i.test(text),
755
+ hasStopAndAsk: /stop and ask|ask (?:the |a )?(?:human|person|maintainer)|do not invent|don't invent|never invent/i.test(text),
756
+ });
757
+ }
381
758
  }
382
759
  return out;
383
760
  }
384
761
 
385
762
  /* ------------------------------------------------------- machine surface */
386
763
 
387
- function findMachineSurface(root, pkg, config) {
388
- const mcpConfigs = [".mcp.json", ".cursor/mcp.json", ".vscode/mcp.json"].filter((f) => exists(join(root, f)));
389
- const declaredServers = mcpConfigs.flatMap((f) => {
390
- const json = readJson(join(root, f)) || {};
391
- return Object.keys(json.mcpServers || json.servers || {});
392
- });
393
- const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
764
+ /** Packages that make up the repository: its own manifest, plus every workspace it declares. */
765
+ function repoPackages(repoRoot) {
766
+ const out = [];
767
+ for (const dir of [repoRoot, ...workspaceDirs(repoRoot)]) {
768
+ const pkg = readJson(join(dir, "package.json"));
769
+ if (pkg) out.push({ dir, pkg });
770
+ }
771
+ return out;
772
+ }
773
+
774
+ /** Libraries a repository uses to *serve* MCP, rather than to consume one. */
775
+ const MCP_SERVER_DEPS = ["mcp-handler", "@modelcontextprotocol/sdk", "@vercel/mcp-adapter", "fastmcp", "mcp-lite"];
776
+ const MCP_CONFIG_FILES = [".mcp.json", ".cursor/mcp.json", ".vscode/mcp.json", ".windsurf/mcp.json"];
777
+ const LLMS_TXT_FILES = ["llms.txt", "public/llms.txt", "static/llms.txt", "docs/llms.txt", "app/llms.txt"];
778
+ const ROUTE_FILES = ["route.ts", "route.tsx", "route.js", "route.mjs", "route.jsx"];
779
+ const MCP_SERVER_FILES = ["mcp-server.ts", "mcp-server.mjs", "mcp-server.js", "mcp.server.ts"];
780
+
781
+ /**
782
+ * What an agent can query instead of reading files. Two things were invisible here
783
+ * before: a surface the repository *serves* rather than commits — an MCP endpoint or
784
+ * an llms.txt behind a route handler — and a surface that lives in a sibling
785
+ * workspace, which is where a monorepo always puts its CLI.
786
+ */
787
+ function findMachineSurface(root, repoRoot, pkg, config) {
788
+ const bases = unique([root, repoRoot]);
789
+ const mcpConfigs = [];
790
+ const declaredServers = [];
791
+ for (const base of bases) {
792
+ for (const candidate of MCP_CONFIG_FILES) {
793
+ const path = join(base, candidate);
794
+ if (!exists(path)) continue;
795
+ mcpConfigs.push(rel(repoRoot, path));
796
+ const json = readJson(path) || {};
797
+ declaredServers.push(...Object.keys(json.mcpServers || json.servers || {}));
798
+ }
799
+ }
800
+
801
+ const packages = repoPackages(repoRoot);
802
+ const repoDeps = Object.assign({}, ...packages.map((p) => ({ ...(p.pkg.dependencies || {}), ...(p.pkg.devDependencies || {}) })));
394
803
  const binNames = Object.keys(pkg.bin && typeof pkg.bin === "object" ? pkg.bin : pkg.bin ? { [pkg.name]: pkg.bin } : {});
395
804
  const shippedFiles = pkg.files || [];
396
805
  const cliShipped = binNames.length > 0 && shippedFiles.some((f) => /^(cli|bin|dist)/.test(f));
397
806
  const mcpInPackage = cliShipped && walk(root, [".mjs", ".ts", ".js"], 2).some((f) => /mcp/i.test(basename(f)));
398
- const skills = [...walk(join(root, ".claude/skills"), ["SKILL.md"], 3), ...walk(join(root, "skills"), ["SKILL.md"], 3)];
807
+ // One walk of the repository, split afterwards: a route that answers on /mcp,
808
+ // a route that renders llms.txt, and a server file named outright.
809
+ const found = walk(repoRoot, [...ROUTE_FILES, ...MCP_SERVER_FILES], 10).map((f) => rel(repoRoot, f));
810
+ const routes = found.filter((f) => ROUTE_FILES.some((r) => f.endsWith(r)));
811
+ const servers = found.filter((f) => MCP_SERVER_FILES.some((r) => f.endsWith(r)));
812
+
399
813
  return {
400
814
  mcpConfigs,
401
815
  declaredServers: unique(declaredServers),
402
- storybookMcp: Boolean(deps["@storybook/addon-mcp"]),
816
+ servedMcp: findServedMcp(repoRoot, packages, routes, servers),
817
+ storybookMcp: Boolean(repoDeps["@storybook/addon-mcp"]),
403
818
  binNames,
404
819
  cliShipped,
405
820
  mcpInPackage,
406
- llmsTxt: ["llms.txt", "public/llms.txt", "static/llms.txt", "docs/llms.txt"].filter((f) => exists(join(root, f))),
407
- skills: skills.map((f) => rel(root, f)),
821
+ siblingClis: findSiblingClis(root, packages),
822
+ llmsTxt: findLlmsTxt(bases, repoRoot, routes),
823
+ skills: findSkills(bases, repoRoot),
824
+ commands: findCommands(bases, repoRoot),
408
825
  };
409
826
  }
410
827
 
828
+ /**
829
+ * An MCP server this repository hosts: the library that implements one, or the route
830
+ * that answers on it. A system that serves MCP from its docs site never writes the
831
+ * `.mcp.json` that consumes it — that file belongs to the projects using the system.
832
+ */
833
+ function findServedMcp(repoRoot, packages, routes, servers) {
834
+ const evidence = [];
835
+ for (const { dir, pkg } of packages) {
836
+ const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
837
+ const found = MCP_SERVER_DEPS.filter((d) => deps[d]);
838
+ if (found.length) evidence.push(`${found.join(", ")} in ${rel(repoRoot, join(dir, "package.json")) || "package.json"}`);
839
+ }
840
+ for (const route of routes) {
841
+ if (/(^|\/)mcp(\/|$)/i.test(dirname(route))) evidence.push(route);
842
+ }
843
+ evidence.push(...servers);
844
+ return unique(evidence);
845
+ }
846
+
847
+ /** A docs CLI shipped from a sibling workspace — where a monorepo keeps it. */
848
+ function findSiblingClis(root, packages) {
849
+ return packages
850
+ .filter((p) => p.dir !== root && p.pkg.bin && !p.pkg.private)
851
+ .map((p) => ({ name: p.pkg.name, bin: Object.keys(typeof p.pkg.bin === "object" ? p.pkg.bin : { [p.pkg.name]: p.pkg.bin }) }))
852
+ .filter((c) => c.name);
853
+ }
854
+
855
+ /** A committed llms.txt, or one a route handler generates from the live docs. */
856
+ function findLlmsTxt(bases, repoRoot, routes) {
857
+ const out = [];
858
+ for (const base of bases) {
859
+ for (const candidate of LLMS_TXT_FILES) {
860
+ if (exists(join(base, candidate))) out.push(rel(repoRoot, join(base, candidate)));
861
+ }
862
+ }
863
+ for (const route of routes) {
864
+ if (/llms[.-]txt/i.test(route)) out.push(route);
865
+ }
866
+ return unique(out);
867
+ }
868
+
869
+ /**
870
+ * Published agent skills. A skill is a markdown file in a skills directory — the
871
+ * `SKILL.md` convention is one shape of that, not the only one, and requiring the
872
+ * filename reported a repo full of skills as having none.
873
+ */
874
+ function findSkills(bases, repoRoot) {
875
+ const out = [];
876
+ for (const base of bases) {
877
+ for (const dir of [".claude/skills", "skills", ".cursor/skills"]) {
878
+ for (const file of walk(join(base, dir), [".md"], 3)) {
879
+ if (/^(README|CHANGELOG)\./i.test(basename(file))) continue;
880
+ out.push(rel(repoRoot, file));
881
+ }
882
+ }
883
+ }
884
+ return unique(out);
885
+ }
886
+
887
+ /** Slash commands are instructions an agent can run by name, same as a skill. */
888
+ function findCommands(bases, repoRoot) {
889
+ const out = [];
890
+ for (const base of bases) {
891
+ for (const file of walk(join(base, ".claude/commands"), [".md"], 2)) {
892
+ if (/^(README|CHANGELOG)\./i.test(basename(file))) continue;
893
+ out.push(rel(repoRoot, file));
894
+ }
895
+ }
896
+ return unique(out);
897
+ }
898
+
411
899
  /* ------------------------------------------------------------------- CI */
412
900
 
413
- function readCi(root, pkg, platform) {
414
- const files = [...walk(join(root, ".github/workflows"), [".yml", ".yaml"], 2)];
901
+ /**
902
+ * CI belongs to the repository, not to a package inside it: a monorepo keeps one
903
+ * `.github/workflows` at the root and runs every package from there. `repoPkg` is
904
+ * the root manifest, whose scripts are what those workflows actually call.
905
+ */
906
+ function readCi(repoRoot, pkg, repoPkg, platform) {
907
+ const files = [...walk(join(repoRoot, ".github/workflows"), [".yml", ".yaml"], 2)];
415
908
  const text = files.map((f) => read(f) || "").join("\n");
416
- const scripts = pkg.scripts || {};
909
+ const scripts = { ...(repoPkg.scripts || {}), ...(pkg.scripts || {}) };
417
910
  const all = text + "\n" + Object.values(scripts).join("\n");
418
911
  return {
419
- workflows: files.map((f) => rel(root, f)),
912
+ workflows: files.map((f) => rel(repoRoot, f)),
420
913
  scripts,
421
914
  runsTests: /\b(vitest|jest|node --test|yarn test|npm test|pnpm test|xcodebuild|swift test|gradlew|gradle test|fastlane)\b/.test(all),
422
915
  runsStoryTests: /(test-storybook|test:storybook|storybook.*test|addon-vitest)/.test(all),
@@ -442,12 +935,15 @@ function matchCoverage(components, guides) {
442
935
  const documented = [];
443
936
  const missing = [];
444
937
  for (const c of components) {
445
- 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;
446
940
  if (guide) documented.push({ ...c, guide: guide.path });
447
941
  else missing.push(c);
448
942
  }
449
943
  const total = components.length;
450
- 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 };
451
947
  }
452
948
 
453
949
  function depluralize(slug) {
@@ -458,6 +954,12 @@ function byPrefix(guides, slug) {
458
954
  return guides.find((g) => g.slug.length >= 4 && (slug.startsWith(g.slug + "-") || slug.startsWith(depluralize(g.slug) + "-")));
459
955
  }
460
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
+
461
963
  function byMention(guides, name) {
462
964
  const re = new RegExp(`(<|\\b)${name}\\b`);
463
965
  return guides.find((g) => re.test(g.body));
@@ -466,7 +968,10 @@ function byMention(guides, name) {
466
968
  function checkFreshness(guides, symbols, packageName, ci) {
467
969
  const unknown = [];
468
970
  for (const guide of guides) {
469
- const fromSystem = guide.imports.filter((i) => (packageName && i.from.startsWith(packageName)) || i.from.startsWith("."));
971
+ // A relative import in a documentation page is the reader's own file —
972
+ // `./components/UserForm` in an example is not a claim about this package.
973
+ // Beside a component, in a colocated guide, `./Button` is exactly that claim.
974
+ const fromSystem = guide.imports.filter((i) => (packageName && i.from.startsWith(packageName)) || (guide.colocated && i.from.startsWith(".")));
470
975
  for (const imp of fromSystem) {
471
976
  if (!symbols.has(imp.name)) unknown.push({ guide: guide.path, name: imp.name, from: imp.from });
472
977
  }
@@ -482,20 +987,29 @@ function checkFreshness(guides, symbols, packageName, ci) {
482
987
  };
483
988
  }
484
989
 
485
- function checkTokens(guides, ci, root, platform) {
486
- 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);
487
996
  const rawHits = guides.flatMap((g) => g.rawPalette.map((c) => ({ guide: g.path, value: c })));
488
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]);
489
1003
  return {
490
1004
  docs: docs.map((g) => g.path),
491
- motion: docs.some((g) => /motion|duration|easing|transition/i.test(g.slug + g.title) || /^#{1,4}\s.*(motion|easing|duration)/im.test(g.body)),
492
- 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"),
493
1007
  rawPalette: rawHits.slice(0, 30),
494
1008
  rawPaletteCount: rawHits.length,
495
1009
  hex: hexHits.slice(0, 20),
496
1010
  hexCount: hexHits.length,
497
1011
  lintEnforced: /(eslint|biome).*(token|palette)|no-restricted-syntax/i.test(Object.values(ci.scripts).join(" ")),
498
- source: findSourceTokens(root, platform),
1012
+ source,
499
1013
  };
500
1014
  }
501
1015
 
@@ -505,7 +1019,26 @@ function checkTokens(guides, ci, root, platform) {
505
1019
  * `colors.xml`/`themes.xml`. Real files, real counts — an agent can open either and
506
1020
  * see the same semantic names a guide would otherwise have to spell out.
507
1021
  */
508
- 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
+ }
509
1042
  if (platform === "swift") {
510
1043
  const catalogs = walkDirs(root, [".xcassets"], 6);
511
1044
  const colorSets = catalogs.flatMap((c) => walkDirs(c, [".colorset"], 3));
@@ -533,6 +1066,41 @@ function findSourceTokens(root, platform) {
533
1066
  return null;
534
1067
  }
535
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
+
536
1104
  function checkPatterns(guides) {
537
1105
  const files = guides.filter((g) => /pattern|template|recipe|layout|page|blueprint|composition/i.test(g.slug + " " + g.title));
538
1106
  // A pattern is worth 5 only if it carries structure: states, traps, a skeleton.
@@ -555,8 +1123,10 @@ function checkA11y(guides, ci, root, platform) {
555
1123
  };
556
1124
  }
557
1125
 
558
- function checkVerification(root, pkg, ci, platform) {
559
- const scripts = pkg.scripts || {};
1126
+ function checkVerification(root, pkg, repoPkg, ci, platform) {
1127
+ // A workspace package often leaves `test` and `lint` to the repository root that
1128
+ // runs them; both sets are the same promise to a reader.
1129
+ const scripts = { ...(repoPkg.scripts || {}), ...(pkg.scripts || {}) };
560
1130
  const tests = walk(root, [".test.ts", ".test.tsx", ".test.mjs", ".spec.ts", ".spec.tsx", "Tests.swift", "Test.kt", "Tests.kt"], 6).length;
561
1131
  const stories = walk(root, [".stories.ts", ".stories.tsx", ".stories.js"], 6).length;
562
1132
  return {
@@ -574,10 +1144,11 @@ function checkVerification(root, pkg, ci, platform) {
574
1144
  };
575
1145
  }
576
1146
 
577
- function checkGaps(root, guides, agentFiles, pkg) {
1147
+ function checkGaps(root, repoRoot, guides, agentFiles, pkg, repoPkg) {
578
1148
  const guideFile = guides.find((g) => /gap|absence|missing|not-built|unsupported|what-we-don/i.test(g.slug + " " + g.title));
579
- const named = ["GAPS.md", "docs/GAPS.md", "guidelines/GAPS.md", "docs/gaps.md"].filter((f) => exists(join(root, f)));
580
- const scripts = Object.entries(pkg.scripts || {}).filter(([k]) => /gap/i.test(k));
1149
+ const candidates = ["GAPS.md", "docs/GAPS.md", "guidelines/GAPS.md", "docs/gaps.md"];
1150
+ const named = unique([root, repoRoot]).flatMap((base) => candidates.filter((f) => exists(join(base, f))).map((f) => (base === root ? f : rel(root, join(base, f)))));
1151
+ const scripts = Object.entries({ ...(repoPkg.scripts || {}), ...(pkg.scripts || {}) }).filter(([k]) => /gap/i.test(k));
581
1152
  return {
582
1153
  file: named[0] || (guideFile ? guideFile.path : null),
583
1154
  reportCommand: scripts.length ? scripts[0][0] : null,