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/README.md +77 -0
- package/bin/iikit-dashboard.js +68 -0
- package/package.json +45 -0
- package/src/board.js +93 -0
- package/src/integrity.js +63 -0
- package/src/parser.js +768 -0
- package/src/pipeline.js +130 -0
- package/src/planview.js +195 -0
- package/src/public/index.html +3322 -0
- package/src/server.js +302 -0
- package/src/storymap.js +40 -0
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 };
|