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/README.md +126 -11
- package/bin/adsa.mjs +121 -16
- package/lib/badge.mjs +28 -12
- package/lib/config.mjs +123 -47
- package/lib/reference.mjs +49 -0
- package/lib/report.mjs +17 -12
- package/lib/scan.mjs +643 -72
- package/lib/score.mjs +44 -11
- package/lib/target.mjs +82 -0
- package/package.json +1 -1
- package/rubric/reference.json +293 -0
- package/skills/ds-audit/SKILL.md +44 -2
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
|
-
|
|
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
|
-
|
|
43
|
-
facts.
|
|
44
|
-
|
|
45
|
-
|
|
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.
|
|
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) => !
|
|
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
|
-
|
|
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
|
-
*
|
|
308
|
-
*
|
|
309
|
-
*
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
-
|
|
316
|
-
|
|
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
|
-
|
|
320
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
367
|
-
|
|
368
|
-
const
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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
|
-
|
|
388
|
-
|
|
389
|
-
const
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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
|
-
|
|
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
|
-
|
|
816
|
+
servedMcp: findServedMcp(repoRoot, packages, routes, servers),
|
|
817
|
+
storybookMcp: Boolean(repoDeps["@storybook/addon-mcp"]),
|
|
403
818
|
binNames,
|
|
404
819
|
cliShipped,
|
|
405
820
|
mcpInPackage,
|
|
406
|
-
|
|
407
|
-
|
|
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
|
-
|
|
414
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
492
|
-
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"),
|
|
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
|
|
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
|
-
|
|
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
|
|
580
|
-
const
|
|
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,
|