adsa-cli 0.1.1 → 0.1.4
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 +98 -11
- package/bin/adsa.mjs +143 -27
- package/lib/badge.mjs +28 -12
- package/lib/config.mjs +123 -47
- package/lib/history.mjs +9 -7
- package/lib/reference.mjs +32 -0
- package/lib/report.mjs +17 -12
- package/lib/scan.mjs +416 -61
- package/lib/score.mjs +19 -7
- package/lib/target.mjs +82 -0
- package/package.json +1 -1
- package/rubric/reference.json +293 -0
- package/skills/ds-audit/SKILL.md +37 -2
package/lib/scan.mjs
CHANGED
|
@@ -3,8 +3,9 @@
|
|
|
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
|
const CODE_FENCE = /```(tsx|jsx|ts|js|typescript|javascript|swift|kotlin|kt)\s*\n([\s\S]*?)```/g;
|
|
10
11
|
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";
|
|
@@ -14,20 +15,44 @@ const HEX = /#[0-9a-fA-F]{3,8}\b/g;
|
|
|
14
15
|
const A11Y_HEADING = /^#{2,4}.*(keyboard|accessib|a11y|screen reader|aria|voiceover|talkback|dynamic type|touch target|contentdescription)/im;
|
|
15
16
|
const GENERATED = /<!--\s*(?:generated|auto-?generated|do not edit|prop-table:start|props:start)/i;
|
|
16
17
|
const PROP_TABLE = /^\|[^\n]*\bprops?\b[^\n]*\|[^\n]*\btype\b[^\n]*\|/im;
|
|
18
|
+
/**
|
|
19
|
+
* A documentation site that renders its props from the types puts a component in the
|
|
20
|
+
* page — `<PropTable component="Button" />` — and never writes the table itself. That
|
|
21
|
+
* is the thing this dimension is looking for, and reading only markdown pipes counted
|
|
22
|
+
* the systems doing it properly as having no prop tables and hand-maintaining them.
|
|
23
|
+
*/
|
|
24
|
+
const PROP_COMPONENT = /<(Props?Table|PropsTable|PropTable|ComponentProps|ArgTypes|ApiTable|TypeTable|AutoTypeTable|PropertyTable|ApiReference|PropDefs)\b/;
|
|
17
25
|
const PASCAL_EXPORT = /export\s+(?:default\s+)?(?:const|function|class)\s+([A-Z][A-Za-z0-9_]*)/g;
|
|
18
26
|
const TYPE_EXPORT = /export\s+(?:type|interface)\s+([A-Z][A-Za-z0-9_]*)/g;
|
|
19
27
|
/** `struct Foo: View`, `public class Foo: UIView` — captures the whole conformance list so a multi-protocol clause still matches. */
|
|
20
28
|
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
29
|
const SWIFT_SYMBOL = /\b(?:public\s+|open\s+)?(?:static\s+)?(?:struct|class|enum|func)\s+([A-Za-z][A-Za-z0-9_]*)/g;
|
|
22
30
|
/** `@Composable` followed, within a short window (annotations, modifiers, newlines), by `fun Name(`. */
|
|
31
|
+
/**
|
|
32
|
+
* Subpaths every second package exports and nobody imports as a component. Without
|
|
33
|
+
* this, a CLI that ships `./registry`, `./schema`, `./mcp` and `./utils` reads as a
|
|
34
|
+
* five-component design system and wins the workspace over the component library.
|
|
35
|
+
*/
|
|
36
|
+
const NOT_A_COMPONENT_SUBPATH = new Set([
|
|
37
|
+
"styles", "theme", "themes", "tokens", "eslint", "prose", "package.json", "registry", "schema", "mcp", "cli",
|
|
38
|
+
"utils", "utilities", "helpers", "hooks", "preset", "presets", "config", "plugin", "plugins", "types", "server",
|
|
39
|
+
"test", "testing", "next", "vite", "webpack", "babel", "codemod", "migrate", "internal", "unstable", "experimental",
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
const NAMESPACE_EXPORT = /export\s+\*\s+as\s+([A-Z][A-Za-z0-9_]*)\s+from/g;
|
|
23
43
|
const COMPOSABLE = /@Composable[\s\S]{0,120}?\bfun\s+([A-Z][A-Za-z0-9_]*)\s*\(/g;
|
|
24
44
|
const KOTLIN_SYMBOL = /\b(?:public\s+|internal\s+)?(?:class|object|interface|enum class)\s+([A-Z][A-Za-z0-9_]*)/g;
|
|
25
45
|
|
|
26
|
-
export function scan(root, config) {
|
|
46
|
+
export function scan(root, config, repoRoot = root) {
|
|
27
47
|
const pkg = readJson(join(root, "package.json")) || {};
|
|
48
|
+
const repoPkg = repoRoot === root ? pkg : readJson(join(repoRoot, "package.json")) || {};
|
|
28
49
|
const platform = detectPlatform(root, pkg);
|
|
29
50
|
const facts = {
|
|
30
51
|
root,
|
|
52
|
+
repoRoot,
|
|
53
|
+
// What a package inherits from the repository around it: agent instructions,
|
|
54
|
+
// CI, the agent surface. Null when the package is the repository.
|
|
55
|
+
monorepo: repoRoot === root ? null : { root: repoRoot, package: rel(repoRoot, root) },
|
|
31
56
|
name: detectName(root, pkg),
|
|
32
57
|
version: pkg.version || null,
|
|
33
58
|
packageJson: Boolean(pkg.name),
|
|
@@ -39,20 +64,102 @@ export function scan(root, config) {
|
|
|
39
64
|
facts.components = findComponents(root, pkg, config, platform.primary);
|
|
40
65
|
facts.icons = countIconExports(pkg);
|
|
41
66
|
facts.symbols = findSymbols(root, config, platform.primary);
|
|
42
|
-
|
|
43
|
-
facts.
|
|
44
|
-
|
|
45
|
-
|
|
67
|
+
let guideDirs = resolveGuideDirs(root, config, repoRoot);
|
|
68
|
+
facts.guides = findGuides(root, config, guideDirs);
|
|
69
|
+
const site = config.guidesExplicit ? null : findDocsSite(repoRoot, facts.components, facts.guides);
|
|
70
|
+
if (site) {
|
|
71
|
+
guideDirs = [...guideDirs, site];
|
|
72
|
+
facts.guides = findGuides(root, config, guideDirs);
|
|
73
|
+
}
|
|
74
|
+
facts.agentFiles = readAgentFiles(root, repoRoot, config, facts.name);
|
|
75
|
+
facts.machine = findMachineSurface(root, repoRoot, pkg, config);
|
|
76
|
+
facts.ci = readCi(repoRoot, pkg, repoPkg, platform.primary);
|
|
46
77
|
facts.coverage = matchCoverage(facts.components, facts.guides);
|
|
47
78
|
facts.freshness = checkFreshness(facts.guides, facts.symbols, facts.name, facts.ci);
|
|
48
79
|
facts.tokens = checkTokens(facts.guides, facts.ci, root, platform.primary);
|
|
49
80
|
facts.patterns = checkPatterns(facts.guides);
|
|
50
81
|
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);
|
|
82
|
+
facts.verification = checkVerification(root, pkg, repoPkg, facts.ci, platform.primary);
|
|
83
|
+
facts.gaps = checkGaps(root, repoRoot, facts.guides, facts.agentFiles, pkg, repoPkg);
|
|
84
|
+
facts.scanned = describeScan(facts, config, guideDirs);
|
|
53
85
|
return facts;
|
|
54
86
|
}
|
|
55
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Which directories this run actually read. Printed by `audit`, because "not found"
|
|
90
|
+
* and "not looked for" are the same sentence otherwise — the exact confusion this
|
|
91
|
+
* tool exists to stop an agent from making.
|
|
92
|
+
*/
|
|
93
|
+
/**
|
|
94
|
+
* The directory where this repository documents its components, found by asking which
|
|
95
|
+
* directory's markdown is *named after the components* — `accordion.mdx`, or
|
|
96
|
+
* `accordion/page.mdx`.
|
|
97
|
+
*
|
|
98
|
+
* No list of directory names finds these. A monorepo keeps its guides in whatever its
|
|
99
|
+
* documentation site happens to be called — `apps/www/content/docs`, `apps/v4/content`,
|
|
100
|
+
* `docs/src/app`, `apps/mantine.dev/src/pages` — and every system whose docs the list
|
|
101
|
+
* missed was reported as a system with no documentation at all. Matching against the
|
|
102
|
+
* component names is what makes the answer checkable: a folder of blog posts scores
|
|
103
|
+
* zero, and a folder of component guides cannot.
|
|
104
|
+
*
|
|
105
|
+
* Returns a directory only when it beats what has already been found.
|
|
106
|
+
*/
|
|
107
|
+
function findDocsSite(repoRoot, components, found) {
|
|
108
|
+
const slugs = new Set(components.map((c) => c.slug));
|
|
109
|
+
if (slugs.size < 3) return null;
|
|
110
|
+
const matched = (files) => new Set(files.filter((f) => slugs.has(guideSlug(f))).map((f) => guideSlug(f))).size;
|
|
111
|
+
const already = new Set(found.filter((g) => slugs.has(g.slug)).map((g) => g.slug)).size;
|
|
112
|
+
|
|
113
|
+
const byDir = new Map();
|
|
114
|
+
for (const file of walk(repoRoot, [".md", ".mdx"], 9)) {
|
|
115
|
+
if (/^(README|CHANGELOG|CONTRIBUTING|LICENSE|CODE_OF_CONDUCT|SECURITY)\./i.test(basename(file))) continue;
|
|
116
|
+
// A folder-routed site gives every guide a folder of its own, so the section
|
|
117
|
+
// that holds them all is one level further up: `components/button/page.mdx`
|
|
118
|
+
// groups with `components/card/page.mdx`, not alone.
|
|
119
|
+
const dir = ROUTE_FILE.test(basename(file)) ? dirname(dirname(file)) : dirname(file);
|
|
120
|
+
if (!byDir.has(dir)) byDir.set(dir, []);
|
|
121
|
+
byDir.get(dir).push(file);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
let best = null;
|
|
125
|
+
for (const [dir, files] of byDir) {
|
|
126
|
+
const score = matched(files);
|
|
127
|
+
if (score >= 3 && score > already && (!best || score > best.score)) best = { dir, score };
|
|
128
|
+
}
|
|
129
|
+
return best ? docsRoot(best.dir) : null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The documentation tree a component folder belongs to, so patterns and token pages
|
|
134
|
+
* beside the component pages are read too. Climbing stops at the first folder a
|
|
135
|
+
* documentation site is ever called, and after three levels, so a site nested in a
|
|
136
|
+
* repository never widens into the repository.
|
|
137
|
+
*/
|
|
138
|
+
const ROUTE_FILE = /^(page|index|content|_index|readme)\.mdx?$/i;
|
|
139
|
+
|
|
140
|
+
const DOCS_ROOT_NAMES = new Set(["docs", "doc", "content", "pages", "guides", "documentation"]);
|
|
141
|
+
|
|
142
|
+
function docsRoot(dir) {
|
|
143
|
+
let at = dir;
|
|
144
|
+
for (let i = 0; i < 3; i++) {
|
|
145
|
+
if (DOCS_ROOT_NAMES.has(basename(at).toLowerCase())) return at;
|
|
146
|
+
const parent = dirname(at);
|
|
147
|
+
if (parent === at) break;
|
|
148
|
+
at = parent;
|
|
149
|
+
}
|
|
150
|
+
return dir;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function describeScan(facts, config, guideDirs) {
|
|
154
|
+
return {
|
|
155
|
+
package: facts.monorepo ? facts.monorepo.package : ".",
|
|
156
|
+
repo: facts.repoRoot,
|
|
157
|
+
guides: guideDirs.map((d) => rel(facts.root, d) || "."),
|
|
158
|
+
source: [...config.source],
|
|
159
|
+
colocatedGuides: facts.guides.filter((g) => g.colocated).length,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
56
163
|
/* ------------------------------------------------------------- platform */
|
|
57
164
|
|
|
58
165
|
/**
|
|
@@ -151,6 +258,17 @@ function detectStack(root, pkg, platform) {
|
|
|
151
258
|
|
|
152
259
|
/* ----------------------------------------------------------- components */
|
|
153
260
|
|
|
261
|
+
/**
|
|
262
|
+
* How many importable components a directory would report if it were audited. This
|
|
263
|
+
* is how the right package is picked out of a monorepo, so it runs on packages that
|
|
264
|
+
* will never be audited — cheap enough to run on all of them, and exactly the same
|
|
265
|
+
* rule as the audit, so the count the reader is shown is the count that was ranked.
|
|
266
|
+
*/
|
|
267
|
+
export function countComponents(dir) {
|
|
268
|
+
const pkg = readJson(join(dir, "package.json")) || {};
|
|
269
|
+
return findComponents(dir, pkg, loadConfig(dir), detectPlatform(dir, pkg).primary).length;
|
|
270
|
+
}
|
|
271
|
+
|
|
154
272
|
/**
|
|
155
273
|
* What a consumer can import. Package `exports` subpaths are the most reliable
|
|
156
274
|
* answer, and when a package ships them they *are* the surface, so the source scan
|
|
@@ -161,10 +279,13 @@ function findComponents(root, pkg, config, platform) {
|
|
|
161
279
|
if (platform === "swift") return findSwiftComponents(root, config);
|
|
162
280
|
if (platform === "android") return findKotlinComponents(root, config);
|
|
163
281
|
|
|
282
|
+
const registry = readRegistry(root);
|
|
283
|
+
if (registry) return registry;
|
|
284
|
+
|
|
164
285
|
const subpaths = Object.keys(pkg.exports || {})
|
|
165
286
|
.filter((k) => k.startsWith("./") && !k.includes("*") && !/\.(css|json|js|mjs|cjs)$/.test(k))
|
|
166
287
|
.map((k) => k.replace(/^\.\//, ""))
|
|
167
|
-
.filter((slug) => !
|
|
288
|
+
.filter((slug) => !NOT_A_COMPONENT_SUBPATH.has(slug));
|
|
168
289
|
if (subpaths.length >= 5) {
|
|
169
290
|
return subpaths
|
|
170
291
|
.filter((slug) => !isIconSlug(slug))
|
|
@@ -173,6 +294,15 @@ function findComponents(root, pkg, config, platform) {
|
|
|
173
294
|
}
|
|
174
295
|
const out = new Map();
|
|
175
296
|
for (const dir of config.source) {
|
|
297
|
+
// `export * as Dialog from "@acme/react-dialog"`: the aggregate package a
|
|
298
|
+
// monorepo publishes, whose own source is one line per component. Reading only
|
|
299
|
+
// JSX files reports the package a consumer actually installs as empty.
|
|
300
|
+
for (const file of walk(join(root, dir), [".ts"], 3)) {
|
|
301
|
+
for (const m of (read(file) || "").matchAll(NAMESPACE_EXPORT)) {
|
|
302
|
+
const slug = kebab(m[1]);
|
|
303
|
+
if (!out.has(slug)) out.set(slug, { slug, name: m[1], from: rel(root, file) });
|
|
304
|
+
}
|
|
305
|
+
}
|
|
176
306
|
for (const file of walk(join(root, dir), [".tsx", ".jsx", ".vue", ".svelte"], 6)) {
|
|
177
307
|
if (isNoiseFile(rel(root, file))) continue;
|
|
178
308
|
const text = read(file) || "";
|
|
@@ -187,6 +317,19 @@ function findComponents(root, pkg, config, platform) {
|
|
|
187
317
|
return [...out.values()].sort((a, b) => a.slug.localeCompare(b.slug));
|
|
188
318
|
}
|
|
189
319
|
|
|
320
|
+
/**
|
|
321
|
+
* A registry ships components as source rather than as an import, and `registry.json`
|
|
322
|
+
* declares which of its items are components: a block, an example and a chart are all
|
|
323
|
+
* in there too, and counting them makes a system of forty components look like one of
|
|
324
|
+
* four hundred, none of them documented.
|
|
325
|
+
*/
|
|
326
|
+
function readRegistry(root) {
|
|
327
|
+
const file = readJson(join(root, "registry.json"));
|
|
328
|
+
const items = Array.isArray(file?.items) ? file.items.filter((i) => i.type === "registry:ui" && i.name) : [];
|
|
329
|
+
if (items.length < 5) return null;
|
|
330
|
+
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));
|
|
331
|
+
}
|
|
332
|
+
|
|
190
333
|
/** `public struct Foo: View` / `open class Foo: UIView` / `struct Foo: UIViewController`. */
|
|
191
334
|
function findSwiftComponents(root, config) {
|
|
192
335
|
const out = new Map();
|
|
@@ -252,12 +395,20 @@ function findSymbols(root, config, platform) {
|
|
|
252
395
|
if (platform === "android") return findKotlinSymbols(root, config);
|
|
253
396
|
|
|
254
397
|
const symbols = new Set();
|
|
255
|
-
|
|
398
|
+
// Wider than the component scan on purpose: `config.source` is where components
|
|
399
|
+
// live, and a provider, a hook or a type exported from `src/styled-system` is
|
|
400
|
+
// still a symbol a guide may legitimately import. Reading only the component
|
|
401
|
+
// folder reported real exports as imports that do not exist.
|
|
402
|
+
const dirs = [...new Set([...config.source, "src", "lib", "dist"])];
|
|
256
403
|
for (const dir of dirs) {
|
|
257
404
|
for (const file of walk(join(root, dir), [".tsx", ".ts", ".jsx", ".vue", ".svelte"], 7)) {
|
|
258
405
|
const text = read(file) || "";
|
|
259
406
|
for (const m of text.matchAll(PASCAL_EXPORT)) symbols.add(m[1]);
|
|
260
407
|
for (const m of text.matchAll(TYPE_EXPORT)) symbols.add(m[1]);
|
|
408
|
+
// `export * as Accordion from "./accordion"` is how a namespace API is
|
|
409
|
+
// published, and a guide importing `Accordion` was being reported as
|
|
410
|
+
// importing something that does not exist.
|
|
411
|
+
for (const m of text.matchAll(NAMESPACE_EXPORT)) symbols.add(m[1]);
|
|
261
412
|
for (const m of text.matchAll(/export\s*\{([^}]+)\}/g)) {
|
|
262
413
|
for (const part of m[1].split(",")) {
|
|
263
414
|
const name = part.trim().split(/\s+as\s+/).pop().trim();
|
|
@@ -304,38 +455,108 @@ function isNotAComponent(symbol, text) {
|
|
|
304
455
|
/* --------------------------------------------------------------- guides */
|
|
305
456
|
|
|
306
457
|
/**
|
|
307
|
-
*
|
|
308
|
-
*
|
|
309
|
-
*
|
|
458
|
+
* Pages a documentation site keeps beside its guides that are not guides: a blog, a
|
|
459
|
+
* changelog, release notes, a migration write-up. They quote APIs that were removed
|
|
460
|
+
* on purpose, and counting them made a well-documented system look stale — a hundred
|
|
461
|
+
* "imports that do not exist", every one of them a symbol a release note is explaining
|
|
462
|
+
* the removal of.
|
|
310
463
|
*/
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
464
|
+
const NOT_A_GUIDE = /(^|\/)(blog|posts?|changelogs?|releases?|release-notes|news|migrations?|migrating[^/]*|upgrade(-guide)?|archive)(\/|\.mdx?$|$)/i;
|
|
465
|
+
|
|
466
|
+
/** Every markdown guide in `guideDirs`, plus the ones colocated with the source. */
|
|
467
|
+
function findGuides(root, config, guideDirs) {
|
|
314
468
|
const guides = [];
|
|
315
|
-
|
|
316
|
-
|
|
469
|
+
const seen = new Set();
|
|
470
|
+
for (const dir of guideDirs) {
|
|
471
|
+
for (const file of walk(dir, [".md", ".mdx"], 6)) {
|
|
317
472
|
const name = basename(file);
|
|
318
473
|
if (/^(README|CHANGELOG|CONTRIBUTING|LICENSE|CODE_OF_CONDUCT|SECURITY)\./i.test(name)) continue;
|
|
319
|
-
|
|
320
|
-
|
|
474
|
+
if (NOT_A_GUIDE.test(file)) continue;
|
|
475
|
+
if (seen.has(file)) continue;
|
|
476
|
+
seen.add(file);
|
|
477
|
+
guides.push(guideFacts(root, file, read(file) || "", false));
|
|
321
478
|
}
|
|
322
479
|
}
|
|
480
|
+
for (const file of colocatedGuides(root, config)) {
|
|
481
|
+
if (seen.has(file)) continue;
|
|
482
|
+
seen.add(file);
|
|
483
|
+
guides.push(guideFacts(root, file, read(file) || "", true));
|
|
484
|
+
}
|
|
323
485
|
return guides;
|
|
324
486
|
}
|
|
325
487
|
|
|
326
|
-
|
|
488
|
+
/**
|
|
489
|
+
* Absolute directories to read guides from.
|
|
490
|
+
*
|
|
491
|
+
* `config.guides` first; then any DocC bundle (`*.docc`) found anywhere in the
|
|
492
|
+
* package, because that is where a Swift package's guides live and it is not a
|
|
493
|
+
* directory a maintainer would think to list; then — unless the config named the
|
|
494
|
+
* directories by hand — the repository's own, because a monorepo commonly documents
|
|
495
|
+
* every package centrally in one `docs/` at the root.
|
|
496
|
+
*/
|
|
497
|
+
export function resolveGuideDirs(root, config, repoRoot = root) {
|
|
498
|
+
const dirs = new Set(config.guides.map((d) => join(root, d)));
|
|
499
|
+
for (const doccDir of walkDirs(root, [".docc"], 6)) dirs.add(doccDir);
|
|
500
|
+
if (repoRoot !== root && !config.guidesExplicit) {
|
|
501
|
+
for (const d of defaultGuideDirs(repoRoot)) dirs.add(join(repoRoot, d));
|
|
502
|
+
}
|
|
503
|
+
// A skill's references are documentation — written for an agent, which is the
|
|
504
|
+
// reader this whole audit is about. A system that ships its catalog, its patterns
|
|
505
|
+
// and its motion rules as a skill has documented itself, and reading only `docs/`
|
|
506
|
+
// reported the most agent-native shape there is as having no documentation at all.
|
|
507
|
+
for (const base of unique([root, repoRoot])) {
|
|
508
|
+
for (const d of SKILL_DIRS) if (isDir(join(base, d))) dirs.add(join(base, d));
|
|
509
|
+
}
|
|
510
|
+
return [...dirs];
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const SKILL_DIRS = [".claude/skills", ".cursor/skills", "skills"];
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* A guide can live next to the component it documents — `Button/Button.spec.md` —
|
|
517
|
+
* rather than in a documentation folder. That is a deliberate layout, not an absent
|
|
518
|
+
* one, and a scanner that only reads `docs/` reports a fully documented system as
|
|
519
|
+
* having no guides at all.
|
|
520
|
+
*/
|
|
521
|
+
function colocatedGuides(root, config) {
|
|
522
|
+
const out = [];
|
|
523
|
+
for (const dir of config.source) {
|
|
524
|
+
for (const file of walk(join(root, dir), [".md", ".mdx"], 6)) {
|
|
525
|
+
if (!COLOCATED_GUIDE.test(basename(file))) continue;
|
|
526
|
+
if (/^(README|CHANGELOG)\./i.test(basename(file))) continue;
|
|
527
|
+
out.push(file);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
return out;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* The component a guide is about. `Button.spec.md`, `ButtonGroup.md` and
|
|
535
|
+
* `button-group.md` all name a component the same way once the documentation
|
|
536
|
+
* suffix is dropped and the casing is normalised.
|
|
537
|
+
*/
|
|
538
|
+
function guideSlug(file) {
|
|
539
|
+
const stem = basename(file).replace(/\.mdx?$/, "").replace(/\.(spec|docs?|guide)$/i, "");
|
|
540
|
+
// A docs site routes by folder: `components/accordion/page.mdx` is the accordion
|
|
541
|
+
// guide, and every one of them would otherwise be a guide called "page".
|
|
542
|
+
if (/^(page|index|content|_index|readme)$/i.test(stem)) return kebab(basename(dirname(file)));
|
|
543
|
+
return kebab(stem);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function guideFacts(root, file, text, colocated = false) {
|
|
327
547
|
const blocks = [...text.matchAll(CODE_FENCE)].map((m) => m[2]);
|
|
328
548
|
const code = blocks.join("\n");
|
|
329
549
|
return {
|
|
330
550
|
path: rel(root, file),
|
|
331
|
-
|
|
551
|
+
colocated,
|
|
552
|
+
slug: guideSlug(file),
|
|
332
553
|
title: (text.match(/^#\s+(.+)$/m) || [, ""])[1].trim(),
|
|
333
554
|
lines: text.split("\n").length,
|
|
334
555
|
blocks: blocks.length,
|
|
335
556
|
imports: importedNames(code),
|
|
336
557
|
tags: jsxTags(code),
|
|
337
|
-
hasPropTable: PROP_TABLE.test(text),
|
|
338
|
-
generated: GENERATED.test(text),
|
|
558
|
+
hasPropTable: PROP_TABLE.test(text) || PROP_COMPONENT.test(text),
|
|
559
|
+
generated: GENERATED.test(text) || PROP_COMPONENT.test(text),
|
|
339
560
|
hasA11y: A11Y_HEADING.test(text),
|
|
340
561
|
rawPalette: unique([...(code.match(RAW_PALETTE) || []), ...(code.match(RAW_NEUTRAL) || [])]),
|
|
341
562
|
hex: unique(code.match(HEX) || []),
|
|
@@ -361,62 +582,190 @@ function jsxTags(code) {
|
|
|
361
582
|
|
|
362
583
|
/* --------------------------------------------------------- agent instructions */
|
|
363
584
|
|
|
364
|
-
|
|
585
|
+
/**
|
|
586
|
+
* Agent instructions, read from the package and from the repository that declares it.
|
|
587
|
+
* A monorepo writes AGENTS.md once, at the root, and it governs every package under
|
|
588
|
+
* it — reading only the package directory reports a documented repo as undocumented.
|
|
589
|
+
*/
|
|
590
|
+
function readAgentFiles(root, repoRoot, config, packageName) {
|
|
365
591
|
const out = [];
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
const
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
592
|
+
const seen = new Set();
|
|
593
|
+
for (const base of [root, repoRoot]) {
|
|
594
|
+
for (const candidate of config.agentFiles) {
|
|
595
|
+
const path = join(base, candidate);
|
|
596
|
+
const text = read(path);
|
|
597
|
+
if (text === null || seen.has(path)) continue;
|
|
598
|
+
seen.add(path);
|
|
599
|
+
const fromRepoRoot = base !== root;
|
|
600
|
+
out.push({
|
|
601
|
+
file: candidate,
|
|
602
|
+
scope: fromRepoRoot ? "repo" : "package",
|
|
603
|
+
label: fromRepoRoot ? `${candidate} at the repository root` : candidate,
|
|
604
|
+
lines: text.split("\n").length,
|
|
605
|
+
mentionsPackage: packageName ? text.includes(packageName) : false,
|
|
606
|
+
hasImportRule: /import\s*\{|from ["'][^"']+["']|import path|subpath|^import\s+\w/im.test(text),
|
|
607
|
+
hasLookupCommand: /```|npx |yarn |pnpm |run `|swift |gradlew/i.test(text),
|
|
608
|
+
hasTokenRule: /token|bg-primary|text-secondary|semantic|colorset|colors\.xml|theme/i.test(text),
|
|
609
|
+
hasForbidden: /forbidden|never|do not|don't|avoid/i.test(text),
|
|
610
|
+
hasFinishCheck: /before you (?:finish|call the work done)|before finishing|definition of done|must pass|checklist/i.test(text),
|
|
611
|
+
hasStopAndAsk: /stop and ask|ask (?:the |a )?(?:human|person|maintainer)|do not invent|don't invent|never invent/i.test(text),
|
|
612
|
+
});
|
|
613
|
+
}
|
|
381
614
|
}
|
|
382
615
|
return out;
|
|
383
616
|
}
|
|
384
617
|
|
|
385
618
|
/* ------------------------------------------------------- machine surface */
|
|
386
619
|
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
const
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
620
|
+
/** Packages that make up the repository: its own manifest, plus every workspace it declares. */
|
|
621
|
+
function repoPackages(repoRoot) {
|
|
622
|
+
const out = [];
|
|
623
|
+
for (const dir of [repoRoot, ...workspaceDirs(repoRoot)]) {
|
|
624
|
+
const pkg = readJson(join(dir, "package.json"));
|
|
625
|
+
if (pkg) out.push({ dir, pkg });
|
|
626
|
+
}
|
|
627
|
+
return out;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/** Libraries a repository uses to *serve* MCP, rather than to consume one. */
|
|
631
|
+
const MCP_SERVER_DEPS = ["mcp-handler", "@modelcontextprotocol/sdk", "@vercel/mcp-adapter", "fastmcp", "mcp-lite"];
|
|
632
|
+
const MCP_CONFIG_FILES = [".mcp.json", ".cursor/mcp.json", ".vscode/mcp.json", ".windsurf/mcp.json"];
|
|
633
|
+
const LLMS_TXT_FILES = ["llms.txt", "public/llms.txt", "static/llms.txt", "docs/llms.txt", "app/llms.txt"];
|
|
634
|
+
const ROUTE_FILES = ["route.ts", "route.tsx", "route.js", "route.mjs", "route.jsx"];
|
|
635
|
+
const MCP_SERVER_FILES = ["mcp-server.ts", "mcp-server.mjs", "mcp-server.js", "mcp.server.ts"];
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* What an agent can query instead of reading files. Two things were invisible here
|
|
639
|
+
* before: a surface the repository *serves* rather than commits — an MCP endpoint or
|
|
640
|
+
* an llms.txt behind a route handler — and a surface that lives in a sibling
|
|
641
|
+
* workspace, which is where a monorepo always puts its CLI.
|
|
642
|
+
*/
|
|
643
|
+
function findMachineSurface(root, repoRoot, pkg, config) {
|
|
644
|
+
const bases = unique([root, repoRoot]);
|
|
645
|
+
const mcpConfigs = [];
|
|
646
|
+
const declaredServers = [];
|
|
647
|
+
for (const base of bases) {
|
|
648
|
+
for (const candidate of MCP_CONFIG_FILES) {
|
|
649
|
+
const path = join(base, candidate);
|
|
650
|
+
if (!exists(path)) continue;
|
|
651
|
+
mcpConfigs.push(rel(repoRoot, path));
|
|
652
|
+
const json = readJson(path) || {};
|
|
653
|
+
declaredServers.push(...Object.keys(json.mcpServers || json.servers || {}));
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
const packages = repoPackages(repoRoot);
|
|
658
|
+
const repoDeps = Object.assign({}, ...packages.map((p) => ({ ...(p.pkg.dependencies || {}), ...(p.pkg.devDependencies || {}) })));
|
|
394
659
|
const binNames = Object.keys(pkg.bin && typeof pkg.bin === "object" ? pkg.bin : pkg.bin ? { [pkg.name]: pkg.bin } : {});
|
|
395
660
|
const shippedFiles = pkg.files || [];
|
|
396
661
|
const cliShipped = binNames.length > 0 && shippedFiles.some((f) => /^(cli|bin|dist)/.test(f));
|
|
397
662
|
const mcpInPackage = cliShipped && walk(root, [".mjs", ".ts", ".js"], 2).some((f) => /mcp/i.test(basename(f)));
|
|
398
|
-
|
|
663
|
+
// One walk of the repository, split afterwards: a route that answers on /mcp,
|
|
664
|
+
// a route that renders llms.txt, and a server file named outright.
|
|
665
|
+
const found = walk(repoRoot, [...ROUTE_FILES, ...MCP_SERVER_FILES], 10).map((f) => rel(repoRoot, f));
|
|
666
|
+
const routes = found.filter((f) => ROUTE_FILES.some((r) => f.endsWith(r)));
|
|
667
|
+
const servers = found.filter((f) => MCP_SERVER_FILES.some((r) => f.endsWith(r)));
|
|
668
|
+
|
|
399
669
|
return {
|
|
400
670
|
mcpConfigs,
|
|
401
671
|
declaredServers: unique(declaredServers),
|
|
402
|
-
|
|
672
|
+
servedMcp: findServedMcp(repoRoot, packages, routes, servers),
|
|
673
|
+
storybookMcp: Boolean(repoDeps["@storybook/addon-mcp"]),
|
|
403
674
|
binNames,
|
|
404
675
|
cliShipped,
|
|
405
676
|
mcpInPackage,
|
|
406
|
-
|
|
407
|
-
|
|
677
|
+
siblingClis: findSiblingClis(root, packages),
|
|
678
|
+
llmsTxt: findLlmsTxt(bases, repoRoot, routes),
|
|
679
|
+
skills: findSkills(bases, repoRoot),
|
|
680
|
+
commands: findCommands(bases, repoRoot),
|
|
408
681
|
};
|
|
409
682
|
}
|
|
410
683
|
|
|
684
|
+
/**
|
|
685
|
+
* An MCP server this repository hosts: the library that implements one, or the route
|
|
686
|
+
* that answers on it. A system that serves MCP from its docs site never writes the
|
|
687
|
+
* `.mcp.json` that consumes it — that file belongs to the projects using the system.
|
|
688
|
+
*/
|
|
689
|
+
function findServedMcp(repoRoot, packages, routes, servers) {
|
|
690
|
+
const evidence = [];
|
|
691
|
+
for (const { dir, pkg } of packages) {
|
|
692
|
+
const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
|
|
693
|
+
const found = MCP_SERVER_DEPS.filter((d) => deps[d]);
|
|
694
|
+
if (found.length) evidence.push(`${found.join(", ")} in ${rel(repoRoot, join(dir, "package.json")) || "package.json"}`);
|
|
695
|
+
}
|
|
696
|
+
for (const route of routes) {
|
|
697
|
+
if (/(^|\/)mcp(\/|$)/i.test(dirname(route))) evidence.push(route);
|
|
698
|
+
}
|
|
699
|
+
evidence.push(...servers);
|
|
700
|
+
return unique(evidence);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/** A docs CLI shipped from a sibling workspace — where a monorepo keeps it. */
|
|
704
|
+
function findSiblingClis(root, packages) {
|
|
705
|
+
return packages
|
|
706
|
+
.filter((p) => p.dir !== root && p.pkg.bin && !p.pkg.private)
|
|
707
|
+
.map((p) => ({ name: p.pkg.name, bin: Object.keys(typeof p.pkg.bin === "object" ? p.pkg.bin : { [p.pkg.name]: p.pkg.bin }) }))
|
|
708
|
+
.filter((c) => c.name);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/** A committed llms.txt, or one a route handler generates from the live docs. */
|
|
712
|
+
function findLlmsTxt(bases, repoRoot, routes) {
|
|
713
|
+
const out = [];
|
|
714
|
+
for (const base of bases) {
|
|
715
|
+
for (const candidate of LLMS_TXT_FILES) {
|
|
716
|
+
if (exists(join(base, candidate))) out.push(rel(repoRoot, join(base, candidate)));
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
for (const route of routes) {
|
|
720
|
+
if (/llms[.-]txt/i.test(route)) out.push(route);
|
|
721
|
+
}
|
|
722
|
+
return unique(out);
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* Published agent skills. A skill is a markdown file in a skills directory — the
|
|
727
|
+
* `SKILL.md` convention is one shape of that, not the only one, and requiring the
|
|
728
|
+
* filename reported a repo full of skills as having none.
|
|
729
|
+
*/
|
|
730
|
+
function findSkills(bases, repoRoot) {
|
|
731
|
+
const out = [];
|
|
732
|
+
for (const base of bases) {
|
|
733
|
+
for (const dir of [".claude/skills", "skills", ".cursor/skills"]) {
|
|
734
|
+
for (const file of walk(join(base, dir), [".md"], 3)) {
|
|
735
|
+
if (/^(README|CHANGELOG)\./i.test(basename(file))) continue;
|
|
736
|
+
out.push(rel(repoRoot, file));
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
return unique(out);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/** Slash commands are instructions an agent can run by name, same as a skill. */
|
|
744
|
+
function findCommands(bases, repoRoot) {
|
|
745
|
+
const out = [];
|
|
746
|
+
for (const base of bases) {
|
|
747
|
+
for (const file of walk(join(base, ".claude/commands"), [".md"], 2)) {
|
|
748
|
+
if (/^(README|CHANGELOG)\./i.test(basename(file))) continue;
|
|
749
|
+
out.push(rel(repoRoot, file));
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
return unique(out);
|
|
753
|
+
}
|
|
754
|
+
|
|
411
755
|
/* ------------------------------------------------------------------- CI */
|
|
412
756
|
|
|
413
|
-
|
|
414
|
-
|
|
757
|
+
/**
|
|
758
|
+
* CI belongs to the repository, not to a package inside it: a monorepo keeps one
|
|
759
|
+
* `.github/workflows` at the root and runs every package from there. `repoPkg` is
|
|
760
|
+
* the root manifest, whose scripts are what those workflows actually call.
|
|
761
|
+
*/
|
|
762
|
+
function readCi(repoRoot, pkg, repoPkg, platform) {
|
|
763
|
+
const files = [...walk(join(repoRoot, ".github/workflows"), [".yml", ".yaml"], 2)];
|
|
415
764
|
const text = files.map((f) => read(f) || "").join("\n");
|
|
416
|
-
const scripts = pkg.scripts || {};
|
|
765
|
+
const scripts = { ...(repoPkg.scripts || {}), ...(pkg.scripts || {}) };
|
|
417
766
|
const all = text + "\n" + Object.values(scripts).join("\n");
|
|
418
767
|
return {
|
|
419
|
-
workflows: files.map((f) => rel(
|
|
768
|
+
workflows: files.map((f) => rel(repoRoot, f)),
|
|
420
769
|
scripts,
|
|
421
770
|
runsTests: /\b(vitest|jest|node --test|yarn test|npm test|pnpm test|xcodebuild|swift test|gradlew|gradle test|fastlane)\b/.test(all),
|
|
422
771
|
runsStoryTests: /(test-storybook|test:storybook|storybook.*test|addon-vitest)/.test(all),
|
|
@@ -466,7 +815,10 @@ function byMention(guides, name) {
|
|
|
466
815
|
function checkFreshness(guides, symbols, packageName, ci) {
|
|
467
816
|
const unknown = [];
|
|
468
817
|
for (const guide of guides) {
|
|
469
|
-
|
|
818
|
+
// A relative import in a documentation page is the reader's own file —
|
|
819
|
+
// `./components/UserForm` in an example is not a claim about this package.
|
|
820
|
+
// Beside a component, in a colocated guide, `./Button` is exactly that claim.
|
|
821
|
+
const fromSystem = guide.imports.filter((i) => (packageName && i.from.startsWith(packageName)) || (guide.colocated && i.from.startsWith(".")));
|
|
470
822
|
for (const imp of fromSystem) {
|
|
471
823
|
if (!symbols.has(imp.name)) unknown.push({ guide: guide.path, name: imp.name, from: imp.from });
|
|
472
824
|
}
|
|
@@ -555,8 +907,10 @@ function checkA11y(guides, ci, root, platform) {
|
|
|
555
907
|
};
|
|
556
908
|
}
|
|
557
909
|
|
|
558
|
-
function checkVerification(root, pkg, ci, platform) {
|
|
559
|
-
|
|
910
|
+
function checkVerification(root, pkg, repoPkg, ci, platform) {
|
|
911
|
+
// A workspace package often leaves `test` and `lint` to the repository root that
|
|
912
|
+
// runs them; both sets are the same promise to a reader.
|
|
913
|
+
const scripts = { ...(repoPkg.scripts || {}), ...(pkg.scripts || {}) };
|
|
560
914
|
const tests = walk(root, [".test.ts", ".test.tsx", ".test.mjs", ".spec.ts", ".spec.tsx", "Tests.swift", "Test.kt", "Tests.kt"], 6).length;
|
|
561
915
|
const stories = walk(root, [".stories.ts", ".stories.tsx", ".stories.js"], 6).length;
|
|
562
916
|
return {
|
|
@@ -574,10 +928,11 @@ function checkVerification(root, pkg, ci, platform) {
|
|
|
574
928
|
};
|
|
575
929
|
}
|
|
576
930
|
|
|
577
|
-
function checkGaps(root, guides, agentFiles, pkg) {
|
|
931
|
+
function checkGaps(root, repoRoot, guides, agentFiles, pkg, repoPkg) {
|
|
578
932
|
const guideFile = guides.find((g) => /gap|absence|missing|not-built|unsupported|what-we-don/i.test(g.slug + " " + g.title));
|
|
579
|
-
const
|
|
580
|
-
const
|
|
933
|
+
const candidates = ["GAPS.md", "docs/GAPS.md", "guidelines/GAPS.md", "docs/gaps.md"];
|
|
934
|
+
const named = unique([root, repoRoot]).flatMap((base) => candidates.filter((f) => exists(join(base, f))).map((f) => (base === root ? f : rel(root, join(base, f)))));
|
|
935
|
+
const scripts = Object.entries({ ...(repoPkg.scripts || {}), ...(pkg.scripts || {}) }).filter(([k]) => /gap/i.test(k));
|
|
581
936
|
return {
|
|
582
937
|
file: named[0] || (guideFile ? guideFile.path : null),
|
|
583
938
|
reportCommand: scripts.length ? scripts[0][0] : null,
|