@actioneer/ads-manifest 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/README.md ADDED
@@ -0,0 +1,39 @@
1
+ # `@actioneer/ads-manifest` — the ADS catalog toolkit
2
+
3
+ Pure, dependency-free cores that turn the Actioneer Design System's component
4
+ barrel + tokens + design principles into the two machine-readable artifacts the
5
+ distribution layer ships, and that answer queries against them:
6
+
7
+ - **`ads-manifest.json`** — one entry per public barrel export (name, family,
8
+ kind, import path, variants/props, showcase URL) + semantic tokens + the
9
+ **design principles** (each flagged `enforcedByLint`).
10
+ - **`llms.txt`** — the LLM-facing family → component index, token roles, and a
11
+ `## Design principles` section, plus the consumer-setup contract.
12
+
13
+ Every core is a pure function (source strings in, data out) with **no filesystem
14
+ access**, so the whole toolkit is unit-tested against fixture strings. The repo's
15
+ `scripts/gen-ads-manifest.mjs` is the thin IO wrapper that reads files and writes
16
+ the artifacts; the ADS MCP server (`@actioneer/ads-mcp`) is a thin adapter over
17
+ the query core.
18
+
19
+ ## Exports
20
+
21
+ | Subpath | What it is |
22
+ |---|---|
23
+ | `.` | Barrel re-exporting the build + query cores. |
24
+ | `@actioneer/ads-manifest/build` | `buildManifest(...)`, `normalizePrinciples(...)`, `validateManifest(...)`. |
25
+ | `@actioneer/ads-manifest/llms` | `renderLlmsTxt(manifest)` → the `llms.txt` string. |
26
+ | `@actioneer/ads-manifest/query` | `searchComponents`, `getComponent`, `getToken`, `getPrinciples`. |
27
+ | `@actioneer/ads-manifest/parse` | The low-level barrel/CVA/prop/token parsers. |
28
+
29
+ ## Determinism
30
+
31
+ `buildManifest` is deterministic — no timestamps, stable sort — so regenerating
32
+ produces no diff. `prebuild` / `build:lib` regenerate the artifacts and a
33
+ malformed or incomplete manifest exits non-zero rather than shipping.
34
+
35
+ ## Development
36
+
37
+ ```bash
38
+ npm run test:manifest # from the ADS repo root — build + query + llms cores
39
+ ```
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@actioneer/ads-manifest",
3
+ "version": "0.1.0",
4
+ "description": "Pure ADS catalog toolkit — builds ads-manifest.json + llms.txt from the component barrel and answers manifest queries. Backs the generation script and the ADS MCP server.",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./src/index.mjs",
8
+ "./build": "./src/build.mjs",
9
+ "./llms": "./src/llms.mjs",
10
+ "./query": "./src/query.mjs",
11
+ "./parse": "./src/parse.mjs"
12
+ },
13
+ "files": [
14
+ "src",
15
+ "README.md"
16
+ ],
17
+ "scripts": {
18
+ "test": "node --test"
19
+ },
20
+ "peerDependencies": {
21
+ "typescript": ">=5"
22
+ },
23
+ "peerDependenciesMeta": {
24
+ "typescript": { "optional": true }
25
+ },
26
+ "publishConfig": {
27
+ "access": "public"
28
+ },
29
+ "engines": {
30
+ "node": ">=18"
31
+ }
32
+ }
package/src/build.mjs ADDED
@@ -0,0 +1,379 @@
1
+ // Pure manifest builder: (barrel + module sources + guidance + nav + tokens)
2
+ // → a schema-valid `ads-manifest.json` object. No filesystem access — the IO
3
+ // wrapper (scripts/gen-ads-manifest.mjs) reads files and hands sources in, so
4
+ // this whole core runs under node:test against fixture strings.
5
+
6
+ import { posix } from "node:path";
7
+ import {
8
+ parseModuleExports,
9
+ parseCvaBlocks,
10
+ parsePropInterfaces,
11
+ parseSemanticTokens,
12
+ classifyValue,
13
+ } from "./parse.mjs";
14
+
15
+ const KINDS = ["component", "type", "hook", "util"];
16
+
17
+ /** Section-comment text → clean family name ("Form controls", "Chat (…)" → "Chat"). */
18
+ function normalizeFamily(section) {
19
+ return section
20
+ .replace(/\(.*$/, "") // drop parenthetical
21
+ .replace(/[—–-].*$/, "") // drop trailing dash-clause
22
+ .trim();
23
+ }
24
+
25
+ /** Kebab/scoped module id → its basename ("components/ai-elements/task" → "task"). */
26
+ const baseOf = (id) => id.split("/").filter(Boolean).pop() ?? id;
27
+
28
+ /** Resolve a relative spec against the importing module's id (POSIX, no ext). */
29
+ function resolveSpec(fromId, spec) {
30
+ if (!spec.startsWith(".")) return null; // bare / alias — not walkable here
31
+ return posix.normalize(posix.join(posix.dirname(fromId), spec)).replace(/\/index$/, "/index");
32
+ }
33
+
34
+ /**
35
+ * Walk the main barrel and every module it re-exports, yielding one record per
36
+ * public name with provenance.
37
+ *
38
+ * @returns {Array<{name:string, kind:string, family:string, leaf:string}>}
39
+ */
40
+ // Star walks into these leaf modules are skipped — mirrors gen-ads-meta's
41
+ // `ui/` + `assistant-ui/` exclusions (the vendored chat internals). Explicit
42
+ // NAMED re-exports (e.g. `export { TooltipProvider } from "./ui/tooltip"`) are
43
+ // sanctioned public names and are kept.
44
+ export const DEFAULT_EXCLUDE = [/(^|\/)assistant-ui(\/|$)/, /(^|\/)ui(\/|$)/];
45
+
46
+ export function resolveExports({ barrelSource, barrelId, readModule, exclude = DEFAULT_EXCLUDE }) {
47
+ const records = [];
48
+ const seen = new Set();
49
+ const isExcluded = (id) => exclude.some((re) => re.test(id));
50
+
51
+ // Line-scan the barrel for section headers + top-level export statements. The
52
+ // barrel is hand-maintained, one export per line — the FIRST line of the
53
+ // comment group above a group of exports is its family (the barrel is the
54
+ // single source of inclusion + family). Continuation lines and the file title
55
+ // are ignored; a blank line starts a new comment group.
56
+ let section = "General";
57
+ let prevWasComment = false;
58
+ const lines = barrelSource.split("\n");
59
+ for (const raw of lines) {
60
+ const line = raw.trim();
61
+ if (line === "") {
62
+ prevWasComment = false;
63
+ continue;
64
+ }
65
+ const cm = line.match(/^\/\/\s*(.+)$/);
66
+ if (cm) {
67
+ // Only the first comment line of a group is the section header.
68
+ if (!prevWasComment && !/^ADS 1\.0/.test(cm[1])) section = cm[1];
69
+ prevWasComment = true;
70
+ continue;
71
+ }
72
+ prevWasComment = false;
73
+ const family = normalizeFamily(section);
74
+
75
+ // `export { A, type B, C as D } from "spec";`
76
+ const named = line.match(/^export\s+\{([^}]*)\}\s+from\s+["']([^"']+)["']/);
77
+ if (named) {
78
+ const spec = named[2];
79
+ const leaf = resolveSpec(barrelId, spec) ?? spec;
80
+ for (const piece of named[1].split(",").map((s) => s.trim()).filter(Boolean)) {
81
+ const isType = /^type\s+/.test(piece);
82
+ const nameTok = piece.replace(/^type\s+/, "").split(/\s+as\s+/);
83
+ const name = (nameTok[1] ?? nameTok[0]).trim();
84
+ const kind = isType ? "type" : classifyValue(name);
85
+ addRecord(records, seen, { name, kind, family, leaf });
86
+ }
87
+ continue;
88
+ }
89
+
90
+ // `export * from "spec";`
91
+ const star = line.match(/^export\s+\*\s+from\s+["']([^"']+)["']/);
92
+ if (star) {
93
+ const spec = star[1];
94
+ const modId = resolveSpec(barrelId, spec);
95
+ if (modId && !isExcluded(modId)) walkStar(modId, family, { readModule, records, seen, isExcluded });
96
+ continue;
97
+ }
98
+ }
99
+ return records;
100
+ }
101
+
102
+ /** Recursively collect the export names a `export * from module` brings in. */
103
+ function walkStar(modId, family, ctx, guard = new Set()) {
104
+ if (guard.has(modId)) return;
105
+ guard.add(modId);
106
+ const source = readResolved(modId, ctx.readModule);
107
+ if (source == null) return;
108
+ // Parse with the module's REAL extension in the filename: parseModuleExports
109
+ // keys TSX vs TS off the filename, and a .tsx module parsed as plain TS
110
+ // misreads its JSX — leaking phantom exports (className, …) and dropping real
111
+ // ones. `source.id` is extensionless (it's also the leaf id), so append ext.
112
+ const { stars, reExports, locals } = parseModuleExports(source.text, `${source.id}${source.ext}`);
113
+
114
+ for (const l of locals) {
115
+ const kind = l.kind === "unknown" ? classifyValue(l.name) : l.kind;
116
+ addRecord(ctx.records, ctx.seen, { name: l.name, kind, family, leaf: source.id });
117
+ }
118
+ for (const r of reExports) {
119
+ const childId = resolveSpec(source.id, r.spec);
120
+ const leaf = childId ?? source.id;
121
+ const kind = r.isType ? "type" : classifyValue(r.name);
122
+ addRecord(ctx.records, ctx.seen, { name: r.name, kind, family, leaf });
123
+ }
124
+ for (const s of stars) {
125
+ const childId = resolveSpec(source.id, s);
126
+ if (childId && !ctx.isExcluded(childId)) walkStar(childId, family, ctx, guard);
127
+ }
128
+ }
129
+
130
+ function addRecord(records, seen, rec) {
131
+ if (seen.has(rec.name)) return; // first occurrence wins (stable across families)
132
+ seen.add(rec.name);
133
+ records.push(rec);
134
+ }
135
+
136
+ /**
137
+ * Read a module trying index resolution; returns {id, text, ext} with the
138
+ * resolved (extensionless) id and the source's real extension.
139
+ *
140
+ * `readModule` may return a bare string (ext defaults to ".tsx" — the dominant
141
+ * component case, and TSX safely parses plain TS) or `{text, ext}` when the IO
142
+ * layer knows the real extension (so a walked `.ts` module isn't parsed as TSX).
143
+ */
144
+ function readResolved(modId, readModule) {
145
+ const direct = readModule(modId);
146
+ if (direct != null) return { id: modId, ...normalizeModule(direct) };
147
+ const idxId = posix.join(modId, "index");
148
+ const idx = readModule(idxId);
149
+ if (idx != null) return { id: idxId, ...normalizeModule(idx) };
150
+ return null;
151
+ }
152
+
153
+ function normalizeModule(read) {
154
+ return typeof read === "string" ? { text: read, ext: ".tsx" } : { text: read.text, ext: read.ext ?? ".tsx" };
155
+ }
156
+
157
+ /**
158
+ * Build the full manifest object from resolved records + enrichment sources.
159
+ * Deterministic: no timestamps, stable sort.
160
+ */
161
+ export function buildManifest({
162
+ barrelSource,
163
+ barrelId = "components/index",
164
+ readModule,
165
+ guidance = {},
166
+ navPaths = [],
167
+ themeCss = "",
168
+ principles = [],
169
+ packageName = "@actioneer/ads",
170
+ showcaseBase = "https://ads.actioneer.dev",
171
+ version = "0.0.0",
172
+ exclude = DEFAULT_EXCLUDE,
173
+ }) {
174
+ const records = resolveExports({ barrelSource, barrelId, readModule, exclude });
175
+
176
+ // basename → nav path ("/ai/answer" indexed under "answer"; "/button" under "button")
177
+ const navByBase = new Map();
178
+ for (const p of navPaths) navByBase.set(baseOf(p), p);
179
+
180
+ // Cache module sources so cva/prop parsing reads each leaf once.
181
+ const modCache = new Map();
182
+ const leafSource = (id) => {
183
+ if (modCache.has(id)) return modCache.get(id);
184
+ const r = readResolved(id, readModule);
185
+ modCache.set(id, r?.text ?? null);
186
+ return r?.text ?? null;
187
+ };
188
+
189
+ const families = [];
190
+ const familySeen = new Set();
191
+ const exportsOut = records.map((rec) => {
192
+ if (!familySeen.has(rec.family)) {
193
+ familySeen.add(rec.family);
194
+ families.push(rec.family);
195
+ }
196
+ const entry = {
197
+ name: rec.name,
198
+ family: rec.family,
199
+ kind: rec.kind,
200
+ importPath: packageName,
201
+ module: baseOf(rec.leaf),
202
+ };
203
+
204
+ const g = guidance[rec.name];
205
+ if (g?.whenToUse) entry.description = g.whenToUse;
206
+
207
+ if (rec.kind === "component") {
208
+ const navPath = navByBase.get(baseOf(rec.leaf));
209
+ entry.showcaseUrl = `${showcaseBase.replace(/\/$/, "")}/#${navPath ?? `/${baseOf(rec.leaf)}`}`;
210
+
211
+ const src = leafSource(rec.leaf);
212
+ if (src) {
213
+ const variants = variantVocabulary(rec.name, src, g);
214
+ if (Object.keys(variants).length) entry.variants = variants;
215
+ const propsByIface = parsePropInterfaces(src, `${baseOf(rec.leaf)}.tsx`);
216
+ const props = propsByIface[`${rec.name}Props`];
217
+ if (props && props.length) entry.props = props;
218
+ }
219
+ }
220
+ return entry;
221
+ });
222
+
223
+ // Stable order: family (barrel order) → kind order → name.
224
+ const famIdx = new Map(families.map((f, i) => [f, i]));
225
+ const kindIdx = new Map(KINDS.map((k, i) => [k, i]));
226
+ exportsOut.sort(
227
+ (a, b) =>
228
+ (famIdx.get(a.family) - famIdx.get(b.family)) ||
229
+ (kindIdx.get(a.kind) - kindIdx.get(b.kind)) ||
230
+ a.name.localeCompare(b.name)
231
+ );
232
+
233
+ const counts = { total: exportsOut.length };
234
+ for (const k of KINDS) counts[k] = exportsOut.filter((e) => e.kind === k).length;
235
+
236
+ const tokens = parseSemanticTokens(themeCss).map((t) => ({
237
+ name: t.name,
238
+ role: t.role,
239
+ utilities: utilitiesFor(t.role),
240
+ description: t.description || "",
241
+ }));
242
+
243
+ const manifest = {
244
+ name: packageName,
245
+ version,
246
+ importPath: packageName,
247
+ styleImport: `@import "${packageName}/styles.css";`,
248
+ families,
249
+ counts,
250
+ tokens,
251
+ exports: exportsOut,
252
+ };
253
+ const normalizedPrinciples = normalizePrinciples(principles);
254
+ if (normalizedPrinciples.length) manifest.principles = normalizedPrinciples;
255
+ return manifest;
256
+ }
257
+
258
+ /**
259
+ * Normalize the structured design-principles source into the manifest block:
260
+ * a stable-ordered array of {id, number, title, rule, do, dont, enforcedByLint,
261
+ * lintRules}. Deterministic (sorted by `number`); tolerant of extra fields.
262
+ */
263
+ export function normalizePrinciples(principles = []) {
264
+ return [...principles]
265
+ .filter((p) => p && p.id && p.title)
266
+ .sort((a, b) => (a.number ?? 0) - (b.number ?? 0) || String(a.id).localeCompare(String(b.id)))
267
+ .map((p) => {
268
+ const out = {
269
+ id: p.id,
270
+ number: p.number ?? 0,
271
+ title: p.title,
272
+ rule: p.rule ?? "",
273
+ do: Array.isArray(p.do) ? p.do : [],
274
+ dont: Array.isArray(p.dont) ? p.dont : [],
275
+ enforcedByLint: Boolean(p.enforcedByLint),
276
+ };
277
+ if (Array.isArray(p.lintRules) && p.lintRules.length) out.lintRules = p.lintRules;
278
+ return out;
279
+ });
280
+ }
281
+
282
+ /** Merge CVA axis values with guidance per-value descriptions for one component. */
283
+ function variantVocabulary(componentName, source, guidance) {
284
+ const cva = parseCvaBlocks(source);
285
+ // Prefer the block whose id matches the component (buttonVariants ↔ Button);
286
+ // else fold every axis found in the module (covers single-cva components).
287
+ const wanted = `${componentName[0].toLowerCase()}${componentName.slice(1)}variants`;
288
+ const match = cva.find((b) => b.id.toLowerCase() === wanted);
289
+ const blocks = match ? [match] : cva;
290
+
291
+ const out = {};
292
+ for (const b of blocks) {
293
+ for (const [axis, spec] of Object.entries(b.axes)) {
294
+ const options = {};
295
+ const descSource = axis === "size" ? guidance?.sizes : axis === "variant" ? guidance?.variants : undefined;
296
+ for (const v of spec.values) {
297
+ if (descSource?.[v]) options[v] = descSource[v];
298
+ }
299
+ out[axis] = { values: spec.values };
300
+ if (spec.default) out[axis].default = spec.default;
301
+ if (Object.keys(options).length) out[axis].options = options;
302
+ }
303
+ }
304
+ return out;
305
+ }
306
+
307
+ /** The Tailwind utilities a semantic color role produces. */
308
+ function utilitiesFor(role) {
309
+ const u = [`bg-${role}`, `text-${role}`, `border-${role}`];
310
+ if (role === "ring") return [`ring-${role}`];
311
+ if (role.endsWith("-foreground")) return [`text-${role}`, `bg-${role}`];
312
+ return u;
313
+ }
314
+
315
+ // ── schema + validation ──────────────────────────────────────────────────────
316
+
317
+ export const MANIFEST_SCHEMA = {
318
+ requiredTop: ["name", "version", "importPath", "families", "counts", "exports"],
319
+ entryRequired: ["name", "family", "kind", "importPath", "module"],
320
+ kinds: KINDS,
321
+ };
322
+
323
+ /**
324
+ * Validate a manifest object. Returns {ok, errors}. A malformed OR incomplete
325
+ * manifest (missing fields, bad kind, wrong importPath, count mismatch, empty
326
+ * catalog, duplicate names) yields errors so the build can fail on it.
327
+ *
328
+ * @param {object} manifest
329
+ * @param {{expectedNames?: string[], packageName?: string}} [opts]
330
+ */
331
+ export function validateManifest(manifest, opts = {}) {
332
+ const errors = [];
333
+ const packageName = opts.packageName ?? "@actioneer/ads";
334
+
335
+ for (const key of MANIFEST_SCHEMA.requiredTop) {
336
+ if (manifest?.[key] == null) errors.push(`manifest missing top-level "${key}"`);
337
+ }
338
+ if (!Array.isArray(manifest?.exports) || manifest.exports.length === 0) {
339
+ errors.push("manifest.exports must be a non-empty array");
340
+ return { ok: false, errors };
341
+ }
342
+ if (manifest.importPath !== packageName) {
343
+ errors.push(`manifest.importPath must be "${packageName}", got "${manifest.importPath}"`);
344
+ }
345
+
346
+ const names = new Set();
347
+ for (const e of manifest.exports) {
348
+ const label = e?.name ? `export "${e.name}"` : "an export";
349
+ for (const key of MANIFEST_SCHEMA.entryRequired) {
350
+ if (e?.[key] == null || e[key] === "") errors.push(`${label} missing "${key}"`);
351
+ }
352
+ if (e?.kind && !MANIFEST_SCHEMA.kinds.includes(e.kind)) {
353
+ errors.push(`${label} has invalid kind "${e.kind}"`);
354
+ }
355
+ if (e?.importPath && e.importPath !== packageName) {
356
+ errors.push(`${label} importPath must be "${packageName}", got "${e.importPath}"`);
357
+ }
358
+ if (e?.kind === "component" && !e.showcaseUrl) {
359
+ errors.push(`component ${label} missing showcaseUrl`);
360
+ }
361
+ if (e?.name) {
362
+ if (names.has(e.name)) errors.push(`duplicate export name "${e.name}"`);
363
+ names.add(e.name);
364
+ }
365
+ }
366
+
367
+ if (manifest.counts?.total != null && manifest.counts.total !== manifest.exports.length) {
368
+ errors.push(`counts.total (${manifest.counts.total}) != exports.length (${manifest.exports.length})`);
369
+ }
370
+
371
+ // Completeness: every independently-enumerated barrel export must be present.
372
+ if (opts.expectedNames) {
373
+ for (const n of opts.expectedNames) {
374
+ if (!names.has(n)) errors.push(`manifest is incomplete — barrel export "${n}" is absent`);
375
+ }
376
+ }
377
+
378
+ return { ok: errors.length === 0, errors };
379
+ }
package/src/index.mjs ADDED
@@ -0,0 +1,19 @@
1
+ // @actioneer/ads-manifest — the pure ADS catalog toolkit.
2
+ //
3
+ // Three pure cores, no filesystem/transport dependency:
4
+ // • build — (barrel + sources) → schema-valid ads-manifest object
5
+ // • llms — manifest → llms.txt
6
+ // • query — (manifest, query) → results (the ADS MCP server adapts these)
7
+ //
8
+ // The IO wrappers live outside: scripts/gen-ads-manifest.mjs (generation) and
9
+ // tools/ads-mcp (the MCP server).
10
+
11
+ export { buildManifest, resolveExports, validateManifest, MANIFEST_SCHEMA } from "./build.mjs";
12
+ export { renderLlmsTxt } from "./llms.mjs";
13
+ export { searchComponents, getComponent, getToken } from "./query.mjs";
14
+ export {
15
+ parseModuleExports,
16
+ parseCvaBlocks,
17
+ parsePropInterfaces,
18
+ parseSemanticTokens,
19
+ } from "./parse.mjs";
package/src/llms.mjs ADDED
@@ -0,0 +1,118 @@
1
+ // Pure llms.txt renderer: manifest → the LLM-facing index shadcn ships at
2
+ // /llms.txt. A hierarchical family → component index (one `[Name] — desc` line
3
+ // each) plus the consumer-setup contract. No IO — string in, string out.
4
+
5
+ const REQUIRED_PROVIDERS = [
6
+ "`TooltipProvider` (from `@actioneer/ads`) — wrap the app; the vendored tooltip/attachment chips crash without an ancestor provider",
7
+ "`ToastProvider` (from `@actioneer/ads`) — required to render toasts",
8
+ '`<MotionConfig reducedMotion="user">` (from `motion/react`) — honor the OS reduced-motion setting',
9
+ ];
10
+
11
+ /**
12
+ * @param {object} manifest the ads-manifest object
13
+ * @returns {string} llms.txt content
14
+ */
15
+ export function renderLlmsTxt(manifest) {
16
+ const pkg = manifest.name ?? "@actioneer/ads";
17
+ const lines = [];
18
+
19
+ lines.push(`# ${pkg}`);
20
+ lines.push("");
21
+ lines.push(
22
+ "> The Actioneer Design System (ADS 1.0) — a React 19 + Tailwind v4 component library. " +
23
+ "Build UI only from the exact named exports below; every value flows from a token. " +
24
+ "Never fork, alias, deep-import, or hand-roll a copy of a component — import the named export " +
25
+ `from \`${pkg}\`. \`@actioneer/ads-lint\` enforces this.`
26
+ );
27
+ lines.push("");
28
+
29
+ // Setup contract (the human twin lives in CONSUMING.md).
30
+ lines.push("## Setup");
31
+ lines.push("");
32
+ lines.push(`1. Install: \`npm install ${pkg}\` (or run \`npx ${pkg} init\`).`);
33
+ lines.push(
34
+ `2. In your global stylesheet, after \`@import "tailwindcss";\`, add \`${manifest.styleImport ?? `@import "${pkg}/styles.css";`}\` — ` +
35
+ "it carries the `@theme` tokens, the Aribau `@font-face`, and the `@source` that makes Tailwind scan ADS classes. " +
36
+ "Imported standalone (not into the tailwindcss graph) its `@theme` is inert and no tokens emit."
37
+ );
38
+ lines.push("3. Wrap the app root with the required providers:");
39
+ for (const p of REQUIRED_PROVIDERS) lines.push(` - ${p}`);
40
+ lines.push(
41
+ `4. Import contract: \`import { Button } from "${pkg}";\` — the exact named export, never \`${pkg}/button\`, ` +
42
+ "never an alias, never a local copy."
43
+ );
44
+ lines.push("");
45
+
46
+ // Tokens pointer (roles, not literals).
47
+ if (Array.isArray(manifest.tokens) && manifest.tokens.length) {
48
+ lines.push("## Tokens");
49
+ lines.push("");
50
+ lines.push(
51
+ "Reference semantic roles, never raw color/dimension literals (ads-lint rejects literals). " +
52
+ "Each role produces `bg-/text-/border-<role>` utilities:"
53
+ );
54
+ for (const t of manifest.tokens) {
55
+ const desc = t.description ? ` — ${t.description}` : "";
56
+ lines.push(`- \`${t.role}\`${desc}`);
57
+ }
58
+ lines.push("");
59
+ }
60
+
61
+ // Design principles — the positive UX conventions the lint/manifest layer
62
+ // can't fully mechanize (a stray `border` utility isn't a literal). Each flags
63
+ // whether ads-lint auto-checks it, so the agent knows teach-vs-enforce.
64
+ if (Array.isArray(manifest.principles) && manifest.principles.length) {
65
+ lines.push("## Design principles");
66
+ lines.push("");
67
+ lines.push(
68
+ "The house rules for every ADS UI. `[lint]` = `@actioneer/ads-lint` auto-checks it " +
69
+ "(pair with `lint_snippet`); `[judgment]` = you must self-apply it — the linter can't."
70
+ );
71
+ lines.push("");
72
+ for (const p of manifest.principles) {
73
+ const tag = p.enforcedByLint
74
+ ? `[lint${p.lintRules?.length ? `: ${p.lintRules.join(", ")}` : ""}]`
75
+ : "[judgment]";
76
+ lines.push(`### ${p.number}. ${p.title} ${tag}`);
77
+ lines.push("");
78
+ if (p.rule) lines.push(p.rule);
79
+ lines.push("");
80
+ for (const d of p.do ?? []) lines.push(`- Do: ${d}`);
81
+ for (const d of p.dont ?? []) lines.push(`- Don't: ${d}`);
82
+ lines.push("");
83
+ }
84
+ }
85
+
86
+ // Component index, grouped by family in manifest order.
87
+ lines.push("## Components");
88
+ lines.push("");
89
+ const byFamily = new Map();
90
+ for (const f of manifest.families ?? []) byFamily.set(f, []);
91
+ for (const e of manifest.exports ?? []) {
92
+ if (!byFamily.has(e.family)) byFamily.set(e.family, []);
93
+ byFamily.get(e.family).push(e);
94
+ }
95
+
96
+ for (const [family, entries] of byFamily) {
97
+ if (!entries.length) continue;
98
+ lines.push(`### ${family}`);
99
+ lines.push("");
100
+ for (const e of entries) {
101
+ const desc = e.description ? ` — ${e.description}` : ` — ${kindLabel(e.kind)}`;
102
+ const variantHint = e.variants
103
+ ? ` (variants: ${Object.entries(e.variants)
104
+ .map(([axis, spec]) => `${axis}: ${spec.values.join("|")}`)
105
+ .join("; ")})`
106
+ : "";
107
+ const url = e.showcaseUrl ? ` [docs](${e.showcaseUrl})` : "";
108
+ lines.push(`- [${e.name}]${desc}${variantHint}${url}`);
109
+ }
110
+ lines.push("");
111
+ }
112
+
113
+ return lines.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
114
+ }
115
+
116
+ function kindLabel(kind) {
117
+ return { type: "TypeScript type", hook: "React hook", util: "utility" }[kind] ?? "component";
118
+ }
package/src/parse.mjs ADDED
@@ -0,0 +1,281 @@
1
+ // Pure source-parsing layer for the ADS manifest builder.
2
+ //
3
+ // Every function here is (source string → data) with no filesystem or transport
4
+ // dependency, so the manifest builder and its node:test suite exercise the exact
5
+ // same code. TypeScript's compiler API does the heavy lifting (exact handling of
6
+ // multi-line specifiers, `export type`, aliasing); a small regex tier backs the
7
+ // CVA / token extraction where an AST buys nothing.
8
+
9
+ let ts = null;
10
+ try {
11
+ ts = (await import("typescript")).default;
12
+ } catch {
13
+ ts = null;
14
+ }
15
+
16
+ export const hasTypeScript = () => ts != null;
17
+
18
+ const scriptKind = (filename) =>
19
+ filename.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS;
20
+
21
+ /**
22
+ * Parse one module's top-level export surface.
23
+ *
24
+ * @returns {{
25
+ * stars: string[], // `export * from "spec"`
26
+ * reExports: Array<{name:string, spec:string, isType:boolean}>, // `export { A as B } from "spec"`
27
+ * locals: Array<{name:string, kind:"component"|"type"|"hook"|"util"}>, // local `export const/function/class/interface/type`
28
+ * }}
29
+ */
30
+ export function parseModuleExports(source, filename = "mod.tsx") {
31
+ if (!ts) throw new Error("ads-manifest: the `typescript` package is required to parse exports");
32
+ const sf = ts.createSourceFile(filename, source, ts.ScriptTarget.Latest, true, scriptKind(filename));
33
+ const stars = [];
34
+ const reExports = [];
35
+ const locals = [];
36
+
37
+ sf.forEachChild((node) => {
38
+ // `export * from "..."` and `export { A, B as C } from "..."`
39
+ if (ts.isExportDeclaration(node) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) {
40
+ const spec = node.moduleSpecifier.text;
41
+ if (!node.exportClause) {
42
+ stars.push(spec);
43
+ } else if (ts.isNamedExports(node.exportClause)) {
44
+ for (const el of node.exportClause.elements) {
45
+ reExports.push({
46
+ name: el.name.text, // the EXPORTED (aliased) name
47
+ spec,
48
+ isType: Boolean(node.isTypeOnly || el.isTypeOnly),
49
+ });
50
+ }
51
+ }
52
+ return;
53
+ }
54
+
55
+ // Local `export { A, type B }` (no module specifier) — treat as local names.
56
+ if (ts.isExportDeclaration(node) && !node.moduleSpecifier && node.exportClause && ts.isNamedExports(node.exportClause)) {
57
+ for (const el of node.exportClause.elements) {
58
+ locals.push({ name: el.name.text, kind: node.isTypeOnly || el.isTypeOnly ? "type" : "unknown" });
59
+ }
60
+ return;
61
+ }
62
+
63
+ const isExported =
64
+ ts.canHaveModifiers?.(node) &&
65
+ ts.getModifiers(node)?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword);
66
+ if (!isExported) return;
67
+
68
+ if (ts.isInterfaceDeclaration(node)) {
69
+ locals.push({ name: node.name.text, kind: "type" });
70
+ } else if (ts.isTypeAliasDeclaration(node)) {
71
+ locals.push({ name: node.name.text, kind: "type" });
72
+ } else if (ts.isFunctionDeclaration(node) && node.name) {
73
+ locals.push({ name: node.name.text, kind: classifyValue(node.name.text) });
74
+ } else if (ts.isClassDeclaration(node) && node.name) {
75
+ locals.push({ name: node.name.text, kind: classifyValue(node.name.text) });
76
+ } else if (ts.isVariableStatement(node)) {
77
+ for (const decl of node.declarationList.declarations) {
78
+ if (ts.isIdentifier(decl.name)) {
79
+ locals.push({ name: decl.name.text, kind: classifyValue(decl.name.text) });
80
+ }
81
+ }
82
+ }
83
+ });
84
+
85
+ return { stars, reExports, locals };
86
+ }
87
+
88
+ /**
89
+ * Classify a value export into component | hook | util by NAME (types are handled
90
+ * by the caller). Heuristics, in order:
91
+ * • `use[A-Z]…` → hook
92
+ * • lowercase first letter → util (cn, interactive, buttonVariants, answerTool)
93
+ * • name ends Schema/Tool/Config/… → util (AnswerSpecSchema, a zod object)
94
+ * • otherwise Uppercase-first → component (Button, TooltipProvider, AnswerRenderer)
95
+ */
96
+ export function classifyValue(name) {
97
+ if (/^use[A-Z]/.test(name)) return "hook";
98
+ if (/^[a-z]/.test(name)) return "util";
99
+ if (/(Schema|Tool|Config|Options|Context|Adapter|Map|Variants|Registry|Store)$/.test(name)) return "util";
100
+ return "component";
101
+ }
102
+
103
+ const CVA_RE = /(?:export\s+)?const\s+([A-Za-z_$][\w$]*)\s*=\s*cva\s*\(/g;
104
+
105
+ /**
106
+ * Extract every `const xVariants = cva(...)` block's variant axes.
107
+ * @returns {Array<{id:string, axes: Record<string, {values:string[], default?:string}>}>}
108
+ */
109
+ export function parseCvaBlocks(source) {
110
+ const out = [];
111
+ for (const m of source.matchAll(CVA_RE)) {
112
+ const id = m[1];
113
+ const body = sliceBalanced(source, m.index + m[0].length - 1); // from the "("
114
+ if (body == null) continue;
115
+ out.push({ id, axes: parseVariantAxes(body) });
116
+ }
117
+ return out;
118
+ }
119
+
120
+ /** Given the text inside cva(...), pull the `variants: { axis: { value: … } }`
121
+ * axis→values map and the `defaultVariants` selections. */
122
+ function parseVariantAxes(cvaBody) {
123
+ const axes = {};
124
+ const vIdx = cvaBody.search(/\bvariants\s*:/);
125
+ if (vIdx !== -1) {
126
+ const braceStart = cvaBody.indexOf("{", vIdx);
127
+ const variantsBlock = sliceBalancedBrace(cvaBody, braceStart);
128
+ if (variantsBlock != null) {
129
+ // Each top-level `axisName: { … }` inside the variants block.
130
+ for (const am of variantsBlock.matchAll(/([A-Za-z_$][\w$]*)\s*:\s*\{/g)) {
131
+ const axisName = am[1];
132
+ const axisBlock = sliceBalancedBrace(variantsBlock, variantsBlock.indexOf("{", am.index + am[0].length - 1));
133
+ if (axisBlock == null) continue;
134
+ const values = [];
135
+ // Top-level keys of the axis block are the allowed values.
136
+ for (const km of axisBlock.matchAll(/(?:^|,|\{)\s*(?:"([^"]+)"|'([^']+)'|([A-Za-z_$][\w$-]*))\s*:/g)) {
137
+ const key = km[1] ?? km[2] ?? km[3];
138
+ if (key && !values.includes(key)) values.push(key);
139
+ }
140
+ if (values.length) axes[axisName] = { values };
141
+ }
142
+ }
143
+ }
144
+ // defaultVariants: { axis: "value" }
145
+ const dIdx = cvaBody.search(/\bdefaultVariants\s*:/);
146
+ if (dIdx !== -1) {
147
+ const dBlock = sliceBalancedBrace(cvaBody, cvaBody.indexOf("{", dIdx));
148
+ if (dBlock != null) {
149
+ for (const dm of dBlock.matchAll(/([A-Za-z_$][\w$]*)\s*:\s*(?:"([^"]+)"|'([^']+)')/g)) {
150
+ const axis = dm[1];
151
+ const value = dm[2] ?? dm[3];
152
+ if (axes[axis]) axes[axis].default = value;
153
+ }
154
+ }
155
+ }
156
+ return axes;
157
+ }
158
+
159
+ /**
160
+ * Names of the members of an `export interface <Name> …` (the prop vocabulary).
161
+ * Ignores index signatures. Includes extended-from names as a hint list.
162
+ * @returns {Record<string, string[]>} interfaceName → member names
163
+ */
164
+ export function parsePropInterfaces(source, filename = "mod.tsx") {
165
+ if (!ts) return {};
166
+ const sf = ts.createSourceFile(filename, source, ts.ScriptTarget.Latest, true, scriptKind(filename));
167
+ const out = {};
168
+ sf.forEachChild((node) => {
169
+ if (!ts.isInterfaceDeclaration(node)) return;
170
+ const members = [];
171
+ for (const m of node.members) {
172
+ if ((ts.isPropertySignature(m) || ts.isMethodSignature(m)) && m.name && ts.isIdentifier(m.name)) {
173
+ members.push(m.name.text);
174
+ }
175
+ }
176
+ out[node.name.text] = members;
177
+ });
178
+ return out;
179
+ }
180
+
181
+ /**
182
+ * Semantic role tokens from a theme.css `@theme inline { --color-role: … }`
183
+ * block. The utility a role produces is its bare name (bg-/text-/border-<role>).
184
+ * The nearest preceding `/* comment *​/` seeds a human description.
185
+ * @returns {Array<{name:string, role:string, description:string}>}
186
+ */
187
+ export function parseSemanticTokens(css) {
188
+ const out = [];
189
+ const inlineIdx = css.indexOf("@theme inline");
190
+ if (inlineIdx === -1) return out;
191
+ const block = sliceBalancedBrace(css, css.indexOf("{", inlineIdx));
192
+ if (block == null) return out;
193
+
194
+ const lines = block.split("\n");
195
+ let comment = ""; // the active group comment (a comment on its own line above a group)
196
+ for (const raw of lines) {
197
+ let line = raw.trim();
198
+ // A blank line ends a token group — the comment must not bleed onto the
199
+ // next, unrelated group (surface/muted have no comment of their own).
200
+ if (line === "") {
201
+ comment = "";
202
+ continue;
203
+ }
204
+ // The shadcn-compat aliases below this marker are internal (consumed only by
205
+ // the vendored chat components; ADS UI never uses them) — stop collecting so
206
+ // they never surface as consumer-facing tokens.
207
+ if (/shadcn-compat/i.test(line)) break;
208
+
209
+ // Pull a closed inline `/* … */` comment out of the line; whatever it
210
+ // documents (a trailing note or a whole-line group header) is captured.
211
+ let inline = "";
212
+ const cm = line.match(/\/\*\s*(.*?)\s*\*\//);
213
+ if (cm) {
214
+ inline = cm[1];
215
+ line = line.replace(cm[0], "").trim();
216
+ }
217
+
218
+ const tm = line.match(/^--color-([a-z0-9-]+)\s*:/i);
219
+ if (tm) {
220
+ const role = tm[1];
221
+ out.push({ name: `--color-${role}`, role, description: inline || comment });
222
+ continue;
223
+ }
224
+ // A comment that stands alone on its line becomes the group header.
225
+ if (cm && line === "") {
226
+ comment = inline;
227
+ continue;
228
+ }
229
+ if (line.startsWith("/*") || line.startsWith("*")) {
230
+ const t = line.replace(/^\/\*|^\*\/?|\*\/$/g, "").trim();
231
+ if (t) comment = t;
232
+ }
233
+ }
234
+ return out;
235
+ }
236
+
237
+ // ── balanced-delimiter helpers ───────────────────────────────────────────────
238
+
239
+ /** From an opening "(" at `open`, return the substring inside the matching ")". */
240
+ function sliceBalanced(src, open) {
241
+ return sliceBetween(src, open, "(", ")");
242
+ }
243
+ /** From an opening "{" at `open`, return the substring inside the matching "}". */
244
+ function sliceBalancedBrace(src, open) {
245
+ return sliceBetween(src, open, "{", "}");
246
+ }
247
+ function sliceBetween(src, open, o, c) {
248
+ if (open < 0 || src[open] !== o) return null;
249
+ let depth = 0;
250
+ let inStr = null;
251
+ for (let i = open; i < src.length; i++) {
252
+ const ch = src[i];
253
+ const next = src[i + 1];
254
+ if (inStr) {
255
+ if (ch === inStr && src[i - 1] !== "\\") inStr = null;
256
+ continue;
257
+ }
258
+ // Skip comments so delimiters/quotes inside them (e.g. `shadcn's`, a `)` in
259
+ // prose) never throw off the brace/paren balance.
260
+ if (ch === "/" && next === "*") {
261
+ const end = src.indexOf("*/", i + 2);
262
+ i = end === -1 ? src.length : end + 1;
263
+ continue;
264
+ }
265
+ if (ch === "/" && next === "/") {
266
+ const nl = src.indexOf("\n", i + 2);
267
+ i = nl === -1 ? src.length : nl;
268
+ continue;
269
+ }
270
+ if (ch === '"' || ch === "'" || ch === "`") {
271
+ inStr = ch;
272
+ continue;
273
+ }
274
+ if (ch === o) depth++;
275
+ else if (ch === c) {
276
+ depth--;
277
+ if (depth === 0) return src.slice(open + 1, i);
278
+ }
279
+ }
280
+ return null;
281
+ }
package/src/query.mjs ADDED
@@ -0,0 +1,138 @@
1
+ // Pure manifest-query core: (manifest, query) → results. No MCP/transport
2
+ // dependency, so it is directly unit-testable and the ADS MCP server (L3) is a
3
+ // thin adapter over these functions.
4
+
5
+ /** Lowercased haystack for one export entry. */
6
+ function haystack(e) {
7
+ return [
8
+ e.name,
9
+ e.family,
10
+ e.kind,
11
+ e.description ?? "",
12
+ ...(e.props ?? []),
13
+ ...Object.entries(e.variants ?? {}).flatMap(([axis, s]) => [axis, ...(s.values ?? [])]),
14
+ ]
15
+ .join(" ")
16
+ .toLowerCase();
17
+ }
18
+
19
+ /**
20
+ * Search the catalog by name or purpose. Ranks exact/prefix name matches above
21
+ * substring, above description/keyword hits.
22
+ *
23
+ * @returns {Array<{name, family, kind, importPath, description?, score}>}
24
+ */
25
+ export function searchComponents(manifest, query, { limit = 25, kind } = {}) {
26
+ const q = String(query ?? "").trim().toLowerCase();
27
+ const pool = (manifest.exports ?? []).filter((e) => !kind || e.kind === kind);
28
+ if (!q) {
29
+ return pool.slice(0, limit).map((e) => ({ ...summary(e), score: 0 }));
30
+ }
31
+ const scored = [];
32
+ for (const e of pool) {
33
+ const name = e.name.toLowerCase();
34
+ let score = 0;
35
+ if (name === q) score = 100;
36
+ else if (name.startsWith(q)) score = 80;
37
+ else if (name.includes(q)) score = 60;
38
+ else if (haystack(e).includes(q)) score = 30;
39
+ if (score > 0) scored.push({ ...summary(e), score });
40
+ }
41
+ scored.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name));
42
+ return scored.slice(0, limit);
43
+ }
44
+
45
+ /**
46
+ * Full record for one export by EXACT name, with a copy-paste usage example.
47
+ * @returns {object|null}
48
+ */
49
+ export function getComponent(manifest, name) {
50
+ const e = (manifest.exports ?? []).find((x) => x.name === name);
51
+ if (!e) return null;
52
+ return {
53
+ ...e,
54
+ import: `import { ${e.name} } from "${e.importPath}";`,
55
+ example: usageExample(e),
56
+ };
57
+ }
58
+
59
+ /**
60
+ * The semantic token(s) for a role. Accepts a role name ("brand"), a utility
61
+ * ("bg-brand"), or a fuzzy purpose ("primary text"); returns exact-role matches
62
+ * first, then fuzzy description hits.
63
+ * @returns {Array<{name, role, utilities, description}>}
64
+ */
65
+ export function getToken(manifest, role) {
66
+ const tokens = manifest.tokens ?? [];
67
+ const q = String(role ?? "").trim().toLowerCase().replace(/^(bg|text|border|ring)-/, "");
68
+ if (!q) return tokens.slice();
69
+ const exact = tokens.filter((t) => t.role === q);
70
+ if (exact.length) return exact;
71
+ const prefix = tokens.filter((t) => t.role.startsWith(q));
72
+ if (prefix.length) return prefix;
73
+ const substr = tokens.filter(
74
+ (t) => t.role.includes(q) || (t.description ?? "").toLowerCase().includes(q)
75
+ );
76
+ if (substr.length) return substr;
77
+ // Multi-word purpose ("primary text", "muted body copy") — match any word
78
+ // against a role segment or the description.
79
+ const words = q.split(/[^a-z0-9]+/).filter(Boolean);
80
+ if (words.length > 1) {
81
+ return tokens.filter((t) => {
82
+ const roleWords = t.role.split("-");
83
+ const desc = (t.description ?? "").toLowerCase();
84
+ return words.some((w) => roleWords.includes(w) || desc.includes(w));
85
+ });
86
+ }
87
+ return [];
88
+ }
89
+
90
+ /**
91
+ * The design principles from the manifest, optionally filtered.
92
+ *
93
+ * @param {object} manifest
94
+ * @param {{ id?: string, enforcedByLint?: boolean }} [opts]
95
+ * - id: return only the principle with this exact id (or its number as string).
96
+ * - enforcedByLint: keep only lint-enforced (true) or judgment-call (false) rules.
97
+ * @returns {Array<object>}
98
+ */
99
+ export function getPrinciples(manifest, { id, enforcedByLint } = {}) {
100
+ let out = manifest.principles ?? [];
101
+ if (id != null && String(id).trim() !== "") {
102
+ const q = String(id).trim().toLowerCase();
103
+ out = out.filter((p) => p.id.toLowerCase() === q || String(p.number) === q);
104
+ }
105
+ if (typeof enforcedByLint === "boolean") {
106
+ out = out.filter((p) => Boolean(p.enforcedByLint) === enforcedByLint);
107
+ }
108
+ return out;
109
+ }
110
+
111
+ function summary(e) {
112
+ return {
113
+ name: e.name,
114
+ family: e.family,
115
+ kind: e.kind,
116
+ importPath: e.importPath,
117
+ description: e.description,
118
+ };
119
+ }
120
+
121
+ /** A minimal, correct usage snippet. Components with a `variant` axis show the
122
+ * default; everything else shows a bare element/import reference. */
123
+ function usageExample(e) {
124
+ if (e.kind !== "component") {
125
+ return `import { ${e.name} } from "${e.importPath}";`;
126
+ }
127
+ const attrs = [];
128
+ const variant = e.variants?.variant;
129
+ const size = e.variants?.size;
130
+ if (variant) attrs.push(`variant="${variant.default ?? variant.values[0]}"`);
131
+ if (size) attrs.push(`size="${size.default ?? size.values[0]}"`);
132
+ const attrStr = attrs.length ? ` ${attrs.join(" ")}` : "";
133
+ return [
134
+ `import { ${e.name} } from "${e.importPath}";`,
135
+ "",
136
+ `<${e.name}${attrStr}>…</${e.name}>`,
137
+ ].join("\n");
138
+ }