@hegemonart/get-design-done 1.51.0 → 1.53.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 (58) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +96 -0
  4. package/README.md +4 -0
  5. package/SKILL.md +2 -0
  6. package/agents/a11y-mapper.md +30 -1
  7. package/agents/component-taxonomy-mapper.md +30 -1
  8. package/agents/design-context-reviewer-gate.md +102 -0
  9. package/agents/design-context-reviewer.md +186 -0
  10. package/agents/design-debt-crawler.md +60 -60
  11. package/agents/design-research-synthesizer.md +27 -1
  12. package/agents/motion-mapper.md +35 -13
  13. package/agents/token-mapper.md +30 -1
  14. package/agents/visual-hierarchy-mapper.md +30 -1
  15. package/dist/claude-code/.claude/skills/context/SKILL.md +137 -0
  16. package/dist/claude-code/.claude/skills/discover/SKILL.md +7 -1
  17. package/dist/claude-code/.claude/skills/explore/SKILL.md +3 -1
  18. package/dist/claude-code/.claude/skills/migrate-context/SKILL.md +123 -0
  19. package/dist/claude-code/.claude/skills/progress/SKILL.md +4 -0
  20. package/package.json +3 -2
  21. package/reference/design-context-schema.md +159 -0
  22. package/reference/design-context-tag-vocab.md +82 -0
  23. package/reference/registry.json +14 -0
  24. package/reference/schemas/design-context.schema.json +130 -0
  25. package/reference/schemas/mcp-gdd-tools.schema.json +34 -1
  26. package/reference/skill-graph.md +3 -1
  27. package/scripts/lib/design-context/extract-a11y.mjs +188 -0
  28. package/scripts/lib/design-context/extract-components.mjs +243 -0
  29. package/scripts/lib/design-context/extract-motion.mjs +248 -0
  30. package/scripts/lib/design-context/extract-tokens.mjs +234 -0
  31. package/scripts/lib/design-context/extract-visual-hierarchy.mjs +178 -0
  32. package/scripts/lib/design-context/integration-map.mjs +251 -0
  33. package/scripts/lib/design-context/merge-fragments.mjs +227 -0
  34. package/scripts/lib/design-context-query.cjs +0 -0
  35. package/scripts/lib/explore-parallel-runner/index.ts +58 -0
  36. package/scripts/lib/explore-parallel-runner/types.ts +58 -0
  37. package/scripts/lib/manifest/skills.json +18 -2
  38. package/scripts/lib/mappers/compute-batches.mjs +625 -0
  39. package/scripts/lib/mappers/graph-adjacency.mjs +129 -0
  40. package/scripts/lib/mappers/incremental-discover.cjs +617 -0
  41. package/scripts/lib/mappers/incremental-discover.d.cts +133 -0
  42. package/scripts/lib/mappers/neighbor-map.mjs +0 -0
  43. package/scripts/lib/mcp-tools-lint/index.cjs +3 -1
  44. package/sdk/cli/index.js +369 -2
  45. package/sdk/fingerprint/classify.cjs +406 -0
  46. package/sdk/fingerprint/index.ts +405 -0
  47. package/sdk/fingerprint/store.cjs +523 -0
  48. package/sdk/index.ts +1 -0
  49. package/sdk/mcp/gdd-mcp/schemas/gdd_context_query.schema.json +60 -0
  50. package/sdk/mcp/gdd-mcp/server.js +474 -158
  51. package/sdk/mcp/gdd-mcp/server.ts +9 -5
  52. package/sdk/mcp/gdd-mcp/tools/gdd_context_query.ts +35 -0
  53. package/sdk/mcp/gdd-mcp/tools/index.ts +18 -13
  54. package/skills/context/SKILL.md +137 -0
  55. package/skills/discover/SKILL.md +7 -1
  56. package/skills/explore/SKILL.md +3 -1
  57. package/skills/migrate-context/SKILL.md +123 -0
  58. 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());