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/config.mjs
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration. Everything has a default, and anything the tool can read from the
|
|
3
|
+
* repo is never asked for: the config exists for the handful of things detection
|
|
4
|
+
* gets wrong in a repo laid out unusually.
|
|
5
|
+
*/
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { readdirSync } from "node:fs";
|
|
8
|
+
import { exists, isDir, read, readJson } from "./fsx.mjs";
|
|
9
|
+
|
|
10
|
+
export const CONFIG_NAME = "adsa.config.json";
|
|
11
|
+
|
|
12
|
+
export const DEFAULTS = {
|
|
13
|
+
/** Directories that hold component guides, in priority order. */
|
|
14
|
+
guides: null,
|
|
15
|
+
/** Where component source lives. */
|
|
16
|
+
source: null,
|
|
17
|
+
/** Extra packages a consumer must never import alongside this system. */
|
|
18
|
+
forbidden: ["lucide-react", "@heroicons/react", "react-icons", "@mui/material", "@chakra-ui/react", "antd", "shadcn-ui"],
|
|
19
|
+
/** Files that carry agent instructions, in priority order. */
|
|
20
|
+
agentFiles: ["AGENTS.md", "CLAUDE.md", ".cursorrules", ".github/copilot-instructions.md"],
|
|
21
|
+
/** Minimum score for `adsa audit --gate`. */
|
|
22
|
+
minScore: null,
|
|
23
|
+
/** Dimensions to skip, e.g. ["patterns"] for a primitives-only library. */
|
|
24
|
+
skip: [],
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const GUIDE_DIRS = ["guidelines", "docs/components", "docs/guides", "documentation/components", "docs", "documentation", "www/content", "apps/docs/content"];
|
|
28
|
+
const SOURCE_DIRS = ["src/components", "Sources", "src/main/kotlin", "src", "packages/ui/src", "lib/components", "components"];
|
|
29
|
+
|
|
30
|
+
export function loadConfig(root) {
|
|
31
|
+
const file = readJson(join(root, CONFIG_NAME)) || {};
|
|
32
|
+
const config = { ...DEFAULTS, ...file };
|
|
33
|
+
config.guides = normalizeDirs(root, config.guides, GUIDE_DIRS);
|
|
34
|
+
config.source = normalizeDirs(root, config.source, SOURCE_DIRS);
|
|
35
|
+
config.configFile = exists(join(root, CONFIG_NAME)) ? CONFIG_NAME : null;
|
|
36
|
+
return config;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function normalizeDirs(root, value, candidates) {
|
|
40
|
+
const list = value ? (Array.isArray(value) ? value : [value]) : candidates;
|
|
41
|
+
const found = list.filter((d) => isDir(join(root, d)));
|
|
42
|
+
// Only the first match from the candidate list, so `docs` does not shadow `guidelines`.
|
|
43
|
+
return value ? found : found.slice(0, 1);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Design systems usually live in a monorepo. When the given directory is a workspace
|
|
48
|
+
* root with nothing to audit in it, pick the package that looks most like the design
|
|
49
|
+
* system — the one with the most guides and exported subpaths — and say so.
|
|
50
|
+
*
|
|
51
|
+
* @returns {{ dir:string, note:string|null, candidates:Array<{dir:string,name:string,score:number}> }}
|
|
52
|
+
*/
|
|
53
|
+
export function resolveTarget(root) {
|
|
54
|
+
const pkg = readJson(join(root, "package.json"));
|
|
55
|
+
if (pkg && (pkg.exports || isDir(join(root, "guidelines")))) return { dir: root, note: null, candidates: [] };
|
|
56
|
+
|
|
57
|
+
const patterns = workspacePatterns(root, pkg);
|
|
58
|
+
if (!patterns.length) return { dir: root, note: null, candidates: [] };
|
|
59
|
+
|
|
60
|
+
const candidates = [];
|
|
61
|
+
for (const pattern of patterns) {
|
|
62
|
+
for (const dir of expand(root, pattern)) {
|
|
63
|
+
const p = readJson(join(dir, "package.json"));
|
|
64
|
+
if (!p || p.private === true) continue;
|
|
65
|
+
const guides = countGuides(dir);
|
|
66
|
+
const exports_ = Object.keys(p.exports || {}).filter((k) => k.startsWith("./")).length;
|
|
67
|
+
const score = guides * 3 + exports_;
|
|
68
|
+
if (score > 0) candidates.push({ dir, name: p.name || dir, score, guides, exports: exports_ });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
candidates.sort((a, b) => b.score - a.score);
|
|
72
|
+
if (!candidates.length) return { dir: root, note: null, candidates: [] };
|
|
73
|
+
return { dir: candidates[0].dir, note: `workspace: audited ${candidates[0].name}`, candidates };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function workspacePatterns(root, pkg) {
|
|
77
|
+
const fromPkg = Array.isArray(pkg?.workspaces) ? pkg.workspaces : pkg?.workspaces?.packages || [];
|
|
78
|
+
const yaml = read(join(root, "pnpm-workspace.yaml")) || "";
|
|
79
|
+
const fromYaml = [...yaml.matchAll(/^\s*-\s*["']?([^"'\n]+)["']?/gm)].map((m) => m[1].trim());
|
|
80
|
+
return [...new Set([...fromPkg, ...fromYaml])].filter(Boolean);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Only the `dir/*` and `dir` shapes real workspace globs use. */
|
|
84
|
+
function expand(root, pattern) {
|
|
85
|
+
const clean = pattern.replace(/\/\*\*$/, "/*");
|
|
86
|
+
if (!clean.includes("*")) return isDir(join(root, clean)) ? [join(root, clean)] : [];
|
|
87
|
+
const base = join(root, clean.slice(0, clean.indexOf("*")).replace(/\/$/, ""));
|
|
88
|
+
if (!isDir(base)) return [];
|
|
89
|
+
try {
|
|
90
|
+
return readdirSync(base, { withFileTypes: true })
|
|
91
|
+
.filter((e) => e.isDirectory() && !e.name.startsWith("."))
|
|
92
|
+
.map((e) => join(base, e.name));
|
|
93
|
+
} catch {
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function countGuides(dir) {
|
|
99
|
+
for (const candidate of ["guidelines", "docs", "documentation"]) {
|
|
100
|
+
if (isDir(join(dir, candidate))) return 1;
|
|
101
|
+
}
|
|
102
|
+
return 0;
|
|
103
|
+
}
|
package/lib/dense.mjs
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** Strips prose, keeps the parts an agent acts on: headings, code, tables, lists. */
|
|
2
|
+
export function dense(markdown) {
|
|
3
|
+
const lines = markdown.split("\n");
|
|
4
|
+
const out = [];
|
|
5
|
+
let inFence = false;
|
|
6
|
+
for (const line of lines) {
|
|
7
|
+
if (/^\s*```/.test(line)) {
|
|
8
|
+
inFence = !inFence;
|
|
9
|
+
out.push(line);
|
|
10
|
+
continue;
|
|
11
|
+
}
|
|
12
|
+
if (inFence) {
|
|
13
|
+
out.push(line);
|
|
14
|
+
continue;
|
|
15
|
+
}
|
|
16
|
+
if (/^\s*#{1,6}\s/.test(line) || /^\s*\|/.test(line) || /^\s*[-*+]\s/.test(line) || /^\s*\d+\.\s/.test(line) || /^\s*$/.test(line) || /^\s*>/.test(line)) {
|
|
17
|
+
out.push(line);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return out.join("\n").replace(/\n{3,}/g, "\n\n").trim() + "\n";
|
|
21
|
+
}
|
package/lib/eval.mjs
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The experiment. A static audit says how well documented a system is; this measures
|
|
3
|
+
* what an agent actually produced when it used the system, which is the only number
|
|
4
|
+
* that has ever changed anyone's mind.
|
|
5
|
+
*
|
|
6
|
+
* The agent does the building. This writes the task and then measures the result.
|
|
7
|
+
*/
|
|
8
|
+
import { join, relative } from "node:path";
|
|
9
|
+
import { isDir, read, rel, walk } from "./fsx.mjs";
|
|
10
|
+
|
|
11
|
+
const IMPORT = /import\s*(?:type\s*)?(?:\{([^}]*)\}|(\w+)|\*\s*as\s*(\w+))?\s*(?:,\s*\{([^}]*)\})?\s*from\s*["']([^"']+)["']/g;
|
|
12
|
+
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";
|
|
13
|
+
const RAW_PALETTE = new RegExp(`\\b(?:bg|text|border|ring|fill|stroke|from|to|via|divide)-(?:${PALETTE})-\\d{2,3}\\b`, "g");
|
|
14
|
+
const HEX = /#[0-9a-fA-F]{6}\b/g;
|
|
15
|
+
|
|
16
|
+
/** Always relative: a task file is committed, and an absolute path is both a leak and wrong for everyone else. */
|
|
17
|
+
function systemPath(root) {
|
|
18
|
+
const rel = relative(process.cwd(), root);
|
|
19
|
+
return !rel ? "." : rel.startsWith("..") ? "<path-to-the-design-system>" : rel;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function taskFile(facts, values) {
|
|
23
|
+
return `# Agent-readiness experiment
|
|
24
|
+
|
|
25
|
+
Give this task to a coding agent in a **fresh session**, with no other context than
|
|
26
|
+
this repository and the design system it depends on.
|
|
27
|
+
|
|
28
|
+
## The task
|
|
29
|
+
|
|
30
|
+
Build one real page using **only ${facts.name}**. Pick a page your product actually
|
|
31
|
+
has — a list with filters, a settings form, a detail view — not a showcase of
|
|
32
|
+
components.
|
|
33
|
+
|
|
34
|
+
Rules for the agent:
|
|
35
|
+
|
|
36
|
+
- Every UI element comes from ${facts.name}. No second component library, no icons
|
|
37
|
+
from anywhere else, no local copies of component source.
|
|
38
|
+
- Colour, spacing and typography come from the system's tokens.
|
|
39
|
+
- If something you need does not exist in the system, **stop and say so** instead of
|
|
40
|
+
building your own version of it.
|
|
41
|
+
- Work to a running page, not a sketch.
|
|
42
|
+
|
|
43
|
+
## Then measure it
|
|
44
|
+
|
|
45
|
+
\`\`\`bash
|
|
46
|
+
adsa eval score <the-project-you-just-built> --system ${systemPath(facts.root)}
|
|
47
|
+
\`\`\`
|
|
48
|
+
|
|
49
|
+
The number that matters is how many imports name components the system does not
|
|
50
|
+
have. Everything else is commentary.
|
|
51
|
+
|
|
52
|
+
## What to record
|
|
53
|
+
|
|
54
|
+
| Metric | Round 1 | Round 2 |
|
|
55
|
+
| :-- | :-- | :-- |
|
|
56
|
+
| Components invented | | |
|
|
57
|
+
| Imports from the system | | |
|
|
58
|
+
| Forbidden packages | | |
|
|
59
|
+
| Raw palette classes | | |
|
|
60
|
+
| Time to a working page | | |
|
|
61
|
+
|
|
62
|
+
Run it again after the fixes. The gap between the two columns is the argument.
|
|
63
|
+
`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* @param {string} consumerRoot project the agent produced
|
|
68
|
+
* @param {object} facts scan of the design system it was supposed to use
|
|
69
|
+
*/
|
|
70
|
+
export function evaluate(consumerRoot, facts, config) {
|
|
71
|
+
const files = walk(consumerRoot, [".tsx", ".jsx", ".ts", ".js", ".vue", ".svelte"], 7).filter((f) => !/\.(test|spec|stories)\./.test(f));
|
|
72
|
+
const known = facts.symbols instanceof Set ? facts.symbols : new Set(facts.symbols || []);
|
|
73
|
+
const forbidden = new Set(config.forbidden || []);
|
|
74
|
+
const result = {
|
|
75
|
+
project: consumerRoot,
|
|
76
|
+
system: facts.name,
|
|
77
|
+
files: files.length,
|
|
78
|
+
used: new Set(),
|
|
79
|
+
invented: [],
|
|
80
|
+
systemImports: 0,
|
|
81
|
+
forbiddenImports: [],
|
|
82
|
+
rawPalette: [],
|
|
83
|
+
hex: [],
|
|
84
|
+
localUiFolder: ["components/ui", "src/components/ui", "app/components/ui"].filter((p) => isDir(join(consumerRoot, p))),
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
for (const file of files) {
|
|
88
|
+
const text = read(file) || "";
|
|
89
|
+
const lines = text.split("\n");
|
|
90
|
+
for (const [index, line] of lines.entries()) {
|
|
91
|
+
for (const m of line.matchAll(RAW_PALETTE)) result.rawPalette.push({ file: rel(consumerRoot, file), line: index + 1, value: m[0] });
|
|
92
|
+
for (const m of line.matchAll(HEX)) result.hex.push({ file: rel(consumerRoot, file), line: index + 1, value: m[0] });
|
|
93
|
+
}
|
|
94
|
+
for (const m of text.matchAll(IMPORT)) {
|
|
95
|
+
const from = m[5];
|
|
96
|
+
const named = `${m[1] || ""},${m[4] || ""}`
|
|
97
|
+
.split(",")
|
|
98
|
+
.map((s) => s.trim().split(/\s+as\s+/)[0].trim())
|
|
99
|
+
.filter((s) => s && /^[A-Z]/.test(s));
|
|
100
|
+
const pkg = packageOf(from);
|
|
101
|
+
if (forbidden.has(pkg)) {
|
|
102
|
+
result.forbiddenImports.push({ file: rel(consumerRoot, file), package: pkg });
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (!isSystemImport(from, facts.name)) continue;
|
|
106
|
+
result.systemImports += named.length;
|
|
107
|
+
for (const name of named) {
|
|
108
|
+
if (known.has(name)) result.used.add(name);
|
|
109
|
+
else result.invented.push({ file: rel(consumerRoot, file), name, from });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
result.used = [...result.used].sort();
|
|
115
|
+
result.inventedRatio = result.systemImports ? result.invented.length / result.systemImports : 0;
|
|
116
|
+
result.verdict = verdict(result);
|
|
117
|
+
return result;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function isSystemImport(from, name) {
|
|
121
|
+
if (!name) return false;
|
|
122
|
+
return from === name || from.startsWith(name + "/");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function packageOf(from) {
|
|
126
|
+
if (from.startsWith(".") || from.startsWith("/")) return null;
|
|
127
|
+
const parts = from.split("/");
|
|
128
|
+
return from.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function verdict(r) {
|
|
132
|
+
if (!r.systemImports) return "The agent did not import from the system at all — check the package name and whether it was installed.";
|
|
133
|
+
if (r.invented.length === 0 && !r.forbiddenImports.length && !r.rawPalette.length) return "Clean run: every component came from the system, no forbidden packages, no raw palette.";
|
|
134
|
+
const bits = [];
|
|
135
|
+
if (r.invented.length) bits.push(`${r.invented.length} of ${r.systemImports} imports name components that do not exist`);
|
|
136
|
+
if (r.forbiddenImports.length) bits.push(`${r.forbiddenImports.length} imports from packages the system forbids`);
|
|
137
|
+
if (r.rawPalette.length) bits.push(`${r.rawPalette.length} raw palette classes`);
|
|
138
|
+
if (r.localUiFolder.length) bits.push(`a local ${r.localUiFolder[0]} folder`);
|
|
139
|
+
return `${bits.join(", ")}.`;
|
|
140
|
+
}
|
package/lib/fix.mjs
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fixes. Two kinds, and the difference is honest rather than cosmetic:
|
|
3
|
+
*
|
|
4
|
+
* apply — a file this tool can write correctly in any repository.
|
|
5
|
+
* brief — a task that needs the repo's own stack to do properly, written as a
|
|
6
|
+
* specification an agent executes, with the traps we already hit in it.
|
|
7
|
+
*
|
|
8
|
+
* Everything is idempotent: applied files are written between markers or created
|
|
9
|
+
* only when absent, so running a fix twice is a no-op.
|
|
10
|
+
*/
|
|
11
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { dirname, join } from "node:path";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
import { exists, read, readJson } from "./fsx.mjs";
|
|
15
|
+
|
|
16
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
const TEMPLATES = join(here, "..", "templates");
|
|
18
|
+
const START = "<!-- adsa:start -->";
|
|
19
|
+
const END = "<!-- adsa:end -->";
|
|
20
|
+
|
|
21
|
+
export const FIXES = {
|
|
22
|
+
"agents-md": { kind: "apply", title: "Write agent instructions", dimension: "agent-instructions", run: fixAgents },
|
|
23
|
+
"gaps-file": { kind: "apply", title: "List what the system does not have", dimension: "gap-handling", run: fixGaps },
|
|
24
|
+
"mcp-config": { kind: "apply", title: "Expose the docs as MCP tools", dimension: "machine-surface", run: fixMcp },
|
|
25
|
+
"tokens-doc": { kind: "apply", title: "Document tokens as tables", dimension: "tokens", run: fixTokens },
|
|
26
|
+
"ci-workflow": { kind: "apply", title: "Put the checks in CI", dimension: "verification", run: fixCi },
|
|
27
|
+
"coverage-gate": { kind: "brief", title: "Fail CI on an undocumented export", dimension: "docs-coverage", brief: "coverage-gate" },
|
|
28
|
+
"prop-tables": { kind: "brief", title: "Generate prop tables from types", dimension: "docs-freshness", brief: "prop-tables" },
|
|
29
|
+
"examples-check": { kind: "brief", title: "Compile guide examples in CI", dimension: "docs-freshness", brief: "examples-check" },
|
|
30
|
+
"a11y-docs": { kind: "brief", title: "Add keyboard and accessibility sections", dimension: "a11y", brief: "a11y-docs" },
|
|
31
|
+
"patterns-doc": { kind: "brief", title: "Write page-level patterns", dimension: "patterns", brief: "patterns-doc" },
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** Values a template may reference. Derived from the scan, never asked for. */
|
|
35
|
+
export function vars(facts, config) {
|
|
36
|
+
const platform = facts.platform?.primary || "web";
|
|
37
|
+
const pm = exists(join(facts.root, "yarn.lock")) ? "yarn" : exists(join(facts.root, "pnpm-lock.yaml")) ? "pnpm" : "npm run";
|
|
38
|
+
const cli = facts.machine.binNames[0];
|
|
39
|
+
const lookup = cli ? `run \`npx ${cli} docs <component>\`` : "run `npx adsa-cli search \"<what you need>\"`, then `npx adsa-cli docs <component>`";
|
|
40
|
+
// Prefer a directory that already holds a real guide — e.g. a Swift package's own
|
|
41
|
+
// .docc bundle, found by scanning rather than listed in adsa.config.json — over a
|
|
42
|
+
// generic per-platform guess.
|
|
43
|
+
const existingGuideDir = facts.guides[0] ? dirname(facts.guides[0].path) : null;
|
|
44
|
+
const guidesDir = (config.guides && config.guides[0]) || existingGuideDir || defaultGuidesDir(platform);
|
|
45
|
+
const sourceDir = (config.source && config.source[0]) || defaultSourceDir(platform);
|
|
46
|
+
|
|
47
|
+
if (platform === "swift") return swiftVars(facts, guidesDir, sourceDir, lookup);
|
|
48
|
+
if (platform === "android") return androidVars(facts, guidesDir, sourceDir, lookup);
|
|
49
|
+
|
|
50
|
+
// Web and React Native share the same import/export syntax; only the habits and
|
|
51
|
+
// the token surface differ.
|
|
52
|
+
const subpath = facts.components.find((c) => c.from === "exports");
|
|
53
|
+
const isNative = platform === "react-native";
|
|
54
|
+
return {
|
|
55
|
+
name: facts.name,
|
|
56
|
+
version: facts.version ? ` ${facts.version}` : "",
|
|
57
|
+
stack: isNative
|
|
58
|
+
? [facts.stack.expo && "Expo", "React Native", facts.stack.typescript && "TypeScript", facts.stack.nativewind && "NativeWind"].filter(Boolean).join(", ")
|
|
59
|
+
: [facts.stack.react && "React", facts.stack.typescript && "TypeScript", facts.stack.tailwind && "Tailwind", facts.stack.storybook && "Storybook"].filter(Boolean).join(", ") || "unknown",
|
|
60
|
+
headless: facts.stack.headless.join(", ") || (isNative ? "Pressable, View and the platform's own accessibility props" : "your headless primitives"),
|
|
61
|
+
guidesDir,
|
|
62
|
+
sourceDir,
|
|
63
|
+
pkgRun: pm,
|
|
64
|
+
lookup,
|
|
65
|
+
importExample: subpath ? `import { ${subpath.name} } from "${facts.name}/${subpath.slug}"` : `import { Button } from "${facts.name}"`,
|
|
66
|
+
importRule: subpath ? "Never from the package root." : "",
|
|
67
|
+
paletteExample: isNative
|
|
68
|
+
? facts.stack.nativewind
|
|
69
|
+
? "Raw palette classes (`bg-gray-100`, `text-blue-600`) and raw hex values"
|
|
70
|
+
: "Raw color literals in `StyleSheet.create` (e.g. `#3366FF`) and inline hex strings"
|
|
71
|
+
: facts.stack.tailwind
|
|
72
|
+
? "`bg-gray-100`, `text-blue-600`"
|
|
73
|
+
: "raw colour literals",
|
|
74
|
+
gapsFile: "GAPS.md",
|
|
75
|
+
finishCheck: finishCheck(facts, pm),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function defaultGuidesDir(platform) {
|
|
80
|
+
if (platform === "swift") return "Sources/Documentation.docc";
|
|
81
|
+
if (platform === "android") return "docs";
|
|
82
|
+
return "docs";
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function defaultSourceDir(platform) {
|
|
86
|
+
if (platform === "swift") return "Sources";
|
|
87
|
+
if (platform === "android") return "src/main/kotlin";
|
|
88
|
+
return "src";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function finishCheck(facts, pm) {
|
|
92
|
+
const scripts = facts.verification;
|
|
93
|
+
const bits = [];
|
|
94
|
+
if (scripts.typecheckScript) bits.push(`\`${pm} typecheck\``);
|
|
95
|
+
if (scripts.lintScript) bits.push(`\`${pm} lint\``);
|
|
96
|
+
if (scripts.testScript) bits.push(`\`${pm} test\``);
|
|
97
|
+
return bits.length ? `${bits.join(", ")} must pass` : "your project's type check and lint must pass";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** `public struct Foo: View` in Swift takes a module import, not a named one. */
|
|
101
|
+
function swiftVars(facts, guidesDir, sourceDir, lookup) {
|
|
102
|
+
const first = facts.components[0];
|
|
103
|
+
return {
|
|
104
|
+
name: facts.name,
|
|
105
|
+
version: facts.version ? ` ${facts.version}` : "",
|
|
106
|
+
stack: "Swift, SwiftUI",
|
|
107
|
+
headless: "UIKit's accessibility protocol and SwiftUI's accessibility modifiers (`.accessibilityLabel`, `.accessibilityAddTraits`)",
|
|
108
|
+
guidesDir,
|
|
109
|
+
sourceDir,
|
|
110
|
+
pkgRun: "swift",
|
|
111
|
+
lookup,
|
|
112
|
+
importExample: `import ${facts.name}`,
|
|
113
|
+
importRule: first ? `Then use the view directly, e.g. \`${first.name}(...)\`. Never copy its source into your app target.` : "Never copy a view's source into your app target.",
|
|
114
|
+
paletteExample: "Raw `Color(red:green:blue:)` literals and hardcoded hex strings",
|
|
115
|
+
gapsFile: "GAPS.md",
|
|
116
|
+
finishCheck: exists(join(facts.root, "Package.swift")) ? "`swift build` and `swift test` must pass" : "your Xcode scheme's build and tests must pass",
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** A Kotlin `@Composable fun Foo(...)` is imported by its real package, not guessed. */
|
|
121
|
+
function androidVars(facts, guidesDir, sourceDir, lookup) {
|
|
122
|
+
const first = facts.components[0];
|
|
123
|
+
const pkg = first ? kotlinPackageOf(facts.root, first) : null;
|
|
124
|
+
return {
|
|
125
|
+
name: facts.name,
|
|
126
|
+
version: facts.version ? ` ${facts.version}` : "",
|
|
127
|
+
stack: "Kotlin, Jetpack Compose",
|
|
128
|
+
headless: "Compose's Semantics API (`contentDescription`, `Role`, `mergeDescendants`)",
|
|
129
|
+
guidesDir,
|
|
130
|
+
sourceDir,
|
|
131
|
+
pkgRun: "./gradlew",
|
|
132
|
+
lookup,
|
|
133
|
+
importExample: pkg && first ? `import ${pkg}.${first.name}` : `import <your.package>.${first ? first.name : "Button"}`,
|
|
134
|
+
importRule: "Never redeclare a composable in your own module.",
|
|
135
|
+
paletteExample: "Hardcoded `Color(0xFF...)` values and raw hex in layout XML",
|
|
136
|
+
gapsFile: "GAPS.md",
|
|
137
|
+
finishCheck: "`./gradlew test lint` must pass",
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function kotlinPackageOf(root, component) {
|
|
142
|
+
const text = read(join(root, component.from)) || "";
|
|
143
|
+
const m = text.match(/^package\s+([\w.]+)/m);
|
|
144
|
+
return m ? m[1] : null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function render(template, values) {
|
|
148
|
+
return template.replace(/\{\{(\w+)\}\}/g, (_, key) => (values[key] === undefined ? `{{${key}}}` : String(values[key])));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function template(name) {
|
|
152
|
+
return readFileSync(join(TEMPLATES, name), "utf8");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** @returns {Array<{file:string, action:"create"|"update"|"skip", note:string}>} */
|
|
156
|
+
export function applyFix(id, facts, config, opts = {}) {
|
|
157
|
+
const fix = FIXES[id];
|
|
158
|
+
if (!fix) throw new Error(`Unknown fix "${id}". Run \`adsa fix --list\`.`);
|
|
159
|
+
const values = vars(facts, config);
|
|
160
|
+
if (fix.kind === "brief") return writeBrief(id, fix, values, facts, opts);
|
|
161
|
+
return fix.run(facts, config, values, opts);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function writeBrief(id, fix, values, facts, opts) {
|
|
165
|
+
const file = join(".adsa", "fixes", `${id}.md`);
|
|
166
|
+
const body = render(template(join("briefs", `${fix.brief}.md`)), values);
|
|
167
|
+
return [write(facts.root, file, body, opts, "brief for your agent")];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/* ------------------------------------------------------------- apply fixes */
|
|
171
|
+
|
|
172
|
+
function agentsTemplateFile(platform) {
|
|
173
|
+
if (platform === "react-native") return "AGENTS.react-native.md.tmpl";
|
|
174
|
+
if (platform === "swift") return "AGENTS.swift.md.tmpl";
|
|
175
|
+
if (platform === "android") return "AGENTS.android.md.tmpl";
|
|
176
|
+
return "AGENTS.md.tmpl";
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function tokensTemplateFile(platform) {
|
|
180
|
+
if (platform === "react-native") return "tokens.native.md.tmpl";
|
|
181
|
+
if (platform === "swift") return "tokens.swift.md.tmpl";
|
|
182
|
+
if (platform === "android") return "tokens.android.md.tmpl";
|
|
183
|
+
return "tokens.md.tmpl";
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function fixAgents(facts, config, values, opts) {
|
|
187
|
+
const target = facts.agentFiles.find((a) => a.file === "AGENTS.md" || a.file === "CLAUDE.md");
|
|
188
|
+
const file = target ? target.file : "AGENTS.md";
|
|
189
|
+
const path = join(facts.root, file);
|
|
190
|
+
const section = render(template(agentsTemplateFile(facts.platform?.primary)), values);
|
|
191
|
+
const current = read(path);
|
|
192
|
+
if (current === null) return [write(facts.root, file, `# Agents\n\n${section}`, opts, "design-system section added")];
|
|
193
|
+
if (current.includes(START) && current.includes(END)) {
|
|
194
|
+
const next = current.slice(0, current.indexOf(START)) + section.trimEnd() + current.slice(current.indexOf(END) + END.length);
|
|
195
|
+
if (next === current) return [{ file, action: "skip", note: "section already up to date" }];
|
|
196
|
+
return [write(facts.root, file, next, opts, "section refreshed")];
|
|
197
|
+
}
|
|
198
|
+
return [write(facts.root, file, current.replace(/\s*$/, "\n\n") + section, opts, "section appended")];
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function fixGaps(facts, config, values, opts) {
|
|
202
|
+
const file = facts.gaps.file || "GAPS.md";
|
|
203
|
+
if (exists(join(facts.root, file))) return [{ file, action: "skip", note: "a gap list already exists" }];
|
|
204
|
+
return [write(facts.root, "GAPS.md", template("GAPS.md.tmpl"), opts, "created; fill in the real absences")];
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function fixMcp(facts, config, values, opts) {
|
|
208
|
+
const entry = { command: "npx", args: ["--yes", "adsa-cli@latest", "mcp"] };
|
|
209
|
+
const results = [];
|
|
210
|
+
for (const file of [".mcp.json", ".cursor/mcp.json", ".vscode/mcp.json"]) {
|
|
211
|
+
const path = join(facts.root, file);
|
|
212
|
+
const present = exists(path);
|
|
213
|
+
if (!present && file !== ".mcp.json") continue;
|
|
214
|
+
const current = present ? readJson(path) : null;
|
|
215
|
+
if (present && !current) {
|
|
216
|
+
results.push({ file, action: "skip", note: "not valid JSON — left alone" });
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
const config_ = current || {};
|
|
220
|
+
const key = config_.servers && !config_.mcpServers ? "servers" : "mcpServers";
|
|
221
|
+
const servers = config_[key] || {};
|
|
222
|
+
if (servers.adsa) {
|
|
223
|
+
results.push({ file, action: "skip", note: "the adsa server is already registered" });
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
const next = { ...config_, [key]: { ...servers, adsa: entry } };
|
|
227
|
+
results.push(write(facts.root, file, JSON.stringify(next, null, indentOf(present ? readFileSync(path, "utf8") : "")) + "\n", opts, present ? "adsa server added" : "adsa server registered"));
|
|
228
|
+
}
|
|
229
|
+
results.push({ file: "—", action: "note", note: "restart your MCP client, then run `adsa doctor`" });
|
|
230
|
+
return results;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function indentOf(text) {
|
|
234
|
+
const m = text.match(/\n([ \t]+)\S/);
|
|
235
|
+
return m ? m[1] : 2;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function fixTokens(facts, config, values, opts) {
|
|
239
|
+
if (facts.tokens.docs.length) return [{ file: facts.tokens.docs[0], action: "skip", note: "token documentation already exists" }];
|
|
240
|
+
const file = join(values.guidesDir, "design-tokens.md");
|
|
241
|
+
return [write(facts.root, file, template(tokensTemplateFile(facts.platform?.primary)), opts, "created; replace the example rows")];
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function fixCi(facts, config, values, opts) {
|
|
245
|
+
const file = ".github/workflows/adsa.yml";
|
|
246
|
+
if (exists(join(facts.root, file))) return [{ file, action: "skip", note: "workflow already present" }];
|
|
247
|
+
return [write(facts.root, file, template(join("ci", "adsa.yml")), opts, "gates the score on every pull request")];
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/* ------------------------------------------------------------------ writing */
|
|
251
|
+
|
|
252
|
+
function write(root, file, body, opts, note) {
|
|
253
|
+
const path = join(root, file);
|
|
254
|
+
const existed = exists(path);
|
|
255
|
+
if (!opts.dryRun) {
|
|
256
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
257
|
+
writeFileSync(path, body);
|
|
258
|
+
}
|
|
259
|
+
return { file, action: existed ? "update" : "create", note };
|
|
260
|
+
}
|
package/lib/fsx.mjs
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/** Filesystem helpers. No dependencies: this runs inside somebody else's repo. */
|
|
2
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
3
|
+
import { join, relative } from "node:path";
|
|
4
|
+
|
|
5
|
+
const SKIP_DIRS = new Set(["node_modules", ".git", "dist", "build", "out", ".next", "coverage", ".turbo", ".yarn", "storybook-static", ".cache"]);
|
|
6
|
+
|
|
7
|
+
/** Every file under `dir` with one of `exts`, depth-limited. Returns absolute paths. */
|
|
8
|
+
export function walk(dir, exts, maxDepth = 8, _depth = 0, _out = []) {
|
|
9
|
+
if (_depth > maxDepth || !existsSync(dir)) return _out;
|
|
10
|
+
let entries;
|
|
11
|
+
try {
|
|
12
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
13
|
+
} catch {
|
|
14
|
+
return _out;
|
|
15
|
+
}
|
|
16
|
+
for (const e of entries) {
|
|
17
|
+
if (e.name.startsWith(".") && e.name !== ".github") continue;
|
|
18
|
+
const path = join(dir, e.name);
|
|
19
|
+
if (e.isDirectory()) {
|
|
20
|
+
if (SKIP_DIRS.has(e.name)) continue;
|
|
21
|
+
walk(path, exts, maxDepth, _depth + 1, _out);
|
|
22
|
+
} else if (!exts || exts.some((x) => e.name.endsWith(x))) {
|
|
23
|
+
_out.push(path);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return _out;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Every directory under `dir` whose name ends with one of `suffixes` — e.g. `.xcodeproj`,
|
|
31
|
+
* `.xcassets`, `.docc`. Does not descend into a matched directory: a bundle like
|
|
32
|
+
* `Assets.xcassets` is a leaf as far as this tool is concerned. Returns absolute paths.
|
|
33
|
+
*/
|
|
34
|
+
export function walkDirs(dir, suffixes, maxDepth = 8, _depth = 0, _out = []) {
|
|
35
|
+
if (_depth > maxDepth || !existsSync(dir)) return _out;
|
|
36
|
+
let entries;
|
|
37
|
+
try {
|
|
38
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
39
|
+
} catch {
|
|
40
|
+
return _out;
|
|
41
|
+
}
|
|
42
|
+
for (const e of entries) {
|
|
43
|
+
if (!e.isDirectory()) continue;
|
|
44
|
+
const path = join(dir, e.name);
|
|
45
|
+
if (suffixes.some((s) => e.name.endsWith(s))) {
|
|
46
|
+
_out.push(path);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (e.name.startsWith(".") || SKIP_DIRS.has(e.name)) continue;
|
|
50
|
+
walkDirs(path, suffixes, maxDepth, _depth + 1, _out);
|
|
51
|
+
}
|
|
52
|
+
return _out;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function read(path) {
|
|
56
|
+
try {
|
|
57
|
+
return readFileSync(path, "utf8");
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function readJson(path) {
|
|
64
|
+
const text = read(path);
|
|
65
|
+
if (!text) return null;
|
|
66
|
+
try {
|
|
67
|
+
return JSON.parse(text);
|
|
68
|
+
} catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function exists(path) {
|
|
74
|
+
return existsSync(path);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function isDir(path) {
|
|
78
|
+
try {
|
|
79
|
+
return statSync(path).isDirectory();
|
|
80
|
+
} catch {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function rel(root, path) {
|
|
86
|
+
const r = relative(root, path);
|
|
87
|
+
return r.split("\\").join("/");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** First existing path from a list of candidates, relative to root. */
|
|
91
|
+
export function firstExisting(root, candidates) {
|
|
92
|
+
for (const c of candidates) if (existsSync(join(root, c))) return c;
|
|
93
|
+
return null;
|
|
94
|
+
}
|
package/lib/history.mjs
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** Score history, committed with the repo so a regression is visible in a diff. */
|
|
2
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { readJson } from "./fsx.mjs";
|
|
5
|
+
|
|
6
|
+
export const DIR = ".adsa";
|
|
7
|
+
|
|
8
|
+
export function historyPath(root) {
|
|
9
|
+
return join(root, DIR, "history.json");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function readHistory(root) {
|
|
13
|
+
const json = readJson(historyPath(root));
|
|
14
|
+
return Array.isArray(json) ? json : [];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Appends a run, collapsing same-day reruns so the file does not grow per invocation. */
|
|
18
|
+
export function appendHistory(root, entry) {
|
|
19
|
+
const history = readHistory(root);
|
|
20
|
+
const day = entry.date.slice(0, 10);
|
|
21
|
+
const filtered = history.filter((h) => h.date.slice(0, 10) !== day);
|
|
22
|
+
filtered.push(entry);
|
|
23
|
+
write(historyPath(root), filtered);
|
|
24
|
+
return filtered;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function write(path, data) {
|
|
28
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
29
|
+
writeFileSync(path, JSON.stringify(data, null, 2) + "\n");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Per-dimension delta against the previous run. */
|
|
33
|
+
export function compare(previous, current) {
|
|
34
|
+
if (!previous) return null;
|
|
35
|
+
const moved = [];
|
|
36
|
+
for (const [id, score] of Object.entries(current.dimensions)) {
|
|
37
|
+
const before = previous.dimensions[id];
|
|
38
|
+
if (before !== score && before != null && score != null) moved.push({ id, from: before, to: score });
|
|
39
|
+
}
|
|
40
|
+
return { from: previous.total, to: current.total, delta: current.total - previous.total, moved, since: previous.generatedAt || previous.date };
|
|
41
|
+
}
|