@radicool/throughline 0.12.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.
Files changed (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +214 -0
  3. package/adapters/codex/AGENTS.md +29 -0
  4. package/adapters/codex/codex-mcp.toml +4 -0
  5. package/adapters/codex/prompts/component-builder.md +272 -0
  6. package/adapters/codex/prompts/component-pipeline.md +81 -0
  7. package/adapters/codex/prompts/design-system-audit.md +122 -0
  8. package/adapters/codex/prompts/design-system-status.md +41 -0
  9. package/adapters/codex/prompts/figma-environment-setup.md +456 -0
  10. package/adapters/codex/prompts/icon-system-builder.md +268 -0
  11. package/adapters/codex/prompts/new-component.md +13 -0
  12. package/adapters/codex/prompts/repository-builder.md +163 -0
  13. package/adapters/codex/prompts/retrofit-planner.md +136 -0
  14. package/adapters/codex/prompts/start.md +22 -0
  15. package/adapters/codex/prompts/storybook-chromatic-builder.md +261 -0
  16. package/adapters/codex/prompts/sync-figma-tokens.md +22 -0
  17. package/adapters/codex/prompts/token-builder.md +361 -0
  18. package/adapters/codex/prompts/token-crosswalk-builder.md +120 -0
  19. package/adapters/codex/prompts/token-sheet-builder.md +162 -0
  20. package/adapters/codex/prompts/token-sync-layer.md +232 -0
  21. package/adapters/cursor/.cursor/commands/design-system-status.md +41 -0
  22. package/adapters/cursor/.cursor/commands/new-component.md +13 -0
  23. package/adapters/cursor/.cursor/commands/start.md +22 -0
  24. package/adapters/cursor/.cursor/commands/sync-figma-tokens.md +22 -0
  25. package/adapters/cursor/.cursor/mcp.json +14 -0
  26. package/adapters/cursor/.cursor/rules/component-builder.mdc +276 -0
  27. package/adapters/cursor/.cursor/rules/component-pipeline.mdc +85 -0
  28. package/adapters/cursor/.cursor/rules/design-system-audit.mdc +126 -0
  29. package/adapters/cursor/.cursor/rules/figma-environment-setup.mdc +460 -0
  30. package/adapters/cursor/.cursor/rules/icon-system-builder.mdc +272 -0
  31. package/adapters/cursor/.cursor/rules/repository-builder.mdc +167 -0
  32. package/adapters/cursor/.cursor/rules/retrofit-planner.mdc +140 -0
  33. package/adapters/cursor/.cursor/rules/storybook-chromatic-builder.mdc +265 -0
  34. package/adapters/cursor/.cursor/rules/token-builder.mdc +365 -0
  35. package/adapters/cursor/.cursor/rules/token-crosswalk-builder.mdc +124 -0
  36. package/adapters/cursor/.cursor/rules/token-sheet-builder.mdc +166 -0
  37. package/adapters/cursor/.cursor/rules/token-sync-layer.mdc +236 -0
  38. package/adapters/generic/AGENTS.md +46 -0
  39. package/adapters/generic/commands/design-system-status.md +41 -0
  40. package/adapters/generic/commands/new-component.md +13 -0
  41. package/adapters/generic/commands/start.md +22 -0
  42. package/adapters/generic/commands/sync-figma-tokens.md +22 -0
  43. package/adapters/generic/skills/component-builder/SKILL.md +272 -0
  44. package/adapters/generic/skills/component-pipeline/SKILL.md +81 -0
  45. package/adapters/generic/skills/design-system-audit/SKILL.md +122 -0
  46. package/adapters/generic/skills/figma-environment-setup/SKILL.md +456 -0
  47. package/adapters/generic/skills/icon-system-builder/SKILL.md +268 -0
  48. package/adapters/generic/skills/repository-builder/SKILL.md +163 -0
  49. package/adapters/generic/skills/retrofit-planner/SKILL.md +136 -0
  50. package/adapters/generic/skills/storybook-chromatic-builder/SKILL.md +261 -0
  51. package/adapters/generic/skills/token-builder/SKILL.md +361 -0
  52. package/adapters/generic/skills/token-crosswalk-builder/SKILL.md +120 -0
  53. package/adapters/generic/skills/token-sheet-builder/SKILL.md +162 -0
  54. package/adapters/generic/skills/token-sync-layer/SKILL.md +232 -0
  55. package/package.json +29 -0
  56. package/references/brainstorm-before-build.md +139 -0
  57. package/references/brownfield-retrofit.md +93 -0
  58. package/references/coding-level.md +70 -0
  59. package/references/crosswalk-schema.md +113 -0
  60. package/references/figma-component-standards.md +532 -0
  61. package/references/figma-publishing.md +125 -0
  62. package/references/figma-scripting.md +278 -0
  63. package/references/manifest-schema.md +341 -0
  64. package/references/scaling-up-handoff.md +61 -0
  65. package/references/sync-adapters.md +141 -0
  66. package/scripts/README.md +78 -0
  67. package/scripts/adapters/emit-codex.mjs +58 -0
  68. package/scripts/adapters/emit-cursor.mjs +26 -0
  69. package/scripts/adapters/emit-generic.mjs +41 -0
  70. package/scripts/adapters/generate.mjs +73 -0
  71. package/scripts/adapters/read-sources.mjs +54 -0
  72. package/scripts/adapters/translate.mjs +51 -0
  73. package/scripts/build-reverse-index.mjs +56 -0
  74. package/scripts/crosswalk.schema.json +33 -0
  75. package/scripts/grep-color-usage.mjs +143 -0
  76. package/scripts/guard-token-removal.mjs +95 -0
  77. package/scripts/install.mjs +143 -0
  78. package/scripts/lib/crosswalk.mjs +91 -0
  79. package/scripts/validate-crosswalk.mjs +96 -0
@@ -0,0 +1,33 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://throughline.dev/schemas/crosswalk.schema.json",
4
+ "title": "ThroughLine token crosswalk",
5
+ "description": "Three-way map: new token <-> old Figma token <-> code identifier. Drives the brownfield code retrofit and the tokens:validate CI gate.",
6
+ "type": "object",
7
+ "required": ["version", "tokens"],
8
+ "additionalProperties": false,
9
+ "properties": {
10
+ "$schema": { "type": "string" },
11
+ "version": { "const": 1 },
12
+ "tokens": {
13
+ "type": "array",
14
+ "items": { "$ref": "#/$defs/row" }
15
+ }
16
+ },
17
+ "$defs": {
18
+ "row": {
19
+ "type": "object",
20
+ "required": ["newToken", "newValue", "tier", "figmaOld", "codeTokens", "status"],
21
+ "additionalProperties": false,
22
+ "properties": {
23
+ "newToken": { "type": "string", "minLength": 1, "description": "DTCG dot-path, e.g. color.text.primary" },
24
+ "newValue": { "type": "string", "minLength": 1, "description": "Resolved leaf value (aliases followed)" },
25
+ "tier": { "type": "string", "enum": ["primitive", "semantic"] },
26
+ "figmaOld": { "type": ["string", "null"], "description": "Old Figma variable name/path, or null if newly added" },
27
+ "codeTokens": { "type": "array", "items": { "type": "string" }, "description": "Old code identifiers this token replaces" },
28
+ "status": { "type": "string", "enum": ["aligned", "renamed", "drift-fix", "added", "mapped-nearest"] },
29
+ "recommendedSemantic": { "type": ["string", "null"] }
30
+ }
31
+ }
32
+ }
33
+ }
@@ -0,0 +1,143 @@
1
+ // Color-usage grep scaffold: size a codebase's color-decision surface by category.
2
+ // Repo-shaped by nature — ships sensible DEFAULT_CATEGORIES and accepts a --config
3
+ // override; logs which categories used the assumed default vs. a detected pattern so
4
+ // coverage is never silently partial. Zero dependencies.
5
+ //
6
+ // Usage:
7
+ // node grep-color-usage.mjs --root <dir> [--config <patterns.json>] [--out <counts.json>]
8
+ // patterns.json: { "<category>": { "files": "<regex>", "pattern": "<regex with g flag>" }, ... }
9
+ import { readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
10
+ import { join, relative } from 'node:path';
11
+ import { parseArgs } from 'node:util';
12
+ import { pathToFileURL } from 'node:url';
13
+
14
+ // The five categories from the case study. Each: which files it applies to, and the
15
+ // (global) match pattern. Tuned defaults — design-system-audit may override per repo.
16
+ export const DEFAULT_CATEGORIES = {
17
+ scssColorVars: {
18
+ files: /\.(scss|sass|css)$/,
19
+ pattern: /\$[\w-]*(?:color|colour|primary|secondary|tertiary|grey|gray|red|blue|green|yellow|orange|purple|pink|teal|cyan|black|white|brand|accent|surface|ink)[\w-]*/gi,
20
+ },
21
+ tailwindColorClasses: {
22
+ files: /\.(tsx?|jsx?|html|vue|svelte)$/,
23
+ pattern: /\b(?:bg|text|border|ring|fill|stroke|from|via|to|outline|divide|placeholder|caret|accent|shadow)-(?:primary|secondary|tertiary|grey|gray|red|blue|green|yellow|orange|purple|pink|teal|cyan|brand|accent|surface|ink)[\w-]*/g,
24
+ },
25
+ jsColorsUsages: {
26
+ files: /\.(tsx?|jsx?|mjs|cjs)$/,
27
+ pattern: /\bColors\.[A-Za-z_$][\w$]*/g,
28
+ },
29
+ rawHexRgba: {
30
+ files: /\.(scss|sass|css|tsx?|jsx?|vue|svelte|html|svg)$/,
31
+ pattern: /#[0-9a-fA-F]{3,8}\b|rgba?\([^)]*\)/g,
32
+ },
33
+ svgFills: {
34
+ files: /\.svg$/,
35
+ pattern: /(?:fill|stroke)="(?!none|currentColor|url\()[^"]+"/g,
36
+ },
37
+ };
38
+
39
+ export const DEFAULT_EXCLUDES = [
40
+ /(^|\/)node_modules(\/|$)/,
41
+ /(^|\/)generated(\/|$)/,
42
+ /\.generated\./,
43
+ /\.test\./,
44
+ /\.spec\./,
45
+ /(^|\/)__tests__(\/|$)/,
46
+ /(^|\/)dist(\/|$)/,
47
+ /(^|\/)\.next(\/|$)/,
48
+ ];
49
+
50
+ // Source extensions worth opening at all (union of every category's `files`).
51
+ const SOURCE_EXT = /\.(scss|sass|css|tsx?|jsx?|mjs|cjs|vue|svelte|html|svg)$/;
52
+
53
+ export function* walk(root, excludes = DEFAULT_EXCLUDES) {
54
+ for (const entry of readdirSync(root)) {
55
+ const full = join(root, entry);
56
+ if (excludes.some((re) => re.test(full))) continue;
57
+ const st = statSync(full);
58
+ if (st.isDirectory()) {
59
+ yield* walk(full, excludes);
60
+ } else if (SOURCE_EXT.test(full)) {
61
+ yield full;
62
+ }
63
+ }
64
+ }
65
+
66
+ // Count matches per category in one file. A category contributes only if its `files`
67
+ // regex matches this path. Returns { <category>: count } for every category key.
68
+ export function scanFile(path, categories) {
69
+ const counts = {};
70
+ for (const key of Object.keys(categories)) counts[key] = 0;
71
+ const text = readFileSync(path, 'utf8');
72
+ for (const [key, { files, pattern }] of Object.entries(categories)) {
73
+ if (!files.test(path)) continue;
74
+ const re = new RegExp(pattern.source, pattern.flags.includes('g') ? pattern.flags : pattern.flags + 'g');
75
+ const m = text.match(re);
76
+ counts[key] = m ? m.length : 0;
77
+ }
78
+ return counts;
79
+ }
80
+
81
+ export function grepColorUsage(root, categories = DEFAULT_CATEGORIES, excludes = DEFAULT_EXCLUDES) {
82
+ const counts = {};
83
+ for (const key of Object.keys(categories)) counts[key] = 0;
84
+ const byFile = [];
85
+ for (const file of walk(root, excludes)) {
86
+ const fileCounts = scanFile(file, categories);
87
+ const total = Object.values(fileCounts).reduce((a, b) => a + b, 0);
88
+ if (total > 0) {
89
+ byFile.push({ file: relative(root, file), counts: fileCounts });
90
+ for (const key of Object.keys(fileCounts)) counts[key] += fileCounts[key];
91
+ }
92
+ }
93
+ return { counts, byFile };
94
+ }
95
+
96
+ // Parse a --config JSON of { category: { files, pattern } } string regexes into RegExp.
97
+ function loadCategories(path) {
98
+ const raw = JSON.parse(readFileSync(path, 'utf8'));
99
+ const out = {};
100
+ for (const [key, { files, pattern }] of Object.entries(raw)) {
101
+ out[key] = { files: new RegExp(files), pattern: new RegExp(pattern, 'g') };
102
+ }
103
+ return out;
104
+ }
105
+
106
+ function main() {
107
+ const { values } = parseArgs({
108
+ options: {
109
+ root: { type: 'string' },
110
+ config: { type: 'string' },
111
+ out: { type: 'string' },
112
+ },
113
+ });
114
+ if (!values.root) {
115
+ console.error('usage: grep-color-usage.mjs --root <dir> [--config <patterns.json>] [--out <counts.json>]');
116
+ process.exit(2);
117
+ }
118
+ const categories = values.config ? loadCategories(values.config) : DEFAULT_CATEGORIES;
119
+ // §11: never let coverage be silently partial — say which patterns were assumed.
120
+ const source = values.config ? `detected (from ${values.config})` : 'ASSUMED defaults';
121
+ console.log(`color-usage grep — pattern source: ${source}`);
122
+ console.log(`categories: ${Object.keys(categories).join(', ')}`);
123
+
124
+ const { counts, byFile } = grepColorUsage(values.root, categories);
125
+ const total = Object.values(counts).reduce((a, b) => a + b, 0);
126
+ console.log('\ncolor-decision surface:');
127
+ for (const [key, n] of Object.entries(counts)) {
128
+ console.log(` ${key.padEnd(24)} ${n}`);
129
+ }
130
+ console.log(` ${'TOTAL'.padEnd(24)} ${total} (across ${byFile.length} file(s))`);
131
+ if (!values.config) {
132
+ console.log('\nnote: patterns are the shipped defaults. Tune them to this repo and re-run with --config for accurate counts.');
133
+ }
134
+
135
+ if (values.out) {
136
+ writeFileSync(values.out, JSON.stringify({ counts, byFile }, null, 2) + '\n');
137
+ console.log(`\nwrote ${values.out}`);
138
+ }
139
+ }
140
+
141
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
142
+ main();
143
+ }
@@ -0,0 +1,95 @@
1
+ // Repo-wide token-removal guard: grep .ts/.tsx (minus generated + tests) for
2
+ // references to about-to-be-deleted symbols. Zero dependencies.
3
+ //
4
+ // Cleanup must not proceed until this returns zero references — deleted Tailwind
5
+ // utilities become silent no-ops that tsc/build will not catch (guardrail 4).
6
+ //
7
+ // Usage:
8
+ // node guard-token-removal.mjs --root <dir> --symbols <symbols.txt>
9
+ // symbols file: one symbol per line (blank lines and # comments ignored)
10
+ import { readFileSync, readdirSync, statSync } from 'node:fs';
11
+ import { join, relative } from 'node:path';
12
+ import { parseArgs } from 'node:util';
13
+ import { pathToFileURL } from 'node:url';
14
+
15
+ export const DEFAULT_EXCLUDES = [
16
+ /(^|\/)node_modules(\/|$)/,
17
+ /(^|\/)generated(\/|$)/,
18
+ /\.generated\./,
19
+ /\.test\./,
20
+ /\.spec\./,
21
+ /(^|\/)__tests__(\/|$)/,
22
+ /(^|\/)dist(\/|$)/,
23
+ /(^|\/)\.next(\/|$)/,
24
+ ];
25
+
26
+ export function* walk(root, excludes = DEFAULT_EXCLUDES) {
27
+ for (const entry of readdirSync(root)) {
28
+ const full = join(root, entry);
29
+ if (excludes.some((re) => re.test(full))) continue;
30
+ const st = statSync(full);
31
+ if (st.isDirectory()) {
32
+ yield* walk(full, excludes);
33
+ } else if (/\.tsx?$/.test(full)) {
34
+ yield full;
35
+ }
36
+ }
37
+ }
38
+
39
+ export function scanFile(path, symbols) {
40
+ const lines = readFileSync(path, 'utf8').split('\n');
41
+ const hits = [];
42
+ lines.forEach((line, i) => {
43
+ for (const sym of symbols) {
44
+ if (line.includes(sym)) {
45
+ hits.push({ line: i + 1, symbol: sym, text: line.trim() });
46
+ }
47
+ }
48
+ });
49
+ return hits;
50
+ }
51
+
52
+ export function guard(root, symbols, excludes = DEFAULT_EXCLUDES) {
53
+ const findings = [];
54
+ for (const file of walk(root, excludes)) {
55
+ for (const hit of scanFile(file, symbols)) {
56
+ findings.push({ file: relative(root, file), ...hit });
57
+ }
58
+ }
59
+ return findings;
60
+ }
61
+
62
+ function readSymbols(path) {
63
+ return readFileSync(path, 'utf8')
64
+ .split('\n')
65
+ .map((l) => l.trim())
66
+ .filter((l) => l && !l.startsWith('#'));
67
+ }
68
+
69
+ function main() {
70
+ const { values } = parseArgs({
71
+ options: {
72
+ root: { type: 'string' },
73
+ symbols: { type: 'string' },
74
+ },
75
+ });
76
+ if (!values.root || !values.symbols) {
77
+ console.error('usage: guard-token-removal.mjs --root <dir> --symbols <symbols.txt>');
78
+ process.exit(2);
79
+ }
80
+ const symbols = readSymbols(values.symbols);
81
+ const findings = guard(values.root, symbols);
82
+ if (findings.length === 0) {
83
+ console.log(`token-removal guard: 0 references to ${symbols.length} symbol(s) — safe to remove.`);
84
+ return;
85
+ }
86
+ console.error(`token-removal guard: ${findings.length} remaining reference(s) — do NOT remove yet:`);
87
+ for (const f of findings) {
88
+ console.error(` ${f.file}:${f.line} ${f.symbol} | ${f.text}`);
89
+ }
90
+ process.exit(1);
91
+ }
92
+
93
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
94
+ main();
95
+ }
@@ -0,0 +1,143 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, copyFileSync } from 'node:fs';
3
+ import { join, dirname, relative, sep } from 'node:path';
4
+ import { fileURLToPath, pathToFileURL } from 'node:url';
5
+ import { rewritePluginRoot } from './adapters/translate.mjs';
6
+
7
+ export const BASE = '.throughline';
8
+ export const TARGETS = ['cursor', 'codex', 'generic'];
9
+ const START = '<!-- throughline:start -->';
10
+ const END = '<!-- throughline:end -->';
11
+
12
+ const MCP_NOTE = {
13
+ cursor: 'Figma MCP written to .cursor/mcp.json — restart Cursor to load it.',
14
+ codex: 'Add the [mcp_servers] block from codex-mcp.toml to your Codex config (e.g. ~/.codex/config.toml).',
15
+ generic: 'Add the MCP server shown under "MCP servers" in AGENTS.md to your agent.',
16
+ };
17
+
18
+ const USAGE = `throughline init --target=cursor|codex|generic [--dir=.]
19
+
20
+ Stamps the ThroughLine adapter for <target> into your project, plus the
21
+ runtime payload it reads (.throughline/references, .throughline/scripts).
22
+ Safe to re-run: merges AGENTS.md and .cursor/mcp.json non-destructively.`;
23
+
24
+ export function parseArgs(argv) {
25
+ const args = { cmd: null, target: null, dir: process.cwd(), help: false };
26
+ for (const a of argv) {
27
+ if (a === '--help' || a === '-h') args.help = true;
28
+ else if (a.startsWith('--target=')) args.target = a.slice('--target='.length);
29
+ else if (a.startsWith('--dir=')) args.dir = a.slice('--dir='.length);
30
+ else if (!a.startsWith('-') && !args.cmd) args.cmd = a;
31
+ }
32
+ return args;
33
+ }
34
+
35
+ export function mergeAgentsBlock(existing, block) {
36
+ const wrapped = `${START}\n${block.trim()}\n${END}`;
37
+ if (!existing || !existing.trim()) return `${wrapped}\n`;
38
+ const s = existing.indexOf(START);
39
+ const e = existing.indexOf(END);
40
+ if (s !== -1 && e !== -1 && e > s) {
41
+ return existing.slice(0, s) + wrapped + existing.slice(e + END.length);
42
+ }
43
+ return `${existing.replace(/\s*$/, '')}\n\n${wrapped}\n`;
44
+ }
45
+
46
+ export function mergeMcpJson(existing, ourServers) {
47
+ let obj = {};
48
+ if (existing) {
49
+ let usable = false;
50
+ try {
51
+ const parsed = JSON.parse(existing);
52
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
53
+ obj = parsed;
54
+ usable = true;
55
+ }
56
+ } catch { /* malformed → start fresh */ }
57
+ if (!usable) {
58
+ console.warn('throughline: existing .cursor/mcp.json is not a JSON object; replacing it with the ThroughLine MCP server config.');
59
+ }
60
+ }
61
+ obj.mcpServers = { ...(obj.mcpServers || {}), ...ourServers };
62
+ return `${JSON.stringify(obj, null, 2)}\n`;
63
+ }
64
+
65
+ const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
66
+ const TEXT_EXT = /\.(md|mdc|mjs|json|toml|txt)$/;
67
+ const toPosix = (p) => p.split(sep).join('/');
68
+
69
+ function walk(dir, base = dir) {
70
+ const out = [];
71
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
72
+ if (entry.name === '.DS_Store') continue;
73
+ const abs = join(dir, entry.name);
74
+ if (entry.isDirectory()) out.push(...walk(abs, base));
75
+ else out.push(relative(base, abs));
76
+ }
77
+ return out;
78
+ }
79
+
80
+ function writeText(dest, content) {
81
+ mkdirSync(dirname(dest), { recursive: true });
82
+ writeFileSync(dest, content);
83
+ }
84
+
85
+ // Copy a source tree into destRoot, rewriting ${CLAUDE_PLUGIN_ROOT} on text
86
+ // files. `skip(relPosix)` drops entries. Returns posix-relative paths written.
87
+ function stagePayload(srcRoot, destRoot, skip) {
88
+ const written = [];
89
+ for (const rel of walk(srcRoot)) {
90
+ const relPosix = toPosix(rel);
91
+ if (skip && skip(relPosix)) continue;
92
+ const src = join(srcRoot, rel);
93
+ const dest = join(destRoot, rel);
94
+ mkdirSync(dirname(dest), { recursive: true });
95
+ if (TEXT_EXT.test(rel)) writeFileSync(dest, rewritePluginRoot(readFileSync(src, 'utf8'), BASE));
96
+ else copyFileSync(src, dest);
97
+ written.push(relPosix);
98
+ }
99
+ return written;
100
+ }
101
+
102
+ export function install({ target, dir, pkgRoot = PKG_ROOT }) {
103
+ if (!TARGETS.includes(target)) {
104
+ throw new Error(`unknown target "${target}"; expected one of: ${TARGETS.join(', ')}`);
105
+ }
106
+ const written = [];
107
+ const adapterDir = join(pkgRoot, 'adapters', target);
108
+ for (const rel of walk(adapterDir)) {
109
+ const relPosix = toPosix(rel);
110
+ const src = join(adapterDir, rel);
111
+ const dest = join(dir, rel);
112
+ if (relPosix === 'AGENTS.md') {
113
+ const existing = existsSync(dest) ? readFileSync(dest, 'utf8') : null;
114
+ writeText(dest, mergeAgentsBlock(existing, readFileSync(src, 'utf8')));
115
+ } else if (relPosix === '.cursor/mcp.json') {
116
+ const ours = JSON.parse(readFileSync(src, 'utf8')).mcpServers || {};
117
+ const existing = existsSync(dest) ? readFileSync(dest, 'utf8') : null;
118
+ writeText(dest, mergeMcpJson(existing, ours));
119
+ } else {
120
+ mkdirSync(dirname(dest), { recursive: true });
121
+ copyFileSync(src, dest);
122
+ }
123
+ written.push(relPosix);
124
+ }
125
+ const payload = [
126
+ ...stagePayload(join(pkgRoot, 'references'), join(dir, BASE, 'references')),
127
+ ...stagePayload(join(pkgRoot, 'scripts'), join(dir, BASE, 'scripts'), (r) => r.startsWith('adapters/') || r.endsWith('.test.mjs') || r === 'install.mjs'),
128
+ ];
129
+ return { target, dir, written, payload };
130
+ }
131
+
132
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
133
+ const args = parseArgs(process.argv.slice(2));
134
+ if (args.help) { console.log(USAGE); process.exit(0); }
135
+ if (args.cmd !== 'init') { console.error(`✗ unknown command; expected "init"\n\n${USAGE}`); process.exit(1); }
136
+ if (!TARGETS.includes(args.target)) {
137
+ console.error(`✗ --target must be one of: ${TARGETS.join(', ')}\n\n${USAGE}`);
138
+ process.exit(1);
139
+ }
140
+ const res = install({ target: args.target, dir: args.dir });
141
+ console.log(`✓ throughline: installed ${res.target} adapter (${res.written.length} files) + ${res.payload.length}-file runtime payload into ${res.dir}`);
142
+ console.log(` ${MCP_NOTE[res.target]}`);
143
+ }
@@ -0,0 +1,91 @@
1
+ // Shared loader + structural validation for crosswalk.json.
2
+ // Zero dependencies. Mirrors scripts/crosswalk.schema.json's required/enum rules.
3
+ import { readFileSync } from 'node:fs';
4
+
5
+ export const STATUS_VALUES = ['aligned', 'renamed', 'drift-fix', 'added', 'mapped-nearest'];
6
+
7
+ // Row status (kebab) -> manifest tokenCrosswalk.statusCounts key (camelCase).
8
+ export const STATUS_COUNT_KEY = {
9
+ 'aligned': 'aligned',
10
+ 'renamed': 'renamed',
11
+ 'drift-fix': 'driftFix',
12
+ 'added': 'added',
13
+ 'mapped-nearest': 'mappedNearest',
14
+ };
15
+
16
+ const TIERS = ['primitive', 'semantic'];
17
+
18
+ export function loadCrosswalk(path) {
19
+ let raw;
20
+ try {
21
+ raw = readFileSync(path, 'utf8');
22
+ } catch (e) {
23
+ throw new Error(`crosswalk: cannot read file at ${path}: ${e.message}`);
24
+ }
25
+ let data;
26
+ try {
27
+ data = JSON.parse(raw);
28
+ } catch (e) {
29
+ throw new Error(`crosswalk: invalid JSON in ${path}: ${e.message}`);
30
+ }
31
+ if (!data || typeof data !== 'object' || !Array.isArray(data.tokens)) {
32
+ throw new Error('crosswalk: expected an object with a "tokens" array');
33
+ }
34
+ if (data.version !== 1) {
35
+ throw new Error(`crosswalk: version must be 1, got ${JSON.stringify(data.version)}`);
36
+ }
37
+ const ALLOWED_TOP_KEYS = new Set(['$schema', 'version', 'tokens']);
38
+ for (const key of Object.keys(data)) {
39
+ if (!ALLOWED_TOP_KEYS.has(key)) {
40
+ throw new Error(`crosswalk: unknown key at top level: "${key}"`);
41
+ }
42
+ }
43
+ const seen = new Set();
44
+ data.tokens.forEach((row, i) => validateRow(row, i, seen));
45
+ return data;
46
+ }
47
+
48
+ function validateRow(row, i, seen) {
49
+ const where = `tokens[${i}]`;
50
+ if (!row || typeof row !== 'object') {
51
+ throw new Error(`crosswalk: ${where} is not an object`);
52
+ }
53
+ for (const field of ['newToken', 'newValue', 'tier', 'status']) {
54
+ if (typeof row[field] !== 'string' || row[field] === '') {
55
+ throw new Error(`crosswalk: ${where}.${field} must be a non-empty string`);
56
+ }
57
+ }
58
+ if (!TIERS.includes(row.tier)) {
59
+ throw new Error(`crosswalk: ${where}.tier must be one of ${TIERS.join(', ')}, got "${row.tier}"`);
60
+ }
61
+ if (!STATUS_VALUES.includes(row.status)) {
62
+ throw new Error(`crosswalk: ${where}.status must be one of ${STATUS_VALUES.join(', ')}, got "${row.status}"`);
63
+ }
64
+ if (!Array.isArray(row.codeTokens) || row.codeTokens.some((t) => typeof t !== 'string')) {
65
+ throw new Error(`crosswalk: ${where}.codeTokens must be an array of strings`);
66
+ }
67
+ if (row.figmaOld === undefined || (row.figmaOld !== null && typeof row.figmaOld !== 'string')) {
68
+ throw new Error(`crosswalk: ${where}.figmaOld must be a string or null`);
69
+ }
70
+ if (row.recommendedSemantic != null && typeof row.recommendedSemantic !== 'string') {
71
+ throw new Error(`crosswalk: ${where}.recommendedSemantic must be a string or null`);
72
+ }
73
+ const ALLOWED_ROW_KEYS = new Set(['newToken', 'newValue', 'tier', 'figmaOld', 'codeTokens', 'status', 'recommendedSemantic']);
74
+ for (const key of Object.keys(row)) {
75
+ if (!ALLOWED_ROW_KEYS.has(key)) {
76
+ throw new Error(`crosswalk: ${where} has unknown key: "${key}"`);
77
+ }
78
+ }
79
+ if (seen.has(row.newToken)) {
80
+ throw new Error(`crosswalk: duplicate newToken "${row.newToken}"`);
81
+ }
82
+ seen.add(row.newToken);
83
+ }
84
+
85
+ export function statusCounts(crosswalk) {
86
+ const counts = { aligned: 0, renamed: 0, driftFix: 0, added: 0, mappedNearest: 0 };
87
+ for (const row of crosswalk.tokens) {
88
+ counts[STATUS_COUNT_KEY[row.status]] += 1;
89
+ }
90
+ return counts;
91
+ }
@@ -0,0 +1,96 @@
1
+ // Crosswalk validator: resolved DTCG value == crosswalk newValue, for every row.
2
+ // The tokens:validate CI gate. Zero dependencies.
3
+ //
4
+ // Usage:
5
+ // node validate-crosswalk.mjs --crosswalk crosswalk.json --tokens dtcg/tokens.json
6
+ import { readFileSync } from 'node:fs';
7
+ import { parseArgs } from 'node:util';
8
+ import { pathToFileURL } from 'node:url';
9
+ import { loadCrosswalk, statusCounts } from './lib/crosswalk.mjs';
10
+
11
+ const REF = /^\{([^}]+)\}$/;
12
+
13
+ // Flatten nested DTCG groups into { "dot.path": rawValue }. Skips $-prefixed meta keys.
14
+ export function flattenDtcg(obj, prefix = [], out = {}) {
15
+ for (const [key, val] of Object.entries(obj)) {
16
+ if (key.startsWith('$')) continue;
17
+ if (val && typeof val === 'object' && '$value' in val) {
18
+ out[[...prefix, key].join('.')] = val.$value;
19
+ } else if (val && typeof val === 'object') {
20
+ flattenDtcg(val, [...prefix, key], out);
21
+ }
22
+ }
23
+ return out;
24
+ }
25
+
26
+ // Follow {alias} chains to a leaf literal. Throws on missing or circular refs.
27
+ export function resolveValue(name, flat, seen = new Set()) {
28
+ if (!(name in flat)) throw new Error(`token "${name}" not found in DTCG source`);
29
+ const val = flat[name];
30
+ if (typeof val === 'string') {
31
+ const m = val.match(REF);
32
+ if (m) {
33
+ if (seen.has(name)) throw new Error(`circular reference at "${name}"`);
34
+ seen.add(name);
35
+ return resolveValue(m[1], flat, seen);
36
+ }
37
+ }
38
+ return val;
39
+ }
40
+
41
+ function norm(v) {
42
+ return String(v).trim().toLowerCase();
43
+ }
44
+
45
+ export function validate(crosswalk, dtcg) {
46
+ const flat = flattenDtcg(dtcg);
47
+ const results = { total: crosswalk.tokens.length, passed: 0, mismatches: [], missing: [] };
48
+ for (const row of crosswalk.tokens) {
49
+ let resolved;
50
+ try {
51
+ resolved = resolveValue(row.newToken, flat);
52
+ } catch {
53
+ results.missing.push(row.newToken);
54
+ continue;
55
+ }
56
+ if (norm(resolved) === norm(row.newValue)) {
57
+ results.passed += 1;
58
+ } else {
59
+ results.mismatches.push({ token: row.newToken, expected: row.newValue, actual: resolved });
60
+ }
61
+ }
62
+ return results;
63
+ }
64
+
65
+ function main() {
66
+ const { values } = parseArgs({
67
+ options: {
68
+ crosswalk: { type: 'string' },
69
+ tokens: { type: 'string' },
70
+ },
71
+ });
72
+ if (!values.crosswalk || !values.tokens) {
73
+ console.error('usage: validate-crosswalk.mjs --crosswalk <crosswalk.json> --tokens <dtcg/tokens.json>');
74
+ process.exit(2);
75
+ }
76
+ const crosswalk = loadCrosswalk(values.crosswalk);
77
+ const dtcg = JSON.parse(readFileSync(values.tokens, 'utf8'));
78
+ const r = validate(crosswalk, dtcg);
79
+
80
+ console.log(`tokens:validate — ${r.passed}/${r.total} resolved values match`);
81
+ console.log('status counts:', JSON.stringify(statusCounts(crosswalk)));
82
+ if (r.missing.length) {
83
+ console.error(`\n${r.missing.length} token(s) missing from the DTCG source:`);
84
+ for (const t of r.missing) console.error(` - ${t}`);
85
+ }
86
+ if (r.mismatches.length) {
87
+ console.error(`\n${r.mismatches.length} mismatch(es):`);
88
+ for (const m of r.mismatches) console.error(` - ${m.token}: crosswalk says ${m.expected}, source resolves to ${m.actual}`);
89
+ }
90
+ const ok = r.passed === r.total;
91
+ if (!ok) process.exit(1);
92
+ }
93
+
94
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
95
+ main();
96
+ }