buildanything 2.0.0 → 2.1.2
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 +1 -1
- package/.claude-plugin/plugin.json +9 -1
- package/README.md +57 -61
- package/agents/a11y-architect.md +2 -0
- package/agents/briefing-officer.md +172 -0
- package/agents/business-model.md +14 -12
- package/agents/code-architect.md +6 -1
- package/agents/code-reviewer.md +3 -2
- package/agents/code-simplifier.md +12 -4
- package/agents/design-brand-guardian.md +19 -0
- package/agents/design-critic.md +16 -11
- package/agents/design-inclusive-visuals-specialist.md +2 -0
- package/agents/design-ui-designer.md +17 -0
- package/agents/design-ux-architect.md +15 -0
- package/agents/design-ux-researcher.md +102 -7
- package/agents/engineering-ai-engineer.md +2 -0
- package/agents/engineering-backend-architect.md +2 -0
- package/agents/engineering-data-engineer.md +2 -0
- package/agents/engineering-devops-automator.md +2 -0
- package/agents/engineering-frontend-developer.md +13 -0
- package/agents/engineering-mobile-app-builder.md +2 -0
- package/agents/engineering-rapid-prototyper.md +15 -2
- package/agents/engineering-security-engineer.md +2 -0
- package/agents/engineering-senior-developer.md +13 -0
- package/agents/engineering-sre.md +2 -0
- package/agents/engineering-technical-writer.md +2 -0
- package/agents/feature-intel.md +8 -7
- package/agents/ios-app-review-guardian.md +2 -0
- package/agents/ios-foundation-models-specialist.md +2 -0
- package/agents/ios-product-reality-auditor.md +292 -0
- package/agents/ios-storekit-specialist.md +2 -0
- package/agents/ios-swift-architect.md +1 -0
- package/agents/ios-swift-search.md +1 -0
- package/agents/ios-swift-ui-design.md +7 -4
- package/agents/marketing-app-store-optimizer.md +2 -0
- package/agents/planner.md +6 -1
- package/agents/pr-test-analyzer.md +3 -2
- package/agents/product-feedback-synthesizer.md +62 -0
- package/agents/product-owner.md +163 -0
- package/agents/product-reality-auditor.md +216 -0
- package/agents/product-spec-writer.md +176 -0
- package/agents/refactor-cleaner.md +9 -1
- package/agents/security-reviewer.md +2 -1
- package/agents/silent-failure-hunter.md +2 -1
- package/agents/swift-build-resolver.md +2 -0
- package/agents/swift-reviewer.md +2 -1
- package/agents/tech-feasibility.md +5 -3
- package/agents/testing-api-tester.md +2 -0
- package/agents/testing-evidence-collector.md +24 -0
- package/agents/testing-performance-benchmarker.md +2 -0
- package/agents/testing-reality-checker.md +2 -1
- package/agents/visual-research.md +7 -5
- package/bin/adapters/scribe-tool.ts +4 -2
- package/bin/adapters/write-lease-tool.ts +1 -1
- package/bin/buildanything-runtime.ts +20 -107
- package/bin/graph-index.js +24 -0
- package/bin/graph-index.ts +340 -0
- package/bin/mcp-servers/graph-mcp.js +26 -0
- package/bin/mcp-servers/graph-mcp.ts +481 -0
- package/bin/mcp-servers/orchestrator-mcp.js +26 -0
- package/bin/mcp-servers/orchestrator-mcp.ts +361 -0
- package/bin/setup.js +272 -111
- package/commands/build.md +424 -177
- package/commands/idea-sweep.md +2 -2
- package/commands/setup.md +15 -4
- package/commands/ux-review.md +3 -3
- package/commands/verify.md +3 -0
- package/docs/migration/phase-graph.yaml +573 -157
- package/hooks/design-md-lint +4 -0
- package/hooks/design-md-lint.ts +295 -0
- package/hooks/pre-tool-use.ts +37 -6
- package/hooks/record-mode-transitions.ts +63 -6
- package/hooks/subagent-start.ts +3 -2
- package/package.json +3 -1
- package/protocols/agent-prompt-authoring.md +165 -0
- package/protocols/architecture-schema.md +10 -3
- package/protocols/cleanup.md +4 -0
- package/protocols/decision-log.md +8 -4
- package/protocols/design-md-authoring.md +520 -0
- package/protocols/design-md-spec.md +362 -0
- package/protocols/fake-data-detector.md +1 -1
- package/protocols/ios-fake-data-detector.md +65 -0
- package/protocols/ios-phase-branches.md +128 -43
- package/protocols/launch-readiness.md +9 -5
- package/protocols/metric-loop.md +1 -1
- package/protocols/page-spec-schema.md +234 -0
- package/protocols/product-spec-schema.md +354 -0
- package/protocols/sprint-tasks-schema.md +53 -0
- package/protocols/state-schema.json +38 -3
- package/protocols/state-schema.md +32 -2
- package/protocols/verify.md +29 -1
- package/protocols/web-phase-branches.md +246 -76
- package/skills/ios/ios-bootstrap/SKILL.md +1 -1
- package/src/graph/ids.ts +86 -0
- package/src/graph/index.ts +32 -0
- package/src/graph/parser/architecture.ts +603 -0
- package/src/graph/parser/component-manifest.ts +268 -0
- package/src/graph/parser/decisions-jsonl.ts +407 -0
- package/src/graph/parser/design-md-pass2.ts +253 -0
- package/src/graph/parser/design-md.ts +477 -0
- package/src/graph/parser/page-spec.ts +496 -0
- package/src/graph/parser/product-spec.ts +930 -0
- package/src/graph/parser/screenshot.ts +342 -0
- package/src/graph/parser/sprint-tasks.ts +317 -0
- package/src/graph/storage/index.ts +1154 -0
- package/src/graph/types.ts +432 -0
- package/src/graph/util/dhash.ts +84 -0
- package/src/lrr/aggregator.ts +105 -10
- package/src/orchestrator/hooks/context-header.ts +34 -10
- package/src/orchestrator/hooks/token-accounting.ts +25 -14
- package/src/orchestrator/mcp/cycle-counter.ts +2 -1
- package/src/orchestrator/mcp/scribe.ts +27 -16
- package/src/orchestrator/mcp/write-lease.ts +30 -13
- package/src/orchestrator/phase4-shared-context.ts +20 -4
- package/protocols/visual-dna.md +0 -185
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
// Deterministic, schema-aware extractor for page-spec markdown files.
|
|
2
|
+
// Source of truth: docs/graph/07-slice3-schema.md.
|
|
3
|
+
// No LLM, no I/O. Caller passes the markdown content and path.
|
|
4
|
+
|
|
5
|
+
import { ids, kebab, sha256Hex } from "../ids.js";
|
|
6
|
+
import type {
|
|
7
|
+
ExtractError,
|
|
8
|
+
ExtractResult,
|
|
9
|
+
GraphEdge,
|
|
10
|
+
GraphFragment,
|
|
11
|
+
GraphNode,
|
|
12
|
+
KeyCopyNode,
|
|
13
|
+
PageSpecNode,
|
|
14
|
+
Relation,
|
|
15
|
+
ScreenComponentUseNode,
|
|
16
|
+
ScreenStateSlotNode,
|
|
17
|
+
WireframeSectionNode,
|
|
18
|
+
} from "../types.js";
|
|
19
|
+
|
|
20
|
+
const PRODUCED_BY = "design-ux-architect";
|
|
21
|
+
const PRODUCED_AT_STEP = "3.3";
|
|
22
|
+
|
|
23
|
+
interface Line {
|
|
24
|
+
n: number;
|
|
25
|
+
text: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface Section {
|
|
29
|
+
heading: string;
|
|
30
|
+
level: number;
|
|
31
|
+
startLine: number;
|
|
32
|
+
bodyLines: Line[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface Ctx {
|
|
36
|
+
mdPath: string;
|
|
37
|
+
errors: ExtractError[];
|
|
38
|
+
nodes: GraphNode[];
|
|
39
|
+
edges: GraphEdge[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function loc(line: number): string {
|
|
43
|
+
return `L${line}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function pushError(ctx: Ctx, line: number, message: string): void {
|
|
47
|
+
ctx.errors.push({ line, message });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function splitLines(content: string): Line[] {
|
|
51
|
+
return content.split(/\r?\n/).map((text, i) => ({ n: i + 1, text }));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function isHeadingAtLevel(text: string, level: number): boolean {
|
|
55
|
+
const prefix = "#".repeat(level) + " ";
|
|
56
|
+
if (!text.startsWith(prefix)) return false;
|
|
57
|
+
return text[level] !== "#";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function isHeadingAtOrAbove(text: string, level: number): boolean {
|
|
61
|
+
for (let l = 1; l <= level; l++) {
|
|
62
|
+
if (isHeadingAtLevel(text, l)) return true;
|
|
63
|
+
}
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function partitionSections(lines: Line[], level: number, start: number, end: number): Section[] {
|
|
68
|
+
const sections: Section[] = [];
|
|
69
|
+
const headingPrefix = "#".repeat(level) + " ";
|
|
70
|
+
let i = start;
|
|
71
|
+
while (i < end) {
|
|
72
|
+
const line = lines[i];
|
|
73
|
+
if (isHeadingAtLevel(line.text, level)) {
|
|
74
|
+
const heading = line.text.slice(headingPrefix.length).trim();
|
|
75
|
+
const bodyStart = i + 1;
|
|
76
|
+
let j = bodyStart;
|
|
77
|
+
while (j < end && !isHeadingAtOrAbove(lines[j].text, level)) j++;
|
|
78
|
+
sections.push({ heading, level, startLine: line.n, bodyLines: lines.slice(bodyStart, j) });
|
|
79
|
+
i = j;
|
|
80
|
+
} else {
|
|
81
|
+
i++;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return sections;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
interface TableRow {
|
|
88
|
+
cells: Record<string, string>;
|
|
89
|
+
line: number;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function parseTable(body: Line[]): { headers: string[]; rows: TableRow[] } | null {
|
|
93
|
+
const sepRe = /^\s*\|?\s*[-:| ]+\s*\|?\s*$/;
|
|
94
|
+
const significant = body.filter((l) => l.text.trim().length > 0);
|
|
95
|
+
let headerIdx = -1;
|
|
96
|
+
for (let i = 0; i < significant.length - 1; i++) {
|
|
97
|
+
if (
|
|
98
|
+
significant[i].text.includes("|") &&
|
|
99
|
+
significant[i + 1].text.includes("|") &&
|
|
100
|
+
sepRe.test(significant[i + 1].text)
|
|
101
|
+
) {
|
|
102
|
+
headerIdx = i;
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (headerIdx < 0) return null;
|
|
107
|
+
const headers = splitRow(significant[headerIdx].text).map((h) => h.toLowerCase());
|
|
108
|
+
const rows: TableRow[] = [];
|
|
109
|
+
for (let i = headerIdx + 2; i < significant.length; i++) {
|
|
110
|
+
const ln = significant[i];
|
|
111
|
+
if (!ln.text.includes("|")) break;
|
|
112
|
+
const cells = splitRow(ln.text);
|
|
113
|
+
if (cells.length === 0) continue;
|
|
114
|
+
const row: Record<string, string> = {};
|
|
115
|
+
for (let c = 0; c < headers.length; c++) {
|
|
116
|
+
row[headers[c]] = (cells[c] ?? "").trim();
|
|
117
|
+
}
|
|
118
|
+
rows.push({ cells: row, line: ln.n });
|
|
119
|
+
}
|
|
120
|
+
return { headers, rows };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function splitRow(text: string): string[] {
|
|
124
|
+
let s = text.trim();
|
|
125
|
+
if (s.startsWith("|")) s = s.slice(1);
|
|
126
|
+
if (s.endsWith("|")) s = s.slice(0, -1);
|
|
127
|
+
return s.split("|").map((c) => c.trim());
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function makeEdge(
|
|
131
|
+
ctx: Ctx,
|
|
132
|
+
source: string,
|
|
133
|
+
target: string,
|
|
134
|
+
relation: Relation,
|
|
135
|
+
line: number,
|
|
136
|
+
): GraphEdge {
|
|
137
|
+
return {
|
|
138
|
+
source,
|
|
139
|
+
target,
|
|
140
|
+
relation,
|
|
141
|
+
confidence: "EXTRACTED",
|
|
142
|
+
source_file: ctx.mdPath,
|
|
143
|
+
source_location: loc(line),
|
|
144
|
+
produced_by_agent: PRODUCED_BY,
|
|
145
|
+
produced_at_step: PRODUCED_AT_STEP,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function truncLabel(text: string): string {
|
|
150
|
+
return text.length > 80 ? text.slice(0, 77) + "..." : text;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function sortNodes(nodes: GraphNode[]): GraphNode[] {
|
|
154
|
+
return [...nodes].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function sortEdges(edges: GraphEdge[]): GraphEdge[] {
|
|
158
|
+
return [...edges].sort((a, b) => {
|
|
159
|
+
const k = (e: GraphEdge): string =>
|
|
160
|
+
`${e.relation} ${e.source} ${e.target} ${e.source_location ?? ""}`;
|
|
161
|
+
return k(a) < k(b) ? -1 : k(a) > k(b) ? 1 : 0;
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
// Section parsers
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
function findH2(sections: Section[], name: string): Section | undefined {
|
|
170
|
+
return sections.find((s) => s.heading.trim().toLowerCase() === name.toLowerCase());
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function parseRoute(section: Section | undefined): string | null {
|
|
174
|
+
if (!section) return null;
|
|
175
|
+
for (const line of section.bodyLines) {
|
|
176
|
+
const t = line.text.trim();
|
|
177
|
+
if (!t) continue;
|
|
178
|
+
const stripped = t.replace(/^`+|`+$/g, "").trim();
|
|
179
|
+
if (!stripped || /^n\/a$/i.test(stripped) || /^modal$/i.test(stripped)) return null;
|
|
180
|
+
return stripped;
|
|
181
|
+
}
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function parseWireframe(
|
|
186
|
+
ctx: Ctx,
|
|
187
|
+
section: Section,
|
|
188
|
+
screenName: string,
|
|
189
|
+
pageSpecId: string,
|
|
190
|
+
): string | null {
|
|
191
|
+
// Find fenced code block in bodyLines (including sub-headings like ### Desktop)
|
|
192
|
+
let inFence = false;
|
|
193
|
+
let fenceContent: string[] = [];
|
|
194
|
+
let foundFence = false;
|
|
195
|
+
for (const line of section.bodyLines) {
|
|
196
|
+
if (!inFence && /^\s*```/.test(line.text)) {
|
|
197
|
+
inFence = true;
|
|
198
|
+
foundFence = true;
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
if (inFence && /^\s*```/.test(line.text)) {
|
|
202
|
+
inFence = false;
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
if (inFence) {
|
|
206
|
+
fenceContent.push(line.text);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (!foundFence) {
|
|
210
|
+
pushError(ctx, section.startLine, `## ASCII Wireframe section has no fenced code block`);
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
const wireframeText = fenceContent.join("\n");
|
|
214
|
+
|
|
215
|
+
// Scan for [SectionName] markers
|
|
216
|
+
const markerRe = /\[([A-Z][A-Za-z0-9 :_-]*)\]/g;
|
|
217
|
+
const seen = new Set<string>();
|
|
218
|
+
const ordered: string[] = [];
|
|
219
|
+
let m: RegExpExecArray | null;
|
|
220
|
+
while ((m = markerRe.exec(wireframeText)) !== null) {
|
|
221
|
+
const name = m[1].trim();
|
|
222
|
+
if (!seen.has(name)) {
|
|
223
|
+
seen.add(name);
|
|
224
|
+
ordered.push(name);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
for (let i = 0; i < ordered.length; i++) {
|
|
229
|
+
const sectionName = ordered[i];
|
|
230
|
+
const node: WireframeSectionNode = {
|
|
231
|
+
id: ids.wireframeSection(screenName, sectionName, i),
|
|
232
|
+
label: sectionName,
|
|
233
|
+
entity_type: "wireframe_section",
|
|
234
|
+
source_file: ctx.mdPath,
|
|
235
|
+
source_location: loc(section.startLine),
|
|
236
|
+
confidence: "EXTRACTED",
|
|
237
|
+
section_name: sectionName,
|
|
238
|
+
parent_page_spec_id: pageSpecId,
|
|
239
|
+
order: i,
|
|
240
|
+
prose: "",
|
|
241
|
+
};
|
|
242
|
+
ctx.nodes.push(node);
|
|
243
|
+
ctx.edges.push(makeEdge(ctx, pageSpecId, node.id, "has_section", section.startLine));
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return wireframeText;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function parseContentHierarchy(ctx: Ctx, section: Section): string[] | null {
|
|
250
|
+
// Try table first
|
|
251
|
+
const table = parseTable(section.bodyLines);
|
|
252
|
+
if (table) {
|
|
253
|
+
const sectionCol = table.headers.find((h) => h === "section" || h === "section name");
|
|
254
|
+
if (sectionCol && table.rows.length > 0) {
|
|
255
|
+
const entries = table.rows.map((r) => r.cells[sectionCol]!.trim()).filter((s) => s);
|
|
256
|
+
if (entries.length > 0) return entries;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
// Try bulleted/numbered list
|
|
260
|
+
const listRe = /^\s*(?:\d+\.|[-*])\s+(.+)$/;
|
|
261
|
+
const entries: string[] = [];
|
|
262
|
+
for (const line of section.bodyLines) {
|
|
263
|
+
const m = line.text.match(listRe);
|
|
264
|
+
if (m) entries.push(m[1].trim());
|
|
265
|
+
}
|
|
266
|
+
if (entries.length > 0) return entries;
|
|
267
|
+
pushError(ctx, section.startLine, `## Content Hierarchy is empty`);
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function parseStates(
|
|
272
|
+
ctx: Ctx,
|
|
273
|
+
section: Section,
|
|
274
|
+
screenName: string,
|
|
275
|
+
screenId: string,
|
|
276
|
+
pageSpecId: string,
|
|
277
|
+
): void {
|
|
278
|
+
// Try table
|
|
279
|
+
const table = parseTable(section.bodyLines);
|
|
280
|
+
if (table && table.headers.includes("state") && table.headers.includes("appearance")) {
|
|
281
|
+
for (const row of table.rows) {
|
|
282
|
+
const stateName = (row.cells["state"] ?? "").trim();
|
|
283
|
+
const appearance = (row.cells["appearance"] ?? "").trim();
|
|
284
|
+
if (!stateName) continue;
|
|
285
|
+
emitStateSlot(ctx, screenName, screenId, pageSpecId, stateName, appearance, row.line);
|
|
286
|
+
}
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
// Try bullets
|
|
290
|
+
const bulletRe = /^\s*-\s+(?:\*\*(.+?)\*\*\s*(?:—|--|-|:)?\s*(.*)$|(.+?):\s+(.*)$)/;
|
|
291
|
+
for (const line of section.bodyLines) {
|
|
292
|
+
const m = line.text.match(bulletRe);
|
|
293
|
+
if (!m) continue;
|
|
294
|
+
const stateName = (m[1] ?? m[3] ?? "").trim();
|
|
295
|
+
const appearance = (m[2] ?? m[4] ?? "").trim();
|
|
296
|
+
if (!stateName) continue;
|
|
297
|
+
emitStateSlot(ctx, screenName, screenId, pageSpecId, stateName, appearance, line.n);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function emitStateSlot(
|
|
302
|
+
ctx: Ctx,
|
|
303
|
+
screenName: string,
|
|
304
|
+
screenId: string,
|
|
305
|
+
pageSpecId: string,
|
|
306
|
+
stateName: string,
|
|
307
|
+
appearance: string,
|
|
308
|
+
line: number,
|
|
309
|
+
): void {
|
|
310
|
+
const node: ScreenStateSlotNode = {
|
|
311
|
+
id: ids.screenStateSlot(screenName, stateName),
|
|
312
|
+
label: stateName,
|
|
313
|
+
entity_type: "screen_state_slot",
|
|
314
|
+
source_file: ctx.mdPath,
|
|
315
|
+
source_location: loc(line),
|
|
316
|
+
confidence: "EXTRACTED",
|
|
317
|
+
screen_id: screenId,
|
|
318
|
+
state_id: kebab(stateName),
|
|
319
|
+
appearance_text: appearance,
|
|
320
|
+
};
|
|
321
|
+
ctx.nodes.push(node);
|
|
322
|
+
ctx.edges.push(makeEdge(ctx, pageSpecId, node.id, "has_screen_state", line));
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function parseKeyCopy(
|
|
326
|
+
ctx: Ctx,
|
|
327
|
+
section: Section,
|
|
328
|
+
screenName: string,
|
|
329
|
+
screenId: string,
|
|
330
|
+
pageSpecId: string,
|
|
331
|
+
): boolean {
|
|
332
|
+
// Regex: capture text between first and last quote (straight or smart) on the line
|
|
333
|
+
const quoteRe = /^\s*-\s+(?:\*\*)?["\u201C\u2018](.+)["\u201D\u2019](?:\*\*)?\s*(?:\u2014|--|(?:-\s))\s*(.*)$/;
|
|
334
|
+
let count = 0;
|
|
335
|
+
for (const line of section.bodyLines) {
|
|
336
|
+
const m = line.text.match(quoteRe);
|
|
337
|
+
if (!m) continue;
|
|
338
|
+
const text = m[1].trim();
|
|
339
|
+
let placement = m[2].trim();
|
|
340
|
+
// Strip leading 'placement:' prefix
|
|
341
|
+
placement = placement.replace(/^placement:\s*/i, "").trim();
|
|
342
|
+
const node: KeyCopyNode = {
|
|
343
|
+
id: ids.keyCopy(screenName, text),
|
|
344
|
+
label: truncLabel(text),
|
|
345
|
+
entity_type: "key_copy",
|
|
346
|
+
source_file: ctx.mdPath,
|
|
347
|
+
source_location: loc(line.n),
|
|
348
|
+
confidence: "EXTRACTED",
|
|
349
|
+
screen_id: screenId,
|
|
350
|
+
text,
|
|
351
|
+
placement,
|
|
352
|
+
};
|
|
353
|
+
ctx.nodes.push(node);
|
|
354
|
+
ctx.edges.push(makeEdge(ctx, pageSpecId, node.id, "key_copy_on_screen", line.n));
|
|
355
|
+
count++;
|
|
356
|
+
}
|
|
357
|
+
if (count === 0) {
|
|
358
|
+
pushError(ctx, section.startLine, `## Key Copy yielded no parsed bullets`);
|
|
359
|
+
return false;
|
|
360
|
+
}
|
|
361
|
+
return true;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function parseComponentPicks(
|
|
365
|
+
ctx: Ctx,
|
|
366
|
+
section: Section,
|
|
367
|
+
screenName: string,
|
|
368
|
+
screenId: string,
|
|
369
|
+
pageSpecId: string,
|
|
370
|
+
): void {
|
|
371
|
+
const table = parseTable(section.bodyLines);
|
|
372
|
+
if (!table) return;
|
|
373
|
+
const slotCol = table.headers.find((h) => h === "manifest slot" || h === "slot");
|
|
374
|
+
const sectionCol = table.headers.find((h) => h === "section" || h === "section name");
|
|
375
|
+
if (!slotCol || !sectionCol) return;
|
|
376
|
+
const propsCol = table.headers.find((h) => h === "prop overrides" || h === "props" || h === "overrides");
|
|
377
|
+
for (const row of table.rows) {
|
|
378
|
+
const sectionName = (row.cells[sectionCol] ?? "").trim();
|
|
379
|
+
let rawSlot = (row.cells[slotCol] ?? "").trim();
|
|
380
|
+
// Strip surrounding backticks
|
|
381
|
+
rawSlot = rawSlot.replace(/^`+|`+$/g, "");
|
|
382
|
+
// Strip trailing italics parenthetical: *(...)*
|
|
383
|
+
rawSlot = rawSlot.replace(/\s*\*\([^)]*\)\*\s*$/, "").trim();
|
|
384
|
+
// Strip trailing plain parenthetical too
|
|
385
|
+
rawSlot = rawSlot.replace(/\s*\([^)]*\)\s*$/, "").trim();
|
|
386
|
+
const slot = kebab(rawSlot);
|
|
387
|
+
if (!slot || !sectionName) continue;
|
|
388
|
+
const propOverrides = propsCol ? (row.cells[propsCol] ?? "").trim() : "";
|
|
389
|
+
const node: ScreenComponentUseNode = {
|
|
390
|
+
id: ids.screenComponentUse(screenName, slot, sectionName),
|
|
391
|
+
label: `${slot} @ ${sectionName}`,
|
|
392
|
+
entity_type: "screen_component_use",
|
|
393
|
+
source_file: ctx.mdPath,
|
|
394
|
+
source_location: loc(row.line),
|
|
395
|
+
confidence: "EXTRACTED",
|
|
396
|
+
screen_id: screenId,
|
|
397
|
+
slot,
|
|
398
|
+
position_in_wireframe: sectionName,
|
|
399
|
+
prop_overrides: propOverrides,
|
|
400
|
+
};
|
|
401
|
+
ctx.nodes.push(node);
|
|
402
|
+
ctx.edges.push(makeEdge(ctx, pageSpecId, node.id, "slot_used_on_screen", row.line));
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// ---------------------------------------------------------------------------
|
|
407
|
+
// Public entrypoint
|
|
408
|
+
// ---------------------------------------------------------------------------
|
|
409
|
+
|
|
410
|
+
export function extractPageSpec(input: { mdPath: string; mdContent: string }): ExtractResult {
|
|
411
|
+
const { mdPath, mdContent } = input;
|
|
412
|
+
const lines = splitLines(mdContent);
|
|
413
|
+
const ctx: Ctx = { mdPath, errors: [], nodes: [], edges: [] };
|
|
414
|
+
|
|
415
|
+
// Parse h1: # Page: <Screen Name>
|
|
416
|
+
const h1 = lines.find((l) => isHeadingAtLevel(l.text, 1));
|
|
417
|
+
const h1Match = h1?.text.match(/^#\s+Page:\s+(.+)$/);
|
|
418
|
+
if (!h1 || !h1Match) {
|
|
419
|
+
pushError(ctx, h1?.n ?? 1, "Missing required h1: '# Page: <Screen Name>'");
|
|
420
|
+
return { ok: false, errors: ctx.errors };
|
|
421
|
+
}
|
|
422
|
+
const screenName = h1Match[1].trim();
|
|
423
|
+
const screenId = ids.screen(screenName);
|
|
424
|
+
const pageSpecId = ids.pageSpec(screenName);
|
|
425
|
+
|
|
426
|
+
const h2Sections = partitionSections(lines, 2, 0, lines.length);
|
|
427
|
+
|
|
428
|
+
// Route (optional)
|
|
429
|
+
const route = parseRoute(findH2(h2Sections, "Route"));
|
|
430
|
+
|
|
431
|
+
// ASCII Wireframe (required)
|
|
432
|
+
const wireframeSec = findH2(h2Sections, "ASCII Wireframe");
|
|
433
|
+
if (!wireframeSec) {
|
|
434
|
+
pushError(ctx, 1, "Missing required section: '## ASCII Wireframe'");
|
|
435
|
+
return { ok: false, errors: ctx.errors };
|
|
436
|
+
}
|
|
437
|
+
const wireframeText = parseWireframe(ctx, wireframeSec, screenName, pageSpecId);
|
|
438
|
+
if (wireframeText === null) return { ok: false, errors: ctx.errors };
|
|
439
|
+
|
|
440
|
+
// Content Hierarchy (required)
|
|
441
|
+
const hierarchySec = findH2(h2Sections, "Content Hierarchy");
|
|
442
|
+
if (!hierarchySec) {
|
|
443
|
+
pushError(ctx, 1, "Missing required section: '## Content Hierarchy'");
|
|
444
|
+
return { ok: false, errors: ctx.errors };
|
|
445
|
+
}
|
|
446
|
+
const contentHierarchy = parseContentHierarchy(ctx, hierarchySec);
|
|
447
|
+
if (!contentHierarchy) return { ok: false, errors: ctx.errors };
|
|
448
|
+
|
|
449
|
+
// States (optional)
|
|
450
|
+
const statesSec = findH2(h2Sections, "States");
|
|
451
|
+
if (statesSec) parseStates(ctx, statesSec, screenName, screenId, pageSpecId);
|
|
452
|
+
|
|
453
|
+
// Key Copy (required)
|
|
454
|
+
const keyCopySec = findH2(h2Sections, "Key Copy");
|
|
455
|
+
if (!keyCopySec) {
|
|
456
|
+
pushError(ctx, 1, "Missing required section: '## Key Copy'");
|
|
457
|
+
return { ok: false, errors: ctx.errors };
|
|
458
|
+
}
|
|
459
|
+
if (!parseKeyCopy(ctx, keyCopySec, screenName, screenId, pageSpecId)) {
|
|
460
|
+
return { ok: false, errors: ctx.errors };
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// Component Picks (optional)
|
|
464
|
+
const compSec = findH2(h2Sections, "Component Picks");
|
|
465
|
+
if (compSec) parseComponentPicks(ctx, compSec, screenName, screenId, pageSpecId);
|
|
466
|
+
|
|
467
|
+
// PageSpecNode
|
|
468
|
+
const pageNode: PageSpecNode = {
|
|
469
|
+
id: pageSpecId,
|
|
470
|
+
label: screenName,
|
|
471
|
+
entity_type: "page_spec",
|
|
472
|
+
source_file: mdPath,
|
|
473
|
+
source_location: loc(h1.n),
|
|
474
|
+
confidence: "EXTRACTED",
|
|
475
|
+
screen_id: screenId,
|
|
476
|
+
wireframe_text: wireframeText,
|
|
477
|
+
content_hierarchy: contentHierarchy,
|
|
478
|
+
route,
|
|
479
|
+
};
|
|
480
|
+
ctx.nodes.push(pageNode);
|
|
481
|
+
|
|
482
|
+
if (ctx.errors.length > 0) {
|
|
483
|
+
return { ok: false, errors: ctx.errors };
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const fragment: GraphFragment = {
|
|
487
|
+
version: 1,
|
|
488
|
+
schema: "buildanything-slice-3",
|
|
489
|
+
source_file: mdPath,
|
|
490
|
+
source_sha: sha256Hex(mdContent),
|
|
491
|
+
produced_at: new Date().toISOString(),
|
|
492
|
+
nodes: sortNodes(ctx.nodes),
|
|
493
|
+
edges: sortEdges(ctx.edges),
|
|
494
|
+
};
|
|
495
|
+
return { ok: true, fragment, errors: [] };
|
|
496
|
+
}
|