iikit-dashboard 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/parser.js ADDED
@@ -0,0 +1,768 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ /**
7
+ * Parse spec.md to extract user stories.
8
+ * Pattern: ### User Story N - Title (Priority: PX)
9
+ *
10
+ * @param {string} content - Raw markdown content of spec.md
11
+ * @returns {Array<{id: string, title: string, priority: string}>}
12
+ */
13
+ function parseSpecStories(content) {
14
+ if (!content || typeof content !== 'string') return [];
15
+
16
+ const regex = /### User Story (\d+) - (.+?) \(Priority: (P\d+)\)/g;
17
+ const stories = [];
18
+ const storyStarts = [];
19
+ let match;
20
+
21
+ while ((match = regex.exec(content)) !== null) {
22
+ storyStarts.push({
23
+ id: `US${match[1]}`,
24
+ title: match[2].trim(),
25
+ priority: match[3],
26
+ index: match.index
27
+ });
28
+ }
29
+
30
+ for (let i = 0; i < storyStarts.length; i++) {
31
+ const start = storyStarts[i].index;
32
+ const end = i + 1 < storyStarts.length ? storyStarts[i + 1].index : content.length;
33
+ const section = content.substring(start, end);
34
+
35
+ // Count Given/When/Then scenario blocks (numbered list items starting with digit + .)
36
+ const scenarioCount = (section.match(/^\d+\.\s+\*\*Given\*\*/gm) || []).length;
37
+
38
+ // Extract body text (everything after the heading line, trimmed, stop at ---)
39
+ const headingEnd = section.indexOf('\n');
40
+ let body = headingEnd >= 0 ? section.substring(headingEnd + 1) : '';
41
+ const separatorIdx = body.indexOf('\n---');
42
+ if (separatorIdx >= 0) body = body.substring(0, separatorIdx);
43
+ body = body.trim();
44
+
45
+ stories.push({
46
+ id: storyStarts[i].id,
47
+ title: storyStarts[i].title,
48
+ priority: storyStarts[i].priority,
49
+ scenarioCount,
50
+ body
51
+ });
52
+ }
53
+
54
+ return stories;
55
+ }
56
+
57
+ /**
58
+ * Parse tasks.md to extract tasks with checkbox status and story tags.
59
+ * Pattern: - [x] TXXX [P]? [USy]? Description
60
+ *
61
+ * @param {string} content - Raw markdown content of tasks.md
62
+ * @returns {Array<{id: string, storyTag: string|null, description: string, checked: boolean}>}
63
+ */
64
+ function parseTasks(content) {
65
+ if (!content || typeof content !== 'string') return [];
66
+
67
+ const regex = /- \[([ x])\] (T\d+)\s+(?:\[P\]\s*)?(?:\[(US\d+)\]\s*)?(.*)/g;
68
+ const tasks = [];
69
+ let match;
70
+
71
+ while ((match = regex.exec(content)) !== null) {
72
+ tasks.push({
73
+ id: match[2],
74
+ storyTag: match[3] || null,
75
+ description: match[4].trim(),
76
+ checked: match[1] === 'x'
77
+ });
78
+ }
79
+
80
+ return tasks;
81
+ }
82
+
83
+ /**
84
+ * Parse all checklist files in a directory and return aggregate completion.
85
+ *
86
+ * @param {string} checklistDir - Path to checklists/ directory
87
+ * @returns {{total: number, checked: number, percentage: number}}
88
+ */
89
+ function parseChecklists(checklistDir) {
90
+ const result = { total: 0, checked: 0, percentage: 0 };
91
+
92
+ if (!fs.existsSync(checklistDir)) return result;
93
+
94
+ const files = fs.readdirSync(checklistDir).filter(f => f.endsWith('.md'));
95
+
96
+ // If the only checklist is requirements.md (spec quality checklist from /iikit-01-specify),
97
+ // don't count it — the /iikit-04-checklist phase hasn't run yet
98
+ const hasDomainChecklists = files.some(f => f !== 'requirements.md');
99
+ if (!hasDomainChecklists) return result;
100
+
101
+ for (const file of files) {
102
+ const content = fs.readFileSync(path.join(checklistDir, file), 'utf-8');
103
+ const lines = content.split('\n');
104
+ for (const line of lines) {
105
+ if (/- \[x\]/i.test(line)) {
106
+ result.total++;
107
+ result.checked++;
108
+ } else if (/- \[ \]/.test(line)) {
109
+ result.total++;
110
+ }
111
+ }
112
+ }
113
+
114
+ result.percentage = result.total > 0 ? Math.round((result.checked / result.total) * 100) : 0;
115
+ return result;
116
+ }
117
+
118
+ /**
119
+ * Parse CONSTITUTION.md to determine if TDD is required.
120
+ * Looks for strong TDD indicators combined with MUST/NON-NEGOTIABLE.
121
+ *
122
+ * @param {string} constitutionPath - Path to CONSTITUTION.md
123
+ * @returns {boolean} true if TDD is required
124
+ */
125
+ function parseConstitutionTDD(constitutionPath) {
126
+ if (!fs.existsSync(constitutionPath)) return false;
127
+
128
+ const content = fs.readFileSync(constitutionPath, 'utf-8').toLowerCase();
129
+ const hasTDDTerms = /\btdd\b|test-first|red-green-refactor|write tests before|tests must be written before/.test(content);
130
+ const hasMandatory = /\bmust\b|\brequired\b|non-negotiable/.test(content);
131
+
132
+ return hasTDDTerms && hasMandatory;
133
+ }
134
+
135
+ /**
136
+ * Check if spec.md content contains a Clarifications section.
137
+ *
138
+ * @param {string} specContent - Raw content of spec.md
139
+ * @returns {boolean}
140
+ */
141
+ function hasClarifications(specContent) {
142
+ if (!specContent || typeof specContent !== 'string') return false;
143
+ return /^## Clarifications/m.test(specContent);
144
+ }
145
+
146
+ /**
147
+ * Parse CONSTITUTION.md to extract principles with full details and version metadata.
148
+ *
149
+ * @param {string} projectPath - Path to the project root
150
+ * @returns {{principles: Array<{number: string, name: string, text: string, rationale: string, level: string}>, version: {version: string, ratified: string, lastAmended: string}|null, exists: boolean}}
151
+ */
152
+ function parseConstitutionPrinciples(projectPath) {
153
+ const constitutionPath = path.join(projectPath, 'CONSTITUTION.md');
154
+
155
+ if (!fs.existsSync(constitutionPath)) {
156
+ return { principles: [], version: null, exists: false };
157
+ }
158
+
159
+ const content = fs.readFileSync(constitutionPath, 'utf-8');
160
+ const lines = content.split('\n');
161
+ const principles = [];
162
+
163
+ // Find principles: ### N. Name pattern (Roman numerals)
164
+ const principleRegex = /^### ([IVXLC]+)\.\s+(.+?)(?:\s+\(.*\))?\s*$/;
165
+
166
+ let currentPrinciple = null;
167
+
168
+ for (let i = 0; i < lines.length; i++) {
169
+ const line = lines[i];
170
+ const match = line.match(principleRegex);
171
+
172
+ if (match) {
173
+ // Save previous principle
174
+ if (currentPrinciple) {
175
+ finalizePrinciple(currentPrinciple);
176
+ principles.push(currentPrinciple);
177
+ }
178
+ currentPrinciple = {
179
+ number: match[1],
180
+ name: match[2].trim(),
181
+ text: '',
182
+ rationale: '',
183
+ level: 'SHOULD'
184
+ };
185
+ } else if (currentPrinciple) {
186
+ // Stop collecting if we hit a ## heading (next section)
187
+ if (/^## /.test(line)) {
188
+ finalizePrinciple(currentPrinciple);
189
+ principles.push(currentPrinciple);
190
+ currentPrinciple = null;
191
+ } else {
192
+ currentPrinciple.text += line + '\n';
193
+ }
194
+ }
195
+ }
196
+
197
+ // Don't forget the last principle
198
+ if (currentPrinciple) {
199
+ finalizePrinciple(currentPrinciple);
200
+ principles.push(currentPrinciple);
201
+ }
202
+
203
+ // Parse version from footer
204
+ const versionMatch = content.match(/\*\*Version\*\*:\s*(\S+)\s*\|\s*\*\*Ratified\*\*:\s*(\S+)\s*\|\s*\*\*Last Amended\*\*:\s*(\S+)/);
205
+ const version = versionMatch
206
+ ? { version: versionMatch[1], ratified: versionMatch[2], lastAmended: versionMatch[3] }
207
+ : null;
208
+
209
+ return { principles, version, exists: true };
210
+ }
211
+
212
+ /**
213
+ * Finalize a principle: extract rationale and determine obligation level.
214
+ */
215
+ function finalizePrinciple(principle) {
216
+ const text = principle.text.trim();
217
+
218
+ // Extract rationale
219
+ const rationaleMatch = text.match(/\*\*Rationale\*\*:\s*([\s\S]*?)$/m);
220
+ if (rationaleMatch) {
221
+ principle.rationale = rationaleMatch[1].trim();
222
+ }
223
+
224
+ // Determine obligation level (strongest keyword wins)
225
+ if (/\bMUST\b/.test(text)) {
226
+ principle.level = 'MUST';
227
+ } else if (/\bSHOULD\b/.test(text)) {
228
+ principle.level = 'SHOULD';
229
+ } else if (/\bMAY\b/.test(text)) {
230
+ principle.level = 'MAY';
231
+ }
232
+
233
+ principle.text = text;
234
+ }
235
+
236
+ /**
237
+ * Parse spec.md to extract functional requirements.
238
+ * Pattern: - **FR-XXX**: description
239
+ *
240
+ * @param {string} content - Raw markdown content of spec.md
241
+ * @returns {Array<{id: string, text: string}>}
242
+ */
243
+ function parseRequirements(content) {
244
+ if (!content || typeof content !== 'string') return [];
245
+
246
+ const regex = /- \*\*FR-(\d+)\*\*:\s*(.*)/g;
247
+ const requirements = [];
248
+ let match;
249
+
250
+ while ((match = regex.exec(content)) !== null) {
251
+ requirements.push({
252
+ id: `FR-${match[1]}`,
253
+ text: match[2].trim()
254
+ });
255
+ }
256
+
257
+ return requirements;
258
+ }
259
+
260
+ /**
261
+ * Parse spec.md to extract success criteria.
262
+ * Pattern: - **SC-XXX**: description
263
+ *
264
+ * @param {string} content - Raw markdown content of spec.md
265
+ * @returns {Array<{id: string, text: string}>}
266
+ */
267
+ function parseSuccessCriteria(content) {
268
+ if (!content || typeof content !== 'string') return [];
269
+
270
+ const regex = /- \*\*SC-(\d+)\*\*:\s*(.*)/g;
271
+ const criteria = [];
272
+ let match;
273
+
274
+ while ((match = regex.exec(content)) !== null) {
275
+ criteria.push({
276
+ id: `SC-${match[1]}`,
277
+ text: match[2].trim()
278
+ });
279
+ }
280
+
281
+ return criteria;
282
+ }
283
+
284
+ /**
285
+ * Parse spec.md to extract clarification Q&A entries.
286
+ * Pattern: ### Session YYYY-MM-DD followed by - Q: question -> A: answer [FR-001, US-2]
287
+ *
288
+ * @param {string} content - Raw markdown content of spec.md
289
+ * @returns {Array<{session: string, question: string, answer: string, refs: string[]}>}
290
+ */
291
+ function parseClarifications(content) {
292
+ if (!content || typeof content !== 'string') return [];
293
+
294
+ // Check for Clarifications section
295
+ if (!/^## Clarifications/m.test(content)) return [];
296
+
297
+ const clarifications = [];
298
+ const lines = content.split('\n');
299
+ let currentSession = null;
300
+ let inClarifications = false;
301
+
302
+ for (const line of lines) {
303
+ if (/^## Clarifications/.test(line)) {
304
+ inClarifications = true;
305
+ continue;
306
+ }
307
+ if (inClarifications && /^## /.test(line) && !/^## Clarifications/.test(line)) {
308
+ break; // Next top-level section
309
+ }
310
+ if (!inClarifications) continue;
311
+
312
+ const sessionMatch = line.match(/^### Session (\d{4}-\d{2}-\d{2})/);
313
+ if (sessionMatch) {
314
+ currentSession = sessionMatch[1];
315
+ continue;
316
+ }
317
+
318
+ const qaMatch = line.match(/^- Q:\s*(.*?)\s*->\s*A:\s*(.*)/);
319
+ if (qaMatch && currentSession) {
320
+ let answer = qaMatch[2].trim();
321
+ let refs = [];
322
+
323
+ // Extract trailing [FR-001, US-2, SC-003] references
324
+ const refsMatch = answer.match(/\[((?:(?:FR|US|SC)-\w+(?:,\s*)?)+)\]\s*$/);
325
+ if (refsMatch) {
326
+ refs = refsMatch[1].split(/,\s*/).map(r => r.trim());
327
+ answer = answer.substring(0, answer.lastIndexOf('[')).trim();
328
+ }
329
+
330
+ clarifications.push({
331
+ session: currentSession,
332
+ question: qaMatch[1].trim(),
333
+ answer,
334
+ refs
335
+ });
336
+ }
337
+ }
338
+
339
+ return clarifications;
340
+ }
341
+
342
+ /**
343
+ * Parse spec.md to extract edges from user stories to requirements.
344
+ * Scans entire story sections for FR-xxx patterns.
345
+ *
346
+ * @param {string} content - Raw markdown content of spec.md
347
+ * @returns {Array<{from: string, to: string}>}
348
+ */
349
+ function parseStoryRequirementRefs(content) {
350
+ if (!content || typeof content !== 'string') return [];
351
+
352
+ const edges = [];
353
+ const storyRegex = /### User Story (\d+) - .+? \(Priority: P\d+\)/g;
354
+ const storyStarts = [];
355
+ let match;
356
+
357
+ while ((match = storyRegex.exec(content)) !== null) {
358
+ storyStarts.push({ id: `US${match[1]}`, index: match.index });
359
+ }
360
+
361
+ for (let i = 0; i < storyStarts.length; i++) {
362
+ const start = storyStarts[i].index;
363
+ const end = i + 1 < storyStarts.length ? storyStarts[i + 1].index : content.length;
364
+ const section = content.substring(start, end);
365
+ const storyId = storyStarts[i].id;
366
+
367
+ const frRegex = /FR-\d+/g;
368
+ const seen = new Set();
369
+ let frMatch;
370
+
371
+ while ((frMatch = frRegex.exec(section)) !== null) {
372
+ const frId = frMatch[0];
373
+ if (!seen.has(frId)) {
374
+ seen.add(frId);
375
+ edges.push({ from: storyId, to: frId });
376
+ }
377
+ }
378
+ }
379
+
380
+ return edges;
381
+ }
382
+
383
+ /**
384
+ * Parse plan.md Technical Context section to extract key-value entries.
385
+ * Pattern: **Label**: Value
386
+ *
387
+ * @param {string} content - Raw markdown content of plan.md
388
+ * @returns {Array<{label: string, value: string}>}
389
+ */
390
+ function parseTechContext(content) {
391
+ if (!content || typeof content !== 'string') return [];
392
+
393
+ // Find Technical Context section
394
+ const sectionMatch = content.match(/^## Technical Context\s*$/m);
395
+ if (!sectionMatch) return [];
396
+
397
+ const sectionStart = sectionMatch.index + sectionMatch[0].length;
398
+ const nextSection = content.indexOf('\n## ', sectionStart);
399
+ const sectionEnd = nextSection >= 0 ? nextSection : content.length;
400
+ const section = content.substring(sectionStart, sectionEnd);
401
+
402
+ const entries = [];
403
+ const regex = /\*\*(.+?)\*\*:\s*(.+)/g;
404
+ let match;
405
+
406
+ while ((match = regex.exec(section)) !== null) {
407
+ entries.push({
408
+ label: match[1].trim(),
409
+ value: match[2].trim()
410
+ });
411
+ }
412
+
413
+ return entries;
414
+ }
415
+
416
+ /**
417
+ * Parse plan.md File Structure section to extract directory tree entries.
418
+ *
419
+ * @param {string} content - Raw markdown content of plan.md
420
+ * @returns {{rootName: string, entries: Array<{name: string, type: string, comment: string|null, depth: number}>}|null}
421
+ */
422
+ function parseFileStructure(content) {
423
+ if (!content || typeof content !== 'string') return null;
424
+
425
+ // Find File Structure section, then first code block
426
+ const sectionRegex = /^##[^#].*(?:File Structure|Project Structure|Source Code)/m;
427
+ const sectionMatch = content.match(sectionRegex);
428
+ if (!sectionMatch) return null;
429
+
430
+ const afterSection = content.substring(sectionMatch.index);
431
+ const codeBlockMatch = afterSection.match(/```(?:\w*)\n([\s\S]*?)```/);
432
+ if (!codeBlockMatch) return null;
433
+
434
+ const treeText = codeBlockMatch[1];
435
+ const lines = treeText.split('\n').filter(l => l.trim());
436
+
437
+ if (lines.length === 0) return null;
438
+
439
+ // First line ending with / could be:
440
+ // a) A project name to strip (like "iikit-kanban/") — NOT a real directory
441
+ // b) A real directory (like "src/") that should be shown as a tree entry
442
+ // We treat it as a project name ONLY if the name contains a hyphen or number prefix
443
+ // (indicating a project/feature name like "iikit-kanban/", "my-project/")
444
+ // Simple names like "src/", "test/", "lib/" are treated as real directories
445
+ let rootName = '';
446
+ let startIdx = 0;
447
+ const firstLine = lines[0].trim();
448
+ if (firstLine.endsWith('/') && !firstLine.includes('├') && !firstLine.includes('└')) {
449
+ const dirName = firstLine.replace(/\/$/, '');
450
+ const commonDirs = new Set(['src', 'lib', 'test', 'tests', 'bin', 'cmd', 'pkg', 'app', 'api', 'docs', 'public', 'config', 'scripts', 'build', 'dist', 'out', 'vendor', 'internal']);
451
+ const isProjectName = !commonDirs.has(dirName);
452
+ if (isProjectName) {
453
+ rootName = dirName;
454
+ startIdx = 1;
455
+ }
456
+ }
457
+
458
+ const entries = [];
459
+ let bareDirDepthOffset = 0; // tracks depth offset from bare directory sections
460
+
461
+ for (let i = startIdx; i < lines.length; i++) {
462
+ const line = lines[i];
463
+
464
+ // Check for bare directory name (no tree characters, like "test/" between sections)
465
+ const bareDirMatch = line.match(/^([a-zA-Z0-9._-]+\/)\s*(?:#\s*(.*))?$/);
466
+ if (bareDirMatch && !line.includes('├') && !line.includes('└') && !line.includes('│')) {
467
+ const name = bareDirMatch[1].replace(/\/$/, '');
468
+ const comment = bareDirMatch[2] ? bareDirMatch[2].trim() : null;
469
+ entries.push({ name, type: 'directory', comment, depth: 0 });
470
+ bareDirDepthOffset = 1; // subsequent tree entries are children of this directory
471
+ continue;
472
+ }
473
+
474
+ // Calculate depth from tree characters
475
+ let depth = 0;
476
+
477
+ // Count depth by finding the position of the tree branch
478
+ const branchMatch = line.match(/^([\s│]*)[├└]/);
479
+ if (branchMatch) {
480
+ const prefix = branchMatch[1];
481
+ // Each nesting level is typically 4 chars (│ or )
482
+ depth = Math.round(prefix.replace(/│/g, ' ').length / 4) + bareDirDepthOffset;
483
+ }
484
+
485
+ // Extract name and optional comment
486
+ const entryMatch = line.match(/[├└]──\s*([^#\n]+?)(?:\s+#\s*(.*))?$/);
487
+ if (!entryMatch) continue;
488
+
489
+ let name = entryMatch[1].trim();
490
+ const comment = entryMatch[2] ? entryMatch[2].trim() : null;
491
+
492
+ // Determine if directory
493
+ const isDir = name.endsWith('/');
494
+ if (isDir) name = name.replace(/\/$/, '');
495
+
496
+ entries.push({
497
+ name,
498
+ type: isDir ? 'directory' : 'file',
499
+ comment,
500
+ depth
501
+ });
502
+ }
503
+
504
+ // Mark entries as directories if they have children at greater depth
505
+ for (let i = 0; i < entries.length; i++) {
506
+ if (i + 1 < entries.length && entries[i + 1].depth > entries[i].depth) {
507
+ entries[i].type = 'directory';
508
+ }
509
+ }
510
+
511
+ return { rootName, entries };
512
+ }
513
+
514
+ /**
515
+ * Parse plan.md Architecture Overview section to extract ASCII diagram.
516
+ * Detects boxes using box-drawing characters and connections between them.
517
+ *
518
+ * @param {string} content - Raw markdown content of plan.md
519
+ * @returns {{nodes: Array, edges: Array, raw: string}|null}
520
+ */
521
+ function parseAsciiDiagram(content) {
522
+ if (!content || typeof content !== 'string') return null;
523
+
524
+ // Find Architecture Overview section
525
+ const sectionMatch = content.match(/^## Architecture Overview\s*$/m);
526
+ if (!sectionMatch) return null;
527
+
528
+ const afterSection = content.substring(sectionMatch.index);
529
+ const codeBlockMatch = afterSection.match(/```(?:\w*)\n([\s\S]*?)```/);
530
+ if (!codeBlockMatch) return null;
531
+
532
+ const raw = codeBlockMatch[1];
533
+ const lines = raw.split('\n');
534
+
535
+ // Build 2D grid
536
+ const grid = lines.map(l => [...l]);
537
+ const height = grid.length;
538
+ const width = Math.max(...grid.map(r => r.length), 0);
539
+
540
+ // Track which cells belong to boxes
541
+ const boxCells = Array.from({ length: height }, () => new Array(width).fill(false));
542
+
543
+ const nodes = [];
544
+ const used = Array.from({ length: height }, () => new Array(width).fill(false));
545
+
546
+ // Find all boxes: scan for ┌ characters (don't skip used — allows nested boxes)
547
+ for (let y = 0; y < height; y++) {
548
+ for (let x = 0; x < (grid[y] ? grid[y].length : 0); x++) {
549
+ if (grid[y][x] === '┌') {
550
+ const box = traceBox(grid, x, y, used);
551
+ if (box) {
552
+ // Mark cells
553
+ for (let by = box.y; by <= box.y2; by++) {
554
+ for (let bx = box.x; bx <= box.x2; bx++) {
555
+ boxCells[by][bx] = true;
556
+ }
557
+ }
558
+
559
+ // Extract text content
560
+ const textLines = [];
561
+ for (let by = box.y + 1; by < box.y2; by++) {
562
+ const lineText = lines[by]
563
+ ? lines[by].substring(box.x + 1, box.x2).replace(/│/g, ' ').trim()
564
+ : '';
565
+ if (lineText) textLines.push(lineText);
566
+ }
567
+
568
+ if (textLines.length > 0) {
569
+ nodes.push({
570
+ id: `node-${nodes.length}`,
571
+ label: textLines[0],
572
+ content: textLines.join('\n'),
573
+ type: 'default',
574
+ x: box.x,
575
+ y: box.y,
576
+ width: box.x2 - box.x,
577
+ height: box.y2 - box.y
578
+ });
579
+ }
580
+ }
581
+ }
582
+ }
583
+ }
584
+
585
+ // Filter out container boxes (boxes that fully enclose other boxes)
586
+ // Keep only leaf nodes — containers are decorative grouping in ASCII art
587
+ const leafNodes = nodes.filter(node => {
588
+ const containsOther = nodes.some(other =>
589
+ other !== node &&
590
+ other.x > node.x && other.y > node.y &&
591
+ other.x + other.width < node.x + node.width &&
592
+ other.y + other.height < node.y + node.height
593
+ );
594
+ return !containsOther;
595
+ });
596
+ nodes.length = 0;
597
+ nodes.push(...leafNodes);
598
+
599
+ // Find edges: look for connector characters between boxes
600
+ const edges = [];
601
+ const connectorChars = new Set(['│', '─', '┬', '┴', '├', '┤', '┼', '┌', '┐', '└', '┘']);
602
+
603
+ // Simple edge detection: find vertical connectors between box boundaries
604
+ for (let x = 0; x < width; x++) {
605
+ let lastBoxIdx = -1;
606
+ let hasConnector = false;
607
+ let labelText = '';
608
+
609
+ for (let y = 0; y < height; y++) {
610
+ const ch = grid[y] && grid[y][x] ? grid[y][x] : ' ';
611
+
612
+ // Check if we're at a box boundary
613
+ for (let ni = 0; ni < nodes.length; ni++) {
614
+ const n = nodes[ni];
615
+ if (x >= n.x && x <= n.x + n.width) {
616
+ if (y === n.y || y === n.y + n.height) {
617
+ if (lastBoxIdx >= 0 && lastBoxIdx !== ni && hasConnector) {
618
+ // Found an edge
619
+ const existingEdge = edges.find(
620
+ e => (e.from === nodes[lastBoxIdx].id && e.to === nodes[ni].id) ||
621
+ (e.from === nodes[ni].id && e.to === nodes[lastBoxIdx].id)
622
+ );
623
+ if (!existingEdge) {
624
+ edges.push({
625
+ from: nodes[lastBoxIdx].id,
626
+ to: nodes[ni].id,
627
+ label: labelText.trim() || null
628
+ });
629
+ }
630
+ }
631
+ lastBoxIdx = ni;
632
+ hasConnector = false;
633
+ labelText = '';
634
+ }
635
+ }
636
+ }
637
+
638
+ if (!boxCells[y][x] && (ch === '│' || ch === '┬' || ch === '┴' || ch === '┤' || ch === '├')) {
639
+ hasConnector = true;
640
+ // Look for label text on the same line, to the right of connector
641
+ if (grid[y]) {
642
+ const restOfLine = lines[y] ? lines[y].substring(x + 1).trim() : '';
643
+ if (restOfLine && !connectorChars.has(restOfLine[0])) {
644
+ labelText = restOfLine.split(/[┌┐└┘│─┬┴├┤┼]/).filter(Boolean)[0] || '';
645
+ }
646
+ }
647
+ }
648
+ }
649
+ }
650
+
651
+ return { nodes, edges, raw };
652
+ }
653
+
654
+ /**
655
+ * Trace a box from its top-left corner.
656
+ */
657
+ function traceBox(grid, startX, startY, used) {
658
+ const height = grid.length;
659
+
660
+ const topEdgeChars = new Set(['─', '┬', '┴', '┼']);
661
+ const leftEdgeChars = new Set(['│', '├', '┤', '┼']);
662
+
663
+ // Find top-right corner (┐)
664
+ let x2 = startX + 1;
665
+ while (x2 < (grid[startY] ? grid[startY].length : 0) && grid[startY][x2] !== '┐') {
666
+ if (!topEdgeChars.has(grid[startY][x2])) return null;
667
+ x2++;
668
+ }
669
+ if (x2 >= (grid[startY] ? grid[startY].length : 0)) return null;
670
+
671
+ // Find bottom-left corner (└)
672
+ let y2 = startY + 1;
673
+ while (y2 < height && grid[y2] && grid[y2][startX] !== '└') {
674
+ if (!leftEdgeChars.has(grid[y2][startX])) return null;
675
+ y2++;
676
+ }
677
+ if (y2 >= height) return null;
678
+
679
+ // Verify bottom-right corner (┘)
680
+ if (!grid[y2] || grid[y2][x2] !== '┘') return null;
681
+
682
+ // Mark used
683
+ for (let y = startY; y <= y2; y++) {
684
+ for (let x = startX; x <= x2; x++) {
685
+ if (used[y]) used[y][x] = true;
686
+ }
687
+ }
688
+
689
+ return { x: startX, y: startY, x2, y2 };
690
+ }
691
+
692
+ /**
693
+ * Parse tessl.json to extract installed tiles.
694
+ *
695
+ * @param {string} projectPath - Path to project root
696
+ * @returns {Array<{name: string, version: string, eval: null}>}
697
+ */
698
+ function parseTesslJson(projectPath) {
699
+ const tesslPath = path.join(projectPath, 'tessl.json');
700
+ if (!fs.existsSync(tesslPath)) return [];
701
+
702
+ try {
703
+ const content = fs.readFileSync(tesslPath, 'utf-8');
704
+ const json = JSON.parse(content);
705
+ if (!json.dependencies || typeof json.dependencies !== 'object') return [];
706
+
707
+ return Object.entries(json.dependencies).map(([name, info]) => ({
708
+ name,
709
+ version: info.version || 'unknown',
710
+ eval: null
711
+ }));
712
+ } catch {
713
+ return [];
714
+ }
715
+ }
716
+
717
+ /**
718
+ * Parse research.md to extract decision entries.
719
+ *
720
+ * @param {string} content - Raw markdown content of research.md
721
+ * @returns {Array<{title: string, decision: string, rationale: string}>}
722
+ */
723
+ function parseResearchDecisions(content) {
724
+ if (!content || typeof content !== 'string') return [];
725
+
726
+ // Check for Decisions section
727
+ if (!/^## Decisions/m.test(content)) return [];
728
+
729
+ const decisions = [];
730
+ const lines = content.split('\n');
731
+ let inDecisions = false;
732
+ let current = null;
733
+
734
+ for (const line of lines) {
735
+ if (/^## Decisions/.test(line)) {
736
+ inDecisions = true;
737
+ continue;
738
+ }
739
+ if (inDecisions && /^## /.test(line) && !/^## Decisions/.test(line)) {
740
+ break;
741
+ }
742
+ if (!inDecisions) continue;
743
+
744
+ const titleMatch = line.match(/^### \d+\.\s+(.+)/);
745
+ if (titleMatch) {
746
+ if (current) decisions.push(current);
747
+ current = { title: titleMatch[1].trim(), decision: '', rationale: '' };
748
+ continue;
749
+ }
750
+
751
+ if (current) {
752
+ const decisionMatch = line.match(/^\*\*Decision\*\*:\s*(.+)/);
753
+ if (decisionMatch) {
754
+ current.decision = decisionMatch[1].trim();
755
+ continue;
756
+ }
757
+ const rationaleMatch = line.match(/^\*\*Rationale\*\*:\s*(.+)/);
758
+ if (rationaleMatch) {
759
+ current.rationale = rationaleMatch[1].trim();
760
+ }
761
+ }
762
+ }
763
+
764
+ if (current) decisions.push(current);
765
+ return decisions;
766
+ }
767
+
768
+ module.exports = { parseSpecStories, parseTasks, parseChecklists, parseConstitutionTDD, hasClarifications, parseConstitutionPrinciples, parseRequirements, parseSuccessCriteria, parseClarifications, parseStoryRequirementRefs, parseTechContext, parseFileStructure, parseAsciiDiagram, parseTesslJson, parseResearchDecisions };