@hegemonart/get-design-done 1.30.5 → 1.31.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 +6 -3
- package/.claude-plugin/plugin.json +5 -2
- package/CHANGELOG.md +129 -0
- package/README.md +22 -1
- package/SKILL.md +1 -0
- package/agents/design-integration-checker.md +1 -1
- package/agents/design-planner.md +1 -1
- package/agents/gdd-graph-refresh.md +90 -0
- package/bin/gdd-graph +261 -0
- package/connections/connections.md +10 -9
- package/connections/graphify.md +65 -54
- package/package.json +8 -3
- package/reference/capability-gap-stage-gate.md +7 -4
- package/reference/model-tiers.md +2 -2
- package/reference/start-interview.md +1 -1
- package/scripts/detect-stale-refs.cjs +6 -0
- package/scripts/lib/figma-extract/digest.cjs +430 -0
- package/scripts/lib/figma-extract/parse-url.cjs +87 -0
- package/scripts/lib/figma-extract/payload-schema.json +108 -0
- package/scripts/lib/figma-extract/pull.cjs +394 -0
- package/scripts/lib/figma-extract/receiver.cjs +273 -0
- package/scripts/lib/figma-extract/render-md.cjs +143 -0
- package/scripts/lib/figma-extract/styles-resolver.cjs +147 -0
- package/scripts/lib/figma-extract/walk.cjs +100 -0
- package/scripts/lib/graph/atomic-write.mjs +68 -0
- package/scripts/lib/graph/build.mjs +124 -0
- package/scripts/lib/graph/diff.mjs +90 -0
- package/scripts/lib/graph/index.mjs +14 -0
- package/scripts/lib/graph/query.mjs +155 -0
- package/scripts/lib/graph/schema.json +69 -0
- package/scripts/lib/graph/schema.mjs +47 -0
- package/scripts/lib/graph/status.mjs +88 -0
- package/scripts/lib/graph/token-estimate.mjs +27 -0
- package/scripts/lib/graph/upsert.mjs +210 -0
- package/scripts/lib/{gsd-health-mirror → health-mirror}/index.cjs +89 -2
- package/scripts/mcp-servers/gdd-mcp/tools/gdd_health.ts +3 -3
- package/skills/connections/connections-onboarding.md +6 -6
- package/skills/figma-extract/SKILL.md +64 -0
- package/skills/graphify/SKILL.md +11 -10
- package/skills/health/SKILL.md +10 -0
- package/skills/scan/scan-procedure.md +9 -8
- package/agents/gdd-graphify-sync.md +0 -110
- /package/scripts/lib/{gsd-health-mirror → health-mirror}/index.d.cts +0 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* Plan 31-02 — productionized from spike 001 digest.mjs buildDesignMd().
|
|
4
|
+
*
|
|
5
|
+
* Deterministic DESIGN.md renderer with a STABLE section order:
|
|
6
|
+
* header (provenance) →
|
|
7
|
+
* ## Tokens (### Color, ### Typography, ### Other — only when non-empty) →
|
|
8
|
+
* ## Components (Total line; sets first, then ### Singleton components) →
|
|
9
|
+
* ## Widgets / Pages
|
|
10
|
+
*
|
|
11
|
+
* Determinism guarantee: identical {tokens, components, widgets, fileMeta} input
|
|
12
|
+
* produces BYTE-IDENTICAL output. The ONLY nondeterministic value is the
|
|
13
|
+
* provenance line's `fetched_at`, which the caller injects (tests pass a fixed
|
|
14
|
+
* value). This module NEVER calls new Date()/Date.now() — required for 31-10's
|
|
15
|
+
* golden-snapshot baseline.
|
|
16
|
+
*
|
|
17
|
+
* Pure CommonJS, no external deps, no I/O.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
// Size-bounding slice caps (carried over verbatim from the spike for parity).
|
|
21
|
+
const CAP_COLOR = 200;
|
|
22
|
+
const CAP_TYPOGRAPHY = 100;
|
|
23
|
+
const CAP_OTHER = 100;
|
|
24
|
+
const CAP_VARIANTS = 20;
|
|
25
|
+
const CAP_SINGLETONS = 100;
|
|
26
|
+
const CAP_WIDGETS = 50;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {object} input
|
|
30
|
+
* @param {Array} input.tokens assembled tokens [{name,type,collection?,modes?,value?,description?}]
|
|
31
|
+
* @param {Array} input.components from walk.cjs collectComponents().components
|
|
32
|
+
* @param {Array} input.widgets from walk.cjs collectComponents().widgets
|
|
33
|
+
* @param {object} input.fileMeta { file_key, fetched_at, name } — fetched_at is the only injected nondeterminism
|
|
34
|
+
* @returns {string} DESIGN.md body
|
|
35
|
+
*/
|
|
36
|
+
function renderDesignMd({ tokens, components, widgets, fileMeta }) {
|
|
37
|
+
const toks = Array.isArray(tokens) ? tokens : [];
|
|
38
|
+
const comps = Array.isArray(components) ? components : [];
|
|
39
|
+
const wids = Array.isArray(widgets) ? widgets : [];
|
|
40
|
+
const meta = fileMeta || {};
|
|
41
|
+
|
|
42
|
+
const colorTokens = toks.filter((t) => t.type === 'COLOR' || t.type === 'FILL');
|
|
43
|
+
const textTokens = toks.filter((t) => t.type === 'TEXT');
|
|
44
|
+
const otherTokens = toks.filter(
|
|
45
|
+
(t) => !['COLOR', 'FILL', 'TEXT'].includes(t.type)
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
const lines = [];
|
|
49
|
+
lines.push(`# DESIGN.md`);
|
|
50
|
+
lines.push(``);
|
|
51
|
+
lines.push(
|
|
52
|
+
`> Auto-generated from Figma file \`${meta.file_key}\` at ${meta.fetched_at}`
|
|
53
|
+
);
|
|
54
|
+
lines.push(`> Source: ${meta.name || 'Design system'}`);
|
|
55
|
+
lines.push(``);
|
|
56
|
+
|
|
57
|
+
// ── ## Tokens ──────────────────────────────────────────────────────────────
|
|
58
|
+
lines.push(`## Tokens`);
|
|
59
|
+
lines.push(``);
|
|
60
|
+
|
|
61
|
+
if (colorTokens.length) {
|
|
62
|
+
lines.push(`### Color`);
|
|
63
|
+
lines.push(``);
|
|
64
|
+
for (const t of colorTokens.slice(0, CAP_COLOR)) {
|
|
65
|
+
const modes = t.modes
|
|
66
|
+
? Object.entries(t.modes)
|
|
67
|
+
.map(([m, v]) => `${m}: ${typeof v === 'string' ? v : JSON.stringify(v)}`)
|
|
68
|
+
.join(' | ')
|
|
69
|
+
: JSON.stringify(t.value);
|
|
70
|
+
lines.push(`- \`${t.name}\` — ${modes}`);
|
|
71
|
+
}
|
|
72
|
+
lines.push(``);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (textTokens.length) {
|
|
76
|
+
lines.push(`### Typography`);
|
|
77
|
+
lines.push(``);
|
|
78
|
+
for (const t of textTokens.slice(0, CAP_TYPOGRAPHY)) {
|
|
79
|
+
const v = t.value || Object.values(t.modes || {})[0];
|
|
80
|
+
lines.push(`- \`${t.name}\` — ${typeof v === 'object' ? JSON.stringify(v) : v}`);
|
|
81
|
+
}
|
|
82
|
+
lines.push(``);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (otherTokens.length) {
|
|
86
|
+
lines.push(`### Other`);
|
|
87
|
+
lines.push(``);
|
|
88
|
+
for (const t of otherTokens.slice(0, CAP_OTHER)) {
|
|
89
|
+
lines.push(`- \`${t.name}\` (${t.type})`);
|
|
90
|
+
}
|
|
91
|
+
lines.push(``);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── ## Components ───────────────────────────────────────────────────────────
|
|
95
|
+
lines.push(`## Components`);
|
|
96
|
+
lines.push(``);
|
|
97
|
+
const sets = comps.filter((c) => c.type === 'COMPONENT_SET');
|
|
98
|
+
const singles = comps.filter((c) => c.type === 'COMPONENT');
|
|
99
|
+
lines.push(
|
|
100
|
+
`Total: ${sets.length} component sets + ${singles.length} singleton components`
|
|
101
|
+
);
|
|
102
|
+
lines.push(``);
|
|
103
|
+
|
|
104
|
+
for (const c of sets) {
|
|
105
|
+
lines.push(`### ${c.name}`);
|
|
106
|
+
if (c.description) lines.push(`> ${c.description}`);
|
|
107
|
+
if (c.variants && c.variants.length) {
|
|
108
|
+
lines.push(`Variants (${c.variants.length}):`);
|
|
109
|
+
for (const v of c.variants.slice(0, CAP_VARIANTS)) lines.push(`- ${v}`);
|
|
110
|
+
if (c.variants.length > CAP_VARIANTS) {
|
|
111
|
+
lines.push(`- … +${c.variants.length - CAP_VARIANTS} more`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (c.props && c.props.length) {
|
|
115
|
+
lines.push(`Props:`);
|
|
116
|
+
for (const p of c.props) {
|
|
117
|
+
const opts = p.options ? ` [${p.options.join(', ')}]` : '';
|
|
118
|
+
lines.push(`- \`${p.name}\` (${p.type})${opts} — default: \`${p.default}\``);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
lines.push(``);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (singles.length) {
|
|
125
|
+
lines.push(`### Singleton components`);
|
|
126
|
+
lines.push(``);
|
|
127
|
+
for (const c of singles.slice(0, CAP_SINGLETONS)) {
|
|
128
|
+
lines.push(`- \`${c.name}\``);
|
|
129
|
+
}
|
|
130
|
+
lines.push(``);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ── ## Widgets / Pages ──────────────────────────────────────────────────────
|
|
134
|
+
lines.push(`## Widgets / Pages`);
|
|
135
|
+
lines.push(``);
|
|
136
|
+
for (const w of wids.slice(0, CAP_WIDGETS)) {
|
|
137
|
+
lines.push(`- ${w.name} (\`${w.id}\`)`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return lines.join('\n');
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
module.exports = { renderDesignMd };
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Plan 31-03 — Path B of D-04 (three-path token extraction).
|
|
3
|
+
//
|
|
4
|
+
// Fixes spike 001's 0-tokens bug. The spike's digest.mjs extractTokensFromStyles
|
|
5
|
+
// (lines 96-132) looked up each /styles entry's `node_id` inside `file.document`
|
|
6
|
+
// and found nothing — because published-style SOURCE nodes are NOT serialized into
|
|
7
|
+
// the main document tree. They live in canvas frames that require a SEPARATE
|
|
8
|
+
// `/files/:key/nodes?ids=...` fetch. This module implements that missing second pass:
|
|
9
|
+
//
|
|
10
|
+
// step 1: read the /styles list (node_id + style_type + name) <- caller supplies
|
|
11
|
+
// step 2: GET /files/:key/nodes?ids=<comma-joined> to read real values <- injected fetcher
|
|
12
|
+
//
|
|
13
|
+
// Resolution priority within D-04: Variables > plugin sync > styles. Styles (this
|
|
14
|
+
// module) is the last-resort fallback for non-Enterprise, legacy-styles DSs.
|
|
15
|
+
//
|
|
16
|
+
// No direct network call lives here except inside the buildStylesResolver-bound
|
|
17
|
+
// fetcher; tests drive resolveStyleTokens fully offline via an injected fetchNodes.
|
|
18
|
+
|
|
19
|
+
// Chunk cap for /nodes?ids= requests. Figma limits URL length, so large style sets
|
|
20
|
+
// are split into batches of this size and the results merged.
|
|
21
|
+
const MAX_IDS_PER_REQUEST = 100;
|
|
22
|
+
|
|
23
|
+
const DEFAULT_API_BASE = 'https://api.figma.com/v1';
|
|
24
|
+
|
|
25
|
+
// rgb(0..1) channels → 2-hex; appends an alpha hex byte only when a < 1.
|
|
26
|
+
// Ported from spike 001 digest.mjs rgbToHex (lines 13-17) — keep value shape identical.
|
|
27
|
+
function rgbToHex({ r, g, b, a }) {
|
|
28
|
+
const to = (v) => Math.round((v || 0) * 255).toString(16).padStart(2, '0');
|
|
29
|
+
const hex = `#${to(r)}${to(g)}${to(b)}`;
|
|
30
|
+
return a !== undefined && a < 1 ? `${hex}${to(a)}` : hex;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Split an array into contiguous chunks of at most `size`.
|
|
34
|
+
function chunk(arr, size) {
|
|
35
|
+
const out = [];
|
|
36
|
+
for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Figma's /nodes response wraps each node under `.document`. Tolerate both the
|
|
41
|
+
// wrapped shape ({ document: <node> }) and a bare node, so the resolver is robust
|
|
42
|
+
// to either the live API or a flattened fixture.
|
|
43
|
+
function unwrapNode(entry) {
|
|
44
|
+
if (!entry) return undefined;
|
|
45
|
+
return entry.document !== undefined ? entry.document : entry;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Resolve a single style's value from its source node, by style_type.
|
|
49
|
+
// Returns undefined when the node lacks the data for that type (style is then skipped).
|
|
50
|
+
function resolveValue(styleType, node) {
|
|
51
|
+
if (!node) return undefined;
|
|
52
|
+
if (styleType === 'FILL') {
|
|
53
|
+
const fill = node.fills && node.fills[0];
|
|
54
|
+
if (fill && fill.color) return rgbToHex({ ...fill.color, a: fill.opacity });
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
if (styleType === 'TEXT') {
|
|
58
|
+
const st = node.style;
|
|
59
|
+
if (!st) return undefined;
|
|
60
|
+
return {
|
|
61
|
+
family: st.fontFamily,
|
|
62
|
+
weight: st.fontWeight,
|
|
63
|
+
size: st.fontSize,
|
|
64
|
+
lineHeight: st.lineHeightPx,
|
|
65
|
+
letterSpacing: st.letterSpacing,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
if (styleType === 'EFFECT') {
|
|
69
|
+
const eff = node.effects && node.effects[0];
|
|
70
|
+
return eff !== undefined ? eff : undefined;
|
|
71
|
+
}
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Core two-step resolver (Path B). Pure transform over injected data — no network.
|
|
76
|
+
// stylesList: the /styles response body
|
|
77
|
+
// ({ meta: { styles: [{ node_id, style_type, name, description }] } })
|
|
78
|
+
// fetchNodes: async (ids: string[]) => /nodes response body
|
|
79
|
+
// ({ nodes: { <id>: { document: <node> } | <node> } })
|
|
80
|
+
// Returns Array<{ name, type:'FILL'|'TEXT'|'EFFECT', value, description }>.
|
|
81
|
+
// FILL → value = hex string (rgb→hex, alpha-aware)
|
|
82
|
+
// TEXT → value = { family, weight, size, lineHeight, letterSpacing }
|
|
83
|
+
// EFFECT → value = the first effect object
|
|
84
|
+
// Returns [] when stylesList has no styles (fetchNodes is NOT called), or when every
|
|
85
|
+
// node lookup misses. A style whose node_id is absent from /nodes is skipped (graceful).
|
|
86
|
+
async function resolveStyleTokens({ stylesList, fetchNodes }) {
|
|
87
|
+
const styles = (stylesList && stylesList.meta && stylesList.meta.styles) || [];
|
|
88
|
+
if (styles.length === 0) return [];
|
|
89
|
+
if (typeof fetchNodes !== 'function') {
|
|
90
|
+
throw new TypeError('resolveStyleTokens: fetchNodes must be a function');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Step 2: batch the node_ids and fetch their real source nodes, merging into one map.
|
|
94
|
+
const ids = styles.map((s) => s.node_id).filter((id) => id != null);
|
|
95
|
+
const nodeMap = {};
|
|
96
|
+
for (const idChunk of chunk(ids, MAX_IDS_PER_REQUEST)) {
|
|
97
|
+
const body = await fetchNodes(idChunk);
|
|
98
|
+
const nodes = (body && body.nodes) || {};
|
|
99
|
+
for (const id of idChunk) {
|
|
100
|
+
const node = unwrapNode(nodes[id]);
|
|
101
|
+
if (node !== undefined) nodeMap[id] = node;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Map each style onto its resolved value. Skip styles whose node missed or whose
|
|
106
|
+
// node lacked the data for its type.
|
|
107
|
+
const out = [];
|
|
108
|
+
for (const s of styles) {
|
|
109
|
+
const node = nodeMap[s.node_id];
|
|
110
|
+
if (!node) continue;
|
|
111
|
+
const value = resolveValue(s.style_type, node);
|
|
112
|
+
if (value !== undefined) {
|
|
113
|
+
out.push({
|
|
114
|
+
name: s.name,
|
|
115
|
+
type: s.style_type,
|
|
116
|
+
value,
|
|
117
|
+
description: s.description || '',
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Bind a resolver to a live (fileKey, token, fetchImpl, apiBase) so digest.cjs can
|
|
125
|
+
// inject Path B. Returns an async fn(file, styles) — exactly the `stylesResolver(file, styles)`
|
|
126
|
+
// seam shape digest.cjs (31-02) calls. It ignores `file` (the document tree never holds
|
|
127
|
+
// the source nodes — that is the spike bug) and resolves `styles` via a /nodes fetcher.
|
|
128
|
+
// 31-07's SKILL wires this for live runs. The token is sent ONLY as the X-Figma-Token
|
|
129
|
+
// header and is NEVER logged or persisted (D-10).
|
|
130
|
+
function buildStylesResolver({ fileKey, token, fetchImpl, apiBase } = {}) {
|
|
131
|
+
const base = apiBase || DEFAULT_API_BASE;
|
|
132
|
+
const doFetch = fetchImpl || (typeof fetch !== 'undefined' ? fetch : undefined);
|
|
133
|
+
return async function stylesResolver(_file, styles) {
|
|
134
|
+
const fetchNodes = async (ids) => {
|
|
135
|
+
if (typeof doFetch !== 'function') {
|
|
136
|
+
throw new Error('buildStylesResolver: no fetch implementation available');
|
|
137
|
+
}
|
|
138
|
+
const url = `${base}/files/${fileKey}/nodes?ids=${ids.join(',')}`;
|
|
139
|
+
const res = await doFetch(url, { headers: { 'X-Figma-Token': token } });
|
|
140
|
+
if (!res.ok) throw new Error(`/nodes ${res.status}`);
|
|
141
|
+
return res.json();
|
|
142
|
+
};
|
|
143
|
+
return resolveStyleTokens({ stylesList: styles, fetchNodes });
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
module.exports = { resolveStyleTokens, buildStylesResolver, MAX_IDS_PER_REQUEST, rgbToHex };
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* Plan 31-02 — productionized from spike 001 digest.mjs walk() + summarizeWidgets().
|
|
4
|
+
*
|
|
5
|
+
* Node-tree walker with VARIANT ROLLUP (decision D-02, variant rollup default-on).
|
|
6
|
+
*
|
|
7
|
+
* The spike proved a naive walk inflates the component count ~16× (2,593 vs 167
|
|
8
|
+
* entries) because each COMPONENT_SET's variant children are counted as separate
|
|
9
|
+
* components. The fix — locked here as the non-optional default — is to SKIP the
|
|
10
|
+
* COMPONENT children of a COMPONENT_SET and record their names as a `variants[]`
|
|
11
|
+
* field on the parent set. A COMPONENT_SET with N variant children therefore
|
|
12
|
+
* yields exactly ONE component entry, not N (+1).
|
|
13
|
+
*
|
|
14
|
+
* Pure CommonJS, no external deps, no I/O, no network.
|
|
15
|
+
*
|
|
16
|
+
* Exports:
|
|
17
|
+
* walkDocument(node, ctx, parentIsSet) — low-level recursive helper (unit-testable)
|
|
18
|
+
* collectComponents(documentNode) — top-level entry over file.document
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Recursive tree walker. Mutates `ctx` in place.
|
|
23
|
+
*
|
|
24
|
+
* @param {object|null|undefined} node a Figma node (document/canvas/frame/component/…)
|
|
25
|
+
* @param {{components:Array, widgets:Array, depth:number}} ctx accumulator
|
|
26
|
+
* @param {boolean} [parentIsSet=false] true when the parent node is a COMPONENT_SET
|
|
27
|
+
*/
|
|
28
|
+
function walkDocument(node, ctx, parentIsSet = false) {
|
|
29
|
+
if (!node) return;
|
|
30
|
+
|
|
31
|
+
// Rollup core: a COMPONENT is only a standalone component when its parent is
|
|
32
|
+
// NOT a COMPONENT_SET. COMPONENT children of a set are variants — skipped here
|
|
33
|
+
// (they are recorded as variants[] on the parent set below).
|
|
34
|
+
const isStandaloneComponent = node.type === 'COMPONENT' && !parentIsSet;
|
|
35
|
+
|
|
36
|
+
if (node.type === 'COMPONENT_SET' || isStandaloneComponent) {
|
|
37
|
+
ctx.components.push({
|
|
38
|
+
id: node.id,
|
|
39
|
+
name: node.name,
|
|
40
|
+
type: node.type,
|
|
41
|
+
description: node.description || '',
|
|
42
|
+
// Variant names live on the set's children; standalone components have none.
|
|
43
|
+
variants:
|
|
44
|
+
node.type === 'COMPONENT_SET'
|
|
45
|
+
? (node.children || []).map((c) => c.name)
|
|
46
|
+
: undefined,
|
|
47
|
+
// componentPropertyDefinitions → flattened props. Figma suffixes prop keys
|
|
48
|
+
// with '#<id>' for uniqueness; strip it for the human-facing name.
|
|
49
|
+
props: node.componentPropertyDefinitions
|
|
50
|
+
? Object.entries(node.componentPropertyDefinitions).map(([k, v]) => ({
|
|
51
|
+
name: k.split('#')[0],
|
|
52
|
+
type: v.type,
|
|
53
|
+
default: v.defaultValue,
|
|
54
|
+
options: v.variantOptions,
|
|
55
|
+
}))
|
|
56
|
+
: undefined,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Top-level FRAMEs (depth 1 — direct children of a page/canvas) are widget /
|
|
61
|
+
// page candidates for downstream classification.
|
|
62
|
+
if (ctx.depth === 1 && node.type === 'FRAME') {
|
|
63
|
+
ctx.widgets.push({ id: node.id, name: node.name });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (node.children) {
|
|
67
|
+
ctx.depth++;
|
|
68
|
+
// Children of a COMPONENT_SET are variants — flag so they are not re-pushed.
|
|
69
|
+
const childParentIsSet = node.type === 'COMPONENT_SET';
|
|
70
|
+
for (const child of node.children) walkDocument(child, ctx, childParentIsSet);
|
|
71
|
+
ctx.depth--;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Collect components (with variant rollup) and top-level frames from a document.
|
|
77
|
+
*
|
|
78
|
+
* @param {object} documentNode file.document — has .children = pages (CANVAS nodes)
|
|
79
|
+
* @returns {{components:Array, widgets:Array}}
|
|
80
|
+
* components: Array<{ id, name, type:'COMPONENT_SET'|'COMPONENT', description,
|
|
81
|
+
* variants?:string[], props?:Array<{name,type,default,options}> }>
|
|
82
|
+
* widgets: Array<{ id, name }> — top-level FRAMEs (depth 1)
|
|
83
|
+
*/
|
|
84
|
+
function collectComponents(documentNode) {
|
|
85
|
+
const ctx = { components: [], widgets: [], depth: 0 };
|
|
86
|
+
if (!documentNode || !documentNode.children) {
|
|
87
|
+
return { components: ctx.components, widgets: ctx.widgets };
|
|
88
|
+
}
|
|
89
|
+
// Pages (CANVAS) sit at depth 0; their children are depth 1 — that's where
|
|
90
|
+
// top-level frames become widget candidates. Mirror the spike's depth handling
|
|
91
|
+
// by entering each page's children at depth 1.
|
|
92
|
+
for (const page of documentNode.children) {
|
|
93
|
+
if (!page || !page.children) continue;
|
|
94
|
+
ctx.depth = 1;
|
|
95
|
+
for (const child of page.children) walkDocument(child, ctx, false);
|
|
96
|
+
}
|
|
97
|
+
return { components: ctx.components, widgets: ctx.widgets };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
module.exports = { walkDocument, collectComponents };
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// scripts/lib/graph/atomic-write.mjs — Plan 30.6-02 Task 1
|
|
2
|
+
//
|
|
3
|
+
// Atomic JSON write seam per D-05: writeFile(tmp) + rename(tmp, target) in
|
|
4
|
+
// the SAME directory (Windows atomicity guarantee — fs.rename is only
|
|
5
|
+
// atomic across same-volume same-device renames). No proper-lockfile.
|
|
6
|
+
// Single-writer assumption for the design pipeline; revisit in Phase 41
|
|
7
|
+
// if multi-writer becomes a real need.
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
writeFileSync,
|
|
11
|
+
renameSync,
|
|
12
|
+
unlinkSync,
|
|
13
|
+
mkdirSync,
|
|
14
|
+
existsSync,
|
|
15
|
+
} from 'node:fs';
|
|
16
|
+
import { dirname, basename, join, resolve } from 'node:path';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Atomically write a JSON payload to `target` using the tmp+rename pattern.
|
|
20
|
+
*
|
|
21
|
+
* Guarantees:
|
|
22
|
+
* - Readers either see the previous file or the new file, never a
|
|
23
|
+
* partial write.
|
|
24
|
+
* - If rename fails, the tmp file is unlinked (no orphan tmp files).
|
|
25
|
+
* - Tmp file lives in the SAME directory as target (Windows-safe).
|
|
26
|
+
*
|
|
27
|
+
* @param {string} target - Absolute or repo-relative path to final file
|
|
28
|
+
* @param {unknown} payload - JSON-serializable value (stringified pretty 2-space)
|
|
29
|
+
*/
|
|
30
|
+
export function atomicWriteJson(target, payload) {
|
|
31
|
+
const parent = dirname(target);
|
|
32
|
+
const base = basename(target);
|
|
33
|
+
const tmp = join(
|
|
34
|
+
parent,
|
|
35
|
+
`.${base}.tmp.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}`,
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
// Defense per D-05: assert tmp is in same dir as target (cross-device
|
|
39
|
+
// rename is NOT atomic on Windows). Resolve both to normalize forward-
|
|
40
|
+
// vs back-slash separators on Windows so the comparison is path-shape-
|
|
41
|
+
// agnostic — string equality on dirname() outputs is fragile when the
|
|
42
|
+
// caller passes a POSIX-style path on Windows (`/tmp/foo`) and Node
|
|
43
|
+
// resolves it to a native-style temp dir (`C:\...\Temp\foo`).
|
|
44
|
+
if (resolve(dirname(tmp)) !== resolve(parent)) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
`atomicWriteJson invariant: tmp not in same dir as target (tmp=${tmp}, target=${target})`,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
mkdirSync(parent, { recursive: true });
|
|
51
|
+
|
|
52
|
+
const body = JSON.stringify(payload, null, 2) + '\n';
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
writeFileSync(tmp, body, 'utf8');
|
|
56
|
+
renameSync(tmp, target);
|
|
57
|
+
} catch (err) {
|
|
58
|
+
// Clean up orphan tmp file on failure (best-effort).
|
|
59
|
+
if (existsSync(tmp)) {
|
|
60
|
+
try {
|
|
61
|
+
unlinkSync(tmp);
|
|
62
|
+
} catch {
|
|
63
|
+
// Swallow cleanup errors — original throw takes precedence.
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
throw err;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// scripts/lib/graph/build.mjs — Plan 30.6-02 Task 2
|
|
2
|
+
//
|
|
3
|
+
// buildGraph: read .design/intel/graph.json, transform per RESEARCH.md
|
|
4
|
+
// intel→graph mapping, validate against schema 1.0, atomic-write to
|
|
5
|
+
// .design/graph/graph.json. Deterministic when `now` is passed (test seam).
|
|
6
|
+
|
|
7
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
8
|
+
import { compileValidator, SCHEMA_VERSION } from './schema.mjs';
|
|
9
|
+
import { atomicWriteJson } from './atomic-write.mjs';
|
|
10
|
+
|
|
11
|
+
const DEFAULT_INTEL = '.design/intel/graph.json';
|
|
12
|
+
const DEFAULT_OUT = '.design/graph/graph.json';
|
|
13
|
+
const DEFAULT_BUILDER_VERSION = '1.30.6';
|
|
14
|
+
const DEFAULT_SOURCE_MARKER = 'gdd-intel-store';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Build .design/graph/graph.json from a .design/intel/graph.json slice.
|
|
18
|
+
*
|
|
19
|
+
* @param {object} opts
|
|
20
|
+
* @param {string} [opts.intelPath] - default '.design/intel/graph.json'
|
|
21
|
+
* @param {string} [opts.outPath] - default '.design/graph/graph.json'
|
|
22
|
+
* @param {string} [opts.builderVersion] - default '1.30.6'
|
|
23
|
+
* @param {string} [opts.now] - ISO timestamp override (deterministic tests)
|
|
24
|
+
* @returns {{ok: true, nodeCount: number, edgeCount: number, outPath: string}}
|
|
25
|
+
* @throws on missing intel, parse failure, schema-invalid output
|
|
26
|
+
*/
|
|
27
|
+
export function buildGraph({
|
|
28
|
+
intelPath = DEFAULT_INTEL,
|
|
29
|
+
outPath = DEFAULT_OUT,
|
|
30
|
+
builderVersion = DEFAULT_BUILDER_VERSION,
|
|
31
|
+
now = undefined,
|
|
32
|
+
} = {}) {
|
|
33
|
+
if (!existsSync(intelPath)) {
|
|
34
|
+
const err = new Error(`buildGraph: intel file not found at ${intelPath}`);
|
|
35
|
+
err.code = 'INTEL_MISSING';
|
|
36
|
+
throw err;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let intel;
|
|
40
|
+
try {
|
|
41
|
+
intel = JSON.parse(readFileSync(intelPath, 'utf8'));
|
|
42
|
+
} catch (e) {
|
|
43
|
+
const err = new Error(
|
|
44
|
+
`buildGraph: failed to parse intel JSON at ${intelPath}: ${e.message}`,
|
|
45
|
+
);
|
|
46
|
+
err.code = 'INTEL_PARSE_FAILED';
|
|
47
|
+
err.cause = e;
|
|
48
|
+
throw err;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const nodes = (Array.isArray(intel.nodes) ? intel.nodes : []).map(
|
|
52
|
+
(n) => transformNode(n),
|
|
53
|
+
);
|
|
54
|
+
const edges = (Array.isArray(intel.edges) ? intel.edges : []).map(
|
|
55
|
+
(e) => transformEdge(e),
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
const payload = {
|
|
59
|
+
schemaVersion: SCHEMA_VERSION,
|
|
60
|
+
metadata: {
|
|
61
|
+
generatedAt: now ?? new Date().toISOString(),
|
|
62
|
+
intelSource: intelPath,
|
|
63
|
+
nodeCount: nodes.length,
|
|
64
|
+
edgeCount: edges.length,
|
|
65
|
+
builderVersion,
|
|
66
|
+
},
|
|
67
|
+
nodes,
|
|
68
|
+
edges,
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const validate = compileValidator();
|
|
72
|
+
if (!validate(payload)) {
|
|
73
|
+
const err = new Error('buildGraph: payload failed schema validation');
|
|
74
|
+
err.code = 'SCHEMA_INVALID';
|
|
75
|
+
err.schemaErrors = validate.errors;
|
|
76
|
+
throw err;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
atomicWriteJson(outPath, payload);
|
|
80
|
+
return {
|
|
81
|
+
ok: true,
|
|
82
|
+
nodeCount: nodes.length,
|
|
83
|
+
edgeCount: edges.length,
|
|
84
|
+
outPath,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Intel → graph node transform per RESEARCH.md §Intel → graph transformation.
|
|
90
|
+
* intel.name → graph.label; any extra fields land in attrs blob.
|
|
91
|
+
*/
|
|
92
|
+
function transformNode(n) {
|
|
93
|
+
if (!n || typeof n !== 'object') return n;
|
|
94
|
+
// Pull off named intel fields; spread rest into attrs (lenient passthrough).
|
|
95
|
+
const { id, type, name, label, attrs, source, ...rest } = n;
|
|
96
|
+
const out = { id, type };
|
|
97
|
+
// Honor explicit label first; fall back to intel.name; otherwise omit.
|
|
98
|
+
const labelOut = label ?? name;
|
|
99
|
+
if (labelOut !== undefined) out.label = labelOut;
|
|
100
|
+
// Merge intel-extra fields into attrs (existing attrs win).
|
|
101
|
+
const restKeys = Object.keys(rest);
|
|
102
|
+
if (attrs || restKeys.length) {
|
|
103
|
+
out.attrs = { ...rest, ...(attrs || {}) };
|
|
104
|
+
}
|
|
105
|
+
out.source = source ?? DEFAULT_SOURCE_MARKER;
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Intel → graph edge transform. Edges already use {from,to,kind} verbatim
|
|
111
|
+
* per D-03.b — pure passthrough plus attrs absorption.
|
|
112
|
+
*/
|
|
113
|
+
function transformEdge(e) {
|
|
114
|
+
if (!e || typeof e !== 'object') return e;
|
|
115
|
+
const { from, to, kind, weight, attrs, source, ...rest } = e;
|
|
116
|
+
const out = { from, to, kind };
|
|
117
|
+
if (typeof weight === 'number') out.weight = weight;
|
|
118
|
+
const restKeys = Object.keys(rest);
|
|
119
|
+
if (attrs || restKeys.length) {
|
|
120
|
+
out.attrs = { ...rest, ...(attrs || {}) };
|
|
121
|
+
}
|
|
122
|
+
out.source = source ?? DEFAULT_SOURCE_MARKER;
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// scripts/lib/graph/diff.mjs — Plan 30.6-02 Task 2
|
|
2
|
+
//
|
|
3
|
+
// diffGraph: compare two graph.json files, emit {addedNodes, removedNodes,
|
|
4
|
+
// changedNodes, addedEdges, removedEdges}. Node identity = .id; edge
|
|
5
|
+
// identity = `${from}::${to}::${kind}` per upstream-key formula.
|
|
6
|
+
|
|
7
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
8
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
9
|
+
import { compileValidator } from './schema.mjs';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Diff two graphs by file path.
|
|
13
|
+
*
|
|
14
|
+
* @param {object} opts
|
|
15
|
+
* @param {string} opts.fromPath - baseline graph path
|
|
16
|
+
* @param {string} opts.toPath - current graph path
|
|
17
|
+
* @returns {{addedNodes: any[], removedNodes: any[], changedNodes: Array<{id:string, before:any, after:any}>, addedEdges: any[], removedEdges: any[]}}
|
|
18
|
+
*/
|
|
19
|
+
export function diffGraph({ fromPath, toPath } = {}) {
|
|
20
|
+
if (!fromPath || !toPath) {
|
|
21
|
+
const err = new Error('diffGraph: fromPath and toPath are required');
|
|
22
|
+
err.code = 'DIFF_ARGS_MISSING';
|
|
23
|
+
throw err;
|
|
24
|
+
}
|
|
25
|
+
const from = readAndValidate(fromPath, 'fromPath');
|
|
26
|
+
const to = readAndValidate(toPath, 'toPath');
|
|
27
|
+
|
|
28
|
+
const fromNodeMap = new Map(from.nodes.map((n) => [n.id, n]));
|
|
29
|
+
const toNodeMap = new Map(to.nodes.map((n) => [n.id, n]));
|
|
30
|
+
|
|
31
|
+
const addedNodes = [];
|
|
32
|
+
const removedNodes = [];
|
|
33
|
+
const changedNodes = [];
|
|
34
|
+
|
|
35
|
+
for (const [id, after] of toNodeMap) {
|
|
36
|
+
if (!fromNodeMap.has(id)) {
|
|
37
|
+
addedNodes.push(after);
|
|
38
|
+
} else {
|
|
39
|
+
const before = fromNodeMap.get(id);
|
|
40
|
+
if (!isDeepStrictEqual(before, after)) {
|
|
41
|
+
changedNodes.push({ id, before, after });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
for (const [id, before] of fromNodeMap) {
|
|
46
|
+
if (!toNodeMap.has(id)) removedNodes.push(before);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const edgeKey = (e) => `${e.from}::${e.to}::${e.kind}`;
|
|
50
|
+
const fromEdgeMap = new Map(from.edges.map((e) => [edgeKey(e), e]));
|
|
51
|
+
const toEdgeMap = new Map(to.edges.map((e) => [edgeKey(e), e]));
|
|
52
|
+
|
|
53
|
+
const addedEdges = [];
|
|
54
|
+
const removedEdges = [];
|
|
55
|
+
for (const [k, e] of toEdgeMap) if (!fromEdgeMap.has(k)) addedEdges.push(e);
|
|
56
|
+
for (const [k, e] of fromEdgeMap) if (!toEdgeMap.has(k)) removedEdges.push(e);
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
addedNodes,
|
|
60
|
+
removedNodes,
|
|
61
|
+
changedNodes,
|
|
62
|
+
addedEdges,
|
|
63
|
+
removedEdges,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function readAndValidate(path, label) {
|
|
68
|
+
if (!existsSync(path)) {
|
|
69
|
+
const err = new Error(`diffGraph: ${label} not found at ${path}`);
|
|
70
|
+
err.code = 'DIFF_FILE_MISSING';
|
|
71
|
+
throw err;
|
|
72
|
+
}
|
|
73
|
+
let parsed;
|
|
74
|
+
try {
|
|
75
|
+
parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
76
|
+
} catch (e) {
|
|
77
|
+
const err = new Error(`diffGraph: ${label} parse failed: ${e.message}`);
|
|
78
|
+
err.code = 'DIFF_PARSE_FAILED';
|
|
79
|
+
err.cause = e;
|
|
80
|
+
throw err;
|
|
81
|
+
}
|
|
82
|
+
const validate = compileValidator();
|
|
83
|
+
if (!validate(parsed)) {
|
|
84
|
+
const err = new Error(`diffGraph: ${label} failed schema validation`);
|
|
85
|
+
err.code = 'DIFF_SCHEMA_INVALID';
|
|
86
|
+
err.schemaErrors = validate.errors;
|
|
87
|
+
throw err;
|
|
88
|
+
}
|
|
89
|
+
return parsed;
|
|
90
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// scripts/lib/graph/index.mjs — Plan 30.6-02 Task 2
|
|
2
|
+
//
|
|
3
|
+
// Barrel re-export for graph subcommand handlers. 30.6-03 layers query,
|
|
4
|
+
// upsertNode, upsertEdge on top of these exports + the schema/atomic-write
|
|
5
|
+
// foundation; 30.6-04 verifies the union decouples from upstream GSD.
|
|
6
|
+
|
|
7
|
+
export { buildGraph } from './build.mjs';
|
|
8
|
+
export { statusGraph } from './status.mjs';
|
|
9
|
+
export { diffGraph } from './diff.mjs';
|
|
10
|
+
export { compileValidator, SCHEMA_VERSION, SCHEMA } from './schema.mjs';
|
|
11
|
+
export { atomicWriteJson } from './atomic-write.mjs';
|
|
12
|
+
export { queryGraph } from './query.mjs'; // 30.6-03 Task 1
|
|
13
|
+
export { estimateTokens } from './token-estimate.mjs'; // 30.6-03 Task 1
|
|
14
|
+
export { upsertNode, upsertEdge } from './upsert.mjs'; // 30.6-03 Task 2
|