@hegemonart/get-design-done 1.50.1 → 1.52.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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +93 -0
- package/README.md +4 -0
- package/SKILL.md +4 -1
- package/agents/a11y-mapper.md +30 -1
- package/agents/component-taxonomy-mapper.md +30 -1
- package/agents/design-debt-crawler.md +60 -60
- package/agents/design-reflector.md +33 -0
- package/agents/design-research-synthesizer.md +27 -1
- package/agents/motion-mapper.md +35 -13
- package/agents/token-mapper.md +30 -1
- package/agents/visual-hierarchy-mapper.md +30 -1
- package/dist/claude-code/.claude/skills/apply-reflections/SKILL.md +17 -0
- package/dist/claude-code/.claude/skills/context/SKILL.md +137 -0
- package/dist/claude-code/.claude/skills/extract-learnings/SKILL.md +16 -0
- package/dist/claude-code/.claude/skills/instinct/SKILL.md +111 -0
- package/dist/claude-code/.claude/skills/migrate-context/SKILL.md +123 -0
- package/dist/claude-code/.claude/skills/progress/SKILL.md +4 -0
- package/hooks/gdd-decision-injector.js +115 -6
- package/package.json +3 -2
- package/reference/design-context-schema.md +159 -0
- package/reference/design-context-tag-vocab.md +82 -0
- package/reference/instinct-format.md +120 -0
- package/reference/registry.json +21 -0
- package/reference/schemas/design-context.schema.json +130 -0
- package/reference/schemas/events.schema.json +1 -1
- package/reference/schemas/instinct.schema.json +91 -0
- package/reference/schemas/mcp-gdd-tools.schema.json +34 -1
- package/reference/skill-graph.md +4 -1
- package/scripts/lib/design-context/extract-a11y.mjs +188 -0
- package/scripts/lib/design-context/extract-components.mjs +243 -0
- package/scripts/lib/design-context/extract-motion.mjs +248 -0
- package/scripts/lib/design-context/extract-tokens.mjs +234 -0
- package/scripts/lib/design-context/extract-visual-hierarchy.mjs +178 -0
- package/scripts/lib/design-context/integration-map.mjs +251 -0
- package/scripts/lib/design-context/merge-fragments.mjs +227 -0
- package/scripts/lib/design-context-query.cjs +0 -0
- package/scripts/lib/instinct-store.cjs +677 -0
- package/scripts/lib/manifest/skills.json +24 -0
- package/scripts/lib/mcp-tools-lint/index.cjs +3 -1
- package/sdk/mcp/gdd-mcp/schemas/gdd_context_query.schema.json +60 -0
- package/sdk/mcp/gdd-mcp/server.js +474 -158
- package/sdk/mcp/gdd-mcp/server.ts +9 -5
- package/sdk/mcp/gdd-mcp/tools/gdd_context_query.ts +35 -0
- package/sdk/mcp/gdd-mcp/tools/index.ts +18 -13
- package/skills/apply-reflections/SKILL.md +17 -0
- package/skills/context/SKILL.md +137 -0
- package/skills/extract-learnings/SKILL.md +16 -0
- package/skills/instinct/SKILL.md +111 -0
- package/skills/migrate-context/SKILL.md +123 -0
- package/skills/progress/SKILL.md +4 -0
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/lib/design-context/extract-tokens.mjs — Phase 52 (DesignContext graph), executor B.
|
|
3
|
+
//
|
|
4
|
+
// Deterministic, dependency-free token extractor. Regex-scans source roots for
|
|
5
|
+
// design tokens (color / spacing / typography / radius / shadow) and emits a
|
|
6
|
+
// Fragment (schema_version 52.0) to stdout. The structural pass fills
|
|
7
|
+
// id/type/name/subtype + `uses-token` edges (component -> token, where a
|
|
8
|
+
// component file references a token value); the LLM/mapper phase fills
|
|
9
|
+
// summary/tags/complexity later — so summary defaults to '' and complexity to
|
|
10
|
+
// 'moderate' (the validator soft-warns on these stubs).
|
|
11
|
+
//
|
|
12
|
+
// Idiom mirrors scripts/lib/detect/engine.cjs walk(): dep-free fs recursion
|
|
13
|
+
// with a SKIP_DIRS set, scannable-extension filter, regex content scan. No
|
|
14
|
+
// network, no optional deps, no top-level Date.now() (stamped only in main()).
|
|
15
|
+
//
|
|
16
|
+
// Public API:
|
|
17
|
+
// extract(roots, opts?) -> Fragment (pure; opts.generatedAt to stamp)
|
|
18
|
+
// main() -> prints Fragment JSON to stdout
|
|
19
|
+
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import { pathToFileURL } from 'node:url';
|
|
23
|
+
|
|
24
|
+
const MAPPER = 'token-mapper';
|
|
25
|
+
const SCHEMA_VERSION = '52.0';
|
|
26
|
+
|
|
27
|
+
const SCANNABLE_EXT = new Set([
|
|
28
|
+
'.css', '.scss', '.sass', '.less',
|
|
29
|
+
'.tsx', '.jsx', '.ts', '.js', '.mjs', '.cjs',
|
|
30
|
+
'.vue', '.svelte', '.html', '.htm',
|
|
31
|
+
]);
|
|
32
|
+
const SKIP_DIRS = new Set([
|
|
33
|
+
'node_modules', '.git', 'dist', 'build', '.next', 'coverage',
|
|
34
|
+
'.design', '.planning', 'out', '.cache', '.turbo', '.svelte-kit',
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
// A file is treated as a "component" file (a `uses-token` edge source) when it
|
|
38
|
+
// is a component-bearing extension. CSS/SCSS are token *definition* surfaces,
|
|
39
|
+
// not components, so they never originate `uses-token` edges.
|
|
40
|
+
const COMPONENT_EXT = new Set(['.tsx', '.jsx', '.vue', '.svelte']);
|
|
41
|
+
|
|
42
|
+
/** Recursively collect scannable files under `root` (a file or dir). */
|
|
43
|
+
function walk(root) {
|
|
44
|
+
const out = [];
|
|
45
|
+
let st;
|
|
46
|
+
try { st = fs.statSync(root); } catch { return out; }
|
|
47
|
+
if (st.isFile()) {
|
|
48
|
+
if (SCANNABLE_EXT.has(path.extname(root).toLowerCase())) out.push(root);
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
const stack = [root];
|
|
52
|
+
while (stack.length) {
|
|
53
|
+
const dir = stack.pop();
|
|
54
|
+
let entries;
|
|
55
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; }
|
|
56
|
+
for (const e of entries) {
|
|
57
|
+
const full = path.join(dir, e.name);
|
|
58
|
+
if (e.isDirectory()) { if (!SKIP_DIRS.has(e.name)) stack.push(full); }
|
|
59
|
+
else if (e.isFile() && SCANNABLE_EXT.has(path.extname(e.name).toLowerCase())) out.push(full);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Slugify an arbitrary token value/name into an id-safe fragment. */
|
|
66
|
+
function slug(s) {
|
|
67
|
+
return String(s)
|
|
68
|
+
.trim()
|
|
69
|
+
.toLowerCase()
|
|
70
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
71
|
+
.replace(/^-+|-+$/g, '')
|
|
72
|
+
.slice(0, 60) || 'x';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function stubNode(id, type, name, extra) {
|
|
76
|
+
return {
|
|
77
|
+
id,
|
|
78
|
+
type,
|
|
79
|
+
name,
|
|
80
|
+
summary: '', // LLM phase fills this
|
|
81
|
+
tags: [],
|
|
82
|
+
complexity: 'moderate', // LLM phase refines this
|
|
83
|
+
...extra,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
// Token matchers. Each returns { subtype, name, value } objects per match.
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
const HEX = /#[0-9a-fA-F]{3,8}\b/g;
|
|
92
|
+
const FUNC_COLOR = /\b(?:rgb|rgba|hsl|hsla|oklch|oklab|lab|lch|color)\([^)]*\)/g;
|
|
93
|
+
const CSS_VAR_DEF = /(--[a-z][a-z0-9-]*)\s*:\s*([^;}\n]+)/gi;
|
|
94
|
+
const TW_COLOR = /\b(?:bg|text|border|ring|fill|stroke|from|to|via)-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d{2,3}\b/g;
|
|
95
|
+
|
|
96
|
+
const SPACING_DECL = /\b(?:padding|margin|gap|inset|top|right|bottom|left|row-gap|column-gap)(?:-[a-z]+)?\s*:\s*(-?[0-9]*\.?[0-9]+(?:px|rem|em))\b/gi;
|
|
97
|
+
const TW_SPACING = /\b(?:p|m|gap|space|inset)(?:[xytrbl])?-(?:px|0|0\.5|\d{1,2}(?:\.5)?)\b/g;
|
|
98
|
+
|
|
99
|
+
const FONT_SIZE = /font-size\s*:\s*([0-9]*\.?[0-9]+(?:px|rem|em))/gi;
|
|
100
|
+
const FONT_WEIGHT = /font-weight\s*:\s*([1-9]00|normal|bold|bolder|lighter)\b/gi;
|
|
101
|
+
const FONT_FAMILY = /font-family\s*:\s*([^;}\n]+)/gi;
|
|
102
|
+
const TW_TEXT = /\btext-(?:xs|sm|base|lg|xl|2xl|3xl|4xl|5xl|6xl|7xl|8xl|9xl)\b/g;
|
|
103
|
+
const TW_FONTWEIGHT = /\bfont-(?:thin|extralight|light|normal|medium|semibold|bold|extrabold|black)\b/g;
|
|
104
|
+
|
|
105
|
+
const RADIUS_DECL = /border-radius\s*:\s*([0-9]*\.?[0-9]+(?:px|rem|em|%)|9999px|50%)/gi;
|
|
106
|
+
const TW_RADIUS = /\brounded(?:-(?:none|sm|md|lg|xl|2xl|3xl|full))?\b/g;
|
|
107
|
+
|
|
108
|
+
const SHADOW_DECL = /box-shadow\s*:\s*([^;}\n]+)/gi;
|
|
109
|
+
const TW_SHADOW = /\bshadow(?:-(?:sm|md|lg|xl|2xl|inner|none))?\b/g;
|
|
110
|
+
|
|
111
|
+
function collect(re, content, mapFn) {
|
|
112
|
+
const out = [];
|
|
113
|
+
re.lastIndex = 0;
|
|
114
|
+
let m;
|
|
115
|
+
while ((m = re.exec(content)) !== null) {
|
|
116
|
+
const t = mapFn(m);
|
|
117
|
+
if (t) out.push(t);
|
|
118
|
+
if (m.index === re.lastIndex) re.lastIndex++; // guard zero-width
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Extract every token reference from one file's content (subtype-tagged). */
|
|
124
|
+
function tokensInContent(content, ext) {
|
|
125
|
+
const found = [];
|
|
126
|
+
const isStyle = ext === '.css' || ext === '.scss' || ext === '.sass' || ext === '.less';
|
|
127
|
+
|
|
128
|
+
// --- color ---
|
|
129
|
+
// CSS custom properties whose value is colorish → name them by the var.
|
|
130
|
+
for (const m of collect(CSS_VAR_DEF, content, (mm) => mm)) {
|
|
131
|
+
const [, varName, rawVal] = m;
|
|
132
|
+
const val = rawVal.trim();
|
|
133
|
+
if (/#[0-9a-fA-F]{3,8}\b|\b(?:rgb|rgba|hsl|hsla|oklch|oklab|lab|lch)\(/.test(val)) {
|
|
134
|
+
found.push({ subtype: 'color', name: varName, value: val });
|
|
135
|
+
} else if (/(?:px|rem|em)\b/.test(val) && /^(?:[0-9.]+(?:px|rem|em)\s*)+$/.test(val)) {
|
|
136
|
+
found.push({ subtype: 'spacing', name: varName, value: val });
|
|
137
|
+
} else if (/(?:[0-9]{3}|bold|normal)/.test(val) && /font|weight|size/i.test(varName)) {
|
|
138
|
+
found.push({ subtype: 'typography', name: varName, value: val });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
for (const v of collect(HEX, content, (mm) => mm[0])) found.push({ subtype: 'color', name: v, value: v });
|
|
142
|
+
for (const v of collect(FUNC_COLOR, content, (mm) => mm[0])) found.push({ subtype: 'color', name: v, value: v });
|
|
143
|
+
for (const v of collect(TW_COLOR, content, (mm) => mm[0])) found.push({ subtype: 'color', name: v, value: v });
|
|
144
|
+
|
|
145
|
+
// --- spacing ---
|
|
146
|
+
for (const m of collect(SPACING_DECL, content, (mm) => mm)) found.push({ subtype: 'spacing', name: m[1], value: m[1] });
|
|
147
|
+
for (const v of collect(TW_SPACING, content, (mm) => mm[0])) found.push({ subtype: 'spacing', name: v, value: v });
|
|
148
|
+
|
|
149
|
+
// --- typography ---
|
|
150
|
+
for (const m of collect(FONT_SIZE, content, (mm) => mm)) found.push({ subtype: 'typography', name: m[1], value: m[1] });
|
|
151
|
+
for (const m of collect(FONT_WEIGHT, content, (mm) => mm)) found.push({ subtype: 'typography', name: `weight-${m[1]}`, value: m[1] });
|
|
152
|
+
for (const m of collect(FONT_FAMILY, content, (mm) => mm)) found.push({ subtype: 'typography', name: m[1].trim().split(',')[0].replace(/['"]/g, ''), value: m[1].trim() });
|
|
153
|
+
for (const v of collect(TW_TEXT, content, (mm) => mm[0])) found.push({ subtype: 'typography', name: v, value: v });
|
|
154
|
+
for (const v of collect(TW_FONTWEIGHT, content, (mm) => mm[0])) found.push({ subtype: 'typography', name: v, value: v });
|
|
155
|
+
|
|
156
|
+
// --- radius ---
|
|
157
|
+
for (const m of collect(RADIUS_DECL, content, (mm) => mm)) found.push({ subtype: 'radius', name: m[1], value: m[1] });
|
|
158
|
+
for (const v of collect(TW_RADIUS, content, (mm) => mm[0])) found.push({ subtype: 'radius', name: v, value: v });
|
|
159
|
+
|
|
160
|
+
// --- shadow ---
|
|
161
|
+
for (const m of collect(SHADOW_DECL, content, (mm) => mm)) found.push({ subtype: 'shadow', name: m[1].trim().slice(0, 40), value: m[1].trim() });
|
|
162
|
+
for (const v of collect(TW_SHADOW, content, (mm) => mm[0])) found.push({ subtype: 'shadow', name: v, value: v });
|
|
163
|
+
|
|
164
|
+
// Suppress raw-utility noise in pure style files is unnecessary; dedupe by id
|
|
165
|
+
// happens at the fragment level. `isStyle` retained for future weighting.
|
|
166
|
+
void isStyle;
|
|
167
|
+
return found;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Pure extractor.
|
|
172
|
+
* @param {string[]|string} roots one or more file/dir roots
|
|
173
|
+
* @param {{generatedAt?: string}} [opts]
|
|
174
|
+
* @returns {{schema_version:string, mapper:string, generated_at:string, nodes:object[], edges:object[]}}
|
|
175
|
+
*/
|
|
176
|
+
export function extract(roots, opts = {}) {
|
|
177
|
+
const rootList = (Array.isArray(roots) ? roots : [roots]).filter(Boolean);
|
|
178
|
+
const nodeMap = new Map(); // id -> node
|
|
179
|
+
const edgeSet = new Map(); // edgeKey -> edge (dedupe)
|
|
180
|
+
|
|
181
|
+
for (const root of rootList) {
|
|
182
|
+
for (const abs of walk(root)) {
|
|
183
|
+
let content;
|
|
184
|
+
try { content = fs.readFileSync(abs, 'utf8'); } catch { continue; }
|
|
185
|
+
const ext = path.extname(abs).toLowerCase();
|
|
186
|
+
const hits = tokensInContent(content, ext);
|
|
187
|
+
if (!hits.length) continue;
|
|
188
|
+
|
|
189
|
+
// Component-file id (only for `uses-token` edge origin).
|
|
190
|
+
const compId = COMPONENT_EXT.has(ext)
|
|
191
|
+
? `component:${slug(path.basename(abs, ext))}`
|
|
192
|
+
: null;
|
|
193
|
+
|
|
194
|
+
for (const h of hits) {
|
|
195
|
+
const id = `token:${h.subtype}:${slug(h.name)}`;
|
|
196
|
+
if (!nodeMap.has(id)) {
|
|
197
|
+
nodeMap.set(id, stubNode(id, 'token', h.name, { subtype: h.subtype, value: h.value }));
|
|
198
|
+
}
|
|
199
|
+
if (compId) {
|
|
200
|
+
const key = `${compId}->${id}`;
|
|
201
|
+
if (!edgeSet.has(key)) {
|
|
202
|
+
edgeSet.set(key, {
|
|
203
|
+
source: compId,
|
|
204
|
+
target: id,
|
|
205
|
+
type: 'uses-token',
|
|
206
|
+
direction: 'forward',
|
|
207
|
+
weight: 0.5,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
schema_version: SCHEMA_VERSION,
|
|
217
|
+
mapper: MAPPER,
|
|
218
|
+
generated_at: opts.generatedAt || '',
|
|
219
|
+
nodes: [...nodeMap.values()],
|
|
220
|
+
edges: [...edgeSet.values()],
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** CLI entry: roots from argv (default cwd), stamp generated_at, print JSON. */
|
|
225
|
+
export function main(argv = process.argv.slice(2)) {
|
|
226
|
+
const roots = argv.length ? argv : [process.cwd()];
|
|
227
|
+
const fragment = extract(roots, { generatedAt: new Date().toISOString() });
|
|
228
|
+
process.stdout.write(JSON.stringify(fragment, null, 2) + '\n');
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ESM "run as script" guard (Windows + POSIX safe via pathToFileURL).
|
|
232
|
+
const invokedDirectly =
|
|
233
|
+
process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
234
|
+
if (invokedDirectly) main();
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/lib/design-context/extract-visual-hierarchy.mjs — Phase 52, executor B.
|
|
3
|
+
//
|
|
4
|
+
// Deterministic, dependency-free visual-hierarchy extractor. Regex-scans source
|
|
5
|
+
// for heading structure (<h1>..<h6>), type-scale steps (Tailwind text-* + CSS
|
|
6
|
+
// font-size), focal/hero weight signals, and layout patterns (flex/grid/
|
|
7
|
+
// F-/Z-/centered), and emits a Fragment (schema_version 52.0) of `layer` +
|
|
8
|
+
// `pattern` nodes with `composes` edges (heading-level layer -> next deeper
|
|
9
|
+
// level, capturing the document outline) and `referenced-by` edges
|
|
10
|
+
// (component file -> pattern it exhibits).
|
|
11
|
+
//
|
|
12
|
+
// Semantics mirror agents/visual-hierarchy-mapper.md (headings / type-scale /
|
|
13
|
+
// focal weight / layout patterns). Structural pass: summary='' and
|
|
14
|
+
// complexity='moderate' are stubs the LLM/mapper phase fills later. No network,
|
|
15
|
+
// no deps, no top-level Date.now() (stamped in main()).
|
|
16
|
+
//
|
|
17
|
+
// Public API:
|
|
18
|
+
// extract(roots, opts?) -> Fragment (pure)
|
|
19
|
+
// main() -> prints Fragment JSON to stdout
|
|
20
|
+
|
|
21
|
+
import fs from 'node:fs';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import { pathToFileURL } from 'node:url';
|
|
24
|
+
|
|
25
|
+
const MAPPER = 'visual-hierarchy-mapper';
|
|
26
|
+
const SCHEMA_VERSION = '52.0';
|
|
27
|
+
|
|
28
|
+
const SCANNABLE_EXT = new Set([
|
|
29
|
+
'.tsx', '.jsx', '.ts', '.js',
|
|
30
|
+
'.vue', '.svelte', '.html', '.htm',
|
|
31
|
+
'.css', '.scss',
|
|
32
|
+
]);
|
|
33
|
+
const SKIP_DIRS = new Set([
|
|
34
|
+
'node_modules', '.git', 'dist', 'build', '.next', 'coverage',
|
|
35
|
+
'.design', '.planning', 'out', '.cache', '.turbo', '.svelte-kit',
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
function walk(root) {
|
|
39
|
+
const out = [];
|
|
40
|
+
let st;
|
|
41
|
+
try { st = fs.statSync(root); } catch { return out; }
|
|
42
|
+
if (st.isFile()) {
|
|
43
|
+
if (SCANNABLE_EXT.has(path.extname(root).toLowerCase())) out.push(root);
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
const stack = [root];
|
|
47
|
+
while (stack.length) {
|
|
48
|
+
const dir = stack.pop();
|
|
49
|
+
let entries;
|
|
50
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; }
|
|
51
|
+
for (const e of entries) {
|
|
52
|
+
const full = path.join(dir, e.name);
|
|
53
|
+
if (e.isDirectory()) { if (!SKIP_DIRS.has(e.name)) stack.push(full); }
|
|
54
|
+
else if (e.isFile() && SCANNABLE_EXT.has(path.extname(e.name).toLowerCase())) out.push(full);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function slug(s) {
|
|
61
|
+
return String(s).trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60) || 'x';
|
|
62
|
+
}
|
|
63
|
+
function stubNode(id, type, name, extra) {
|
|
64
|
+
return { id, type, name, summary: '', tags: [], complexity: 'moderate', ...extra };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// Visual-hierarchy matchers.
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
const HEADING = /<h([1-6])\b/g;
|
|
72
|
+
const TW_TEXT_SCALE = /\btext-(xs|sm|base|lg|xl|2xl|3xl|4xl|5xl|6xl|7xl|8xl|9xl)\b/g;
|
|
73
|
+
const CSS_FONTSIZE = /font-size\s*:\s*([0-9.]+(?:px|rem|em))/gi;
|
|
74
|
+
const HERO = /\b(?:hero|headline|display-[a-z0-9]+|page-title|page-heading)\b/g;
|
|
75
|
+
const LAYOUT = /\b(?:justify-(?:center|between|around|evenly)|items-center|grid-template|grid-cols-\d+|flex-(?:row|col)|flex-direction)\b/g;
|
|
76
|
+
const CENTERED = /\b(?:mx-auto|justify-center|items-center|place-items-center|text-center)\b/g;
|
|
77
|
+
|
|
78
|
+
function collect(re, content, mapFn) {
|
|
79
|
+
const out = [];
|
|
80
|
+
re.lastIndex = 0;
|
|
81
|
+
let m;
|
|
82
|
+
while ((m = re.exec(content)) !== null) {
|
|
83
|
+
const v = mapFn(m);
|
|
84
|
+
if (v != null) out.push(v);
|
|
85
|
+
if (m.index === re.lastIndex) re.lastIndex++;
|
|
86
|
+
}
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Pure extractor.
|
|
92
|
+
* @param {string[]|string} roots
|
|
93
|
+
* @param {{generatedAt?: string}} [opts]
|
|
94
|
+
* @returns {object} Fragment
|
|
95
|
+
*/
|
|
96
|
+
export function extract(roots, opts = {}) {
|
|
97
|
+
const rootList = (Array.isArray(roots) ? roots : [roots]).filter(Boolean);
|
|
98
|
+
const nodeMap = new Map();
|
|
99
|
+
const edgeSet = new Map();
|
|
100
|
+
const headingLevels = new Set(); // numeric levels seen (1..6) across corpus
|
|
101
|
+
const typeScaleSteps = new Set(); // text-* scale steps seen
|
|
102
|
+
|
|
103
|
+
const addNode = (id, type, name, extra) => {
|
|
104
|
+
if (!nodeMap.has(id)) nodeMap.set(id, stubNode(id, type, name, extra || {}));
|
|
105
|
+
return id;
|
|
106
|
+
};
|
|
107
|
+
const addEdge = (source, target, type, weight) => {
|
|
108
|
+
const key = `${source}--${type}-->${target}`;
|
|
109
|
+
if (!edgeSet.has(key)) edgeSet.set(key, { source, target, type, direction: 'forward', weight });
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
for (const root of rootList) {
|
|
113
|
+
for (const abs of walk(root)) {
|
|
114
|
+
let content;
|
|
115
|
+
try { content = fs.readFileSync(abs, 'utf8'); } catch { continue; }
|
|
116
|
+
const ext = path.extname(abs).toLowerCase();
|
|
117
|
+
const baseName = path.basename(abs, ext);
|
|
118
|
+
const compId = `component:${slug(baseName)}`;
|
|
119
|
+
|
|
120
|
+
// Heading levels -> layer nodes (one per level seen).
|
|
121
|
+
for (const lvl of collect(HEADING, content, (m) => Number(m[1]))) {
|
|
122
|
+
headingLevels.add(lvl);
|
|
123
|
+
addNode(`layer:heading-h${lvl}`, 'layer', `Heading level h${lvl}`, { subtype: 'Template', level: lvl });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Type-scale steps -> pattern nodes (the scale vocabulary in use).
|
|
127
|
+
for (const step of collect(TW_TEXT_SCALE, content, (m) => m[1])) {
|
|
128
|
+
typeScaleSteps.add(step);
|
|
129
|
+
addNode(`pattern:type-scale-${slug(step)}`, 'pattern', `Type scale: text-${step}`, { step });
|
|
130
|
+
}
|
|
131
|
+
for (const size of collect(CSS_FONTSIZE, content, (m) => m[1])) {
|
|
132
|
+
addNode(`pattern:type-scale-${slug(size)}`, 'pattern', `Type scale: ${size}`, { size });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Focal / hero signals -> pattern node + referenced-by from the file.
|
|
136
|
+
if ((content.match(HERO) || []).length) {
|
|
137
|
+
const pid = addNode('pattern:focal-hero', 'pattern', 'Focal/hero emphasis', {});
|
|
138
|
+
addEdge(compId, pid, 'referenced-by', 0.5);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Layout patterns.
|
|
142
|
+
if ((content.match(CENTERED) || []).length) {
|
|
143
|
+
const pid = addNode('pattern:layout-centered-column', 'pattern', 'Centered column layout', {});
|
|
144
|
+
addEdge(compId, pid, 'referenced-by', 0.4);
|
|
145
|
+
} else if ((content.match(LAYOUT) || []).length) {
|
|
146
|
+
const pid = addNode('pattern:layout-flow', 'pattern', 'Flex/grid flow layout', {});
|
|
147
|
+
addEdge(compId, pid, 'referenced-by', 0.4);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// composes edges: connect each heading level to the next-deeper one present,
|
|
153
|
+
// capturing the document-outline shape (h1 composes h2 composes h3 ...).
|
|
154
|
+
const sortedLevels = [...headingLevels].sort((a, b) => a - b);
|
|
155
|
+
for (let i = 0; i < sortedLevels.length - 1; i++) {
|
|
156
|
+
const src = `layer:heading-h${sortedLevels[i]}`;
|
|
157
|
+
const dst = `layer:heading-h${sortedLevels[i + 1]}`;
|
|
158
|
+
addEdge(src, dst, 'composes', 0.5);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
schema_version: SCHEMA_VERSION,
|
|
163
|
+
mapper: MAPPER,
|
|
164
|
+
generated_at: opts.generatedAt || '',
|
|
165
|
+
nodes: [...nodeMap.values()],
|
|
166
|
+
edges: [...edgeSet.values()],
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function main(argv = process.argv.slice(2)) {
|
|
171
|
+
const roots = argv.length ? argv : [process.cwd()];
|
|
172
|
+
const fragment = extract(roots, { generatedAt: new Date().toISOString() });
|
|
173
|
+
process.stdout.write(JSON.stringify(fragment, null, 2) + '\n');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const invokedDirectly =
|
|
177
|
+
process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
178
|
+
if (invokedDirectly) main();
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/lib/design-context/integration-map.mjs - Phase 52 (DesignContext graph), executor D.
|
|
3
|
+
//
|
|
4
|
+
// Renders an Atomic-Design integration map (mermaid) from the canonical
|
|
5
|
+
// DesignContext graph at .design/context-graph.json. The map groups nodes into
|
|
6
|
+
// the four Atomic-Design tiers (Atomic, Molecular, Organism, Template) using the
|
|
7
|
+
// `layer` node subtype as the tier authority, then draws the `composes` and
|
|
8
|
+
// `extends` edges between entities so a reader sees how the system assembles.
|
|
9
|
+
//
|
|
10
|
+
// Tier assignment
|
|
11
|
+
// ---------------
|
|
12
|
+
// A `layer` node carries a subtype of Atomic / Molecular / Organism / Template
|
|
13
|
+
// and a set of `composes`/`extends` edges to the entities that sit in that tier.
|
|
14
|
+
// Every entity reachable from a layer node by a composes/extends edge is placed
|
|
15
|
+
// in that layer's tier. An entity with no layer membership lands in an
|
|
16
|
+
// "Unlayered" bucket so nothing is silently dropped. When the graph has NO layer
|
|
17
|
+
// nodes at all (a pre-taxonomy graph), every composes/extends participant falls
|
|
18
|
+
// into "Unlayered" and the map still renders.
|
|
19
|
+
//
|
|
20
|
+
// Pure render seam
|
|
21
|
+
// ----------------
|
|
22
|
+
// render(graph) -> mermaid markdown string. No I/O, no Date.now(), no deps. The
|
|
23
|
+
// CLI main() reads .design/context-graph.json and atomic-writes
|
|
24
|
+
// .design/INTEGRATION-MAP.md via the sibling graph/atomic-write helper. main() is
|
|
25
|
+
// non-fatal when the graph is absent or unreadable: it prints a notice to stderr
|
|
26
|
+
// and returns 0, so a pipeline step that always runs it never breaks a build.
|
|
27
|
+
//
|
|
28
|
+
// No network, no optional deps, no top-level Date.now() (stamped only in main()).
|
|
29
|
+
|
|
30
|
+
import fs from 'node:fs';
|
|
31
|
+
import path from 'node:path';
|
|
32
|
+
import { pathToFileURL } from 'node:url';
|
|
33
|
+
import { writeFileSync, renameSync, unlinkSync, mkdirSync, existsSync } from 'node:fs';
|
|
34
|
+
import { dirname, basename, join, resolve } from 'node:path';
|
|
35
|
+
|
|
36
|
+
const DEFAULT_GRAPH = path.join('.design', 'context-graph.json');
|
|
37
|
+
const DEFAULT_OUT = path.join('.design', 'INTEGRATION-MAP.md');
|
|
38
|
+
|
|
39
|
+
// The four Atomic-Design tiers, in render order (broad to composed).
|
|
40
|
+
const TIERS = ['Atomic', 'Molecular', 'Organism', 'Template'];
|
|
41
|
+
const UNLAYERED = 'Unlayered';
|
|
42
|
+
|
|
43
|
+
// Edges that express assembly. composes = whole -> part; extends = special -> base.
|
|
44
|
+
const ASSEMBLY_EDGE_TYPES = new Set(['composes', 'extends']);
|
|
45
|
+
|
|
46
|
+
function nodeList(graph) {
|
|
47
|
+
return Array.isArray(graph && graph.nodes) ? graph.nodes : [];
|
|
48
|
+
}
|
|
49
|
+
function edgeList(graph) {
|
|
50
|
+
return Array.isArray(graph && graph.edges) ? graph.edges : [];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Map every node id to its Atomic-Design tier. A `layer` node's subtype names a
|
|
55
|
+
* tier; the entities it composes/extends inherit that tier. Ids not reached from
|
|
56
|
+
* any layer land in UNLAYERED. A node id may appear under at most one tier (first
|
|
57
|
+
* layer membership wins, layers walked in TIERS order then by id for stability).
|
|
58
|
+
*/
|
|
59
|
+
function tierByNodeId(graph) {
|
|
60
|
+
const nodes = nodeList(graph);
|
|
61
|
+
const edges = edgeList(graph);
|
|
62
|
+
const byId = new Map(nodes.filter((n) => n && typeof n.id === 'string').map((n) => [n.id, n]));
|
|
63
|
+
|
|
64
|
+
// Layer nodes grouped by their tier subtype.
|
|
65
|
+
const layerNodes = nodes.filter((n) => n && n.type === 'layer' && typeof n.id === 'string');
|
|
66
|
+
const tierOfLayer = new Map();
|
|
67
|
+
for (const ln of layerNodes) {
|
|
68
|
+
const sub = TIERS.includes(ln.subtype) ? ln.subtype : UNLAYERED;
|
|
69
|
+
tierOfLayer.set(ln.id, sub);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// For each layer, the entities it reaches via a composes/extends edge.
|
|
73
|
+
const assignment = new Map(); // nodeId -> tier
|
|
74
|
+
const orderedLayers = [...layerNodes].sort((a, b) => {
|
|
75
|
+
const ta = TIERS.indexOf(tierOfLayer.get(a.id));
|
|
76
|
+
const tb = TIERS.indexOf(tierOfLayer.get(b.id));
|
|
77
|
+
if (ta !== tb) return (ta === -1 ? TIERS.length : ta) - (tb === -1 ? TIERS.length : tb);
|
|
78
|
+
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
for (const ln of orderedLayers) {
|
|
82
|
+
const tier = tierOfLayer.get(ln.id) || UNLAYERED;
|
|
83
|
+
for (const e of edges) {
|
|
84
|
+
if (!e || !ASSEMBLY_EDGE_TYPES.has(e.type)) continue;
|
|
85
|
+
// A layer "owns" the member regardless of edge direction: the member is the
|
|
86
|
+
// non-layer endpoint of an assembly edge that touches this layer.
|
|
87
|
+
let member = null;
|
|
88
|
+
if (e.source === ln.id) member = e.target;
|
|
89
|
+
else if (e.target === ln.id) member = e.source;
|
|
90
|
+
if (member && byId.has(member) && !assignment.has(member)) assignment.set(member, tier);
|
|
91
|
+
}
|
|
92
|
+
// The layer node itself sits in its own tier.
|
|
93
|
+
if (!assignment.has(ln.id)) assignment.set(ln.id, tier);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return { assignment, byId };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Sanitize a node id into a mermaid-safe node key (alnum + underscore). */
|
|
100
|
+
function mermaidKey(id) {
|
|
101
|
+
return 'n_' + String(id).replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Escape a label for a mermaid quoted node label. */
|
|
105
|
+
function mermaidLabel(node) {
|
|
106
|
+
const name = (node && (node.name || node.id)) || '';
|
|
107
|
+
const type = (node && node.type) || '';
|
|
108
|
+
const text = type ? `${name} (${type})` : `${name}`;
|
|
109
|
+
return text.replace(/"/g, "'").replace(/[\r\n]+/g, ' ');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Pure render: build the Atomic-Design integration map as mermaid markdown.
|
|
114
|
+
* @param {object} graph a DesignContext graph ({nodes[], edges[]}) or empty/absent.
|
|
115
|
+
* @returns {string} markdown with a fenced mermaid flowchart, always non-empty.
|
|
116
|
+
*/
|
|
117
|
+
export function render(graph) {
|
|
118
|
+
const nodes = nodeList(graph);
|
|
119
|
+
const edges = edgeList(graph);
|
|
120
|
+
const { assignment, byId } = tierByNodeId(graph);
|
|
121
|
+
|
|
122
|
+
// Bucket every assembly participant by tier (so the diagram only draws nodes
|
|
123
|
+
// that actually take part in composition, plus their layer nodes).
|
|
124
|
+
const buckets = new Map([...TIERS, UNLAYERED].map((t) => [t, []]));
|
|
125
|
+
const drawn = new Set();
|
|
126
|
+
const place = (id) => {
|
|
127
|
+
if (!byId.has(id) || drawn.has(id)) return;
|
|
128
|
+
const tier = assignment.get(id) || UNLAYERED;
|
|
129
|
+
buckets.get(tier).push(id);
|
|
130
|
+
drawn.add(id);
|
|
131
|
+
};
|
|
132
|
+
for (const e of edges) {
|
|
133
|
+
if (!e || !ASSEMBLY_EDGE_TYPES.has(e.type)) continue;
|
|
134
|
+
place(e.source);
|
|
135
|
+
place(e.target);
|
|
136
|
+
}
|
|
137
|
+
// Always show layer nodes even if they have no assembly edge yet.
|
|
138
|
+
for (const n of nodes) if (n && n.type === 'layer') place(n.id);
|
|
139
|
+
|
|
140
|
+
const lines = [];
|
|
141
|
+
lines.push('# Integration Map');
|
|
142
|
+
lines.push('');
|
|
143
|
+
lines.push(
|
|
144
|
+
'Atomic-Design composition map derived from `.design/context-graph.json`. Nodes are grouped ' +
|
|
145
|
+
'by Atomic-Design tier (from `layer` node subtype); edges show `composes` and `extends` ' +
|
|
146
|
+
'relationships. Regenerate with `node scripts/lib/design-context/integration-map.mjs`.',
|
|
147
|
+
);
|
|
148
|
+
lines.push('');
|
|
149
|
+
|
|
150
|
+
const totalDrawn = [...buckets.values()].reduce((acc, ids) => acc + ids.length, 0);
|
|
151
|
+
if (totalDrawn === 0) {
|
|
152
|
+
lines.push('_No `composes`/`extends` relationships in the graph yet, so there is nothing to map._');
|
|
153
|
+
lines.push('');
|
|
154
|
+
return lines.join('\n');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
lines.push('```mermaid');
|
|
158
|
+
lines.push('flowchart TD');
|
|
159
|
+
|
|
160
|
+
// Subgraphs per tier (only non-empty ones), in TIERS order then Unlayered.
|
|
161
|
+
for (const tier of [...TIERS, UNLAYERED]) {
|
|
162
|
+
const ids = buckets.get(tier);
|
|
163
|
+
if (!ids.length) continue;
|
|
164
|
+
lines.push(` subgraph ${tier}`);
|
|
165
|
+
for (const id of ids.sort()) {
|
|
166
|
+
const node = byId.get(id);
|
|
167
|
+
lines.push(` ${mermaidKey(id)}["${mermaidLabel(node)}"]`);
|
|
168
|
+
}
|
|
169
|
+
lines.push(' end');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Assembly edges (composes = solid arrow; extends = thick "extends" arrow).
|
|
173
|
+
const seenEdge = new Set();
|
|
174
|
+
for (const e of edges) {
|
|
175
|
+
if (!e || !ASSEMBLY_EDGE_TYPES.has(e.type)) continue;
|
|
176
|
+
if (!byId.has(e.source) || !byId.has(e.target)) continue;
|
|
177
|
+
const key = `${e.source}--${e.type}-->${e.target}`;
|
|
178
|
+
if (seenEdge.has(key)) continue;
|
|
179
|
+
seenEdge.add(key);
|
|
180
|
+
const a = mermaidKey(e.source);
|
|
181
|
+
const b = mermaidKey(e.target);
|
|
182
|
+
if (e.type === 'extends') lines.push(` ${a} -. extends .-> ${b}`);
|
|
183
|
+
else lines.push(` ${a} --> ${b}`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
lines.push('```');
|
|
187
|
+
lines.push('');
|
|
188
|
+
return lines.join('\n');
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
// Atomic write (inlined twin of scripts/lib/graph/atomic-write.mjs for a text
|
|
193
|
+
// payload; that helper is JSON-only and the map is markdown). Same tmp+rename
|
|
194
|
+
// invariant: tmp lives in the SAME dir as target (Windows-atomic rename).
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
function atomicWriteText(target, body) {
|
|
198
|
+
const parent = dirname(target);
|
|
199
|
+
const base = basename(target);
|
|
200
|
+
const tmp = join(
|
|
201
|
+
parent,
|
|
202
|
+
`.${base}.tmp.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}`,
|
|
203
|
+
);
|
|
204
|
+
if (resolve(dirname(tmp)) !== resolve(parent)) {
|
|
205
|
+
throw new Error(`atomicWriteText invariant: tmp not in same dir as target (tmp=${tmp}, target=${target})`);
|
|
206
|
+
}
|
|
207
|
+
mkdirSync(parent, { recursive: true });
|
|
208
|
+
try {
|
|
209
|
+
writeFileSync(tmp, body, 'utf8');
|
|
210
|
+
renameSync(tmp, target);
|
|
211
|
+
} catch (err) {
|
|
212
|
+
if (existsSync(tmp)) {
|
|
213
|
+
try { unlinkSync(tmp); } catch { /* swallow cleanup error; original throw wins */ }
|
|
214
|
+
}
|
|
215
|
+
throw err;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* CLI entry. Reads the graph (argv[0] or the default path), renders the map, and
|
|
221
|
+
* atomic-writes it (argv[1] or the default out). Non-fatal when the graph is
|
|
222
|
+
* absent or unreadable: prints a notice and returns 0.
|
|
223
|
+
* @returns {number} process exit code (0 always, by design; this is advisory).
|
|
224
|
+
*/
|
|
225
|
+
export function main(argv = process.argv.slice(2)) {
|
|
226
|
+
const graphPath = argv[0] || DEFAULT_GRAPH;
|
|
227
|
+
const outPath = argv[1] || DEFAULT_OUT;
|
|
228
|
+
|
|
229
|
+
if (!fs.existsSync(graphPath)) {
|
|
230
|
+
process.stderr.write(`integration-map: no graph at ${graphPath} (skipping, non-fatal)\n`);
|
|
231
|
+
return 0;
|
|
232
|
+
}
|
|
233
|
+
let graph;
|
|
234
|
+
try {
|
|
235
|
+
graph = JSON.parse(fs.readFileSync(graphPath, 'utf8'));
|
|
236
|
+
} catch (err) {
|
|
237
|
+
process.stderr.write(`integration-map: unreadable graph ${graphPath} (${err.message}) (skipping, non-fatal)\n`);
|
|
238
|
+
return 0;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const md = render(graph);
|
|
242
|
+
const stamp = `<!-- generated ${new Date().toISOString()} by scripts/lib/design-context/integration-map.mjs -->\n`;
|
|
243
|
+
atomicWriteText(outPath, stamp + md);
|
|
244
|
+
process.stderr.write(`integration-map: wrote ${outPath}\n`);
|
|
245
|
+
return 0;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ESM "run as script" guard (Windows + POSIX safe via pathToFileURL).
|
|
249
|
+
const invokedDirectly =
|
|
250
|
+
process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
251
|
+
if (invokedDirectly) process.exit(main());
|