@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.
Files changed (43) hide show
  1. package/.claude-plugin/marketplace.json +6 -3
  2. package/.claude-plugin/plugin.json +5 -2
  3. package/CHANGELOG.md +129 -0
  4. package/README.md +22 -1
  5. package/SKILL.md +1 -0
  6. package/agents/design-integration-checker.md +1 -1
  7. package/agents/design-planner.md +1 -1
  8. package/agents/gdd-graph-refresh.md +90 -0
  9. package/bin/gdd-graph +261 -0
  10. package/connections/connections.md +10 -9
  11. package/connections/graphify.md +65 -54
  12. package/package.json +8 -3
  13. package/reference/capability-gap-stage-gate.md +7 -4
  14. package/reference/model-tiers.md +2 -2
  15. package/reference/start-interview.md +1 -1
  16. package/scripts/detect-stale-refs.cjs +6 -0
  17. package/scripts/lib/figma-extract/digest.cjs +430 -0
  18. package/scripts/lib/figma-extract/parse-url.cjs +87 -0
  19. package/scripts/lib/figma-extract/payload-schema.json +108 -0
  20. package/scripts/lib/figma-extract/pull.cjs +394 -0
  21. package/scripts/lib/figma-extract/receiver.cjs +273 -0
  22. package/scripts/lib/figma-extract/render-md.cjs +143 -0
  23. package/scripts/lib/figma-extract/styles-resolver.cjs +147 -0
  24. package/scripts/lib/figma-extract/walk.cjs +100 -0
  25. package/scripts/lib/graph/atomic-write.mjs +68 -0
  26. package/scripts/lib/graph/build.mjs +124 -0
  27. package/scripts/lib/graph/diff.mjs +90 -0
  28. package/scripts/lib/graph/index.mjs +14 -0
  29. package/scripts/lib/graph/query.mjs +155 -0
  30. package/scripts/lib/graph/schema.json +69 -0
  31. package/scripts/lib/graph/schema.mjs +47 -0
  32. package/scripts/lib/graph/status.mjs +88 -0
  33. package/scripts/lib/graph/token-estimate.mjs +27 -0
  34. package/scripts/lib/graph/upsert.mjs +210 -0
  35. package/scripts/lib/{gsd-health-mirror → health-mirror}/index.cjs +89 -2
  36. package/scripts/mcp-servers/gdd-mcp/tools/gdd_health.ts +3 -3
  37. package/skills/connections/connections-onboarding.md +6 -6
  38. package/skills/figma-extract/SKILL.md +64 -0
  39. package/skills/graphify/SKILL.md +11 -10
  40. package/skills/health/SKILL.md +10 -0
  41. package/skills/scan/scan-procedure.md +9 -8
  42. package/agents/gdd-graphify-sync.md +0 -110
  43. /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