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,930 @@
|
|
|
1
|
+
// Deterministic, schema-aware extractor for product-spec.md.
|
|
2
|
+
// Source of truth: docs/graph/04-slice1-schema.md + protocols/product-spec-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
|
+
AcceptanceCriterionNode,
|
|
8
|
+
BusinessRuleNode,
|
|
9
|
+
ExtractError,
|
|
10
|
+
ExtractResult,
|
|
11
|
+
FailureModeNode,
|
|
12
|
+
FeatureNode,
|
|
13
|
+
GraphEdge,
|
|
14
|
+
GraphFragment,
|
|
15
|
+
GraphNode,
|
|
16
|
+
PersonaConstraintNode,
|
|
17
|
+
PersonaNode,
|
|
18
|
+
Relation,
|
|
19
|
+
ScreenNode,
|
|
20
|
+
StateNode,
|
|
21
|
+
TransitionNode,
|
|
22
|
+
} from "../types.js";
|
|
23
|
+
|
|
24
|
+
const PRODUCED_BY = "product-spec-writer";
|
|
25
|
+
const PRODUCED_AT_STEP = "1.6";
|
|
26
|
+
const META_STATE_NAMES = new Set([
|
|
27
|
+
"loading",
|
|
28
|
+
"empty",
|
|
29
|
+
"error",
|
|
30
|
+
"stale",
|
|
31
|
+
"offline",
|
|
32
|
+
"disabled",
|
|
33
|
+
"permission-denied",
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
interface Line {
|
|
37
|
+
n: number; // 1-based line number
|
|
38
|
+
text: string; // raw text, no trailing newline
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface Section {
|
|
42
|
+
heading: string; // e.g. "App Overview", "Feature: Checkout", "States"
|
|
43
|
+
level: number; // 2 = ##, 3 = ###
|
|
44
|
+
startLine: number; // line number of the heading itself
|
|
45
|
+
bodyLines: Line[]; // all lines until the next heading at <= same level
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface Ctx {
|
|
49
|
+
mdPath: string;
|
|
50
|
+
errors: ExtractError[];
|
|
51
|
+
nodes: GraphNode[];
|
|
52
|
+
edges: GraphEdge[];
|
|
53
|
+
personasByKey: Map<string, PersonaNode>; // canonical persona label → node
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function loc(line: number): string {
|
|
57
|
+
return `L${line}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function pushError(ctx: Ctx, line: number, message: string): void {
|
|
61
|
+
ctx.errors.push({ line, message });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function splitLines(content: string): Line[] {
|
|
65
|
+
const raw = content.split(/\r?\n/);
|
|
66
|
+
return raw.map((text, i) => ({ n: i + 1, text }));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Walk lines and produce sections at the requested heading levels (2 or 3).
|
|
70
|
+
// A section ends at the next heading of equal or shallower level.
|
|
71
|
+
function partitionSections(lines: Line[], level: number, start: number, end: number): Section[] {
|
|
72
|
+
const sections: Section[] = [];
|
|
73
|
+
const headingPrefix = "#".repeat(level) + " ";
|
|
74
|
+
let i = start;
|
|
75
|
+
while (i < end) {
|
|
76
|
+
const line = lines[i];
|
|
77
|
+
if (isHeadingAtLevel(line.text, level)) {
|
|
78
|
+
const heading = line.text.slice(headingPrefix.length).trim();
|
|
79
|
+
const bodyStart = i + 1;
|
|
80
|
+
let j = bodyStart;
|
|
81
|
+
while (j < end && !isHeadingAtOrAbove(lines[j].text, level)) j++;
|
|
82
|
+
sections.push({
|
|
83
|
+
heading,
|
|
84
|
+
level,
|
|
85
|
+
startLine: line.n,
|
|
86
|
+
bodyLines: lines.slice(bodyStart, j),
|
|
87
|
+
});
|
|
88
|
+
i = j;
|
|
89
|
+
} else {
|
|
90
|
+
i++;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return sections;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function isHeadingAtLevel(text: string, level: number): boolean {
|
|
97
|
+
const prefix = "#".repeat(level) + " ";
|
|
98
|
+
if (!text.startsWith(prefix)) return false;
|
|
99
|
+
// make sure it's exactly this level, not deeper
|
|
100
|
+
return text[level] !== "#";
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function isHeadingAtOrAbove(text: string, level: number): boolean {
|
|
104
|
+
for (let l = 1; l <= level; l++) {
|
|
105
|
+
if (isHeadingAtLevel(text, l)) return true;
|
|
106
|
+
}
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Parse a markdown pipe table from a slice of lines starting at the first
|
|
111
|
+
// non-blank, non-comment line. Returns headers (lowercased, trimmed) +
|
|
112
|
+
// rows (each row keyed by header). Each cell tracks its source line number
|
|
113
|
+
// (the row line, not per-cell — multi-line cells are not supported here).
|
|
114
|
+
interface TableRow {
|
|
115
|
+
cells: Record<string, string>;
|
|
116
|
+
line: number;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function parseTable(body: Line[]): { headers: string[]; rows: TableRow[] } | null {
|
|
120
|
+
// Find a header+separator pair anywhere in the body. A section may have a
|
|
121
|
+
// grounding paragraph before the table.
|
|
122
|
+
const sepRe = /^\s*\|?\s*[-:| ]+\s*\|?\s*$/;
|
|
123
|
+
const significant = body.filter((l) => l.text.trim().length > 0);
|
|
124
|
+
let headerIdx = -1;
|
|
125
|
+
for (let i = 0; i < significant.length - 1; i++) {
|
|
126
|
+
if (
|
|
127
|
+
significant[i].text.includes("|") &&
|
|
128
|
+
significant[i + 1].text.includes("|") &&
|
|
129
|
+
sepRe.test(significant[i + 1].text)
|
|
130
|
+
) {
|
|
131
|
+
headerIdx = i;
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (headerIdx < 0) return null;
|
|
136
|
+
|
|
137
|
+
const headerLine = significant[headerIdx];
|
|
138
|
+
const headers = splitRow(headerLine.text).map((h) => h.toLowerCase());
|
|
139
|
+
const rows: TableRow[] = [];
|
|
140
|
+
for (let i = headerIdx + 2; i < significant.length; i++) {
|
|
141
|
+
const ln = significant[i];
|
|
142
|
+
if (!ln.text.includes("|")) break;
|
|
143
|
+
const cells = splitRow(ln.text);
|
|
144
|
+
if (cells.length === 0) continue;
|
|
145
|
+
const row: Record<string, string> = {};
|
|
146
|
+
for (let c = 0; c < headers.length; c++) {
|
|
147
|
+
row[headers[c]] = (cells[c] ?? "").trim();
|
|
148
|
+
}
|
|
149
|
+
rows.push({ cells: row, line: ln.n });
|
|
150
|
+
}
|
|
151
|
+
return { headers, rows };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function splitRow(text: string): string[] {
|
|
155
|
+
// Strip leading/trailing pipes, then split.
|
|
156
|
+
let s = text.trim();
|
|
157
|
+
if (s.startsWith("|")) s = s.slice(1);
|
|
158
|
+
if (s.endsWith("|")) s = s.slice(0, -1);
|
|
159
|
+
return s.split("|").map((c) => c.trim());
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// =============================================================================
|
|
163
|
+
// Top-level: App Overview persona table
|
|
164
|
+
// =============================================================================
|
|
165
|
+
|
|
166
|
+
function parsePersonas(ctx: Ctx, section: Section): void {
|
|
167
|
+
const table = parseTable(section.bodyLines);
|
|
168
|
+
if (!table) {
|
|
169
|
+
pushError(ctx, section.startLine, "App Overview: persona table not found or malformed");
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
const required = ["persona", "role", "primary jtbd", "relationship to other personas"];
|
|
173
|
+
for (const r of required) {
|
|
174
|
+
if (!table.headers.includes(r)) {
|
|
175
|
+
pushError(
|
|
176
|
+
ctx,
|
|
177
|
+
section.startLine,
|
|
178
|
+
`App Overview: persona table missing required column "${r}"`,
|
|
179
|
+
);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (table.rows.length < 1) {
|
|
184
|
+
pushError(ctx, section.startLine, "App Overview: persona table has zero rows");
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
let primaryCount = 0;
|
|
189
|
+
for (const row of table.rows) {
|
|
190
|
+
const rawName = row.cells["persona"] ?? "";
|
|
191
|
+
if (!rawName) {
|
|
192
|
+
pushError(ctx, row.line, "Persona row missing name");
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
const isPrimary = /\(primary\)/i.test(rawName);
|
|
196
|
+
if (isPrimary) primaryCount++;
|
|
197
|
+
const cleanLabel = rawName.replace(/\(primary\)/gi, "").trim();
|
|
198
|
+
const role = row.cells["role"] ?? "";
|
|
199
|
+
const jtbd = row.cells["primary jtbd"] ?? "";
|
|
200
|
+
const relationship = row.cells["relationship to other personas"] ?? "";
|
|
201
|
+
if (!role || !jtbd) {
|
|
202
|
+
pushError(ctx, row.line, `Persona "${cleanLabel}" missing role or JTBD`);
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
const node: PersonaNode = {
|
|
206
|
+
id: ids.persona(cleanLabel),
|
|
207
|
+
label: cleanLabel,
|
|
208
|
+
entity_type: "persona",
|
|
209
|
+
source_file: ctx.mdPath,
|
|
210
|
+
source_location: loc(row.line),
|
|
211
|
+
confidence: "EXTRACTED",
|
|
212
|
+
description: relationship,
|
|
213
|
+
role,
|
|
214
|
+
is_primary: isPrimary,
|
|
215
|
+
primary_jtbd: jtbd,
|
|
216
|
+
};
|
|
217
|
+
ctx.nodes.push(node);
|
|
218
|
+
ctx.personasByKey.set(cleanLabel.toLowerCase(), node);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (primaryCount !== 1) {
|
|
222
|
+
pushError(
|
|
223
|
+
ctx,
|
|
224
|
+
section.startLine,
|
|
225
|
+
`App Overview: expected exactly one (primary) persona, found ${primaryCount}`,
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// =============================================================================
|
|
231
|
+
// Top-level: Screen Inventory
|
|
232
|
+
// =============================================================================
|
|
233
|
+
|
|
234
|
+
interface ScreenInfo {
|
|
235
|
+
rawName: string;
|
|
236
|
+
description: string;
|
|
237
|
+
featureNames: string[];
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function parseScreenInventory(ctx: Ctx, section: Section): ScreenInfo[] {
|
|
241
|
+
const table = parseTable(section.bodyLines);
|
|
242
|
+
if (!table) {
|
|
243
|
+
pushError(ctx, section.startLine, "Screen Inventory: table not found or malformed");
|
|
244
|
+
return [];
|
|
245
|
+
}
|
|
246
|
+
const required = ["screen", "description", "features"];
|
|
247
|
+
for (const r of required) {
|
|
248
|
+
if (!table.headers.includes(r)) {
|
|
249
|
+
pushError(
|
|
250
|
+
ctx,
|
|
251
|
+
section.startLine,
|
|
252
|
+
`Screen Inventory: missing required column "${r}"`,
|
|
253
|
+
);
|
|
254
|
+
return [];
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const out: ScreenInfo[] = [];
|
|
259
|
+
for (const row of table.rows) {
|
|
260
|
+
const rawName = row.cells["screen"] ?? "";
|
|
261
|
+
const description = row.cells["description"] ?? "";
|
|
262
|
+
const features = row.cells["features"] ?? "";
|
|
263
|
+
if (!rawName) {
|
|
264
|
+
pushError(ctx, row.line, "Screen Inventory: row missing screen name");
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
const featureNames = features
|
|
268
|
+
.split(",")
|
|
269
|
+
.map((s) => s.trim())
|
|
270
|
+
.filter((s) => s.length > 0);
|
|
271
|
+
|
|
272
|
+
const countMatch = rawName.match(/\((\d+)\s+screens?\)/i);
|
|
273
|
+
const cleanName = rawName.replace(/\(\d+\s+screens?\)/i, "").trim();
|
|
274
|
+
const count = countMatch ? Number.parseInt(countMatch[1], 10) : undefined;
|
|
275
|
+
|
|
276
|
+
const node: ScreenNode = {
|
|
277
|
+
id: ids.screen(cleanName),
|
|
278
|
+
label: cleanName,
|
|
279
|
+
entity_type: "screen",
|
|
280
|
+
source_file: ctx.mdPath,
|
|
281
|
+
source_location: loc(row.line),
|
|
282
|
+
confidence: "EXTRACTED",
|
|
283
|
+
description,
|
|
284
|
+
feature_ids: featureNames.map((f) => ids.feature(f)),
|
|
285
|
+
...(count ? { count } : {}),
|
|
286
|
+
};
|
|
287
|
+
ctx.nodes.push(node);
|
|
288
|
+
out.push({ rawName: cleanName, description, featureNames });
|
|
289
|
+
|
|
290
|
+
// has_screen edges per feature attribution
|
|
291
|
+
for (const fname of featureNames) {
|
|
292
|
+
ctx.edges.push({
|
|
293
|
+
source: ids.feature(fname),
|
|
294
|
+
target: node.id,
|
|
295
|
+
relation: "has_screen",
|
|
296
|
+
confidence: "EXTRACTED",
|
|
297
|
+
source_file: ctx.mdPath,
|
|
298
|
+
source_location: loc(row.line),
|
|
299
|
+
produced_by_agent: PRODUCED_BY,
|
|
300
|
+
produced_at_step: PRODUCED_AT_STEP,
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
return out;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// =============================================================================
|
|
308
|
+
// Top-level: Cross-Feature Interactions
|
|
309
|
+
// =============================================================================
|
|
310
|
+
|
|
311
|
+
function parseCrossFeature(ctx: Ctx, section: Section): void {
|
|
312
|
+
// Bullets like "- Auth → Checkout: user must be authenticated"
|
|
313
|
+
// Persona-crossing variants: "- Order Placement (Buyer) → Order Notification (Seller): ..."
|
|
314
|
+
const arrow = /^\s*-\s+(.+?)\s*(?:→|->)\s*(.+?)\s*:\s*(.*)$/u;
|
|
315
|
+
for (const line of section.bodyLines) {
|
|
316
|
+
const m = line.text.match(arrow);
|
|
317
|
+
if (!m) continue;
|
|
318
|
+
const lhs = m[1].trim();
|
|
319
|
+
const rhs = m[2].trim();
|
|
320
|
+
const ruleText = m[3].trim() || undefined;
|
|
321
|
+
// Strip trailing "(Persona)" annotations from each side; the feature
|
|
322
|
+
// name is what precedes any parenthetical.
|
|
323
|
+
const lhsFeature = lhs.replace(/\s*\([^)]*\)\s*$/u, "").trim();
|
|
324
|
+
const rhsFeatureRaw = rhs.replace(/\s*\([^)]*\)\s*$/u, "").trim();
|
|
325
|
+
// RHS may also list multiple comma-separated features (e.g.
|
|
326
|
+
// "Auth, Checkout, Dashboard"). Fan out one edge per target.
|
|
327
|
+
const rhsFeatures = rhsFeatureRaw
|
|
328
|
+
.split(",")
|
|
329
|
+
.map((s) => s.trim())
|
|
330
|
+
.filter((s) => s.length > 0);
|
|
331
|
+
for (const target of rhsFeatures) {
|
|
332
|
+
ctx.edges.push({
|
|
333
|
+
source: ids.feature(lhsFeature),
|
|
334
|
+
target: ids.feature(target),
|
|
335
|
+
relation: "depends_on",
|
|
336
|
+
confidence: "EXTRACTED",
|
|
337
|
+
source_file: ctx.mdPath,
|
|
338
|
+
source_location: loc(line.n),
|
|
339
|
+
produced_by_agent: PRODUCED_BY,
|
|
340
|
+
produced_at_step: PRODUCED_AT_STEP,
|
|
341
|
+
...(ruleText ? { label: ruleText } : {}),
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// =============================================================================
|
|
348
|
+
// Per-feature
|
|
349
|
+
// =============================================================================
|
|
350
|
+
|
|
351
|
+
function parseFeature(ctx: Ctx, section: Section): void {
|
|
352
|
+
const headingLabel = section.heading.replace(/^Feature:\s*/i, "").trim();
|
|
353
|
+
if (!headingLabel) {
|
|
354
|
+
pushError(ctx, section.startLine, "Feature heading missing name");
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
const featureNode: FeatureNode = {
|
|
358
|
+
id: ids.feature(headingLabel),
|
|
359
|
+
label: headingLabel,
|
|
360
|
+
entity_type: "feature",
|
|
361
|
+
source_file: ctx.mdPath,
|
|
362
|
+
source_location: loc(section.startLine),
|
|
363
|
+
confidence: "EXTRACTED",
|
|
364
|
+
name: headingLabel,
|
|
365
|
+
kebab_anchor: kebab(headingLabel),
|
|
366
|
+
};
|
|
367
|
+
ctx.nodes.push(featureNode);
|
|
368
|
+
|
|
369
|
+
// Re-tokenize this feature's body to extract ### subsections.
|
|
370
|
+
const subSections = partitionSubsections(section.bodyLines);
|
|
371
|
+
const stateNamesInOrder: { name: string; line: number; isInitial: boolean }[] = [];
|
|
372
|
+
|
|
373
|
+
for (const sub of subSections) {
|
|
374
|
+
const lower = sub.heading.toLowerCase();
|
|
375
|
+
if (lower === "states") {
|
|
376
|
+
parseStates(ctx, featureNode, sub, stateNamesInOrder);
|
|
377
|
+
} else if (lower === "transitions") {
|
|
378
|
+
parseTransitions(ctx, featureNode, sub);
|
|
379
|
+
} else if (lower === "business rules") {
|
|
380
|
+
parseBusinessRules(ctx, featureNode, sub);
|
|
381
|
+
} else if (lower === "failure modes") {
|
|
382
|
+
parseFailureModes(ctx, featureNode, sub);
|
|
383
|
+
} else if (lower === "acceptance criteria") {
|
|
384
|
+
parseAcceptanceCriteria(ctx, featureNode, sub);
|
|
385
|
+
} else if (lower === "persona constraints") {
|
|
386
|
+
parsePersonaConstraints(ctx, featureNode, sub);
|
|
387
|
+
}
|
|
388
|
+
// Other subsections (Data Requirements, Happy Path, Empty States,
|
|
389
|
+
// Notification Triggers, etc.) are intentionally ignored in Slice 1.
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function partitionSubsections(body: Line[]): Section[] {
|
|
394
|
+
// Treat the body as a virtual document; reuse partitionSections at level 3.
|
|
395
|
+
// Need to feed line objects with their absolute line numbers.
|
|
396
|
+
const sections: Section[] = [];
|
|
397
|
+
let i = 0;
|
|
398
|
+
while (i < body.length) {
|
|
399
|
+
const line = body[i];
|
|
400
|
+
if (isHeadingAtLevel(line.text, 3)) {
|
|
401
|
+
const heading = line.text.slice(4).trim();
|
|
402
|
+
const start = i + 1;
|
|
403
|
+
let j = start;
|
|
404
|
+
while (j < body.length && !isHeadingAtOrAbove(body[j].text, 3)) j++;
|
|
405
|
+
sections.push({
|
|
406
|
+
heading,
|
|
407
|
+
level: 3,
|
|
408
|
+
startLine: line.n,
|
|
409
|
+
bodyLines: body.slice(start, j),
|
|
410
|
+
});
|
|
411
|
+
i = j;
|
|
412
|
+
} else {
|
|
413
|
+
i++;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
return sections;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// ----- States -----
|
|
420
|
+
|
|
421
|
+
function parseStates(
|
|
422
|
+
ctx: Ctx,
|
|
423
|
+
feature: FeatureNode,
|
|
424
|
+
section: Section,
|
|
425
|
+
collect: { name: string; line: number; isInitial: boolean }[],
|
|
426
|
+
): void {
|
|
427
|
+
// Two accepted forms:
|
|
428
|
+
// "States: idle (initial), loading, loaded, empty, error"
|
|
429
|
+
// bullets: "- idle (initial)" "- loading"
|
|
430
|
+
const inline = section.bodyLines.find((l) => /^\s*states\s*:/i.test(l.text));
|
|
431
|
+
let entries: { name: string; isInitial: boolean; line: number }[] = [];
|
|
432
|
+
|
|
433
|
+
if (inline) {
|
|
434
|
+
const after = inline.text.replace(/^\s*states\s*:\s*/i, "");
|
|
435
|
+
entries = after
|
|
436
|
+
.split(",")
|
|
437
|
+
.map((s) => s.trim())
|
|
438
|
+
.filter((s) => s.length > 0)
|
|
439
|
+
.map((s) => ({
|
|
440
|
+
name: s.replace(/\(initial\)/i, "").trim(),
|
|
441
|
+
isInitial: /\(initial\)/i.test(s),
|
|
442
|
+
line: inline.n,
|
|
443
|
+
}));
|
|
444
|
+
} else {
|
|
445
|
+
for (const line of section.bodyLines) {
|
|
446
|
+
const m = line.text.match(/^\s*-\s+(.+?)\s*$/);
|
|
447
|
+
if (!m) continue;
|
|
448
|
+
const raw = m[1];
|
|
449
|
+
entries.push({
|
|
450
|
+
name: raw.replace(/\(initial\)/i, "").trim(),
|
|
451
|
+
isInitial: /\(initial\)/i.test(raw),
|
|
452
|
+
line: line.n,
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if (entries.length === 0) {
|
|
458
|
+
pushError(ctx, section.startLine, `Feature "${feature.label}": States section is empty`);
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// If nothing is explicitly marked (initial), the first entry is initial.
|
|
463
|
+
if (!entries.some((e) => e.isInitial)) {
|
|
464
|
+
entries[0].isInitial = true;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
for (const e of entries) {
|
|
468
|
+
const stateNode: StateNode = {
|
|
469
|
+
id: ids.state(feature.name, e.name),
|
|
470
|
+
label: e.name,
|
|
471
|
+
entity_type: "state",
|
|
472
|
+
source_file: ctx.mdPath,
|
|
473
|
+
source_location: loc(e.line),
|
|
474
|
+
confidence: "EXTRACTED",
|
|
475
|
+
feature_id: feature.id,
|
|
476
|
+
is_initial: e.isInitial,
|
|
477
|
+
meta_state: META_STATE_NAMES.has(kebab(e.name)),
|
|
478
|
+
};
|
|
479
|
+
ctx.nodes.push(stateNode);
|
|
480
|
+
ctx.edges.push({
|
|
481
|
+
source: feature.id,
|
|
482
|
+
target: stateNode.id,
|
|
483
|
+
relation: "has_state",
|
|
484
|
+
confidence: "EXTRACTED",
|
|
485
|
+
source_file: ctx.mdPath,
|
|
486
|
+
source_location: loc(e.line),
|
|
487
|
+
produced_by_agent: PRODUCED_BY,
|
|
488
|
+
produced_at_step: PRODUCED_AT_STEP,
|
|
489
|
+
});
|
|
490
|
+
if (e.isInitial) {
|
|
491
|
+
ctx.edges.push({
|
|
492
|
+
source: feature.id,
|
|
493
|
+
target: stateNode.id,
|
|
494
|
+
relation: "has_initial_state",
|
|
495
|
+
confidence: "EXTRACTED",
|
|
496
|
+
source_file: ctx.mdPath,
|
|
497
|
+
source_location: loc(e.line),
|
|
498
|
+
produced_by_agent: PRODUCED_BY,
|
|
499
|
+
produced_at_step: PRODUCED_AT_STEP,
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
collect.push({ name: e.name, line: e.line, isInitial: e.isInitial });
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// ----- Transitions -----
|
|
507
|
+
|
|
508
|
+
function parseTransitions(ctx: Ctx, feature: FeatureNode, section: Section): void {
|
|
509
|
+
const table = parseTable(section.bodyLines);
|
|
510
|
+
if (!table) {
|
|
511
|
+
pushError(
|
|
512
|
+
ctx,
|
|
513
|
+
section.startLine,
|
|
514
|
+
`Feature "${feature.label}": Transitions table not found or malformed`,
|
|
515
|
+
);
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
// header may be "from → to" with arrow, or "from->to". Find the from→to col.
|
|
519
|
+
const fromToKey = table.headers.find((h) => /from\s*(?:→|->)\s*to/u.test(h));
|
|
520
|
+
if (!fromToKey) {
|
|
521
|
+
pushError(
|
|
522
|
+
ctx,
|
|
523
|
+
section.startLine,
|
|
524
|
+
`Feature "${feature.label}": Transitions table missing "From → To" column`,
|
|
525
|
+
);
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
for (const row of table.rows) {
|
|
529
|
+
const fromTo = row.cells[fromToKey] ?? "";
|
|
530
|
+
const m = fromTo.match(/^(.+?)\s*(?:→|->)\s*(.+)$/u);
|
|
531
|
+
if (!m) {
|
|
532
|
+
pushError(ctx, row.line, `Transition row malformed: "${fromTo}"`);
|
|
533
|
+
continue;
|
|
534
|
+
}
|
|
535
|
+
const from = m[1].trim();
|
|
536
|
+
const to = m[2].trim();
|
|
537
|
+
const trigger = row.cells["trigger"] ?? "";
|
|
538
|
+
const preconditions = row.cells["preconditions"] ?? "";
|
|
539
|
+
const sideEffects = row.cells["side effects"] ?? "";
|
|
540
|
+
|
|
541
|
+
const fromId = ids.state(feature.name, from);
|
|
542
|
+
const toId = ids.state(feature.name, to);
|
|
543
|
+
const transitionNode: TransitionNode = {
|
|
544
|
+
id: ids.transition(feature.name, from, to),
|
|
545
|
+
label: `${from} → ${to}`,
|
|
546
|
+
entity_type: "transition",
|
|
547
|
+
source_file: ctx.mdPath,
|
|
548
|
+
source_location: loc(row.line),
|
|
549
|
+
confidence: "EXTRACTED",
|
|
550
|
+
from_state_id: fromId,
|
|
551
|
+
to_state_id: toId,
|
|
552
|
+
trigger,
|
|
553
|
+
preconditions,
|
|
554
|
+
side_effects: sideEffects,
|
|
555
|
+
};
|
|
556
|
+
ctx.nodes.push(transitionNode);
|
|
557
|
+
ctx.edges.push({
|
|
558
|
+
source: fromId,
|
|
559
|
+
target: toId,
|
|
560
|
+
relation: "transitions_to",
|
|
561
|
+
confidence: "EXTRACTED",
|
|
562
|
+
source_file: ctx.mdPath,
|
|
563
|
+
source_location: loc(row.line),
|
|
564
|
+
produced_by_agent: PRODUCED_BY,
|
|
565
|
+
produced_at_step: PRODUCED_AT_STEP,
|
|
566
|
+
});
|
|
567
|
+
ctx.edges.push({
|
|
568
|
+
source: fromId,
|
|
569
|
+
target: transitionNode.id,
|
|
570
|
+
relation: "triggered_by_transition",
|
|
571
|
+
confidence: "EXTRACTED",
|
|
572
|
+
source_file: ctx.mdPath,
|
|
573
|
+
source_location: loc(row.line),
|
|
574
|
+
produced_by_agent: PRODUCED_BY,
|
|
575
|
+
produced_at_step: PRODUCED_AT_STEP,
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// ----- Business Rules -----
|
|
581
|
+
|
|
582
|
+
function parseBusinessRules(ctx: Ctx, feature: FeatureNode, section: Section): void {
|
|
583
|
+
const bullets = collectBullets(section.bodyLines);
|
|
584
|
+
for (const b of bullets) {
|
|
585
|
+
const text = b.text.trim();
|
|
586
|
+
if (!text) continue;
|
|
587
|
+
const decisionNeeded = /\[DECISION NEEDED/i.test(text);
|
|
588
|
+
const value = extractRuleValue(text);
|
|
589
|
+
const node: BusinessRuleNode = {
|
|
590
|
+
id: ids.businessRule(feature.name, text),
|
|
591
|
+
label: text.length > 80 ? text.slice(0, 77) + "..." : text,
|
|
592
|
+
entity_type: "business_rule",
|
|
593
|
+
source_file: ctx.mdPath,
|
|
594
|
+
source_location: loc(b.line),
|
|
595
|
+
confidence: "EXTRACTED",
|
|
596
|
+
feature_id: feature.id,
|
|
597
|
+
text,
|
|
598
|
+
value,
|
|
599
|
+
decision_needed: decisionNeeded,
|
|
600
|
+
};
|
|
601
|
+
ctx.nodes.push(node);
|
|
602
|
+
ctx.edges.push({
|
|
603
|
+
source: feature.id,
|
|
604
|
+
target: node.id,
|
|
605
|
+
relation: "has_rule",
|
|
606
|
+
confidence: "EXTRACTED",
|
|
607
|
+
source_file: ctx.mdPath,
|
|
608
|
+
source_location: loc(b.line),
|
|
609
|
+
produced_by_agent: PRODUCED_BY,
|
|
610
|
+
produced_at_step: PRODUCED_AT_STEP,
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function extractRuleValue(text: string): string | null {
|
|
616
|
+
// Prefer "= <value>" explicit assignment, else first numeric+unit run.
|
|
617
|
+
const eq = text.match(/=\s*([^.\[]+?)(?:\s*\[|$)/);
|
|
618
|
+
if (eq) return eq[1].trim();
|
|
619
|
+
const num = text.match(
|
|
620
|
+
/(\d+(?:\.\d+)?)\s*(seconds?|minutes?|hours?|days?|items?|orders?|requests?|%|percent|MB|GB|KB|ms)/i,
|
|
621
|
+
);
|
|
622
|
+
if (num) return `${num[1]} ${num[2]}`;
|
|
623
|
+
return null;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// ----- Failure Modes -----
|
|
627
|
+
|
|
628
|
+
function parseFailureModes(ctx: Ctx, feature: FeatureNode, section: Section): void {
|
|
629
|
+
// Multi-line bullet structure:
|
|
630
|
+
// - <trigger> →
|
|
631
|
+
// User sees: "..."
|
|
632
|
+
// User can: ...
|
|
633
|
+
// System: ...
|
|
634
|
+
// Bullets are separated by either a blank line or the start of a new "- " bullet.
|
|
635
|
+
const blocks = splitFailureBlocks(section.bodyLines);
|
|
636
|
+
for (const block of blocks) {
|
|
637
|
+
if (block.length === 0) continue;
|
|
638
|
+
const headLine = block[0];
|
|
639
|
+
const headText = headLine.text.replace(/^\s*-\s+/, "").trim();
|
|
640
|
+
// Trigger is the part before a trailing arrow if present.
|
|
641
|
+
const trigger = headText.replace(/\s*(?:→|->)\s*$/u, "").trim();
|
|
642
|
+
if (!trigger) continue;
|
|
643
|
+
|
|
644
|
+
const userSees = readLabel(block, /^\s*user\s+sees\s*:/i);
|
|
645
|
+
const userCan = readLabel(block, /^\s*user\s+can\s*:/i);
|
|
646
|
+
const systemDoes = readLabel(block, /^\s*system\s*:/i);
|
|
647
|
+
|
|
648
|
+
const node: FailureModeNode = {
|
|
649
|
+
id: ids.failureMode(feature.name, trigger),
|
|
650
|
+
label: trigger,
|
|
651
|
+
entity_type: "failure_mode",
|
|
652
|
+
source_file: ctx.mdPath,
|
|
653
|
+
source_location: loc(headLine.n),
|
|
654
|
+
confidence: "EXTRACTED",
|
|
655
|
+
feature_id: feature.id,
|
|
656
|
+
trigger,
|
|
657
|
+
user_sees: userSees,
|
|
658
|
+
user_can: userCan,
|
|
659
|
+
system_does: systemDoes,
|
|
660
|
+
};
|
|
661
|
+
ctx.nodes.push(node);
|
|
662
|
+
ctx.edges.push({
|
|
663
|
+
source: feature.id,
|
|
664
|
+
target: node.id,
|
|
665
|
+
relation: "has_failure_mode",
|
|
666
|
+
confidence: "EXTRACTED",
|
|
667
|
+
source_file: ctx.mdPath,
|
|
668
|
+
source_location: loc(headLine.n),
|
|
669
|
+
produced_by_agent: PRODUCED_BY,
|
|
670
|
+
produced_at_step: PRODUCED_AT_STEP,
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function splitFailureBlocks(body: Line[]): Line[][] {
|
|
676
|
+
const blocks: Line[][] = [];
|
|
677
|
+
let current: Line[] = [];
|
|
678
|
+
for (const line of body) {
|
|
679
|
+
const isBulletStart = /^\s*-\s+/.test(line.text);
|
|
680
|
+
if (isBulletStart) {
|
|
681
|
+
if (current.length > 0) blocks.push(current);
|
|
682
|
+
current = [line];
|
|
683
|
+
} else if (line.text.trim() === "") {
|
|
684
|
+
if (current.length > 0) {
|
|
685
|
+
blocks.push(current);
|
|
686
|
+
current = [];
|
|
687
|
+
}
|
|
688
|
+
} else if (current.length > 0) {
|
|
689
|
+
current.push(line);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
if (current.length > 0) blocks.push(current);
|
|
693
|
+
return blocks;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function readLabel(block: Line[], pattern: RegExp): string {
|
|
697
|
+
const idx = block.findIndex((l) => pattern.test(l.text));
|
|
698
|
+
if (idx < 0) return "";
|
|
699
|
+
// Take the text after the label, plus any non-labelled continuation lines
|
|
700
|
+
// until we hit another label or end of block.
|
|
701
|
+
const first = block[idx].text.replace(pattern, "").trim();
|
|
702
|
+
const labelStartRe = /^\s*(user\s+sees|user\s+can|system)\s*:/i;
|
|
703
|
+
const parts: string[] = [];
|
|
704
|
+
if (first) parts.push(first.replace(/^"+|"+$/g, ""));
|
|
705
|
+
for (let i = idx + 1; i < block.length; i++) {
|
|
706
|
+
const t = block[i].text.trim();
|
|
707
|
+
if (!t) break;
|
|
708
|
+
if (labelStartRe.test(t)) break;
|
|
709
|
+
parts.push(t.replace(/^"+|"+$/g, ""));
|
|
710
|
+
}
|
|
711
|
+
return parts.join(" ").trim();
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// ----- Acceptance Criteria -----
|
|
715
|
+
|
|
716
|
+
function parseAcceptanceCriteria(ctx: Ctx, feature: FeatureNode, section: Section): void {
|
|
717
|
+
const re = /^\s*-\s+\[\s\]\s+(.+)$/;
|
|
718
|
+
for (const line of section.bodyLines) {
|
|
719
|
+
const m = line.text.match(re);
|
|
720
|
+
if (!m) continue;
|
|
721
|
+
const text = m[1].trim();
|
|
722
|
+
if (!text) continue;
|
|
723
|
+
const node: AcceptanceCriterionNode = {
|
|
724
|
+
id: ids.acceptanceCriterion(feature.name, text),
|
|
725
|
+
label: text.length > 80 ? text.slice(0, 77) + "..." : text,
|
|
726
|
+
entity_type: "acceptance_criterion",
|
|
727
|
+
source_file: ctx.mdPath,
|
|
728
|
+
source_location: loc(line.n),
|
|
729
|
+
confidence: "EXTRACTED",
|
|
730
|
+
feature_id: feature.id,
|
|
731
|
+
text,
|
|
732
|
+
verified: false,
|
|
733
|
+
};
|
|
734
|
+
ctx.nodes.push(node);
|
|
735
|
+
ctx.edges.push({
|
|
736
|
+
source: feature.id,
|
|
737
|
+
target: node.id,
|
|
738
|
+
relation: "has_acceptance",
|
|
739
|
+
confidence: "EXTRACTED",
|
|
740
|
+
source_file: ctx.mdPath,
|
|
741
|
+
source_location: loc(line.n),
|
|
742
|
+
produced_by_agent: PRODUCED_BY,
|
|
743
|
+
produced_at_step: PRODUCED_AT_STEP,
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// ----- Persona Constraints -----
|
|
749
|
+
|
|
750
|
+
function parsePersonaConstraints(ctx: Ctx, feature: FeatureNode, section: Section): void {
|
|
751
|
+
// Block structure (per protocols/product-spec-schema.md):
|
|
752
|
+
// - Persona: <name> [(primary)] — <one-liner> [src]
|
|
753
|
+
// Constraint: <text> [src]
|
|
754
|
+
// Constraint: <text> [src]
|
|
755
|
+
// - Persona: <other> ...
|
|
756
|
+
let currentPersona: PersonaNode | null = null;
|
|
757
|
+
|
|
758
|
+
for (const line of section.bodyLines) {
|
|
759
|
+
const text = line.text;
|
|
760
|
+
const personaMatch = text.match(/^\s*-\s+Persona\s*:\s*(.+?)\s+(?:—|--|-)\s+(.*)$/u);
|
|
761
|
+
if (personaMatch) {
|
|
762
|
+
const rawName = personaMatch[1].trim();
|
|
763
|
+
const cleanName = rawName.replace(/\(primary\)/gi, "").trim();
|
|
764
|
+
const found = ctx.personasByKey.get(cleanName.toLowerCase());
|
|
765
|
+
if (!found) {
|
|
766
|
+
pushError(
|
|
767
|
+
ctx,
|
|
768
|
+
line.n,
|
|
769
|
+
`Feature "${feature.label}": persona "${cleanName}" not found in App Overview persona table`,
|
|
770
|
+
);
|
|
771
|
+
currentPersona = null;
|
|
772
|
+
} else {
|
|
773
|
+
currentPersona = found;
|
|
774
|
+
}
|
|
775
|
+
continue;
|
|
776
|
+
}
|
|
777
|
+
const constraintMatch = text.match(/^\s*Constraint\s*:\s*(.+)$/i);
|
|
778
|
+
if (constraintMatch && currentPersona) {
|
|
779
|
+
const full = constraintMatch[1].trim();
|
|
780
|
+
// Last [src] suffix is the citation; everything before is the constraint.
|
|
781
|
+
const citeMatch = full.match(/\[([^\]]+)\]\s*$/);
|
|
782
|
+
const constraintText = citeMatch ? full.slice(0, citeMatch.index).trim() : full;
|
|
783
|
+
const citedSource = citeMatch ? citeMatch[1].trim() : "";
|
|
784
|
+
const node: PersonaConstraintNode = {
|
|
785
|
+
id: ids.personaConstraint(feature.name, constraintText),
|
|
786
|
+
label:
|
|
787
|
+
constraintText.length > 80 ? constraintText.slice(0, 77) + "..." : constraintText,
|
|
788
|
+
entity_type: "persona_constraint",
|
|
789
|
+
source_file: ctx.mdPath,
|
|
790
|
+
source_location: loc(line.n),
|
|
791
|
+
confidence: "EXTRACTED",
|
|
792
|
+
feature_id: feature.id,
|
|
793
|
+
persona_id: currentPersona.id,
|
|
794
|
+
constraint_text: constraintText,
|
|
795
|
+
cited_source: citedSource,
|
|
796
|
+
};
|
|
797
|
+
ctx.nodes.push(node);
|
|
798
|
+
ctx.edges.push({
|
|
799
|
+
source: node.id,
|
|
800
|
+
target: feature.id,
|
|
801
|
+
relation: "constrains",
|
|
802
|
+
confidence: "EXTRACTED",
|
|
803
|
+
source_file: ctx.mdPath,
|
|
804
|
+
source_location: loc(line.n),
|
|
805
|
+
produced_by_agent: PRODUCED_BY,
|
|
806
|
+
produced_at_step: PRODUCED_AT_STEP,
|
|
807
|
+
});
|
|
808
|
+
ctx.edges.push({
|
|
809
|
+
source: node.id,
|
|
810
|
+
target: currentPersona.id,
|
|
811
|
+
relation: "applies_to_persona",
|
|
812
|
+
confidence: "EXTRACTED",
|
|
813
|
+
source_file: ctx.mdPath,
|
|
814
|
+
source_location: loc(line.n),
|
|
815
|
+
produced_by_agent: PRODUCED_BY,
|
|
816
|
+
produced_at_step: PRODUCED_AT_STEP,
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
// =============================================================================
|
|
823
|
+
// Bullet collector (treats indented continuations as part of the same bullet)
|
|
824
|
+
// =============================================================================
|
|
825
|
+
|
|
826
|
+
function collectBullets(body: Line[]): { text: string; line: number }[] {
|
|
827
|
+
const out: { text: string; line: number }[] = [];
|
|
828
|
+
let current: { text: string; line: number } | null = null;
|
|
829
|
+
for (const line of body) {
|
|
830
|
+
const m = line.text.match(/^(\s*)-\s+(.+)$/);
|
|
831
|
+
if (m) {
|
|
832
|
+
if (current) out.push(current);
|
|
833
|
+
current = { text: m[2].trim(), line: line.n };
|
|
834
|
+
} else if (current && /^\s+\S/.test(line.text)) {
|
|
835
|
+
current.text += " " + line.text.trim();
|
|
836
|
+
} else if (line.text.trim() === "") {
|
|
837
|
+
if (current) {
|
|
838
|
+
out.push(current);
|
|
839
|
+
current = null;
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
if (current) out.push(current);
|
|
844
|
+
return out;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
// =============================================================================
|
|
848
|
+
// Determinism: stable sort
|
|
849
|
+
// =============================================================================
|
|
850
|
+
|
|
851
|
+
function sortNodes(nodes: GraphNode[]): GraphNode[] {
|
|
852
|
+
return [...nodes].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
function sortEdges(edges: GraphEdge[]): GraphEdge[] {
|
|
856
|
+
return [...edges].sort((a, b) => {
|
|
857
|
+
const k = (e: GraphEdge): string =>
|
|
858
|
+
`${e.relation}${e.source}${e.target}${e.source_location ?? ""}`;
|
|
859
|
+
const ka = k(a);
|
|
860
|
+
const kb = k(b);
|
|
861
|
+
return ka < kb ? -1 : ka > kb ? 1 : 0;
|
|
862
|
+
});
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
// =============================================================================
|
|
866
|
+
// Public entrypoint
|
|
867
|
+
// =============================================================================
|
|
868
|
+
|
|
869
|
+
export function extractProductSpec(input: { mdPath: string; mdContent: string }): ExtractResult {
|
|
870
|
+
const { mdPath, mdContent } = input;
|
|
871
|
+
const lines = splitLines(mdContent);
|
|
872
|
+
const ctx: Ctx = {
|
|
873
|
+
mdPath,
|
|
874
|
+
errors: [],
|
|
875
|
+
nodes: [],
|
|
876
|
+
edges: [],
|
|
877
|
+
personasByKey: new Map(),
|
|
878
|
+
};
|
|
879
|
+
|
|
880
|
+
const topSections = partitionSections(lines, 2, 0, lines.length);
|
|
881
|
+
const byHeading = (name: string): Section | undefined =>
|
|
882
|
+
topSections.find((s) => s.heading.trim().toLowerCase() === name.toLowerCase());
|
|
883
|
+
|
|
884
|
+
const required = ["App Overview", "Screen Inventory", "Cross-Feature Interactions"];
|
|
885
|
+
for (const r of required) {
|
|
886
|
+
if (!byHeading(r)) {
|
|
887
|
+
pushError(ctx, 1, `Missing required top-level section: "## ${r}"`);
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
if (ctx.errors.length > 0) {
|
|
891
|
+
return { ok: false, errors: ctx.errors };
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
// 1. Personas first — features need persona lookup.
|
|
895
|
+
const overviewSection = byHeading("App Overview")!;
|
|
896
|
+
parsePersonas(ctx, overviewSection);
|
|
897
|
+
|
|
898
|
+
// 2. Features (we want the feature nodes to exist before screen edges so
|
|
899
|
+
// that has_screen edges target real feature ids; though screens encode
|
|
900
|
+
// the target id directly via kebab, this also catches stray feature
|
|
901
|
+
// names that don't match any feature heading — out of scope for fail
|
|
902
|
+
// loud, but the feature_ids array reflects the inventory verbatim).
|
|
903
|
+
const featureSections = topSections.filter((s) => /^Feature\s*:/i.test(s.heading));
|
|
904
|
+
if (featureSections.length === 0) {
|
|
905
|
+
pushError(ctx, 1, "No `## Feature: ...` sections found");
|
|
906
|
+
}
|
|
907
|
+
for (const fs of featureSections) parseFeature(ctx, fs);
|
|
908
|
+
|
|
909
|
+
// 3. Screen inventory (after personas; doesn't depend on features but
|
|
910
|
+
// emits feature-targeted edges).
|
|
911
|
+
parseScreenInventory(ctx, byHeading("Screen Inventory")!);
|
|
912
|
+
|
|
913
|
+
// 4. Cross-feature interactions (depends on nothing, but emits feature edges).
|
|
914
|
+
parseCrossFeature(ctx, byHeading("Cross-Feature Interactions")!);
|
|
915
|
+
|
|
916
|
+
if (ctx.errors.length > 0) {
|
|
917
|
+
return { ok: false, errors: ctx.errors };
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
const fragment: GraphFragment = {
|
|
921
|
+
version: 1,
|
|
922
|
+
schema: "buildanything-slice-1",
|
|
923
|
+
source_file: mdPath,
|
|
924
|
+
source_sha: sha256Hex(mdContent),
|
|
925
|
+
produced_at: new Date().toISOString(),
|
|
926
|
+
nodes: sortNodes(ctx.nodes),
|
|
927
|
+
edges: sortEdges(ctx.edges),
|
|
928
|
+
};
|
|
929
|
+
return { ok: true, fragment, errors: [] };
|
|
930
|
+
}
|