iikit-dashboard 1.1.0 → 1.3.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iikit-dashboard",
3
- "version": "1.1.0",
3
+ "version": "1.3.1",
4
4
  "description": "Real-time dashboard for Intent Integrity Kit (IIKit) — visualizes every phase of specification-driven AI development",
5
5
  "main": "src/server.js",
6
6
  "bin": {
@@ -26,7 +26,7 @@
26
26
  ],
27
27
  "repository": {
28
28
  "type": "git",
29
- "url": "https://github.com/intent-integrity-chain/iikit-dashboard"
29
+ "url": "git+https://github.com/intent-integrity-chain/iikit-dashboard.git"
30
30
  },
31
31
  "author": "Baruch Sadogursky",
32
32
  "license": "MIT",
package/src/analyze.js ADDED
@@ -0,0 +1,244 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const {
6
+ parseAnalysisFindings,
7
+ parseAnalysisCoverage,
8
+ parseAnalysisMetrics,
9
+ parseConstitutionAlignment,
10
+ parsePhaseSeparation,
11
+ parseRequirements,
12
+ parseSuccessCriteria
13
+ } = require('./parser');
14
+
15
+ const SEVERITY_PENALTIES = { CRITICAL: 25, HIGH: 15, MEDIUM: 5, LOW: 2 };
16
+
17
+ /**
18
+ * Compute phase separation score from violation entries.
19
+ * Score = max(0, 100 - sum(penalties)).
20
+ *
21
+ * @param {Array<{severity: string}>} violations
22
+ * @returns {number}
23
+ */
24
+ function computePhaseSeparationScore(violations) {
25
+ if (!violations || violations.length === 0) return 100;
26
+
27
+ const penalty = violations.reduce((sum, v) => {
28
+ return sum + (SEVERITY_PENALTIES[v.severity] || 0);
29
+ }, 0);
30
+
31
+ return Math.max(0, 100 - penalty);
32
+ }
33
+
34
+ /**
35
+ * Compute constitution compliance percentage from alignment entries.
36
+ * (ALIGNED count / total) * 100, rounded.
37
+ *
38
+ * @param {Array<{status: string}>} entries
39
+ * @returns {number}
40
+ */
41
+ function computeConstitutionCompliance(entries) {
42
+ if (!entries || entries.length === 0) return 100;
43
+
44
+ const aligned = entries.filter(e => e.status === 'ALIGNED').length;
45
+ return Math.round((aligned / entries.length) * 100);
46
+ }
47
+
48
+ /**
49
+ * Compute health score from four factors (25% each).
50
+ *
51
+ * @param {{requirementsCoverage: number, constitutionCompliance: number, phaseSeparation: number, testCoverage: number}} factors
52
+ * @returns {{score: number, zone: string, factors: Object}}
53
+ */
54
+ function computeHealthScore(factors) {
55
+ const { requirementsCoverage, constitutionCompliance, phaseSeparation, testCoverage } = factors;
56
+
57
+ const score = Math.round(
58
+ (requirementsCoverage + constitutionCompliance + phaseSeparation + testCoverage) / 4
59
+ );
60
+
61
+ let zone;
62
+ if (score <= 40) zone = 'red';
63
+ else if (score <= 70) zone = 'yellow';
64
+ else zone = 'green';
65
+
66
+ return {
67
+ score,
68
+ zone,
69
+ factors: {
70
+ requirementsCoverage: { value: requirementsCoverage, label: 'Requirements Coverage' },
71
+ constitutionCompliance: { value: constitutionCompliance, label: 'Constitution Compliance' },
72
+ phaseSeparation: { value: phaseSeparation, label: 'Phase Separation' },
73
+ testCoverage: { value: testCoverage, label: 'Test Coverage' }
74
+ }
75
+ };
76
+ }
77
+
78
+ /**
79
+ * Map a coverage entry to a cell status object.
80
+ *
81
+ * @param {boolean} hasArtifact
82
+ * @param {string[]} ids
83
+ * @param {string|null} statusStr - e.g., "Partial", "Full"
84
+ * @returns {{status: string, refs: string[]}}
85
+ */
86
+ function mapCellStatus(hasArtifact, ids, statusStr) {
87
+ if (statusStr && /partial/i.test(statusStr)) {
88
+ return { status: 'partial', refs: ids || [] };
89
+ }
90
+ if (hasArtifact && ids && ids.length > 0) {
91
+ return { status: 'covered', refs: ids };
92
+ }
93
+ if (!hasArtifact || !ids || ids.length === 0) {
94
+ return { status: 'missing', refs: [] };
95
+ }
96
+ return { status: 'covered', refs: ids };
97
+ }
98
+
99
+ /**
100
+ * Build heatmap rows from requirements and coverage data.
101
+ *
102
+ * @param {Array<{id: string, text: string}>} requirements
103
+ * @param {Array} coverageEntries
104
+ * @returns {Array<{id: string, text: string, cells: Object}>}
105
+ */
106
+ function buildHeatmapRows(requirements, coverageEntries) {
107
+ if (!requirements || requirements.length === 0) return [];
108
+
109
+ const coverageMap = {};
110
+ for (const entry of (coverageEntries || [])) {
111
+ coverageMap[entry.id] = entry;
112
+ }
113
+
114
+ return requirements.map(req => {
115
+ const coverage = coverageMap[req.id];
116
+ if (!coverage) {
117
+ return {
118
+ id: req.id,
119
+ text: req.text,
120
+ cells: {
121
+ tasks: { status: 'missing', refs: [] },
122
+ tests: { status: 'missing', refs: [] },
123
+ plan: { status: 'na', refs: [] }
124
+ }
125
+ };
126
+ }
127
+
128
+ return {
129
+ id: req.id,
130
+ text: req.text,
131
+ cells: {
132
+ tasks: mapCellStatus(coverage.hasTask, coverage.taskIds, coverage.status === 'Partial' && !coverage.hasTask ? 'Partial' : null),
133
+ tests: mapCellStatus(coverage.hasTest, coverage.testIds, null),
134
+ plan: coverage.hasPlan !== undefined
135
+ ? mapCellStatus(coverage.hasPlan, coverage.planRefs, null)
136
+ : { status: 'na', refs: [] }
137
+ }
138
+ };
139
+ });
140
+ }
141
+
142
+ /**
143
+ * Compute the full analyze view state for a feature.
144
+ *
145
+ * @param {string} projectPath
146
+ * @param {string} featureId
147
+ * @returns {Object}
148
+ */
149
+ function computeAnalyzeState(projectPath, featureId) {
150
+ const featureDir = path.join(projectPath, 'specs', featureId);
151
+ const analysisPath = path.join(featureDir, 'analysis.md');
152
+ const specPath = path.join(featureDir, 'spec.md');
153
+
154
+ if (!fs.existsSync(analysisPath)) {
155
+ return {
156
+ healthScore: null,
157
+ heatmap: { columns: [], rows: [] },
158
+ issues: [],
159
+ metrics: null,
160
+ constitutionAlignment: [],
161
+ exists: false
162
+ };
163
+ }
164
+
165
+ const analysisContent = fs.readFileSync(analysisPath, 'utf-8');
166
+ const specContent = fs.existsSync(specPath) ? fs.readFileSync(specPath, 'utf-8') : '';
167
+
168
+ // Parse all sections
169
+ const findings = parseAnalysisFindings(analysisContent);
170
+ const coverage = parseAnalysisCoverage(analysisContent);
171
+ const metrics = parseAnalysisMetrics(analysisContent);
172
+ const constitutionAlignment = parseConstitutionAlignment(analysisContent);
173
+ const phaseSeparationViolations = parsePhaseSeparation(analysisContent);
174
+
175
+ // Build heatmap from spec requirements + coverage data
176
+ const requirements = [
177
+ ...parseRequirements(specContent),
178
+ ...parseSuccessCriteria(specContent)
179
+ ];
180
+ const heatmapRows = buildHeatmapRows(requirements, coverage);
181
+
182
+ // Compute health score factors
183
+ const reqCovPct = metrics.requirementCoveragePct || 0;
184
+ const testCovPct = metrics.testCoveragePct || 100;
185
+ const constitutionCompliancePct = computeConstitutionCompliance(constitutionAlignment);
186
+ const phaseSepScore = computePhaseSeparationScore(
187
+ phaseSeparationViolations.filter(v => v.severity)
188
+ );
189
+
190
+ const healthScore = computeHealthScore({
191
+ requirementsCoverage: reqCovPct,
192
+ constitutionCompliance: constitutionCompliancePct,
193
+ phaseSeparation: phaseSepScore,
194
+ testCoverage: testCovPct
195
+ });
196
+
197
+ // Map findings to issues (API terminology)
198
+ const issues = findings.map(f => ({
199
+ id: f.id,
200
+ category: f.category,
201
+ severity: f.severity.toLowerCase(),
202
+ location: f.location,
203
+ summary: f.summary,
204
+ recommendation: f.recommendation,
205
+ resolved: f.resolved
206
+ }));
207
+
208
+ return {
209
+ healthScore: {
210
+ ...healthScore,
211
+ trend: null
212
+ },
213
+ heatmap: {
214
+ columns: ['tasks', 'tests', 'plan'],
215
+ rows: heatmapRows
216
+ },
217
+ issues,
218
+ metrics: {
219
+ totalRequirements: metrics.totalRequirements,
220
+ totalTasks: metrics.totalTasks,
221
+ totalTestSpecs: metrics.totalTestSpecs,
222
+ requirementCoverage: metrics.requirementCoverage,
223
+ criticalIssues: metrics.criticalIssues,
224
+ highIssues: metrics.highIssues,
225
+ mediumIssues: metrics.mediumIssues,
226
+ lowIssues: metrics.lowIssues
227
+ },
228
+ constitutionAlignment: constitutionAlignment.map(a => ({
229
+ principle: a.principle,
230
+ status: a.status,
231
+ evidence: a.evidence
232
+ })),
233
+ exists: true
234
+ };
235
+ }
236
+
237
+ module.exports = {
238
+ computeHealthScore,
239
+ computeAnalyzeState,
240
+ buildHeatmapRows,
241
+ mapCellStatus,
242
+ computePhaseSeparationScore,
243
+ computeConstitutionCompliance
244
+ };
package/src/parser.js CHANGED
@@ -855,4 +855,328 @@ function parseResearchDecisions(content) {
855
855
  return decisions;
856
856
  }
