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,477 @@
|
|
|
1
|
+
// Deterministic extractor for DESIGN.md Pass 1 (Brand DNA + Do's/Don'ts).
|
|
2
|
+
// Source of truth: docs/graph/05-slice2-schema.md + protocols/design-md-authoring.md.
|
|
3
|
+
|
|
4
|
+
import YAML from "yaml";
|
|
5
|
+
import { ids, sha256Hex } from "../ids.js";
|
|
6
|
+
import type {
|
|
7
|
+
BrandDnaGuidelineNode,
|
|
8
|
+
BrandReferenceNode,
|
|
9
|
+
DesignDocRootNode,
|
|
10
|
+
DnaAxisNode,
|
|
11
|
+
ExtractError,
|
|
12
|
+
ExtractResult,
|
|
13
|
+
GraphEdge,
|
|
14
|
+
GraphFragment,
|
|
15
|
+
GraphNode,
|
|
16
|
+
} from "../types.js";
|
|
17
|
+
|
|
18
|
+
const PRODUCED_BY = "design-brand-guardian";
|
|
19
|
+
const PRODUCED_AT_STEP = "3.0";
|
|
20
|
+
const REQUIRED_AXES = ["scope", "density", "character", "material", "motion", "type", "copy"] as const;
|
|
21
|
+
type AxisName = (typeof REQUIRED_AXES)[number];
|
|
22
|
+
const AXIS_SET = new Set<string>(REQUIRED_AXES);
|
|
23
|
+
const AXIS_WORD_REGEXES: ReadonlyArray<{ axis: AxisName; re: RegExp }> = REQUIRED_AXES.map((axis) => ({
|
|
24
|
+
axis,
|
|
25
|
+
re: new RegExp(`\\b${axis}\\b`, "i"),
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
// --- Line / section helpers (mirrors product-spec.ts) ---
|
|
29
|
+
|
|
30
|
+
interface Line { n: number; text: string }
|
|
31
|
+
interface Section { heading: string; level: number; startLine: number; bodyLines: Line[] }
|
|
32
|
+
interface Ctx { mdPath: string; errors: ExtractError[]; nodes: GraphNode[]; edges: GraphEdge[] }
|
|
33
|
+
|
|
34
|
+
function loc(line: number): string { return `L${line}`; }
|
|
35
|
+
function pushError(ctx: Ctx, line: number, message: string): void { ctx.errors.push({ line, message }); }
|
|
36
|
+
|
|
37
|
+
function splitLines(content: string): Line[] {
|
|
38
|
+
return content.split(/\r?\n/).map((text, i) => ({ n: i + 1, text }));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isHeadingAtLevel(text: string, level: number): boolean {
|
|
42
|
+
const prefix = "#".repeat(level) + " ";
|
|
43
|
+
return text.startsWith(prefix) && text[level] !== "#";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isHeadingAtOrAbove(text: string, level: number): boolean {
|
|
47
|
+
for (let l = 1; l <= level; l++) if (isHeadingAtLevel(text, l)) return true;
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function partitionSections(lines: Line[], level: number, start: number, end: number): Section[] {
|
|
52
|
+
const sections: Section[] = [];
|
|
53
|
+
const prefix = "#".repeat(level) + " ";
|
|
54
|
+
let i = start;
|
|
55
|
+
while (i < end) {
|
|
56
|
+
const line = lines[i];
|
|
57
|
+
if (isHeadingAtLevel(line.text, level)) {
|
|
58
|
+
const heading = line.text.slice(prefix.length).trim();
|
|
59
|
+
let j = i + 1;
|
|
60
|
+
while (j < end && !isHeadingAtOrAbove(lines[j].text, level)) j++;
|
|
61
|
+
sections.push({ heading, level, startLine: line.n, bodyLines: lines.slice(i + 1, j) });
|
|
62
|
+
i = j;
|
|
63
|
+
} else {
|
|
64
|
+
i++;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return sections;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function findH3Sections(bodyLines: Line[]): Section[] {
|
|
71
|
+
const sections: Section[] = [];
|
|
72
|
+
let i = 0;
|
|
73
|
+
while (i < bodyLines.length) {
|
|
74
|
+
const line = bodyLines[i];
|
|
75
|
+
if (isHeadingAtLevel(line.text, 3)) {
|
|
76
|
+
const heading = line.text.slice(4).trim();
|
|
77
|
+
let j = i + 1;
|
|
78
|
+
while (j < bodyLines.length && !isHeadingAtOrAbove(bodyLines[j].text, 3)) j++;
|
|
79
|
+
sections.push({ heading, level: 3, startLine: line.n, bodyLines: bodyLines.slice(i + 1, j) });
|
|
80
|
+
i = j;
|
|
81
|
+
} else {
|
|
82
|
+
i++;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return sections;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function truncateLabel(text: string): string {
|
|
89
|
+
return text.length > 80 ? text.slice(0, 77) + "..." : text;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function makeEdge(
|
|
93
|
+
source: string, target: string, relation: GraphEdge["relation"],
|
|
94
|
+
sourceFile: string, sourceLoc?: string,
|
|
95
|
+
): GraphEdge {
|
|
96
|
+
return {
|
|
97
|
+
source, target, relation, confidence: "EXTRACTED",
|
|
98
|
+
source_file: sourceFile, source_location: sourceLoc,
|
|
99
|
+
produced_by_agent: PRODUCED_BY, produced_at_step: PRODUCED_AT_STEP,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// --- YAML frontmatter ---
|
|
104
|
+
|
|
105
|
+
interface FrontmatterResult {
|
|
106
|
+
name: string; description: string; yamlPass2Populated: boolean; endLine: number;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function parseFrontmatter(lines: Line[], ctx: Ctx): FrontmatterResult | null {
|
|
110
|
+
if (lines.length === 0 || lines[0].text.trim() !== "---") {
|
|
111
|
+
pushError(ctx, 1, "Missing YAML frontmatter (no opening `---` at line 1)");
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
let closeIdx = -1;
|
|
115
|
+
for (let i = 1; i < lines.length; i++) {
|
|
116
|
+
if (lines[i].text.trim() === "---") { closeIdx = i; break; }
|
|
117
|
+
}
|
|
118
|
+
if (closeIdx < 0) {
|
|
119
|
+
pushError(ctx, 1, "YAML frontmatter never closed (missing closing `---`)");
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
const yamlText = lines.slice(1, closeIdx).map((l) => l.text).join("\n");
|
|
123
|
+
let parsed: Record<string, unknown>;
|
|
124
|
+
try {
|
|
125
|
+
parsed = YAML.parse(yamlText) as Record<string, unknown>;
|
|
126
|
+
} catch (e: unknown) {
|
|
127
|
+
pushError(ctx, 1, `YAML parse error: ${e instanceof Error ? e.message : String(e)}`);
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
if (!parsed || typeof parsed !== "object") {
|
|
131
|
+
pushError(ctx, 1, "YAML frontmatter is not an object");
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
const name = typeof parsed["name"] === "string" ? parsed["name"].trim() : "";
|
|
135
|
+
if (!name) {
|
|
136
|
+
pushError(ctx, 1, "Missing required YAML key: `name`");
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
const description = typeof parsed["description"] === "string" ? parsed["description"].trim() : "";
|
|
140
|
+
const pass2Keys = ["colors", "typography", "rounded", "spacing", "components"];
|
|
141
|
+
const yamlPass2Populated = pass2Keys.some((k) => {
|
|
142
|
+
const v = parsed[k];
|
|
143
|
+
if (v === null || v === undefined) return false;
|
|
144
|
+
if (typeof v === "object" && Object.keys(v as object).length === 0) return false;
|
|
145
|
+
if (typeof v === "string" && v.trim() === "") return false;
|
|
146
|
+
return true;
|
|
147
|
+
});
|
|
148
|
+
return { name, description, yamlPass2Populated, endLine: lines[closeIdx].n + 1 };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// --- Overview helpers ---
|
|
152
|
+
|
|
153
|
+
function extractOverviewDescription(bodyLines: Line[]): string {
|
|
154
|
+
const parts: string[] = [];
|
|
155
|
+
for (const line of bodyLines) {
|
|
156
|
+
if (isHeadingAtLevel(line.text, 3)) break;
|
|
157
|
+
if (line.text.trim() === "") { if (parts.length > 0) break; continue; }
|
|
158
|
+
parts.push(line.text.trim());
|
|
159
|
+
}
|
|
160
|
+
return parts.join(" ");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// --- Brand DNA ---
|
|
164
|
+
|
|
165
|
+
interface AxisParsed { name: AxisName; value: string; rationale: string; line: number }
|
|
166
|
+
|
|
167
|
+
function parseBrandDna(section: Section, _ctx: Ctx): AxisParsed[] {
|
|
168
|
+
const axes: AxisParsed[] = [];
|
|
169
|
+
const bulletRe = /^\s*-\s+\*\*([^:*]+)\*?\*?:\*?\*?\s*(.*)$/;
|
|
170
|
+
const bodyLines = section.bodyLines;
|
|
171
|
+
|
|
172
|
+
for (let i = 0; i < bodyLines.length; i++) {
|
|
173
|
+
const line = bodyLines[i];
|
|
174
|
+
const m = line.text.match(bulletRe);
|
|
175
|
+
if (!m) continue;
|
|
176
|
+
const rawAxis = m[1].trim().toLowerCase();
|
|
177
|
+
if (!AXIS_SET.has(rawAxis)) continue;
|
|
178
|
+
|
|
179
|
+
const afterColon = m[2].trim();
|
|
180
|
+
let value: string;
|
|
181
|
+
let inlineRationale = "";
|
|
182
|
+
const dashIdx = afterColon.indexOf("\u2014"); // em-dash
|
|
183
|
+
if (dashIdx >= 0) {
|
|
184
|
+
value = afterColon.slice(0, dashIdx).trim();
|
|
185
|
+
inlineRationale = afterColon.slice(dashIdx + 1).trim();
|
|
186
|
+
} else {
|
|
187
|
+
value = afterColon;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const continuationParts: string[] = [];
|
|
191
|
+
let j = i + 1;
|
|
192
|
+
while (j < bodyLines.length) {
|
|
193
|
+
const nextTrimmed = bodyLines[j].text.trim();
|
|
194
|
+
if (nextTrimmed === "" || /^\s*-\s+\*\*/.test(bodyLines[j].text)) break;
|
|
195
|
+
continuationParts.push(nextTrimmed);
|
|
196
|
+
j++;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const rationale = [inlineRationale, ...continuationParts]
|
|
200
|
+
.filter((s) => s.length > 0).join(" ").trim();
|
|
201
|
+
|
|
202
|
+
axes.push({ name: rawAxis as AxisName, value, rationale, line: line.n });
|
|
203
|
+
}
|
|
204
|
+
return axes;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// --- Locked At ---
|
|
208
|
+
|
|
209
|
+
function parseLockedAt(section: Section | undefined): string {
|
|
210
|
+
if (!section) return "";
|
|
211
|
+
const isoRe = /\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}Z?)?/;
|
|
212
|
+
for (const line of section.bodyLines) {
|
|
213
|
+
const m = line.text.match(isoRe);
|
|
214
|
+
if (m) return m[0];
|
|
215
|
+
}
|
|
216
|
+
return "";
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// --- References ---
|
|
220
|
+
|
|
221
|
+
interface RefParsed { label: string; urlOrPath: string; exemplifiesAxes: string[]; line: number }
|
|
222
|
+
|
|
223
|
+
function parseReferences(section: Section | undefined): RefParsed[] {
|
|
224
|
+
if (!section) return [];
|
|
225
|
+
const refs: RefParsed[] = [];
|
|
226
|
+
const urlRe = /https?:\/\/[^)\s]+/;
|
|
227
|
+
|
|
228
|
+
for (const line of section.bodyLines) {
|
|
229
|
+
const bulletMatch = line.text.match(/^\s*-\s+(.+)$/);
|
|
230
|
+
if (!bulletMatch) continue;
|
|
231
|
+
const raw = bulletMatch[1].trim();
|
|
232
|
+
let url = "";
|
|
233
|
+
let label = "";
|
|
234
|
+
|
|
235
|
+
const mdLink = raw.match(/^\[([^\]]+)\]\((https?:\/\/[^)]+)\)/);
|
|
236
|
+
if (mdLink) {
|
|
237
|
+
label = mdLink[1].trim();
|
|
238
|
+
url = mdLink[2].trim();
|
|
239
|
+
} else {
|
|
240
|
+
const urlMatch = raw.match(urlRe);
|
|
241
|
+
if (urlMatch) {
|
|
242
|
+
url = urlMatch[0];
|
|
243
|
+
const beforeUrl = raw.slice(0, raw.indexOf(url));
|
|
244
|
+
label = beforeUrl.replace(/\(\s*$/, "").replace(/\s*\u2014.*$/, "").trim();
|
|
245
|
+
if (!label) label = url;
|
|
246
|
+
} else {
|
|
247
|
+
const dashIdx = raw.indexOf(" \u2014 ");
|
|
248
|
+
label = dashIdx >= 0 ? raw.slice(0, dashIdx).trim() : raw.trim();
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const urlOrPath = url || label;
|
|
253
|
+
const exemplifiesAxes = AXIS_WORD_REGEXES
|
|
254
|
+
.filter(({ re }) => re.test(raw))
|
|
255
|
+
.map(({ axis }) => axis as string)
|
|
256
|
+
.slice()
|
|
257
|
+
.sort();
|
|
258
|
+
refs.push({ label, urlOrPath, exemplifiesAxes, line: line.n });
|
|
259
|
+
}
|
|
260
|
+
return refs;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// --- Do's and Don'ts ---
|
|
264
|
+
|
|
265
|
+
interface GuidelineParsed { polarity: "do" | "dont"; text: string; axisScope: string | null; line: number }
|
|
266
|
+
|
|
267
|
+
function parseDosAndDonts(section: Section, _ctx: Ctx): GuidelineParsed[] {
|
|
268
|
+
const h3s = findH3Sections(section.bodyLines);
|
|
269
|
+
const hasPatternA = h3s.some(
|
|
270
|
+
(s) => /^do['\u2018\u2019]?s$/i.test(s.heading) || /^don['\u2018\u2019]?ts$/i.test(s.heading),
|
|
271
|
+
);
|
|
272
|
+
const guidelines: GuidelineParsed[] = [];
|
|
273
|
+
|
|
274
|
+
if (hasPatternA) {
|
|
275
|
+
for (const sub of h3s) {
|
|
276
|
+
const headingLower = sub.heading.toLowerCase().replace(/['\u2018\u2019]/g, "");
|
|
277
|
+
let polarity: "do" | "dont" | null = null;
|
|
278
|
+
if (/^dos$/.test(headingLower)) polarity = "do";
|
|
279
|
+
else if (/^donts$/.test(headingLower)) polarity = "dont";
|
|
280
|
+
if (!polarity) continue;
|
|
281
|
+
for (const line of sub.bodyLines) {
|
|
282
|
+
const m = line.text.match(/^\s*-\s+(.+)$/);
|
|
283
|
+
if (!m) continue;
|
|
284
|
+
guidelines.push({ polarity, text: m[1].trim(), axisScope: matchAxisScope(m[1]), line: line.n });
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
} else {
|
|
288
|
+
for (const line of section.bodyLines) {
|
|
289
|
+
const m = line.text.match(/^\s*-\s+(.+)$/);
|
|
290
|
+
if (!m) continue;
|
|
291
|
+
const raw = m[1].trim();
|
|
292
|
+
const parsed = classifyGuideline(raw);
|
|
293
|
+
if (!parsed) continue;
|
|
294
|
+
guidelines.push({ polarity: parsed.polarity, text: parsed.text, axisScope: matchAxisScope(raw), line: line.n });
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return guidelines;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function classifyGuideline(raw: string): { polarity: "do" | "dont"; text: string } | null {
|
|
301
|
+
if (/^don['\u2018\u2019]?t\s+/i.test(raw))
|
|
302
|
+
return { polarity: "dont", text: raw.replace(/^don['\u2018\u2019]?t\s+/i, "").trim() };
|
|
303
|
+
if (/^do\s+/i.test(raw))
|
|
304
|
+
return { polarity: "do", text: raw.replace(/^do\s+/i, "").trim() };
|
|
305
|
+
if (/^DON['\u2018\u2019]?T:\s*/i.test(raw))
|
|
306
|
+
return { polarity: "dont", text: raw.replace(/^DON['\u2018\u2019]?T:\s*/i, "").trim() };
|
|
307
|
+
if (/^DO:\s*/i.test(raw))
|
|
308
|
+
return { polarity: "do", text: raw.replace(/^DO:\s*/i, "").trim() };
|
|
309
|
+
if (raw.startsWith("\u2713") || raw.startsWith("\u2713"))
|
|
310
|
+
return { polarity: "do", text: raw.slice(1).trim() };
|
|
311
|
+
if (raw.startsWith("\u2717") || raw.startsWith("\u2717"))
|
|
312
|
+
return { polarity: "dont", text: raw.slice(1).trim() };
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function matchAxisScope(text: string): string | null {
|
|
317
|
+
const matches = AXIS_WORD_REGEXES.filter(({ re }) => re.test(text)).map(({ axis }) => axis as string);
|
|
318
|
+
return matches.length === 1 ? matches[0] : null;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// --- Pass 2 detection ---
|
|
322
|
+
|
|
323
|
+
const PASS2_HEADINGS = new Set(["colors", "typography", "layout", "elevation & depth", "shapes", "components"]);
|
|
324
|
+
const PLACEHOLDER_RE = /^\s*(<!--.*-->|_<placeholder>_|TBD|TODO)\s*$/i;
|
|
325
|
+
|
|
326
|
+
function isPass2SectionPopulated(section: Section): boolean {
|
|
327
|
+
for (const line of section.bodyLines) {
|
|
328
|
+
const trimmed = line.text.trim();
|
|
329
|
+
if (trimmed === "" || PLACEHOLDER_RE.test(trimmed)) continue;
|
|
330
|
+
return true;
|
|
331
|
+
}
|
|
332
|
+
return false;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// --- Determinism: stable sort (mirrors product-spec.ts) ---
|
|
336
|
+
|
|
337
|
+
function sortNodes(nodes: GraphNode[]): GraphNode[] {
|
|
338
|
+
return [...nodes].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function sortEdges(edges: GraphEdge[]): GraphEdge[] {
|
|
342
|
+
return [...edges].sort((a, b) => {
|
|
343
|
+
const k = (e: GraphEdge): string =>
|
|
344
|
+
`${e.relation} ${e.source} ${e.target} ${e.source_location ?? ""}`;
|
|
345
|
+
return k(a) < k(b) ? -1 : k(a) > k(b) ? 1 : 0;
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// --- Public entrypoint ---
|
|
350
|
+
|
|
351
|
+
export function extractDesignMd(input: { mdPath: string; mdContent: string }): ExtractResult {
|
|
352
|
+
const { mdPath, mdContent } = input;
|
|
353
|
+
const lines = splitLines(mdContent);
|
|
354
|
+
const ctx: Ctx = { mdPath, errors: [], nodes: [], edges: [] };
|
|
355
|
+
|
|
356
|
+
const fm = parseFrontmatter(lines, ctx);
|
|
357
|
+
if (!fm) return { ok: false, errors: ctx.errors };
|
|
358
|
+
|
|
359
|
+
const h2Sections = partitionSections(lines, 2, 0, lines.length);
|
|
360
|
+
|
|
361
|
+
// Overview (required)
|
|
362
|
+
const overviewSection =
|
|
363
|
+
h2Sections.find((s) => /^overview$/i.test(s.heading.trim())) ??
|
|
364
|
+
h2Sections.find((s) => /^brand\s*&\s*style$/i.test(s.heading.trim()));
|
|
365
|
+
if (!overviewSection) {
|
|
366
|
+
pushError(ctx, 1, "Missing required `## Overview` h2");
|
|
367
|
+
return { ok: false, errors: ctx.errors };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const description = fm.description || extractOverviewDescription(overviewSection.bodyLines);
|
|
371
|
+
|
|
372
|
+
// Brand DNA (required h3 inside Overview)
|
|
373
|
+
const overviewH3s = findH3Sections(overviewSection.bodyLines);
|
|
374
|
+
const brandDnaSections = overviewH3s.filter((s) => s.heading.toLowerCase() === "brand dna");
|
|
375
|
+
|
|
376
|
+
if (brandDnaSections.length === 0) {
|
|
377
|
+
pushError(ctx, overviewSection.startLine, "Missing required `### Brand DNA` h3 inside `## Overview`");
|
|
378
|
+
return { ok: false, errors: ctx.errors };
|
|
379
|
+
}
|
|
380
|
+
if (brandDnaSections.length > 1) {
|
|
381
|
+
pushError(ctx, brandDnaSections[1].startLine, "Duplicate `### Brand DNA` h3 inside `## Overview`");
|
|
382
|
+
return { ok: false, errors: ctx.errors };
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const axes = parseBrandDna(brandDnaSections[0], ctx);
|
|
386
|
+
const foundAxisNames = new Set(axes.map((a) => a.name));
|
|
387
|
+
for (const req of REQUIRED_AXES) {
|
|
388
|
+
if (!foundAxisNames.has(req)) pushError(ctx, brandDnaSections[0].startLine, `Missing required axis: ${req}`);
|
|
389
|
+
}
|
|
390
|
+
if (ctx.errors.length > 0) return { ok: false, errors: ctx.errors };
|
|
391
|
+
|
|
392
|
+
// Locked At (non-fatal if missing)
|
|
393
|
+
const lockedAt = parseLockedAt(overviewH3s.find((s) => s.heading.toLowerCase() === "locked at"));
|
|
394
|
+
|
|
395
|
+
// References (non-fatal if missing)
|
|
396
|
+
const refs = parseReferences(overviewH3s.find((s) => s.heading.toLowerCase() === "references"));
|
|
397
|
+
|
|
398
|
+
// Do's and Don'ts (required h2) — heading may use straight or curly apostrophes
|
|
399
|
+
const dosSection = h2Sections.find((s) =>
|
|
400
|
+
/^do['\u2018\u2019]?s\s+and\s+don['\u2018\u2019]?ts$/i.test(s.heading.trim()),
|
|
401
|
+
);
|
|
402
|
+
if (!dosSection) {
|
|
403
|
+
pushError(ctx, 1, "Missing required `## Do's and Don'ts` h2");
|
|
404
|
+
return { ok: false, errors: ctx.errors };
|
|
405
|
+
}
|
|
406
|
+
const guidelines = parseDosAndDonts(dosSection, ctx);
|
|
407
|
+
|
|
408
|
+
// Pass completeness
|
|
409
|
+
let pass2ProsePopulated = false;
|
|
410
|
+
for (const s of h2Sections) {
|
|
411
|
+
if (PASS2_HEADINGS.has(s.heading.toLowerCase()) && isPass2SectionPopulated(s)) {
|
|
412
|
+
pass2ProsePopulated = true;
|
|
413
|
+
break;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
const pass2 = fm.yamlPass2Populated || pass2ProsePopulated;
|
|
417
|
+
const pass1 = axes.length === REQUIRED_AXES.length
|
|
418
|
+
&& axes.every((a) => a.value.length > 0)
|
|
419
|
+
&& guidelines.length >= 4;
|
|
420
|
+
|
|
421
|
+
// --- Emit nodes and edges ---
|
|
422
|
+
const rootId = ids.designDocRoot();
|
|
423
|
+
const rootNode: DesignDocRootNode = {
|
|
424
|
+
id: rootId, label: fm.name, entity_type: "design_doc_root",
|
|
425
|
+
source_file: mdPath, source_location: "L1", confidence: "EXTRACTED",
|
|
426
|
+
name: fm.name, description, locked_at: lockedAt,
|
|
427
|
+
pass_complete: { pass1, pass2 },
|
|
428
|
+
};
|
|
429
|
+
ctx.nodes.push(rootNode);
|
|
430
|
+
|
|
431
|
+
for (const axis of axes) {
|
|
432
|
+
const axisId = ids.dnaAxis(axis.name);
|
|
433
|
+
const node: DnaAxisNode = {
|
|
434
|
+
id: axisId,
|
|
435
|
+
label: `${axis.name.charAt(0).toUpperCase() + axis.name.slice(1)}: ${axis.value}`,
|
|
436
|
+
entity_type: "dna_axis", source_file: mdPath, source_location: loc(axis.line),
|
|
437
|
+
confidence: "EXTRACTED", axis_name: axis.name, value: axis.value, rationale: axis.rationale,
|
|
438
|
+
};
|
|
439
|
+
ctx.nodes.push(node);
|
|
440
|
+
ctx.edges.push(makeEdge(rootId, axisId, "has_axis", mdPath, loc(axis.line)));
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
for (const ref of refs) {
|
|
444
|
+
const refId = ids.brandReference(ref.urlOrPath);
|
|
445
|
+
const node: BrandReferenceNode = {
|
|
446
|
+
id: refId, label: ref.label, entity_type: "brand_reference",
|
|
447
|
+
source_file: mdPath, source_location: loc(ref.line), confidence: "EXTRACTED",
|
|
448
|
+
url_or_path: ref.urlOrPath, exemplifies_axes: ref.exemplifiesAxes,
|
|
449
|
+
};
|
|
450
|
+
ctx.nodes.push(node);
|
|
451
|
+
for (const axisName of ref.exemplifiesAxes) {
|
|
452
|
+
ctx.edges.push(makeEdge(refId, ids.dnaAxis(axisName), "references_axis", mdPath, loc(ref.line)));
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
for (const g of guidelines) {
|
|
457
|
+
const gId = ids.dnaGuideline(g.polarity, g.text);
|
|
458
|
+
const node: BrandDnaGuidelineNode = {
|
|
459
|
+
id: gId, label: truncateLabel(g.text), entity_type: "brand_dna_guideline",
|
|
460
|
+
source_file: mdPath, source_location: loc(g.line), confidence: "EXTRACTED",
|
|
461
|
+
polarity: g.polarity, text: g.text, axis_scope: g.axisScope,
|
|
462
|
+
};
|
|
463
|
+
ctx.nodes.push(node);
|
|
464
|
+
// Slice 2 omits `forbids` edges — GraphEdge lacks edge-attribute fields for arbitrary strings.
|
|
465
|
+
if (g.axisScope) {
|
|
466
|
+
ctx.edges.push(makeEdge(gId, ids.dnaAxis(g.axisScope), "applies_to", mdPath, loc(g.line)));
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const fragment: GraphFragment = {
|
|
471
|
+
version: 1, schema: "buildanything-slice-2",
|
|
472
|
+
source_file: mdPath, source_sha: sha256Hex(mdContent),
|
|
473
|
+
produced_at: new Date().toISOString(),
|
|
474
|
+
nodes: sortNodes(ctx.nodes), edges: sortEdges(ctx.edges),
|
|
475
|
+
};
|
|
476
|
+
return { ok: true, fragment, errors: [] };
|
|
477
|
+
}
|