@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,155 @@
|
|
|
1
|
+
// scripts/lib/graph/query.mjs — Plan 30.6-03 Task 1
|
|
2
|
+
//
|
|
3
|
+
// queryGraph: read .design/graph/graph.json, tokenize the query, score
|
|
4
|
+
// nodes per D-04.a (+100 exact-id / +50 exact-label / +20 seed / +10
|
|
5
|
+
// token-in-label / +5 token-in-type / +1 token-in-attrs), walk 1-hop
|
|
6
|
+
// outbound edges to attach neighbors, then enforce a token budget
|
|
7
|
+
// (D-04 chars/4 heuristic via token-estimate.mjs) by dropping
|
|
8
|
+
// lowest-scored matches until the payload fits.
|
|
9
|
+
//
|
|
10
|
+
// Deterministic: identical (graph, query, budget) inputs produce
|
|
11
|
+
// byte-identical JSON output. Ties broken by lexicographic id.
|
|
12
|
+
|
|
13
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
14
|
+
import { compileValidator } from './schema.mjs';
|
|
15
|
+
import { estimateTokens } from './token-estimate.mjs';
|
|
16
|
+
|
|
17
|
+
const DEFAULT_GRAPH = '.design/graph/graph.json';
|
|
18
|
+
const DEFAULT_BUDGET = 8000;
|
|
19
|
+
const DEFAULT_TOP_K = 10;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Query the native graph.
|
|
23
|
+
*
|
|
24
|
+
* @param {object} opts
|
|
25
|
+
* @param {string} [opts.graphPath] - default '.design/graph/graph.json'
|
|
26
|
+
* @param {string} opts.query - search term (lowercased, tokenized)
|
|
27
|
+
* @param {number} [opts.budget] - max estimated tokens for return payload
|
|
28
|
+
* @returns {{query:string, matches:Array<{node:object, score:number, neighbors:object[]}>, truncated:boolean}}
|
|
29
|
+
* @throws GRAPH_MISSING when graphPath does not exist
|
|
30
|
+
* @throws GRAPH_PARSE_FAILED when graph JSON cannot be parsed
|
|
31
|
+
* @throws GRAPH_SCHEMA_INVALID when graph fails schema validation
|
|
32
|
+
*/
|
|
33
|
+
export function queryGraph({
|
|
34
|
+
graphPath = DEFAULT_GRAPH,
|
|
35
|
+
query = '',
|
|
36
|
+
budget = DEFAULT_BUDGET,
|
|
37
|
+
} = {}) {
|
|
38
|
+
if (!existsSync(graphPath)) {
|
|
39
|
+
const err = new Error(`queryGraph: graph file not found at ${graphPath}`);
|
|
40
|
+
err.code = 'GRAPH_MISSING';
|
|
41
|
+
throw err;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let graph;
|
|
45
|
+
try {
|
|
46
|
+
graph = JSON.parse(readFileSync(graphPath, 'utf8'));
|
|
47
|
+
} catch (e) {
|
|
48
|
+
const err = new Error(
|
|
49
|
+
`queryGraph: failed to parse graph JSON at ${graphPath}: ${e.message}`,
|
|
50
|
+
);
|
|
51
|
+
err.code = 'GRAPH_PARSE_FAILED';
|
|
52
|
+
err.cause = e;
|
|
53
|
+
throw err;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const validate = compileValidator();
|
|
57
|
+
if (!validate(graph)) {
|
|
58
|
+
const err = new Error('queryGraph: graph failed schema validation');
|
|
59
|
+
err.code = 'GRAPH_SCHEMA_INVALID';
|
|
60
|
+
err.schemaErrors = validate.errors;
|
|
61
|
+
throw err;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ── Tokenize ────────────────────────────────────────────────────────
|
|
65
|
+
const lowerQuery = String(query).toLowerCase().trim();
|
|
66
|
+
const tokens = lowerQuery
|
|
67
|
+
.split(/[^a-z0-9:_\-/.]+/)
|
|
68
|
+
.filter((t) => t.length > 0);
|
|
69
|
+
|
|
70
|
+
// ── Empty / no-token query short-circuit ────────────────────────────
|
|
71
|
+
if (tokens.length === 0) {
|
|
72
|
+
return { query, matches: [], truncated: false };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── Score each node ─────────────────────────────────────────────────
|
|
76
|
+
// Identify seed nodes (label or id contains the full lowercase query) for the
|
|
77
|
+
// +20 seed bonus before per-token scoring.
|
|
78
|
+
const seedIds = new Set();
|
|
79
|
+
for (const n of graph.nodes) {
|
|
80
|
+
const idLower = String(n.id || '').toLowerCase();
|
|
81
|
+
const labelLower = String(n.label || '').toLowerCase();
|
|
82
|
+
if (
|
|
83
|
+
lowerQuery.length > 0 &&
|
|
84
|
+
(idLower.includes(lowerQuery) || labelLower.includes(lowerQuery))
|
|
85
|
+
) {
|
|
86
|
+
seedIds.add(n.id);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const scored = graph.nodes
|
|
91
|
+
.map((node) => ({ node, score: scoreNode(node, tokens, lowerQuery, seedIds) }))
|
|
92
|
+
.filter((m) => m.score > 0)
|
|
93
|
+
.sort((a, b) => b.score - a.score || a.node.id.localeCompare(b.node.id));
|
|
94
|
+
|
|
95
|
+
// ── Build adjacency for 1-hop outbound walk ─────────────────────────
|
|
96
|
+
const outbound = new Map();
|
|
97
|
+
for (const e of graph.edges) {
|
|
98
|
+
if (!outbound.has(e.from)) outbound.set(e.from, []);
|
|
99
|
+
outbound.get(e.from).push(e);
|
|
100
|
+
}
|
|
101
|
+
const nodeById = new Map(graph.nodes.map((n) => [n.id, n]));
|
|
102
|
+
|
|
103
|
+
// ── Assemble matches with neighbors ─────────────────────────────────
|
|
104
|
+
const candidates = scored.slice(0, DEFAULT_TOP_K).map(({ node, score }) => {
|
|
105
|
+
const outs = outbound.get(node.id) || [];
|
|
106
|
+
const neighbors = outs
|
|
107
|
+
.map((e) => nodeById.get(e.to))
|
|
108
|
+
.filter((n) => n !== undefined);
|
|
109
|
+
return { node, score, neighbors };
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// ── Budget-truncation loop ──────────────────────────────────────────
|
|
113
|
+
let payload = { query, matches: candidates.slice(), truncated: false };
|
|
114
|
+
while (
|
|
115
|
+
estimateTokens(payload) > budget &&
|
|
116
|
+
payload.matches.length > 0
|
|
117
|
+
) {
|
|
118
|
+
payload.matches.pop(); // drop lowest-score (sorted desc → tail = lowest)
|
|
119
|
+
}
|
|
120
|
+
if (estimateTokens(payload) > budget) {
|
|
121
|
+
payload = { query, matches: [], truncated: true };
|
|
122
|
+
} else if (payload.matches.length < candidates.length) {
|
|
123
|
+
payload.truncated = true;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return payload;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Score a node per D-04.a:
|
|
131
|
+
* +100 exact-id match (case-insensitive)
|
|
132
|
+
* +50 exact-label match
|
|
133
|
+
* +20 seed bonus (node contained the full query as substring)
|
|
134
|
+
* +10 per token substring-match in label
|
|
135
|
+
* +5 per token substring-match in type
|
|
136
|
+
* +1 per token substring-match in attrs (JSON-stringified, lowercased)
|
|
137
|
+
*/
|
|
138
|
+
function scoreNode(node, tokens, lowerQuery, seedIds) {
|
|
139
|
+
let score = 0;
|
|
140
|
+
const idLower = String(node.id || '').toLowerCase();
|
|
141
|
+
const labelLower = String(node.label || '').toLowerCase();
|
|
142
|
+
const typeLower = String(node.type || '').toLowerCase();
|
|
143
|
+
const attrsLower = JSON.stringify(node.attrs || {}).toLowerCase();
|
|
144
|
+
|
|
145
|
+
if (lowerQuery && idLower === lowerQuery) score += 100;
|
|
146
|
+
if (lowerQuery && labelLower === lowerQuery) score += 50;
|
|
147
|
+
if (seedIds.has(node.id)) score += 20;
|
|
148
|
+
|
|
149
|
+
for (const t of tokens) {
|
|
150
|
+
if (labelLower.includes(t)) score += 10;
|
|
151
|
+
if (typeLower.includes(t)) score += 5;
|
|
152
|
+
if (attrsLower.includes(t)) score += 1;
|
|
153
|
+
}
|
|
154
|
+
return score;
|
|
155
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
|
+
"$id": "https://get-design-done.example/schemas/graph.schema.json",
|
|
4
|
+
"title": ".design/graph/graph.json",
|
|
5
|
+
"description": "Native gdd-graph store. Schema version 1.0 — frozen at Phase 30.6 per D-03.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"required": ["schemaVersion", "metadata", "nodes", "edges"],
|
|
8
|
+
"additionalProperties": false,
|
|
9
|
+
"properties": {
|
|
10
|
+
"schemaVersion": {
|
|
11
|
+
"type": "string",
|
|
12
|
+
"const": "1.0",
|
|
13
|
+
"description": "Pinned to 1.0 at ship; future migrations bump explicitly (D-03)."
|
|
14
|
+
},
|
|
15
|
+
"metadata": {
|
|
16
|
+
"type": "object",
|
|
17
|
+
"required": ["generatedAt", "nodeCount", "edgeCount"],
|
|
18
|
+
"additionalProperties": true,
|
|
19
|
+
"properties": {
|
|
20
|
+
"generatedAt": { "type": "string", "description": "ISO 8601 timestamp" },
|
|
21
|
+
"intelSource": { "type": "string", "description": "Path to source intel file, e.g. .design/intel/graph.json" },
|
|
22
|
+
"nodeCount": { "type": "integer", "minimum": 0 },
|
|
23
|
+
"edgeCount": { "type": "integer", "minimum": 0 },
|
|
24
|
+
"builderVersion": { "type": "string", "description": "gdd-graph version that wrote this file" }
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"nodes": {
|
|
28
|
+
"type": "array",
|
|
29
|
+
"items": {
|
|
30
|
+
"type": "object",
|
|
31
|
+
"required": ["id", "type"],
|
|
32
|
+
"additionalProperties": true,
|
|
33
|
+
"properties": {
|
|
34
|
+
"id": { "type": "string", "minLength": 1, "description": "Stable globally-unique node ID" },
|
|
35
|
+
"type": { "type": "string", "description": "Node category (component, token, decision, etc.) — lenient enum per D-03" },
|
|
36
|
+
"label": { "type": "string", "description": "Human-readable display name" },
|
|
37
|
+
"attrs": { "type": "object", "additionalProperties": true, "description": "Free-form node attributes" },
|
|
38
|
+
"source": {
|
|
39
|
+
"oneOf": [
|
|
40
|
+
{ "type": "string", "description": "Source file path or origin marker" },
|
|
41
|
+
{ "type": "object", "additionalProperties": true, "description": "Structured source descriptor per D-03.a" }
|
|
42
|
+
]
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
"edges": {
|
|
48
|
+
"type": "array",
|
|
49
|
+
"items": {
|
|
50
|
+
"type": "object",
|
|
51
|
+
"required": ["from", "to", "kind"],
|
|
52
|
+
"additionalProperties": true,
|
|
53
|
+
"properties": {
|
|
54
|
+
"from": { "type": "string", "minLength": 1, "description": "Source node id (matches a nodes[].id)" },
|
|
55
|
+
"to": { "type": "string", "minLength": 1, "description": "Target node id" },
|
|
56
|
+
"kind": { "type": "string", "description": "Edge type per D-03.b" },
|
|
57
|
+
"weight": { "type": "number", "description": "Optional edge weight per D-03.c; used by query ranking + budget eviction" },
|
|
58
|
+
"attrs": { "type": "object", "additionalProperties": true },
|
|
59
|
+
"source": {
|
|
60
|
+
"oneOf": [
|
|
61
|
+
{ "type": "string" },
|
|
62
|
+
{ "type": "object", "additionalProperties": true }
|
|
63
|
+
]
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// scripts/lib/graph/schema.mjs — Plan 30.6-02 Task 1
|
|
2
|
+
//
|
|
3
|
+
// Ajv-compiled validator factory for .design/graph/graph.json (schema 1.0,
|
|
4
|
+
// frozen at Phase 30.6 per D-03). Compile-once + memoized so callers can
|
|
5
|
+
// invoke compileValidator() liberally without paying per-call cost.
|
|
6
|
+
|
|
7
|
+
import { readFileSync } from 'node:fs';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { dirname, join } from 'node:path';
|
|
10
|
+
import Ajv from 'ajv';
|
|
11
|
+
|
|
12
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
|
|
14
|
+
// Read schema via fs.readFileSync rather than `import ... with { type:'json' }`
|
|
15
|
+
// to keep this working on Node 22+ without flag tweaks across runtimes.
|
|
16
|
+
const schemaJson = JSON.parse(
|
|
17
|
+
readFileSync(join(__dirname, 'schema.json'), 'utf8'),
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
export const SCHEMA_VERSION = '1.0';
|
|
21
|
+
export const SCHEMA = schemaJson;
|
|
22
|
+
|
|
23
|
+
let _validator = null;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Returns a compiled Ajv validator function for the native graph schema.
|
|
27
|
+
* Memoized: subsequent calls return the same instance (no recompile).
|
|
28
|
+
*
|
|
29
|
+
* Strict mode is disabled so additionalProperties:true on nodes/edges is
|
|
30
|
+
* honored (forward-compat lenience per 30.6-01 RESEARCH.md).
|
|
31
|
+
*
|
|
32
|
+
* @returns {(payload: unknown) => boolean} validator with .errors after a failed call
|
|
33
|
+
*/
|
|
34
|
+
export function compileValidator() {
|
|
35
|
+
if (_validator) return _validator;
|
|
36
|
+
// Ajv ESM: the default export is the constructor.
|
|
37
|
+
const AjvCtor = Ajv.default || Ajv;
|
|
38
|
+
const ajv = new AjvCtor({ strict: false, allErrors: true });
|
|
39
|
+
_validator = ajv.compile(schemaJson);
|
|
40
|
+
return _validator;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Test-only hook: reset the memo so test runs can verify compile-once semantics
|
|
44
|
+
// without polluting other suites. Not part of the public CLI surface.
|
|
45
|
+
export function _resetValidatorMemoForTests() {
|
|
46
|
+
_validator = null;
|
|
47
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// scripts/lib/graph/status.mjs — Plan 30.6-02 Task 2
|
|
2
|
+
//
|
|
3
|
+
// statusGraph: read .design/graph/graph.json, return structured status JSON.
|
|
4
|
+
// Gracefully degrades when graph file is missing — returns
|
|
5
|
+
// {configured:false, exists:false} without throwing (the 6 status callsites
|
|
6
|
+
// probe before issuing other commands).
|
|
7
|
+
|
|
8
|
+
import { readFileSync, existsSync, statSync } from 'node:fs';
|
|
9
|
+
import { compileValidator } from './schema.mjs';
|
|
10
|
+
|
|
11
|
+
const DEFAULT_GRAPH = '.design/graph/graph.json';
|
|
12
|
+
const DEFAULT_INTEL = '.design/intel/graph.json';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Report status of the native graph store.
|
|
16
|
+
*
|
|
17
|
+
* @param {object} opts
|
|
18
|
+
* @param {string} [opts.graphPath] - default '.design/graph/graph.json'
|
|
19
|
+
* @param {string} [opts.intelPath] - default '.design/intel/graph.json' (used for staleness)
|
|
20
|
+
* @returns {object} structured status — see RESEARCH.md §Subcommand inventory
|
|
21
|
+
*/
|
|
22
|
+
export function statusGraph({
|
|
23
|
+
graphPath = DEFAULT_GRAPH,
|
|
24
|
+
intelPath = DEFAULT_INTEL,
|
|
25
|
+
} = {}) {
|
|
26
|
+
if (!existsSync(graphPath)) {
|
|
27
|
+
return { configured: false, exists: false };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let raw;
|
|
31
|
+
try {
|
|
32
|
+
raw = readFileSync(graphPath, 'utf8');
|
|
33
|
+
} catch (e) {
|
|
34
|
+
return {
|
|
35
|
+
configured: true,
|
|
36
|
+
exists: true,
|
|
37
|
+
schemaInvalid: true,
|
|
38
|
+
errors: [{ message: `read failed: ${e.message}` }],
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let graph;
|
|
43
|
+
try {
|
|
44
|
+
graph = JSON.parse(raw);
|
|
45
|
+
} catch (e) {
|
|
46
|
+
return {
|
|
47
|
+
configured: true,
|
|
48
|
+
exists: true,
|
|
49
|
+
schemaInvalid: true,
|
|
50
|
+
errors: [{ message: `parse failed: ${e.message}` }],
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const validate = compileValidator();
|
|
55
|
+
if (!validate(graph)) {
|
|
56
|
+
return {
|
|
57
|
+
configured: true,
|
|
58
|
+
exists: true,
|
|
59
|
+
schemaInvalid: true,
|
|
60
|
+
errors: validate.errors,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const lastBuiltAt = graph.metadata?.generatedAt ?? null;
|
|
65
|
+
|
|
66
|
+
let stale = false;
|
|
67
|
+
if (existsSync(intelPath) && lastBuiltAt) {
|
|
68
|
+
try {
|
|
69
|
+
const intelMtime = statSync(intelPath).mtimeMs;
|
|
70
|
+
const builtMs = Date.parse(lastBuiltAt);
|
|
71
|
+
if (Number.isFinite(builtMs) && intelMtime > builtMs) {
|
|
72
|
+
stale = true;
|
|
73
|
+
}
|
|
74
|
+
} catch {
|
|
75
|
+
// Stat failed — leave stale=false; not a hard error.
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
configured: true,
|
|
81
|
+
exists: true,
|
|
82
|
+
nodeCount: graph.nodes.length,
|
|
83
|
+
edgeCount: graph.edges.length,
|
|
84
|
+
schemaVersion: graph.schemaVersion,
|
|
85
|
+
lastBuiltAt,
|
|
86
|
+
stale,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// scripts/lib/graph/token-estimate.mjs — Plan 30.6-03 Task 1
|
|
2
|
+
//
|
|
3
|
+
// Token-budget heuristic per D-04: ceil(JSON.stringify(payload).length / 4).
|
|
4
|
+
// Crude approximation of the Anthropic tokenizer's chars-per-token ratio;
|
|
5
|
+
// overridable via GDD_GRAPH_TOKEN_FACTOR env var for test seams (D-04 +
|
|
6
|
+
// RESEARCH.md §Query algorithm).
|
|
7
|
+
//
|
|
8
|
+
// This module is the single place to swap heuristic for a real tokenizer
|
|
9
|
+
// (tiktoken, anthropic-tokenizer, etc.) if/when we want a more faithful
|
|
10
|
+
// budget calculation. All callers funnel through estimateTokens().
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Estimate the token count of a payload.
|
|
14
|
+
*
|
|
15
|
+
* @param {unknown} payload - string OR JSON-serializable value
|
|
16
|
+
* @returns {number} ceiling of chars/divisor; divisor = GDD_GRAPH_TOKEN_FACTOR
|
|
17
|
+
* (when finite, positive number) or 4 (default)
|
|
18
|
+
*/
|
|
19
|
+
export function estimateTokens(payload) {
|
|
20
|
+
const str = typeof payload === 'string' ? payload : JSON.stringify(payload);
|
|
21
|
+
// JSON.stringify can return undefined for symbol/function inputs — guard.
|
|
22
|
+
const safe = typeof str === 'string' ? str : '';
|
|
23
|
+
const envFactor = Number(process.env.GDD_GRAPH_TOKEN_FACTOR);
|
|
24
|
+
const divisor =
|
|
25
|
+
Number.isFinite(envFactor) && envFactor > 0 ? envFactor : 4;
|
|
26
|
+
return Math.ceil(safe.length / divisor);
|
|
27
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// scripts/lib/graph/upsert.mjs — Plan 30.6-03 Task 2
|
|
2
|
+
//
|
|
3
|
+
// upsertNode + upsertEdge: read existing graph.json (bootstrap empty
|
|
4
|
+
// schemaVersion-1.0 envelope if missing), apply mutation, schema-validate
|
|
5
|
+
// the full graph BEFORE atomic write (per D-03 + D-05). Referential
|
|
6
|
+
// integrity is enforced at the upsert layer (NOT in the JSON schema) per
|
|
7
|
+
// 30.6-02's schema design: edges must reference nodes that already exist.
|
|
8
|
+
//
|
|
9
|
+
// Idempotency contract: same (id) for nodes / same (from,to,kind) for
|
|
10
|
+
// edges → last-write-wins, no duplicate rows, action='updated'.
|
|
11
|
+
|
|
12
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
13
|
+
import { compileValidator, SCHEMA_VERSION } from './schema.mjs';
|
|
14
|
+
import { atomicWriteJson } from './atomic-write.mjs';
|
|
15
|
+
|
|
16
|
+
const DEFAULT_GRAPH = '.design/graph/graph.json';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Upsert a node into the graph (create if missing, replace by id if present).
|
|
20
|
+
*
|
|
21
|
+
* @param {object} opts
|
|
22
|
+
* @param {string} [opts.graphPath] - default '.design/graph/graph.json'
|
|
23
|
+
* @param {object} opts.node - { id, type, label?, attrs?, source? }
|
|
24
|
+
* @returns {{ok: true, action: 'created'|'updated', nodeCount: number}}
|
|
25
|
+
* @throws GDD_GRAPH_INVALID_NODE on missing/invalid id
|
|
26
|
+
* @throws GDD_GRAPH_SCHEMA_INVALID on schema-violating input
|
|
27
|
+
*/
|
|
28
|
+
export function upsertNode({ graphPath = DEFAULT_GRAPH, node } = {}) {
|
|
29
|
+
if (!node || typeof node !== 'object') {
|
|
30
|
+
const err = new Error('upsertNode: node parameter is required and must be an object');
|
|
31
|
+
err.code = 'GDD_GRAPH_INVALID_NODE';
|
|
32
|
+
throw err;
|
|
33
|
+
}
|
|
34
|
+
if (typeof node.id !== 'string' || node.id.length === 0) {
|
|
35
|
+
const err = new Error('upsertNode: node.id is required and must be a non-empty string');
|
|
36
|
+
err.code = 'GDD_GRAPH_INVALID_NODE';
|
|
37
|
+
throw err;
|
|
38
|
+
}
|
|
39
|
+
if (typeof node.type !== 'string' || node.type.length === 0) {
|
|
40
|
+
const err = new Error('upsertNode: node.type is required and must be a non-empty string');
|
|
41
|
+
err.code = 'GDD_GRAPH_INVALID_NODE';
|
|
42
|
+
throw err;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const graph = loadOrBootstrap(graphPath);
|
|
46
|
+
|
|
47
|
+
const idx = graph.nodes.findIndex((n) => n.id === node.id);
|
|
48
|
+
let action;
|
|
49
|
+
if (idx === -1) {
|
|
50
|
+
graph.nodes.push(node);
|
|
51
|
+
action = 'created';
|
|
52
|
+
} else {
|
|
53
|
+
graph.nodes[idx] = node;
|
|
54
|
+
action = 'updated';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
graph.metadata.nodeCount = graph.nodes.length;
|
|
58
|
+
graph.metadata.edgeCount = graph.edges.length;
|
|
59
|
+
|
|
60
|
+
validateOrThrow(graph, 'upsertNode');
|
|
61
|
+
atomicWriteJson(graphPath, graph);
|
|
62
|
+
|
|
63
|
+
return { ok: true, action, nodeCount: graph.nodes.length };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Upsert an edge into the graph. Identity = `${from}::${to}::${kind}`.
|
|
68
|
+
*
|
|
69
|
+
* Referential integrity: both `from` and `to` must reference existing nodes.
|
|
70
|
+
* Schema enforces only field-shape; the existence check is an upsert-layer
|
|
71
|
+
* concern (per 30.6-02 schema design + RESEARCH.md §upsert-edge contract).
|
|
72
|
+
*
|
|
73
|
+
* @param {object} opts
|
|
74
|
+
* @param {string} [opts.graphPath]
|
|
75
|
+
* @param {object} opts.edge - { from, to, kind, weight?, attrs?, source? }
|
|
76
|
+
* @returns {{ok: true, action: 'created'|'updated', edgeCount: number}}
|
|
77
|
+
* @throws GDD_GRAPH_INVALID_EDGE on missing required fields
|
|
78
|
+
* @throws GDD_GRAPH_MISSING when graph file does not exist (edges can't
|
|
79
|
+
* precede nodes; create at least one node first via upsertNode)
|
|
80
|
+
* @throws GDD_GRAPH_MISSING_ENDPOINT with missingEndpoints[] when from/to
|
|
81
|
+
* do not reference existing nodes
|
|
82
|
+
* @throws GDD_GRAPH_SCHEMA_INVALID when the mutated graph violates schema
|
|
83
|
+
*/
|
|
84
|
+
export function upsertEdge({ graphPath = DEFAULT_GRAPH, edge } = {}) {
|
|
85
|
+
if (!edge || typeof edge !== 'object') {
|
|
86
|
+
const err = new Error('upsertEdge: edge parameter is required and must be an object');
|
|
87
|
+
err.code = 'GDD_GRAPH_INVALID_EDGE';
|
|
88
|
+
throw err;
|
|
89
|
+
}
|
|
90
|
+
for (const field of ['from', 'to', 'kind']) {
|
|
91
|
+
if (typeof edge[field] !== 'string' || edge[field].length === 0) {
|
|
92
|
+
const err = new Error(
|
|
93
|
+
`upsertEdge: edge.${field} is required and must be a non-empty string`,
|
|
94
|
+
);
|
|
95
|
+
err.code = 'GDD_GRAPH_INVALID_EDGE';
|
|
96
|
+
throw err;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Edges cannot exist before nodes — file-missing is a hard error.
|
|
101
|
+
if (!existsSync(graphPath)) {
|
|
102
|
+
const err = new Error(
|
|
103
|
+
`upsertEdge: graph file not found at ${graphPath} — create at least one node first via upsertNode`,
|
|
104
|
+
);
|
|
105
|
+
err.code = 'GDD_GRAPH_MISSING';
|
|
106
|
+
throw err;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const graph = loadOrBootstrap(graphPath);
|
|
110
|
+
|
|
111
|
+
// Referential integrity check.
|
|
112
|
+
const nodeIds = new Set(graph.nodes.map((n) => n.id));
|
|
113
|
+
const missingEndpoints = [];
|
|
114
|
+
if (!nodeIds.has(edge.from)) missingEndpoints.push(edge.from);
|
|
115
|
+
if (!nodeIds.has(edge.to)) missingEndpoints.push(edge.to);
|
|
116
|
+
if (missingEndpoints.length) {
|
|
117
|
+
const err = new Error(
|
|
118
|
+
`upsertEdge: missing endpoint node(s): ${missingEndpoints.join(', ')}`,
|
|
119
|
+
);
|
|
120
|
+
err.code = 'GDD_GRAPH_MISSING_ENDPOINT';
|
|
121
|
+
err.missingEndpoints = missingEndpoints;
|
|
122
|
+
throw err;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Edge identity per D-03.b + upstream diff formula: from::to::kind.
|
|
126
|
+
const idx = graph.edges.findIndex(
|
|
127
|
+
(e) => e.from === edge.from && e.to === edge.to && e.kind === edge.kind,
|
|
128
|
+
);
|
|
129
|
+
let action;
|
|
130
|
+
if (idx === -1) {
|
|
131
|
+
graph.edges.push(edge);
|
|
132
|
+
action = 'created';
|
|
133
|
+
} else {
|
|
134
|
+
graph.edges[idx] = edge;
|
|
135
|
+
action = 'updated';
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
graph.metadata.nodeCount = graph.nodes.length;
|
|
139
|
+
graph.metadata.edgeCount = graph.edges.length;
|
|
140
|
+
|
|
141
|
+
validateOrThrow(graph, 'upsertEdge');
|
|
142
|
+
atomicWriteJson(graphPath, graph);
|
|
143
|
+
|
|
144
|
+
return { ok: true, action, edgeCount: graph.edges.length };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ────────────────────────── helpers ──────────────────────────
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Load graph from disk, or bootstrap a schema-1.0 envelope if missing.
|
|
151
|
+
* Throws GDD_GRAPH_PARSE_FAILED on JSON parse error (manual edit broke it).
|
|
152
|
+
*/
|
|
153
|
+
function loadOrBootstrap(graphPath) {
|
|
154
|
+
if (!existsSync(graphPath)) {
|
|
155
|
+
return {
|
|
156
|
+
schemaVersion: SCHEMA_VERSION,
|
|
157
|
+
metadata: {
|
|
158
|
+
generatedAt: new Date().toISOString(),
|
|
159
|
+
nodeCount: 0,
|
|
160
|
+
edgeCount: 0,
|
|
161
|
+
builderVersion: '1.30.6',
|
|
162
|
+
},
|
|
163
|
+
nodes: [],
|
|
164
|
+
edges: [],
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
try {
|
|
169
|
+
const graph = JSON.parse(readFileSync(graphPath, 'utf8'));
|
|
170
|
+
// Defensive: ensure metadata/nodes/edges containers exist (a hand-edited
|
|
171
|
+
// file may have shed them — upsert should not crash before the
|
|
172
|
+
// validator can flag the corruption).
|
|
173
|
+
if (!graph.metadata) graph.metadata = { generatedAt: new Date().toISOString(), nodeCount: 0, edgeCount: 0 };
|
|
174
|
+
if (!Array.isArray(graph.nodes)) graph.nodes = [];
|
|
175
|
+
if (!Array.isArray(graph.edges)) graph.edges = [];
|
|
176
|
+
return graph;
|
|
177
|
+
} catch (e) {
|
|
178
|
+
const err = new Error(
|
|
179
|
+
`upsert: failed to parse graph JSON at ${graphPath}: ${e.message}`,
|
|
180
|
+
);
|
|
181
|
+
err.code = 'GDD_GRAPH_PARSE_FAILED';
|
|
182
|
+
err.cause = e;
|
|
183
|
+
throw err;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Validate full graph against schema 1.0 before write.
|
|
189
|
+
* Distinguish input-shape errors (caller passed a bad node/edge) from
|
|
190
|
+
* GDD_GRAPH_INVALID_{NODE,EDGE} (which are pre-write field-presence checks).
|
|
191
|
+
*/
|
|
192
|
+
function validateOrThrow(graph, op) {
|
|
193
|
+
const validate = compileValidator();
|
|
194
|
+
if (!validate(graph)) {
|
|
195
|
+
// Map Ajv path errors back to actionable codes when possible.
|
|
196
|
+
const hasNodeError = (validate.errors || []).some((e) =>
|
|
197
|
+
String(e.instancePath || '').startsWith('/nodes'),
|
|
198
|
+
);
|
|
199
|
+
const hasEdgeError = (validate.errors || []).some((e) =>
|
|
200
|
+
String(e.instancePath || '').startsWith('/edges'),
|
|
201
|
+
);
|
|
202
|
+
let code = 'GDD_GRAPH_SCHEMA_INVALID';
|
|
203
|
+
if (op === 'upsertNode' && hasNodeError) code = 'GDD_GRAPH_INVALID_NODE';
|
|
204
|
+
if (op === 'upsertEdge' && hasEdgeError) code = 'GDD_GRAPH_INVALID_EDGE';
|
|
205
|
+
const err = new Error(`${op}: graph failed schema validation`);
|
|
206
|
+
err.code = code;
|
|
207
|
+
err.schemaErrors = validate.errors;
|
|
208
|
+
throw err;
|
|
209
|
+
}
|
|
210
|
+
}
|