857
857
 
858
- module.exports = { parseSpecStories, parseTasks, parseChecklists, parseChecklistsDetailed, parseConstitutionTDD, hasClarifications, parseConstitutionPrinciples, parseRequirements, parseSuccessCriteria, parseClarifications, parseStoryRequirementRefs, parseTechContext, parseFileStructure, parseAsciiDiagram, parseTesslJson, parseResearchDecisions };
858
+ /**
859
+ * Parse tests/test-specs.md to extract test specification entries.
860
+ * Pattern: ### TS-XXX: Title, then **Type**: value, **Priority**: value, **Traceability**: refs
861
+ *
862
+ * @param {string} content - Raw markdown content of test-specs.md
863
+ * @returns {Array<{id: string, title: string, type: string, priority: string, traceability: string[]}>}
864
+ */
865
+ function parseTestSpecs(content) {
866
+ if (!content || typeof content !== 'string') return [];
867
+
868
+ const specs = [];
869
+ const headingRegex = /### TS-(\d+): (.+)/g;
870
+ const headingStarts = [];
871
+ let match;
872
+
873
+ while ((match = headingRegex.exec(content)) !== null) {
874
+ headingStarts.push({
875
+ id: `TS-${match[1]}`,
876
+ title: match[2].trim(),
877
+ index: match.index
878
+ });
879
+ }
880
+
881
+ for (let i = 0; i < headingStarts.length; i++) {
882
+ const start = headingStarts[i].index;
883
+ const end = i + 1 < headingStarts.length ? headingStarts[i + 1].index : content.length;
884
+ const section = content.substring(start, end);
885
+
886
+ // Extract type
887
+ const typeMatch = section.match(/\*\*Type\*\*:\s*(acceptance|contract|validation)/);
888
+ const type = typeMatch ? typeMatch[1] : 'validation';
889
+
890
+ // Extract priority
891
+ const priorityMatch = section.match(/\*\*Priority\*\*:\s*(P\d+)/);
892
+ const priority = priorityMatch ? priorityMatch[1] : 'P3';
893
+
894
+ // Extract traceability — comma-separated IDs, filter to FR-/SC- only
895
+ let traceability = [];
896
+ const traceMatch = section.match(/\*\*Traceability\*\*:\s*(.+)/);
897
+ if (traceMatch) {
898
+ traceability = traceMatch[1]
899
+ .split(/,\s*/)
900
+ .map(s => s.trim())
901
+ .filter(s => /^(FR|SC)-\d+$/.test(s));
902
+ }
903
+
904
+ specs.push({
905
+ id: headingStarts[i].id,
906
+ title: headingStarts[i].title,
907
+ type,
908
+ priority,
909
+ traceability
910
+ });
911
+ }
912
+
913
+ return specs;
914
+ }
915
+
916
+ /**
917
+ * Extract "must pass TS-xxx" references from already-parsed task descriptions.
918
+ *
919
+ * @param {Array<{id: string, description: string}>} tasks - Parsed tasks array
920
+ * @returns {Object<string, string[]>} Map of taskId to testSpecIds array
921
+ */
922
+ function parseTaskTestRefs(tasks) {
923
+ if (!tasks || !Array.isArray(tasks)) return {};
924
+
925
+ const refs = {};
926
+ for (const task of tasks) {
927
+ const matches = task.description ? task.description.match(/TS-\d+/g) : null;
928
+ refs[task.id] = matches ? [...new Set(matches)] : [];
929
+ }
930
+ return refs;
931
+ }
932
+
933
+ /**
934
+ * Extract a markdown section by heading (## Title), returning content until next ## heading.
935
+ */
936
+ function extractSection(content, heading) {
937
+ const regex = new RegExp(`^## ${heading}\\s*$`, 'm');
938
+ const match = content.match(regex);
939
+ if (!match) return null;
940
+
941
+ const start = match.index + match[0].length;
942
+ const nextSection = content.indexOf('\n## ', start);
943
+ return content.substring(start, nextSection >= 0 ? nextSection : content.length).trim();
944
+ }
945
+
946
+ /**
947
+ * Parse rows from a pipe-delimited markdown table.
948
+ * Returns array of arrays (one per row, cells trimmed). Skips header separator row (|---|).
949
+ */
950
+ function parseMarkdownTable(text) {
951
+ const lines = text.split('\n').filter(l => l.trim().startsWith('|'));
952
+ if (lines.length < 2) return [];
953
+
954
+ // Skip header row (index 0) and separator row (index 1)
955
+ return lines.slice(2).map(line =>
956
+ line.split('|').slice(1, -1).map(cell => cell.trim())
957
+ ).filter(cells => cells.length > 0 && cells.some(c => c !== ''));
958
+ }
959
+
960
+ /**
961
+ * Parse analysis.md Findings section.
962
+ * Extracts issues with id, category, severity, resolved, location, summary, recommendation.
963
+ *
964
+ * @param {string} content - Raw analysis.md content
965
+ * @returns {Array<{id: string, category: string, severity: string, resolved: boolean, location: string, summary: string, recommendation: string}>}
966
+ */
967
+ function parseAnalysisFindings(content) {
968
+ if (!content || typeof content !== 'string') return [];
969
+
970
+ const section = extractSection(content, 'Findings');
971
+ if (!section) return [];
972
+
973
+ const rows = parseMarkdownTable(section);
974
+ if (rows.length === 0) return [];
975
+
976
+ return rows.map(cells => {
977
+ if (cells.length < 6) return null;
978
+
979
+ const rawSeverity = cells[2];
980
+ // Detect ~~SEVERITY~~ RESOLVED pattern
981
+ const resolvedMatch = rawSeverity.match(/~~(\w+)~~\s*RESOLVED/);
982
+ const resolved = !!resolvedMatch;
983
+ const severity = resolved ? resolvedMatch[1] : rawSeverity;
984
+
985
+ return {
986
+ id: cells[0],
987
+ category: cells[1],
988
+ severity,
989
+ resolved,
990
+ location: cells[3],
991
+ summary: cells[4],
992
+ recommendation: cells[5]
993
+ };
994
+ }).filter(Boolean);
995
+ }
996
+
997
+ /**
998
+ * Parse analysis.md Coverage Summary section.
999
+ * Handles both simple (Requirement, Has Task?, Notes) and
1000
+ * detailed (Requirement, Has Task?, Task IDs, Has Test?, Test IDs, Status) formats.
1001
+ *
1002
+ * @param {string} content - Raw analysis.md content
1003
+ * @returns {Array<{id: string, hasTask: boolean, taskIds: string[], hasTest: boolean, testIds: string[], status: string|null, notes: string}>}
1004
+ */
1005
+ function parseAnalysisCoverage(content) {
1006
+ if (!content || typeof content !== 'string') return [];
1007
+
1008
+ const section = extractSection(content, 'Coverage Summary');
1009
+ if (!section) return [];
1010
+
1011
+ const rows = parseMarkdownTable(section);
1012
+ if (rows.length === 0) return [];
1013
+
1014
+ // Detect format by number of columns in first data row
1015
+ const hasPlanCols = rows[0].length >= 8;
1016
+ const isDetailed = rows[0].length >= 6;
1017
+
1018
+ return rows.map(cells => {
1019
+ const id = cells[0];
1020
+ const hasTask = /^yes$/i.test(cells[1]);
1021
+
1022
+ if (isDetailed) {
1023
+ // Detailed: Requirement | Has Task? | Task IDs | Has Test? | Test IDs | [Has Plan? | Plan Refs |] Status
1024
+ const taskIds = parseIdList(cells[2]);
1025
+ const hasTest = /^yes$/i.test(cells[3]);
1026
+ const testIds = parseIdList(cells[4]);
1027
+
1028
+ if (hasPlanCols) {
1029
+ const hasPlan = /^yes$/i.test(cells[5]);
1030
+ const planRefs = parseIdList(cells[6]);
1031
+ const status = cells[7] && cells[7] !== '—' && cells[7] !== '-' ? cells[7] : null;
1032
+ return { id, hasTask, taskIds, hasTest, testIds, hasPlan, planRefs, status, notes: '' };
1033
+ }
1034
+
1035
+ const status = cells[5] && cells[5] !== '—' && cells[5] !== '-' ? cells[5] : null;
1036
+ return { id, hasTask, taskIds, hasTest, testIds, status, notes: '' };
1037
+ } else {
1038
+ // Simple: Requirement | Has Task? | Notes
1039
+ const notes = cells[2] || '';
1040
+ return { id, hasTask, taskIds: [], hasTest: false, testIds: [], status: null, notes };
1041
+ }
1042
+ });
1043
+ }
1044
+
1045
+ /**
1046
+ * Parse a comma-separated list of IDs (e.g., "T001, T002" or "TS-001, TS-037").
1047
+ * Filters out dashes and empty entries.
1048
+ */
1049
+ function parseIdList(cell) {
1050
+ if (!cell || cell === '—' || cell === '-' || cell === '–') return [];
1051
+ return cell.split(',').map(s => s.trim()).filter(s => s && s !== '—' && s !== '-' && s !== '–');
1052
+ }
1053
+
1054
+ /**
1055
+ * Parse analysis.md Metrics section.
1056
+ * Handles both table format (| Metric | Value |) and bullet format (- Metric: Value).
1057
+ *
1058
+ * @param {string} content - Raw analysis.md content
1059
+ * @returns {{totalRequirements: number, totalTasks: number, totalTestSpecs: number, requirementCoverage: string, requirementCoveragePct: number, testCoverage: string|null, testCoveragePct: number, criticalIssues: number, highIssues: number, mediumIssues: number, lowIssues: number}}
1060
+ */
1061
+ function parseAnalysisMetrics(content) {
1062
+ const defaults = {
1063
+ totalRequirements: 0, totalTasks: 0, totalTestSpecs: 0,
1064
+ requirementCoverage: '', requirementCoveragePct: 0,
1065
+ testCoverage: null, testCoveragePct: 100,
1066
+ criticalIssues: 0, highIssues: 0, mediumIssues: 0, lowIssues: 0
1067
+ };
1068
+ if (!content || typeof content !== 'string') return defaults;
1069
+
1070
+ const section = extractSection(content, 'Metrics');
1071
+ if (!section) return defaults;
1072
+
1073
+ // Build key-value map from either table or bullet format
1074
+ const kvMap = {};
1075
+
1076
+ // Try table format first
1077
+ const tableRows = parseMarkdownTable(section);
1078
+ if (tableRows.length > 0) {
1079
+ for (const cells of tableRows) {
1080
+ if (cells.length >= 2) kvMap[cells[0].toLowerCase()] = cells[1];
1081
+ }
1082
+ } else {
1083
+ // Try bullet format: - Key: Value
1084
+ const bulletRegex = /^-\s+(.+?):\s+(.+)$/gm;
1085
+ let match;
1086
+ while ((match = bulletRegex.exec(section)) !== null) {
1087
+ kvMap[match[1].trim().toLowerCase()] = match[2].trim();
1088
+ }
1089
+ }
1090
+
1091
+ function findValue(keys) {
1092
+ for (const key of keys) {
1093
+ for (const [k, v] of Object.entries(kvMap)) {
1094
+ if (k.includes(key)) return v;
1095
+ }
1096
+ }
1097
+ return null;
1098
+ }
1099
+
1100
+ function extractPct(raw) {
1101
+ if (!raw) return null;
1102
+ const pctMatch = raw.match(/(\d+)%/);
1103
+ if (pctMatch) return parseInt(pctMatch[1], 10);
1104
+ const fracMatch = raw.match(/\((\d+)%\)/);
1105
+ if (fracMatch) return parseInt(fracMatch[1], 10);
1106
+ return null;
1107
+ }
1108
+
1109
+ const reqCovRaw = findValue(['requirement coverage']);
1110
+ const testCovRaw = findValue(['test coverage']);
1111
+
1112
+ return {
1113
+ totalRequirements: parseInt(findValue(['total requirements']) || '0', 10),
1114
+ totalTasks: parseInt(findValue(['total tasks']) || '0', 10),
1115
+ totalTestSpecs: parseInt(findValue(['total test spec']) || '0', 10),
1116
+ requirementCoverage: reqCovRaw || '',
1117
+ requirementCoveragePct: extractPct(reqCovRaw) || 0,
1118
+ testCoverage: testCovRaw || null,
1119
+ testCoveragePct: testCovRaw ? (extractPct(testCovRaw) || 0) : 100,
1120
+ criticalIssues: parseInt(findValue(['critical']) || '0', 10),
1121
+ highIssues: parseInt(findValue(['high']) || '0', 10),
1122
+ mediumIssues: parseInt(findValue(['medium']) || '0', 10),
1123
+ lowIssues: parseInt(findValue(['low']) || '0', 10)
1124
+ };
1125
+ }
1126
+
1127
+ /**
1128
+ * Parse analysis.md Constitution Alignment section.
1129
+ *
1130
+ * @param {string} content - Raw analysis.md content
1131
+ * @returns {Array<{principle: string, status: string, evidence: string}>}
1132
+ */
1133
+ function parseConstitutionAlignment(content) {
1134
+ if (!content || typeof content !== 'string') return [];
1135
+
1136
+ const section = extractSection(content, 'Constitution Alignment');
1137
+ if (!section) return [];
1138
+
1139
+ // Check for "None detected" text
1140
+ if (/none detected/i.test(section) && !section.includes('|')) return [];
1141
+
1142
+ const rows = parseMarkdownTable(section);
1143
+ return rows.map(cells => {
1144
+ if (cells.length < 3) return null;
1145
+ return {
1146
+ principle: cells[0],
1147
+ status: cells[1],
1148
+ evidence: cells[2]
1149
+ };
1150
+ }).filter(Boolean);
1151
+ }
1152
+
1153
+ /**
1154
+ * Parse analysis.md Phase Separation Violations section.
1155
+ *
1156
+ * @param {string} content - Raw analysis.md content
1157
+ * @returns {Array<{artifact: string, status: string, severity: string|null}>}
1158
+ */
1159
+ function parsePhaseSeparation(content) {
1160
+ if (!content || typeof content !== 'string') return [];
1161
+
1162
+ const section = extractSection(content, 'Phase Separation Violations');
1163
+ if (!section) return [];
1164
+
1165
+ // Check for "None detected" before the table
1166
+ const noneIdx = section.search(/none detected/i);
1167
+ const tableIdx = section.indexOf('|');
1168
+ if (noneIdx >= 0 && (tableIdx < 0 || noneIdx < tableIdx)) return [];
1169
+
1170
+ const rows = parseMarkdownTable(section);
1171
+ return rows.map(cells => {
1172
+ if (cells.length < 2) return null;
1173
+ const severity = cells.length >= 3 && cells[2] && cells[2] !== '—' && cells[2] !== '-' && cells[2] !== '–' ? cells[2] : null;
1174
+ return {
1175
+ artifact: cells[0],
1176
+ status: cells[1],
1177
+ severity
1178
+ };
1179
+ }).filter(Boolean);
1180
+ }
1181
+
1182
+ module.exports = { parseSpecStories, parseTasks, parseChecklists, parseChecklistsDetailed, parseConstitutionTDD, hasClarifications, parseConstitutionPrinciples, parseRequirements, parseSuccessCriteria, parseClarifications, parseStoryRequirementRefs, parseTechContext, parseFileStructure, parseAsciiDiagram, parseTesslJson, parseResearchDecisions, parseTestSpecs, parseTaskTestRefs, parseAnalysisFindings, parseAnalysisCoverage, parseAnalysisMetrics, parseConstitutionAlignment, parsePhaseSeparation };