adsa-cli 0.1.2 → 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/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
- 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);
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) => !["styles", "theme", "tokens", "eslint", "prose", "package.json"].includes(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
- const dirs = [...config.source, "dist"];
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
- * 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`.
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
- 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));
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
- for (const dir of dirs) {
316
- for (const file of walk(join(root, dir), [".md", ".mdx"], 6)) {
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
- const text = read(file) || "";
320
- guides.push(guideFacts(root, file, text));
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
- function guideFacts(root, file, text) {
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
- slug: basename(file).replace(/\.mdx?$/, "").toLowerCase(),
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
- function readAgentFiles(root, config, packageName) {
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
- 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
- });
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
- 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 || {}) };
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
- const skills = [...walk(join(root, ".claude/skills"), ["SKILL.md"], 3), ...walk(join(root, "skills"), ["SKILL.md"], 3)];
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
- storybookMcp: Boolean(deps["@storybook/addon-mcp"]),
672
+ servedMcp: findServedMcp(repoRoot, packages, routes, servers),
673
+ storybookMcp: Boolean(repoDeps["@storybook/addon-mcp"]),
403
674
  binNames,
404
675
  cliShipped,
405
676
  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)),
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
- function readCi(root, pkg, platform) {
414
- const files = [...walk(join(root, ".github/workflows"), [".yml", ".yaml"], 2)];
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(root, f)),
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
- const fromSystem = guide.imports.filter((i) => (packageName && i.from.startsWith(packageName)) || i.from.startsWith("."));
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
- const scripts = pkg.scripts || {};
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 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));
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,