adsa-cli 0.1.0
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/LICENSE +21 -0
- package/README.md +169 -0
- package/bin/adsa.mjs +411 -0
- package/lib/badge.mjs +28 -0
- package/lib/color.mjs +32 -0
- package/lib/config.mjs +103 -0
- package/lib/dense.mjs +21 -0
- package/lib/eval.mjs +140 -0
- package/lib/fix.mjs +260 -0
- package/lib/fsx.mjs +94 -0
- package/lib/history.mjs +41 -0
- package/lib/mcp.mjs +162 -0
- package/lib/report.mjs +716 -0
- package/lib/scan.mjs +607 -0
- package/lib/score.mjs +197 -0
- package/package.json +41 -0
- package/rubric/rubric.json +115 -0
- package/skills/ds-audit/SKILL.md +135 -0
- package/templates/AGENTS.android.md.tmpl +22 -0
- package/templates/AGENTS.md.tmpl +17 -0
- package/templates/AGENTS.react-native.md.tmpl +21 -0
- package/templates/AGENTS.swift.md.tmpl +23 -0
- package/templates/GAPS.md.tmpl +17 -0
- package/templates/briefs/a11y-docs.md +35 -0
- package/templates/briefs/coverage-gate.md +23 -0
- package/templates/briefs/examples-check.md +37 -0
- package/templates/briefs/patterns-doc.md +32 -0
- package/templates/briefs/prop-tables.md +41 -0
- package/templates/ci/adsa.yml +19 -0
- package/templates/mcp/mcp.json +8 -0
- package/templates/tokens.android.md.tmpl +32 -0
- package/templates/tokens.md.tmpl +31 -0
- package/templates/tokens.native.md.tmpl +32 -0
- package/templates/tokens.swift.md.tmpl +32 -0
package/lib/scan.mjs
ADDED
|
@@ -0,0 +1,607 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads a design system repository and returns facts. Nothing here scores anything:
|
|
3
|
+
* every field is something observed in the repo, so a report can quote its evidence
|
|
4
|
+
* and a maintainer can disagree with the reading rather than the number.
|
|
5
|
+
*/
|
|
6
|
+
import { basename, join } from "node:path";
|
|
7
|
+
import { exists, isDir, read, readJson, rel, walk, walkDirs } from "./fsx.mjs";
|
|
8
|
+
|
|
9
|
+
const CODE_FENCE = /```(tsx|jsx|ts|js|typescript|javascript|swift|kotlin|kt)\s*\n([\s\S]*?)```/g;
|
|
10
|
+
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
|
+
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
|
+
const RAW_NEUTRAL = /\b(?:bg|text|border)-(?:white|black)\b/g;
|
|
13
|
+
const HEX = /#[0-9a-fA-F]{3,8}\b/g;
|
|
14
|
+
const A11Y_HEADING = /^#{2,4}.*(keyboard|accessib|a11y|screen reader|aria|voiceover|talkback|dynamic type|touch target|contentdescription)/im;
|
|
15
|
+
const GENERATED = /<!--\s*(?:generated|auto-?generated|do not edit|prop-table:start|props:start)/i;
|
|
16
|
+
const PROP_TABLE = /^\|[^\n]*\bprops?\b[^\n]*\|[^\n]*\btype\b[^\n]*\|/im;
|
|
17
|
+
const PASCAL_EXPORT = /export\s+(?:default\s+)?(?:const|function|class)\s+([A-Z][A-Za-z0-9_]*)/g;
|
|
18
|
+
const TYPE_EXPORT = /export\s+(?:type|interface)\s+([A-Z][A-Za-z0-9_]*)/g;
|
|
19
|
+
/** `struct Foo: View`, `public class Foo: UIView` — captures the whole conformance list so a multi-protocol clause still matches. */
|
|
20
|
+
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
|
+
const SWIFT_SYMBOL = /\b(?:public\s+|open\s+)?(?:static\s+)?(?:struct|class|enum|func)\s+([A-Za-z][A-Za-z0-9_]*)/g;
|
|
22
|
+
/** `@Composable` followed, within a short window (annotations, modifiers, newlines), by `fun Name(`. */
|
|
23
|
+
const COMPOSABLE = /@Composable[\s\S]{0,120}?\bfun\s+([A-Z][A-Za-z0-9_]*)\s*\(/g;
|
|
24
|
+
const KOTLIN_SYMBOL = /\b(?:public\s+|internal\s+)?(?:class|object|interface|enum class)\s+([A-Z][A-Za-z0-9_]*)/g;
|
|
25
|
+
|
|
26
|
+
export function scan(root, config) {
|
|
27
|
+
const pkg = readJson(join(root, "package.json")) || {};
|
|
28
|
+
const platform = detectPlatform(root, pkg);
|
|
29
|
+
const facts = {
|
|
30
|
+
root,
|
|
31
|
+
name: detectName(root, pkg),
|
|
32
|
+
version: pkg.version || null,
|
|
33
|
+
packageJson: Boolean(pkg.name),
|
|
34
|
+
platform,
|
|
35
|
+
config: { guides: config.guides, source: config.source, file: config.configFile },
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
facts.stack = detectStack(root, pkg, platform);
|
|
39
|
+
facts.components = findComponents(root, pkg, config, platform.primary);
|
|
40
|
+
facts.icons = countIconExports(pkg);
|
|
41
|
+
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);
|
|
46
|
+
facts.coverage = matchCoverage(facts.components, facts.guides);
|
|
47
|
+
facts.freshness = checkFreshness(facts.guides, facts.symbols, facts.name, facts.ci);
|
|
48
|
+
facts.tokens = checkTokens(facts.guides, facts.ci, root, platform.primary);
|
|
49
|
+
facts.patterns = checkPatterns(facts.guides);
|
|
50
|
+
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);
|
|
53
|
+
return facts;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/* ------------------------------------------------------------- platform */
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* What platform this design system is built for, from real evidence: a package.json
|
|
60
|
+
* dependency, a Package.swift, an .xcodeproj bundle, a Gradle build next to Kotlin
|
|
61
|
+
* files. A repo can carry more than one signal — a monorepo with an app and a native
|
|
62
|
+
* shell — so every platform with real evidence is kept in `detected`, strongest
|
|
63
|
+
* first, and the strongest becomes `primary`. Nothing here is invented: a platform
|
|
64
|
+
* is reported only when a file that actually exists says so.
|
|
65
|
+
*/
|
|
66
|
+
function detectPlatform(root, pkg) {
|
|
67
|
+
const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}), ...(pkg.peerDependencies || {}) };
|
|
68
|
+
const has = (name) => Boolean(deps[name]);
|
|
69
|
+
const candidates = [];
|
|
70
|
+
|
|
71
|
+
if (has("react-native") || has("expo")) {
|
|
72
|
+
const files = walk(root, [".tsx", ".jsx"], 6).length;
|
|
73
|
+
const ev = [];
|
|
74
|
+
if (has("react-native")) ev.push("react-native in package.json dependencies");
|
|
75
|
+
if (has("expo")) ev.push("expo in package.json dependencies");
|
|
76
|
+
if (files) ev.push(`${files} .tsx/.jsx files`);
|
|
77
|
+
candidates.push({ id: "react-native", score: 100 + files, evidence: ev });
|
|
78
|
+
} else if (has("react") || has("react-dom") || has("vue") || has("svelte")) {
|
|
79
|
+
const files = walk(root, [".tsx", ".jsx", ".vue", ".svelte"], 6).length;
|
|
80
|
+
const ev = [];
|
|
81
|
+
if (has("react") || has("react-dom")) ev.push("react in package.json dependencies");
|
|
82
|
+
if (has("vue")) ev.push("vue in package.json dependencies");
|
|
83
|
+
if (has("svelte")) ev.push("svelte in package.json dependencies");
|
|
84
|
+
if (files) ev.push(`${files} component files`);
|
|
85
|
+
candidates.push({ id: "web", score: 90 + files, evidence: ev });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const swiftFiles = walk(root, [".swift"], 8);
|
|
89
|
+
const hasPackageSwift = exists(join(root, "Package.swift"));
|
|
90
|
+
const xcodeProjects = walkDirs(root, [".xcodeproj", ".xcworkspace"], 4);
|
|
91
|
+
if (hasPackageSwift || xcodeProjects.length || swiftFiles.length >= 3) {
|
|
92
|
+
const ev = [];
|
|
93
|
+
if (hasPackageSwift) ev.push("Package.swift present");
|
|
94
|
+
if (xcodeProjects.length) ev.push(`${xcodeProjects.length} .xcodeproj/.xcworkspace bundle(s)`);
|
|
95
|
+
if (swiftFiles.length) ev.push(`${swiftFiles.length} .swift files`);
|
|
96
|
+
candidates.push({ id: "swift", score: (hasPackageSwift ? 50 : 0) + (xcodeProjects.length ? 50 : 0) + swiftFiles.length, evidence: ev });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const ktFiles = walk(root, [".kt"], 8);
|
|
100
|
+
const gradleFiles = walk(root, [".gradle", ".gradle.kts"], 5);
|
|
101
|
+
if ((gradleFiles.length && ktFiles.length) || ktFiles.length >= 3) {
|
|
102
|
+
const ev = [];
|
|
103
|
+
if (gradleFiles.length) ev.push(`${gradleFiles.length} Gradle build file(s)`);
|
|
104
|
+
if (ktFiles.length) ev.push(`${ktFiles.length} .kt files`);
|
|
105
|
+
candidates.push({ id: "android", score: (gradleFiles.length ? 50 : 0) + ktFiles.length, evidence: ev });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
candidates.sort((a, b) => b.score - a.score);
|
|
109
|
+
const primary = candidates.length ? candidates[0].id : "web";
|
|
110
|
+
return {
|
|
111
|
+
primary,
|
|
112
|
+
detected: candidates.map(({ id, evidence }) => ({ id, evidence })),
|
|
113
|
+
evidence: candidates.length ? candidates[0].evidence : ["No platform-specific evidence found; defaulting to web."],
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** package.json first, then the platform's own manifest, then the directory name. */
|
|
118
|
+
function detectName(root, pkg) {
|
|
119
|
+
if (pkg.name) return pkg.name;
|
|
120
|
+
const packageSwift = read(join(root, "Package.swift"));
|
|
121
|
+
const swiftName = packageSwift && packageSwift.match(/name:\s*"([^"]+)"/);
|
|
122
|
+
if (swiftName) return swiftName[1];
|
|
123
|
+
const settings = read(join(root, "settings.gradle.kts")) || read(join(root, "settings.gradle"));
|
|
124
|
+
const gradleName = settings && settings.match(/rootProject\.name\s*=\s*["']([^"']+)["']/);
|
|
125
|
+
if (gradleName) return gradleName[1];
|
|
126
|
+
return basename(root);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/* ---------------------------------------------------------------- stack */
|
|
130
|
+
|
|
131
|
+
function detectStack(root, pkg, platform) {
|
|
132
|
+
const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}), ...(pkg.peerDependencies || {}) };
|
|
133
|
+
const has = (name) => Boolean(deps[name]);
|
|
134
|
+
const headless = ["react-aria-components", "@radix-ui/react-dialog", "@radix-ui/themes", "@headlessui/react", "@ark-ui/react", "@base-ui-components/react"].filter(has);
|
|
135
|
+
return {
|
|
136
|
+
react: has("react") || has("react-dom"),
|
|
137
|
+
reactNative: has("react-native") || has("expo"),
|
|
138
|
+
expo: has("expo"),
|
|
139
|
+
nativewind: has("nativewind"),
|
|
140
|
+
vue: has("vue"),
|
|
141
|
+
svelte: has("svelte"),
|
|
142
|
+
typescript: has("typescript") || exists(join(root, "tsconfig.json")),
|
|
143
|
+
tailwind: has("tailwindcss"),
|
|
144
|
+
storybook: Object.keys(deps).some((d) => d.startsWith("@storybook/")) || isDir(join(root, ".storybook")),
|
|
145
|
+
headless,
|
|
146
|
+
types: exists(join(root, "dist")) && walk(join(root, "dist"), [".d.ts"], 3).length > 0,
|
|
147
|
+
swiftPackage: platform?.primary === "swift" && exists(join(root, "Package.swift")),
|
|
148
|
+
gradleKts: platform?.primary === "android" && walk(root, [".gradle.kts"], 3).length > 0,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/* ----------------------------------------------------------- components */
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* What a consumer can import. Package `exports` subpaths are the most reliable
|
|
156
|
+
* answer, and when a package ships them they *are* the surface, so the source scan
|
|
157
|
+
* is only a fallback for repos with a single entry point. Native platforms have no
|
|
158
|
+
* `package.json`, so they go straight to their own declaration syntax.
|
|
159
|
+
*/
|
|
160
|
+
function findComponents(root, pkg, config, platform) {
|
|
161
|
+
if (platform === "swift") return findSwiftComponents(root, config);
|
|
162
|
+
if (platform === "android") return findKotlinComponents(root, config);
|
|
163
|
+
|
|
164
|
+
const subpaths = Object.keys(pkg.exports || {})
|
|
165
|
+
.filter((k) => k.startsWith("./") && !k.includes("*") && !/\.(css|json|js|mjs|cjs)$/.test(k))
|
|
166
|
+
.map((k) => k.replace(/^\.\//, ""))
|
|
167
|
+
.filter((slug) => !["styles", "theme", "tokens", "eslint", "prose", "package.json"].includes(slug));
|
|
168
|
+
if (subpaths.length >= 5) {
|
|
169
|
+
return subpaths
|
|
170
|
+
.filter((slug) => !isIconSlug(slug))
|
|
171
|
+
.map((slug) => ({ slug, name: pascal(slug), from: "exports" }))
|
|
172
|
+
.sort((a, b) => a.slug.localeCompare(b.slug));
|
|
173
|
+
}
|
|
174
|
+
const out = new Map();
|
|
175
|
+
for (const dir of config.source) {
|
|
176
|
+
for (const file of walk(join(root, dir), [".tsx", ".jsx", ".vue", ".svelte"], 6)) {
|
|
177
|
+
if (isNoiseFile(rel(root, file))) continue;
|
|
178
|
+
const text = read(file) || "";
|
|
179
|
+
for (const m of text.matchAll(PASCAL_EXPORT)) {
|
|
180
|
+
const symbol = m[1];
|
|
181
|
+
if (isNotAComponent(symbol, text) || /Icon$|^Logo/.test(symbol)) continue;
|
|
182
|
+
const slug = kebab(symbol);
|
|
183
|
+
if (!out.has(slug)) out.set(slug, { slug, name: symbol, from: rel(root, file) });
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return [...out.values()].sort((a, b) => a.slug.localeCompare(b.slug));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** `public struct Foo: View` / `open class Foo: UIView` / `struct Foo: UIViewController`. */
|
|
191
|
+
function findSwiftComponents(root, config) {
|
|
192
|
+
const out = new Map();
|
|
193
|
+
for (const dir of config.source.length ? config.source : ["."]) {
|
|
194
|
+
for (const file of walk(join(root, dir), [".swift"], 8)) {
|
|
195
|
+
if (isNoiseSwiftFile(rel(root, file))) continue;
|
|
196
|
+
const text = read(file) || "";
|
|
197
|
+
for (const m of text.matchAll(SWIFT_TYPE)) {
|
|
198
|
+
const [, , name, conforms] = m;
|
|
199
|
+
if (!/\b(View|UIView|UIViewController)\b/.test(conforms)) continue;
|
|
200
|
+
const slug = kebab(name);
|
|
201
|
+
if (!out.has(slug)) out.set(slug, { slug, name, from: rel(root, file) });
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return [...out.values()].sort((a, b) => a.slug.localeCompare(b.slug));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function isNoiseSwiftFile(path) {
|
|
209
|
+
return /(Tests?|Mock|Preview)\.swift$/.test(path) || /\/(Tests?|Preview Content)\//i.test(path);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** `@Composable fun Foo(...)`. */
|
|
213
|
+
function findKotlinComponents(root, config) {
|
|
214
|
+
const out = new Map();
|
|
215
|
+
for (const dir of config.source.length ? config.source : ["."]) {
|
|
216
|
+
for (const file of walk(join(root, dir), [".kt"], 8)) {
|
|
217
|
+
if (isNoiseKotlinFile(rel(root, file))) continue;
|
|
218
|
+
const text = read(file) || "";
|
|
219
|
+
for (const m of text.matchAll(COMPOSABLE)) {
|
|
220
|
+
const name = m[1];
|
|
221
|
+
const slug = kebab(name);
|
|
222
|
+
if (!out.has(slug)) out.set(slug, { slug, name, from: rel(root, file) });
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return [...out.values()].sort((a, b) => a.slug.localeCompare(b.slug));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function isNoiseKotlinFile(path) {
|
|
230
|
+
return /Test\.kt$/.test(path) || /\/(test|androidTest)\//i.test(path);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** A per-icon guide is not a thing anyone writes, so icon exports are not coverage. */
|
|
234
|
+
function isIconSlug(slug) {
|
|
235
|
+
return /(^|[-/])(icons?|logos?|flags?|illustrations?)([-/]|$)/i.test(slug) || /-(icon|logo)s?$/i.test(slug);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Icon and logo sets are exports, but nobody writes a guide per icon. */
|
|
239
|
+
function isNoiseFile(path) {
|
|
240
|
+
if (/\.(test|spec|stories|story)\.|\.d\.ts$/.test(path)) return true;
|
|
241
|
+
return /(^|\/)(icons?|logos?|illustrations?|flags?|payment-?icons?|social-?icons?|brand)(\/|$)/i.test(path);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Exports set aside as icon or logo sets, reported so the count is not a surprise. */
|
|
245
|
+
function countIconExports(pkg) {
|
|
246
|
+
return Object.keys(pkg.exports || {}).filter((k) => k.startsWith("./") && isIconSlug(k.replace(/^\.\//, ""))).length;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Every exported symbol anywhere in the source: the universe a guide may legitimately import. */
|
|
250
|
+
function findSymbols(root, config, platform) {
|
|
251
|
+
if (platform === "swift") return findSwiftSymbols(root, config);
|
|
252
|
+
if (platform === "android") return findKotlinSymbols(root, config);
|
|
253
|
+
|
|
254
|
+
const symbols = new Set();
|
|
255
|
+
const dirs = [...config.source, "dist"];
|
|
256
|
+
for (const dir of dirs) {
|
|
257
|
+
for (const file of walk(join(root, dir), [".tsx", ".ts", ".jsx", ".vue", ".svelte"], 7)) {
|
|
258
|
+
const text = read(file) || "";
|
|
259
|
+
for (const m of text.matchAll(PASCAL_EXPORT)) symbols.add(m[1]);
|
|
260
|
+
for (const m of text.matchAll(TYPE_EXPORT)) symbols.add(m[1]);
|
|
261
|
+
for (const m of text.matchAll(/export\s*\{([^}]+)\}/g)) {
|
|
262
|
+
for (const part of m[1].split(",")) {
|
|
263
|
+
const name = part.trim().split(/\s+as\s+/).pop().trim();
|
|
264
|
+
if (/^[A-Z]/.test(name)) symbols.add(name);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
for (const m of text.matchAll(/declare\s+const\s+([A-Z][A-Za-z0-9_]*)/g)) symbols.add(m[1]);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return symbols;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function findSwiftSymbols(root, config) {
|
|
274
|
+
const symbols = new Set();
|
|
275
|
+
for (const dir of config.source.length ? config.source : ["."]) {
|
|
276
|
+
for (const file of walk(join(root, dir), [".swift"], 8)) {
|
|
277
|
+
const text = read(file) || "";
|
|
278
|
+
for (const m of text.matchAll(SWIFT_SYMBOL)) symbols.add(m[1]);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return symbols;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function findKotlinSymbols(root, config) {
|
|
285
|
+
const symbols = new Set();
|
|
286
|
+
for (const dir of config.source.length ? config.source : ["."]) {
|
|
287
|
+
for (const file of walk(join(root, dir), [".kt"], 8)) {
|
|
288
|
+
const text = read(file) || "";
|
|
289
|
+
for (const m of text.matchAll(COMPOSABLE)) symbols.add(m[1]);
|
|
290
|
+
for (const m of text.matchAll(KOTLIN_SYMBOL)) symbols.add(m[1]);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return symbols;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Hooks, contexts and helpers are exports, but not things a page is built from. */
|
|
297
|
+
function isNotAComponent(symbol, text) {
|
|
298
|
+
if (/^(use[A-Z]|with[A-Z])/.test(symbol)) return false; // handled by the next check
|
|
299
|
+
if (/^use[A-Z]/.test(symbol)) return true;
|
|
300
|
+
if (/(Context|Provider|Props|Type|Config|Schema|Utils?|Helpers?)$/.test(symbol) && !new RegExp(`<${symbol}[\\s/>]`).test(text)) return true;
|
|
301
|
+
return false;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/* --------------------------------------------------------------- guides */
|
|
305
|
+
|
|
306
|
+
/**
|
|
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`.
|
|
310
|
+
*/
|
|
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));
|
|
314
|
+
const guides = [];
|
|
315
|
+
for (const dir of dirs) {
|
|
316
|
+
for (const file of walk(join(root, dir), [".md", ".mdx"], 6)) {
|
|
317
|
+
const name = basename(file);
|
|
318
|
+
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));
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return guides;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function guideFacts(root, file, text) {
|
|
327
|
+
const blocks = [...text.matchAll(CODE_FENCE)].map((m) => m[2]);
|
|
328
|
+
const code = blocks.join("\n");
|
|
329
|
+
return {
|
|
330
|
+
path: rel(root, file),
|
|
331
|
+
slug: basename(file).replace(/\.mdx?$/, "").toLowerCase(),
|
|
332
|
+
title: (text.match(/^#\s+(.+)$/m) || [, ""])[1].trim(),
|
|
333
|
+
lines: text.split("\n").length,
|
|
334
|
+
blocks: blocks.length,
|
|
335
|
+
imports: importedNames(code),
|
|
336
|
+
tags: jsxTags(code),
|
|
337
|
+
hasPropTable: PROP_TABLE.test(text),
|
|
338
|
+
generated: GENERATED.test(text),
|
|
339
|
+
hasA11y: A11Y_HEADING.test(text),
|
|
340
|
+
rawPalette: unique([...(code.match(RAW_PALETTE) || []), ...(code.match(RAW_NEUTRAL) || [])]),
|
|
341
|
+
hex: unique(code.match(HEX) || []),
|
|
342
|
+
body: text,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function importedNames(code) {
|
|
347
|
+
const out = [];
|
|
348
|
+
for (const m of code.matchAll(/import\s*\{([^}]+)\}\s*from\s*["']([^"']+)["']/g)) {
|
|
349
|
+
const from = m[2];
|
|
350
|
+
for (const part of m[1].split(",")) {
|
|
351
|
+
const name = part.trim().split(/\s+as\s+/)[0].trim();
|
|
352
|
+
if (name && /^[A-Z]/.test(name)) out.push({ name, from });
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return out;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function jsxTags(code) {
|
|
359
|
+
return unique([...code.matchAll(/<([A-Z][A-Za-z0-9_]*(?:\.[A-Z][A-Za-z0-9_]*)?)/g)].map((m) => m[1]));
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/* --------------------------------------------------------- agent instructions */
|
|
363
|
+
|
|
364
|
+
function readAgentFiles(root, config, packageName) {
|
|
365
|
+
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
|
+
});
|
|
381
|
+
}
|
|
382
|
+
return out;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/* ------------------------------------------------------- machine surface */
|
|
386
|
+
|
|
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 || {}) };
|
|
394
|
+
const binNames = Object.keys(pkg.bin && typeof pkg.bin === "object" ? pkg.bin : pkg.bin ? { [pkg.name]: pkg.bin } : {});
|
|
395
|
+
const shippedFiles = pkg.files || [];
|
|
396
|
+
const cliShipped = binNames.length > 0 && shippedFiles.some((f) => /^(cli|bin|dist)/.test(f));
|
|
397
|
+
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)];
|
|
399
|
+
return {
|
|
400
|
+
mcpConfigs,
|
|
401
|
+
declaredServers: unique(declaredServers),
|
|
402
|
+
storybookMcp: Boolean(deps["@storybook/addon-mcp"]),
|
|
403
|
+
binNames,
|
|
404
|
+
cliShipped,
|
|
405
|
+
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)),
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/* ------------------------------------------------------------------- CI */
|
|
412
|
+
|
|
413
|
+
function readCi(root, pkg, platform) {
|
|
414
|
+
const files = [...walk(join(root, ".github/workflows"), [".yml", ".yaml"], 2)];
|
|
415
|
+
const text = files.map((f) => read(f) || "").join("\n");
|
|
416
|
+
const scripts = pkg.scripts || {};
|
|
417
|
+
const all = text + "\n" + Object.values(scripts).join("\n");
|
|
418
|
+
return {
|
|
419
|
+
workflows: files.map((f) => rel(root, f)),
|
|
420
|
+
scripts,
|
|
421
|
+
runsTests: /\b(vitest|jest|node --test|yarn test|npm test|pnpm test|xcodebuild|swift test|gradlew|gradle test|fastlane)\b/.test(all),
|
|
422
|
+
runsStoryTests: /(test-storybook|test:storybook|storybook.*test|addon-vitest)/.test(all),
|
|
423
|
+
runsAxe: /(axe|addon-a11y|a11y|accessibility.?scanner|accessibility.?snapshot)/i.test(all),
|
|
424
|
+
checksDocs: /(check:(props|guidelines|examples|docs|a11y)|prop-table|generate-prop|guide-examples|check-docs)/.test(all),
|
|
425
|
+
runsAdsa: /adsa\b/.test(all),
|
|
426
|
+
runsLint: /(eslint|biome|lint|swiftlint|ktlint|detekt)/i.test(all),
|
|
427
|
+
runsTypecheck: /(tsc|typecheck|type-check)/.test(all),
|
|
428
|
+
platform,
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/* --------------------------------------------------------- derived checks */
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* A component counts as documented when a guide actually talks about it: same slug,
|
|
436
|
+
* a singular/plural variant, a guide that owns its prefix (calendar-header -> calendar),
|
|
437
|
+
* or a guide whose body names the symbol. Deliberately generous — a false "missing"
|
|
438
|
+
* in someone else's repo is worse than a slightly kind number.
|
|
439
|
+
*/
|
|
440
|
+
function matchCoverage(components, guides) {
|
|
441
|
+
const bySlug = new Map(guides.map((g) => [g.slug, g]));
|
|
442
|
+
const documented = [];
|
|
443
|
+
const missing = [];
|
|
444
|
+
for (const c of components) {
|
|
445
|
+
const guide = bySlug.get(c.slug) || bySlug.get(depluralize(c.slug)) || bySlug.get(c.slug + "s") || byPrefix(guides, c.slug) || byMention(guides, c.name);
|
|
446
|
+
if (guide) documented.push({ ...c, guide: guide.path });
|
|
447
|
+
else missing.push(c);
|
|
448
|
+
}
|
|
449
|
+
const total = components.length;
|
|
450
|
+
return { total, documented: documented.length, missing: missing.map((c) => c.name), ratio: total ? documented.length / total : 0 };
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function depluralize(slug) {
|
|
454
|
+
return slug.replace(/ies$/, "y").replace(/([^s])s$/, "$1");
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function byPrefix(guides, slug) {
|
|
458
|
+
return guides.find((g) => g.slug.length >= 4 && (slug.startsWith(g.slug + "-") || slug.startsWith(depluralize(g.slug) + "-")));
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function byMention(guides, name) {
|
|
462
|
+
const re = new RegExp(`(<|\\b)${name}\\b`);
|
|
463
|
+
return guides.find((g) => re.test(g.body));
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function checkFreshness(guides, symbols, packageName, ci) {
|
|
467
|
+
const unknown = [];
|
|
468
|
+
for (const guide of guides) {
|
|
469
|
+
const fromSystem = guide.imports.filter((i) => (packageName && i.from.startsWith(packageName)) || i.from.startsWith("."));
|
|
470
|
+
for (const imp of fromSystem) {
|
|
471
|
+
if (!symbols.has(imp.name)) unknown.push({ guide: guide.path, name: imp.name, from: imp.from });
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
return {
|
|
475
|
+
unknownImports: unknown.slice(0, 40),
|
|
476
|
+
unknownCount: unknown.length,
|
|
477
|
+
guidesWithPropTable: guides.filter((g) => g.hasPropTable).length,
|
|
478
|
+
guidesGenerated: guides.filter((g) => g.generated).length,
|
|
479
|
+
blocks: guides.reduce((n, g) => n + g.blocks, 0),
|
|
480
|
+
compiledInCi: ci.checksDocs,
|
|
481
|
+
symbolsKnown: symbols.size,
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function checkTokens(guides, ci, root, platform) {
|
|
486
|
+
const docs = guides.filter((g) => /token|colour|color|typography|spacing|motion|elevation|shadow|radius|theme|palette/i.test(g.slug + " " + g.title));
|
|
487
|
+
const rawHits = guides.flatMap((g) => g.rawPalette.map((c) => ({ guide: g.path, value: c })));
|
|
488
|
+
const hexHits = guides.flatMap((g) => g.hex.map((c) => ({ guide: g.path, value: c })));
|
|
489
|
+
return {
|
|
490
|
+
docs: docs.map((g) => g.path),
|
|
491
|
+
motion: docs.some((g) => /motion|duration|easing|transition/i.test(g.slug + g.title) || /^#{1,4}\s.*(motion|easing|duration)/im.test(g.body)),
|
|
492
|
+
spacing: docs.some((g) => /spacing|space|layout|grid/i.test(g.slug + g.title) || /^#{1,4}\s.*(spacing|space scale)/im.test(g.body)),
|
|
493
|
+
rawPalette: rawHits.slice(0, 30),
|
|
494
|
+
rawPaletteCount: rawHits.length,
|
|
495
|
+
hex: hexHits.slice(0, 20),
|
|
496
|
+
hexCount: hexHits.length,
|
|
497
|
+
lintEnforced: /(eslint|biome).*(token|palette)|no-restricted-syntax/i.test(Object.values(ci.scripts).join(" ")),
|
|
498
|
+
source: findSourceTokens(root, platform),
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Named tokens defined in the platform's own resource format, rather than prose:
|
|
504
|
+
* a Swift asset catalog's colorsets, or the named `<color>` entries in an Android
|
|
505
|
+
* `colors.xml`/`themes.xml`. Real files, real counts — an agent can open either and
|
|
506
|
+
* see the same semantic names a guide would otherwise have to spell out.
|
|
507
|
+
*/
|
|
508
|
+
function findSourceTokens(root, platform) {
|
|
509
|
+
if (platform === "swift") {
|
|
510
|
+
const catalogs = walkDirs(root, [".xcassets"], 6);
|
|
511
|
+
const colorSets = catalogs.flatMap((c) => walkDirs(c, [".colorset"], 3));
|
|
512
|
+
if (!catalogs.length) return null;
|
|
513
|
+
return {
|
|
514
|
+
kind: "asset catalog",
|
|
515
|
+
files: catalogs.map((c) => rel(root, c)),
|
|
516
|
+
count: colorSets.length,
|
|
517
|
+
names: colorSets.slice(0, 6).map((c) => basename(c).replace(/\.colorset$/, "")),
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
if (platform === "android") {
|
|
521
|
+
const files = walk(root, ["colors.xml", "themes.xml"], 8);
|
|
522
|
+
if (!files.length) return null;
|
|
523
|
+
const names = [];
|
|
524
|
+
let count = 0;
|
|
525
|
+
for (const f of files) {
|
|
526
|
+
for (const m of (read(f) || "").matchAll(/<color\s+name="([^"]+)"/g)) {
|
|
527
|
+
count++;
|
|
528
|
+
if (names.length < 6) names.push(m[1]);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
return { kind: "XML resources", files: files.map((f) => rel(root, f)), count, names };
|
|
532
|
+
}
|
|
533
|
+
return null;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function checkPatterns(guides) {
|
|
537
|
+
const files = guides.filter((g) => /pattern|template|recipe|layout|page|blueprint|composition/i.test(g.slug + " " + g.title));
|
|
538
|
+
// A pattern is worth 5 only if it carries structure: states, traps, a skeleton.
|
|
539
|
+
const rich = files.filter((g) => /empty|loading|error|state|skeleton|trap/i.test(g.body) && g.lines > 40);
|
|
540
|
+
// A task-to-component map is the cheap version: any guide with such a table.
|
|
541
|
+
const mapFound = guides.some((g) => /^\|[^\n]*\b(pattern|task|use case|when you need|goal)\b[^\n]*\|/im.test(g.body));
|
|
542
|
+
return { files: files.map((g) => g.path), rich: rich.length, mapFound };
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function checkA11y(guides, ci, root, platform) {
|
|
546
|
+
const withSection = guides.filter((g) => g.hasA11y);
|
|
547
|
+
return {
|
|
548
|
+
total: guides.length,
|
|
549
|
+
withSection: withSection.length,
|
|
550
|
+
ratio: guides.length ? withSection.length / guides.length : 0,
|
|
551
|
+
generated: withSection.some((g) => g.generated),
|
|
552
|
+
automation: ci.runsAxe,
|
|
553
|
+
baseline: ["docs/a11y-baseline.md", "a11y-baseline.md", ".a11y-baseline.json"].filter((f) => exists(join(root, f))),
|
|
554
|
+
platform,
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function checkVerification(root, pkg, ci, platform) {
|
|
559
|
+
const scripts = pkg.scripts || {};
|
|
560
|
+
const tests = walk(root, [".test.ts", ".test.tsx", ".test.mjs", ".spec.ts", ".spec.tsx", "Tests.swift", "Test.kt", "Tests.kt"], 6).length;
|
|
561
|
+
const stories = walk(root, [".stories.ts", ".stories.tsx", ".stories.js"], 6).length;
|
|
562
|
+
return {
|
|
563
|
+
testScript: Boolean(scripts.test),
|
|
564
|
+
typecheckScript: Boolean(scripts.typecheck || scripts["type-check"]),
|
|
565
|
+
lintScript: Boolean(scripts.lint),
|
|
566
|
+
testFiles: tests,
|
|
567
|
+
storyFiles: stories,
|
|
568
|
+
storyTests: ci.runsStoryTests,
|
|
569
|
+
axe: ci.runsAxe,
|
|
570
|
+
workflows: ci.workflows,
|
|
571
|
+
docChecks: ci.checksDocs,
|
|
572
|
+
scoreGate: ci.runsAdsa,
|
|
573
|
+
platform,
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function checkGaps(root, guides, agentFiles, pkg) {
|
|
578
|
+
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));
|
|
581
|
+
return {
|
|
582
|
+
file: named[0] || (guideFile ? guideFile.path : null),
|
|
583
|
+
reportCommand: scripts.length ? scripts[0][0] : null,
|
|
584
|
+
stopAndAsk: agentFiles.some((f) => f.hasStopAndAsk),
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/* --------------------------------------------------------------- helpers */
|
|
589
|
+
|
|
590
|
+
export function kebab(s) {
|
|
591
|
+
return s
|
|
592
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
|
|
593
|
+
.replace(/[\s_.]+/g, "-")
|
|
594
|
+
.toLowerCase();
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
export function pascal(s) {
|
|
598
|
+
return s
|
|
599
|
+
.split(/[-_/\s]+/)
|
|
600
|
+
.filter(Boolean)
|
|
601
|
+
.map((p) => p[0].toUpperCase() + p.slice(1))
|
|
602
|
+
.join("");
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function unique(list) {
|
|
606
|
+
return [...new Set(list)];
|
|
607
|
+
}
|