iikit-dashboard 1.2.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 +2 -2
- package/src/analyze.js +244 -0
- package/src/parser.js +251 -6
- package/src/planview.js +116 -3
- package/src/public/index.html +668 -5
- package/src/server.js +23 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "iikit-dashboard",
|
|
3
|
-
"version": "1.
|
|
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
|
@@ -924,14 +924,259 @@ function parseTaskTestRefs(tasks) {
|
|
|
924
924
|
|
|
925
925
|
const refs = {};
|
|
926
926
|
for (const task of tasks) {
|
|
927
|
-
const
|
|
928
|
-
|
|
929
|
-
|
|
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: '' };
|
|
930
1037
|
} else {
|
|
931
|
-
|
|
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();
|
|
932
1088
|
}
|
|
933
1089
|
}
|
|
934
|
-
|
|
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);
|
|
935
1180
|
}
|
|
936
1181
|
|
|
937
|
-
module.exports = { parseSpecStories, parseTasks, parseChecklists, parseChecklistsDetailed, parseConstitutionTDD, hasClarifications, parseConstitutionPrinciples, parseRequirements, parseSuccessCriteria, parseClarifications, parseStoryRequirementRefs, parseTechContext, parseFileStructure, parseAsciiDiagram, parseTesslJson, parseResearchDecisions, parseTestSpecs, parseTaskTestRefs };
|
|
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 };
|
package/src/planview.js
CHANGED
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
const path = require('path');
|
|
5
|
+
const childProcess = require('child_process');
|
|
6
|
+
const { promisify } = require('util');
|
|
5
7
|
const { parseTechContext, parseFileStructure, parseAsciiDiagram, parseTesslJson, parseResearchDecisions } = require('./parser');
|
|
6
8
|
|
|
7
|
-
module.exports = { computePlanViewState, classifyNodeTypes };
|
|
9
|
+
module.exports = { computePlanViewState, classifyNodeTypes, fetchTesslEvalData };
|
|
8
10
|
|
|
9
11
|
// In-memory cache for LLM classification results per feature
|
|
10
12
|
const classificationCache = new Map();
|
|
@@ -15,9 +17,12 @@ const classificationCache = new Map();
|
|
|
15
17
|
*
|
|
16
18
|
* @param {string} projectPath - Path to project root
|
|
17
19
|
* @param {string} featureId - Feature directory name
|
|
20
|
+
* @param {Object} [options] - Optional configuration
|
|
21
|
+
* @param {Function} [options.fetchEvalData] - Custom eval data fetcher (for testing)
|
|
18
22
|
* @returns {Promise<Object>} Plan view state
|
|
19
23
|
*/
|
|
20
|
-
async function computePlanViewState(projectPath, featureId) {
|
|
24
|
+
async function computePlanViewState(projectPath, featureId, options = {}) {
|
|
25
|
+
const evalFetcher = options.fetchEvalData || fetchTesslEvalData;
|
|
21
26
|
const featureDir = path.join(projectPath, 'specs', featureId);
|
|
22
27
|
const planPath = path.join(featureDir, 'plan.md');
|
|
23
28
|
|
|
@@ -77,8 +82,14 @@ async function computePlanViewState(projectPath, featureId) {
|
|
|
77
82
|
}
|
|
78
83
|
}
|
|
79
84
|
|
|
80
|
-
// Parse tessl.json
|
|
85
|
+
// Parse tessl.json and enrich with eval data
|
|
81
86
|
const tesslTiles = parseTesslJson(projectPath);
|
|
87
|
+
const evalResults = await Promise.all(
|
|
88
|
+
tesslTiles.map(tile => evalFetcher(tile.name).catch(() => null))
|
|
89
|
+
);
|
|
90
|
+
tesslTiles.forEach((tile, i) => {
|
|
91
|
+
tile.eval = evalResults[i];
|
|
92
|
+
});
|
|
82
93
|
|
|
83
94
|
return {
|
|
84
95
|
techContext,
|
|
@@ -181,6 +192,108 @@ No explanation, just the JSON.`
|
|
|
181
192
|
return result;
|
|
182
193
|
}
|
|
183
194
|
|
|
195
|
+
// In-memory cache for eval data per tile name
|
|
196
|
+
const evalCache = new Map();
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Fetch eval data for a Tessl tile using the tessl CLI.
|
|
200
|
+
* Returns structured eval data or null if unavailable.
|
|
201
|
+
*
|
|
202
|
+
* @param {string} tileName - Tile name (e.g., "tessl/npm-express")
|
|
203
|
+
* @returns {Promise<{score: number, multiplier: number, chartData: {pass: number, fail: number}}|null>}
|
|
204
|
+
*/
|
|
205
|
+
async function fetchTesslEvalData(tileName) {
|
|
206
|
+
if (evalCache.has(tileName)) return evalCache.get(tileName);
|
|
207
|
+
|
|
208
|
+
try {
|
|
209
|
+
const execAsync = promisify(childProcess.exec);
|
|
210
|
+
|
|
211
|
+
// List eval runs for the tile
|
|
212
|
+
const { stdout: listOut } = await execAsync(
|
|
213
|
+
`tessl eval list --json --tile "${tileName}" --limit 1`,
|
|
214
|
+
{ timeout: 10000 }
|
|
215
|
+
);
|
|
216
|
+
const listJson = JSON.parse(listOut.substring(listOut.indexOf('{')));
|
|
217
|
+
|
|
218
|
+
if (!listJson.data || listJson.data.length === 0) {
|
|
219
|
+
evalCache.set(tileName, null);
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Find the most recent completed eval run
|
|
224
|
+
const completedRun = listJson.data.find(r => r.attributes && r.attributes.status === 'completed');
|
|
225
|
+
if (!completedRun) {
|
|
226
|
+
evalCache.set(tileName, null);
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Fetch detailed eval results
|
|
231
|
+
const { stdout: viewOut } = await execAsync(
|
|
232
|
+
`tessl eval view --json ${completedRun.id}`,
|
|
233
|
+
{ timeout: 15000 }
|
|
234
|
+
);
|
|
235
|
+
const viewJson = JSON.parse(viewOut.substring(viewOut.indexOf('{')));
|
|
236
|
+
|
|
237
|
+
const evalData = computeEvalSummary(viewJson.data);
|
|
238
|
+
evalCache.set(tileName, evalData);
|
|
239
|
+
return evalData;
|
|
240
|
+
} catch {
|
|
241
|
+
evalCache.set(tileName, null);
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Compute eval summary from a detailed eval run response.
|
|
248
|
+
* Extracts score, multiplier, and pass/fail chart data from scenarios.
|
|
249
|
+
*/
|
|
250
|
+
function computeEvalSummary(evalRun) {
|
|
251
|
+
if (!evalRun || !evalRun.attributes || !evalRun.attributes.scenarios) return null;
|
|
252
|
+
|
|
253
|
+
let usageTotal = 0, usageMax = 0, baselineTotal = 0;
|
|
254
|
+
let pass = 0, fail = 0;
|
|
255
|
+
|
|
256
|
+
for (const scenario of evalRun.attributes.scenarios) {
|
|
257
|
+
if (!scenario.solutions) continue;
|
|
258
|
+
|
|
259
|
+
const usageSpec = scenario.solutions.find(s => s.variant === 'usage-spec');
|
|
260
|
+
const baseline = scenario.solutions.find(s => s.variant === 'baseline');
|
|
261
|
+
|
|
262
|
+
if (usageSpec && usageSpec.assessmentResults) {
|
|
263
|
+
for (const r of usageSpec.assessmentResults) {
|
|
264
|
+
usageTotal += r.score;
|
|
265
|
+
usageMax += r.max_score;
|
|
266
|
+
if (r.score === r.max_score) pass++;
|
|
267
|
+
else fail++;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (baseline && baseline.assessmentResults) {
|
|
272
|
+
for (const r of baseline.assessmentResults) {
|
|
273
|
+
baselineTotal += r.score;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (usageMax === 0) return null;
|
|
279
|
+
|
|
280
|
+
const score = Math.round(usageTotal / usageMax * 100);
|
|
281
|
+
const multiplier = baselineTotal > 0
|
|
282
|
+
? Math.round(usageTotal / baselineTotal * 100) / 100
|
|
283
|
+
: null;
|
|
284
|
+
|
|
285
|
+
return { score, multiplier, chartData: { pass, fail } };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Invalidate eval cache (e.g., when tessl.json changes).
|
|
290
|
+
*/
|
|
291
|
+
function invalidateEvalCache() {
|
|
292
|
+
evalCache.clear();
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
module.exports.invalidateEvalCache = invalidateEvalCache;
|
|
296
|
+
|
|
184
297
|
/**
|
|
185
298
|
* Invalidate classification cache for a feature.
|
|
186
299
|
*/
|