-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcheck-spec-coverage.mjs
More file actions
186 lines (151 loc) · 5.91 KB
/
check-spec-coverage.mjs
File metadata and controls
186 lines (151 loc) · 5.91 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
#!/usr/bin/env node
// OpenSpec scenario <-> test traceability checker.
//
// 1. Parses every "#### Scenario: <name>" from openspec/specs/*/spec.md
// 2. Scans all *.test.ts files for it('Scenario: <name>') strings
// 3. Reports coverage gaps and exits non-zero if any scenario lacks a test.
import { readdir, readFile } from 'node:fs/promises';
import { join, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = join(fileURLToPath(import.meta.url), '..', '..');
const SPECS_DIR = join(ROOT, 'openspec', 'specs');
const PACKAGES_DIR = join(ROOT, 'packages');
// ── Spec parsing ────────────────────────────────────────────────────────────
/**
* Walk openspec/specs/* /spec.md and extract every `#### Scenario: <name>`.
* Returns Map<specId, { requirement: string, scenario: string }[]>
*/
async function parseScenariosFromSpecs() {
/** @type {Map<string, { requirement: string, scenario: string }[]>} */
const specMap = new Map();
const specDirs = await readdir(SPECS_DIR, { withFileTypes: true });
for (const dir of specDirs) {
if (!dir.isDirectory()) continue;
const specId = dir.name;
const specPath = join(SPECS_DIR, specId, 'spec.md');
let content;
try {
content = await readFile(specPath, 'utf-8');
} catch {
continue; // skip if no spec.md
}
const scenarios = [];
let currentRequirement = '(unknown)';
for (const line of content.split('\n')) {
const reqMatch = line.match(/^###\s+Requirement:\s+(.+)/);
if (reqMatch) {
currentRequirement = reqMatch[1].trim();
continue;
}
const scenarioMatch = line.match(/^####\s+Scenario:\s+(.+)/);
if (scenarioMatch) {
scenarios.push({
requirement: currentRequirement,
scenario: scenarioMatch[1].trim(),
});
}
}
if (scenarios.length > 0) {
specMap.set(specId, scenarios);
}
}
return specMap;
}
// ── Test scanning ───────────────────────────────────────────────────────────
/**
* Recursively find all *.test.ts files under packages/.
* @param {string} dir
* @returns {Promise<string[]>}
*/
async function findTestFiles(dir) {
/** @type {string[]} */
const results = [];
const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const full = join(dir, entry.name);
if (entry.isDirectory() && entry.name !== 'node_modules' && entry.name !== 'dist') {
results.push(...(await findTestFiles(full)));
} else if (entry.isFile() && entry.name.endsWith('.test.ts')) {
results.push(full);
}
}
return results;
}
/**
* Extract all scenario names from test files.
* Looks for patterns:
* it('Scenario: <name>', ...)
* it("Scenario: <name>", ...)
* it(`Scenario: <name>`, ...)
*
* Also extracts the describe block's spec-id for mapping.
*
* Returns Map<specId, Set<scenarioName>>
*/
async function parseScenariosFromTests() {
/** @type {Map<string, Set<string>>} */
const testMap = new Map();
const testFiles = await findTestFiles(PACKAGES_DIR);
for (const filePath of testFiles) {
const content = await readFile(filePath, 'utf-8');
// Extract describe block spec-ids: describe('spec-id/requirement', ...)
const describeMatches = [...content.matchAll(/describe\s*\(\s*['"`]([^'"`/]+)\//g)];
const specIds = new Set(describeMatches.map((m) => m[1].trim()));
// Extract scenario names: it('Scenario: name', ...)
const scenarioMatches = [...content.matchAll(/it\s*\(\s*['"`]Scenario:\s+([^'"`]+)['"`]/g)];
for (const specId of specIds) {
if (!testMap.has(specId)) {
testMap.set(specId, new Set());
}
const set = testMap.get(specId);
for (const match of scenarioMatches) {
set.add(match[1].trim());
}
}
}
return testMap;
}
// ── Report ──────────────────────────────────────────────────────────────────
async function main() {
const specMap = await parseScenariosFromSpecs();
const testMap = await parseScenariosFromTests();
let totalScenarios = 0;
let coveredScenarios = 0;
let missingScenarios = 0;
/** @type {{ specId: string, requirement: string, scenario: string }[]} */
const missing = [];
console.log('\n📋 OpenSpec Scenario Coverage Report\n');
console.log('='.repeat(60));
for (const [specId, scenarios] of specMap) {
const testScenarios = testMap.get(specId) ?? new Set();
const covered = scenarios.filter((s) => testScenarios.has(s.scenario));
const uncovered = scenarios.filter((s) => !testScenarios.has(s.scenario));
totalScenarios += scenarios.length;
coveredScenarios += covered.length;
missingScenarios += uncovered.length;
const status = uncovered.length === 0 ? '✅' : '❌';
console.log(`\n${status} ${specId} — ${covered.length}/${scenarios.length} scenarios covered`);
for (const s of uncovered) {
console.log(` MISSING: ${s.requirement} → "${s.scenario}"`);
missing.push({ specId, ...s });
}
}
console.log('\n' + '='.repeat(60));
console.log(`\nTotal: ${coveredScenarios}/${totalScenarios} scenarios covered`);
console.log(`Missing: ${missingScenarios}`);
if (missingScenarios > 0) {
console.log('\n❌ FAIL — Some scenarios have no tests:\n');
for (const m of missing) {
console.log(` - ${m.specId}/${m.requirement} → "Scenario: ${m.scenario}"`);
}
console.log('');
process.exit(1);
} else {
console.log('\n✅ PASS — All scenarios have tests.\n');
process.exit(0);
}
}
main().catch((err) => {
console.error('Script failed:', err);
process.exit(2);
});