@popoverai/dotrequirements 0.20.4 → 0.21.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/dist/cli.js +20 -0
- package/dist/commands/finalize.d.ts +7 -0
- package/dist/commands/finalize.js +40 -0
- package/dist/commands/prepare.d.ts +6 -0
- package/dist/commands/prepare.js +20 -0
- package/dist/commands/report.d.ts +7 -0
- package/dist/commands/report.js +152 -0
- package/dist/harness/cache.d.ts +7 -0
- package/dist/harness/cache.js +24 -6
- package/dist/harness/finalize.js +19 -3
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -6,6 +6,9 @@ import { pullCommand } from './commands/pull.js';
|
|
|
6
6
|
import { pushCommand } from './commands/push.js';
|
|
7
7
|
import { testCommand } from './commands/test.js';
|
|
8
8
|
import { browserTestCommand } from './commands/browsertest.js';
|
|
9
|
+
import { prepareCommand } from './commands/prepare.js';
|
|
10
|
+
import { finalizeCommand } from './commands/finalize.js';
|
|
11
|
+
import { reportCommand } from './commands/report.js';
|
|
9
12
|
import { mcpCommand } from './commands/mcp.js';
|
|
10
13
|
import { mcpSetupCommand } from './commands/mcp-setup.js';
|
|
11
14
|
import { loadEnvFile } from './utils/env.js';
|
|
@@ -77,6 +80,23 @@ program
|
|
|
77
80
|
.option('--json', 'Output results as JSON')
|
|
78
81
|
.option('--useAgent', 'Encourage Claude to use the Agent tool for multi-step tasks')
|
|
79
82
|
.action(wrapCommand(browserTestCommand));
|
|
83
|
+
program
|
|
84
|
+
.command('prepare')
|
|
85
|
+
.description('Parse requirements and build lookup cache for multi-language test tracking')
|
|
86
|
+
.option('-q, --quiet', 'Suppress output (for scripting)')
|
|
87
|
+
.action(wrapCommand(prepareCommand));
|
|
88
|
+
program
|
|
89
|
+
.command('finalize')
|
|
90
|
+
.description('Aggregate test tracking data and generate coverage report')
|
|
91
|
+
.option('--push', 'Push coverage to DotRequirements Cloud')
|
|
92
|
+
.option('-q, --quiet', 'Output only coverage percentage (for scripting)')
|
|
93
|
+
.action(wrapCommand(finalizeCommand));
|
|
94
|
+
program
|
|
95
|
+
.command('report')
|
|
96
|
+
.description('Display coverage report from most recent test run')
|
|
97
|
+
.option('-f, --format <format>', 'Output format: console, json, markdown', 'console')
|
|
98
|
+
.option('-r, --requirement <id>', 'Filter to specific requirement and its children')
|
|
99
|
+
.action(wrapCommand(reportCommand));
|
|
80
100
|
program
|
|
81
101
|
.command('mcp')
|
|
82
102
|
.description('Start the MCP (Model Context Protocol) server for AI assistant integration')
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { finalize } from '../harness/finalize.js';
|
|
4
|
+
import { findRequirementsDir, isCacheStale } from '../harness/cache.js';
|
|
5
|
+
import { findProjectRoot } from '../utils/project-settings.js';
|
|
6
|
+
export async function finalizeCommand(options) {
|
|
7
|
+
// Warn if cache is stale before finalizing
|
|
8
|
+
if (!options.quiet) {
|
|
9
|
+
const projectRoot = findProjectRoot(process.cwd());
|
|
10
|
+
if (projectRoot) {
|
|
11
|
+
const requirementsDir = findRequirementsDir(projectRoot);
|
|
12
|
+
if (requirementsDir) {
|
|
13
|
+
const lookupPath = path.join(requirementsDir, '.cache', 'lookup.json');
|
|
14
|
+
try {
|
|
15
|
+
const cacheStat = fs.statSync(lookupPath);
|
|
16
|
+
if (isCacheStale(requirementsDir, cacheStat.mtimeMs)) {
|
|
17
|
+
console.error('Warning: Cache may be stale — requirements have changed since last prepare. Run "dotrequirements prepare" to rebuild.\n');
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
// Ignore — finalize will handle missing cache
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
const result = await finalize({
|
|
27
|
+
cwd: process.cwd(),
|
|
28
|
+
reportToCloud: options.push ?? false,
|
|
29
|
+
cleanup: true,
|
|
30
|
+
showSummary: !options.quiet,
|
|
31
|
+
showTestedList: true,
|
|
32
|
+
showUntestedList: true,
|
|
33
|
+
showCloudStatus: !options.quiet,
|
|
34
|
+
});
|
|
35
|
+
if (options.quiet) {
|
|
36
|
+
// In quiet mode, just output the coverage percent for scripting
|
|
37
|
+
console.log(result.coveragePercent.toFixed(1));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
//# sourceMappingURL=finalize.js.map
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { prepare } from '../harness/prepare.js';
|
|
2
|
+
export async function prepareCommand(options) {
|
|
3
|
+
const result = prepare({
|
|
4
|
+
cwd: process.cwd(),
|
|
5
|
+
logWarnings: !options.quiet,
|
|
6
|
+
});
|
|
7
|
+
if (!options.quiet) {
|
|
8
|
+
console.log(`Parsed ${result.filesProcessed} file(s), loaded ${result.requirementsLoaded} requirement(s) into cache.`);
|
|
9
|
+
if (result.parseErrors.length > 0) {
|
|
10
|
+
console.log(`\n⚠️ ${result.parseErrors.length} file(s) had parse errors (skipped):`);
|
|
11
|
+
for (const err of result.parseErrors) {
|
|
12
|
+
console.log(` - ${err.file}: ${err.error}`);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
console.log('\nCache written to .requirements/.cache/lookup.json');
|
|
16
|
+
console.log('Tracking initialized at .requirements/.cache/tracking.jsonl');
|
|
17
|
+
console.log('\nRun your tests, then run "dotrequirements finalize" to generate coverage report.');
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
//# sourceMappingURL=prepare.js.map
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { findProjectRoot } from '../utils/project-settings.js';
|
|
4
|
+
import { findRequirementsDir, readTrackingEntries, isCacheStale, } from '../harness/cache.js';
|
|
5
|
+
function buildCoverageEntries(lookup, entries, filterKey) {
|
|
6
|
+
// Aggregate tracking by key
|
|
7
|
+
const locationsByKey = new Map();
|
|
8
|
+
for (const entry of entries) {
|
|
9
|
+
if (!locationsByKey.has(entry.requirementKey)) {
|
|
10
|
+
locationsByKey.set(entry.requirementKey, new Set());
|
|
11
|
+
}
|
|
12
|
+
locationsByKey.get(entry.requirementKey).add(entry.callerLocation);
|
|
13
|
+
}
|
|
14
|
+
const results = [];
|
|
15
|
+
for (const [key, req] of Object.entries(lookup.requirements)) {
|
|
16
|
+
// Skip alias entries to avoid double-counting
|
|
17
|
+
if (req.isAlias)
|
|
18
|
+
continue;
|
|
19
|
+
// Filter if requested
|
|
20
|
+
if (filterKey && key !== filterKey && !key.startsWith(filterKey + '.')) {
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
// Collect locations from both the numeric key and any alias keys that map to the same id
|
|
24
|
+
const locations = new Set();
|
|
25
|
+
const keyLocations = locationsByKey.get(key);
|
|
26
|
+
if (keyLocations)
|
|
27
|
+
keyLocations.forEach(l => locations.add(l));
|
|
28
|
+
// Also check if tracking data was written using a label path
|
|
29
|
+
for (const [aliasKey, aliasReq] of Object.entries(lookup.requirements)) {
|
|
30
|
+
if (aliasReq.isAlias && aliasReq.id === req.id) {
|
|
31
|
+
const aliasLocations = locationsByKey.get(aliasKey);
|
|
32
|
+
if (aliasLocations)
|
|
33
|
+
aliasLocations.forEach(l => locations.add(l));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
results.push({
|
|
37
|
+
key,
|
|
38
|
+
label: req.label || null,
|
|
39
|
+
content: req.content,
|
|
40
|
+
tested: locations.size > 0,
|
|
41
|
+
locations: Array.from(locations),
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
return results;
|
|
45
|
+
}
|
|
46
|
+
function formatConsole(coverage) {
|
|
47
|
+
const tested = coverage.filter(c => c.tested).length;
|
|
48
|
+
const total = coverage.length;
|
|
49
|
+
const pct = total > 0 ? ((tested / total) * 100).toFixed(1) : '0.0';
|
|
50
|
+
let out = `\n=== Requirements Coverage Report ===\n`;
|
|
51
|
+
out += `\nTotal: ${total} | Tested: ${tested} | Untested: ${total - tested} | Coverage: ${pct}%\n`;
|
|
52
|
+
const testedEntries = coverage.filter(c => c.tested);
|
|
53
|
+
if (testedEntries.length > 0) {
|
|
54
|
+
out += '\n✓ Tested:\n';
|
|
55
|
+
for (const c of testedEntries) {
|
|
56
|
+
const preview = c.content.length > 60 ? c.content.substring(0, 60) + '...' : c.content;
|
|
57
|
+
out += ` ${c.key}: ${preview}\n`;
|
|
58
|
+
for (const loc of c.locations) {
|
|
59
|
+
out += ` ← ${loc}\n`;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const untestedEntries = coverage.filter(c => !c.tested);
|
|
64
|
+
if (untestedEntries.length > 0) {
|
|
65
|
+
out += '\n✗ Untested:\n';
|
|
66
|
+
for (const c of untestedEntries) {
|
|
67
|
+
const preview = c.content.length > 60 ? c.content.substring(0, 60) + '...' : c.content;
|
|
68
|
+
out += ` ${c.key}: ${preview}\n`;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
out += '\n====================================\n';
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
function formatJson(coverage) {
|
|
75
|
+
const tested = coverage.filter(c => c.tested).length;
|
|
76
|
+
const total = coverage.length;
|
|
77
|
+
return JSON.stringify({
|
|
78
|
+
summary: {
|
|
79
|
+
total,
|
|
80
|
+
tested,
|
|
81
|
+
untested: total - tested,
|
|
82
|
+
coveragePercent: total > 0 ? parseFloat(((tested / total) * 100).toFixed(1)) : 0,
|
|
83
|
+
},
|
|
84
|
+
requirements: coverage,
|
|
85
|
+
}, null, 2);
|
|
86
|
+
}
|
|
87
|
+
function formatMarkdown(coverage) {
|
|
88
|
+
const tested = coverage.filter(c => c.tested).length;
|
|
89
|
+
const total = coverage.length;
|
|
90
|
+
const pct = total > 0 ? ((tested / total) * 100).toFixed(1) : '0.0';
|
|
91
|
+
let out = `# Requirements Coverage Report\n\n`;
|
|
92
|
+
out += `**Coverage:** ${tested}/${total} (${pct}%)\n\n`;
|
|
93
|
+
out += `| Requirement | Status | Locations |\n`;
|
|
94
|
+
out += `|---|---|---|\n`;
|
|
95
|
+
for (const c of coverage) {
|
|
96
|
+
const status = c.tested ? '✓' : '✗';
|
|
97
|
+
const locs = c.locations.join(', ') || '-';
|
|
98
|
+
const preview = c.content.length > 40 ? c.content.substring(0, 40) + '...' : c.content;
|
|
99
|
+
out += `| ${c.key}: ${preview} | ${status} | ${locs} |\n`;
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
export async function reportCommand(options) {
|
|
104
|
+
const projectRoot = findProjectRoot(process.cwd());
|
|
105
|
+
if (!projectRoot) {
|
|
106
|
+
throw new Error('Could not find .requirements directory. Run "dotrequirements init" to initialize your project.');
|
|
107
|
+
}
|
|
108
|
+
const requirementsDir = findRequirementsDir(projectRoot);
|
|
109
|
+
const cacheDir = path.join(requirementsDir, '.cache');
|
|
110
|
+
const lookupPath = path.join(cacheDir, 'lookup.json');
|
|
111
|
+
if (!fs.existsSync(lookupPath)) {
|
|
112
|
+
throw new Error('No lookup cache found. Run "dotrequirements prepare" first to build the cache.');
|
|
113
|
+
}
|
|
114
|
+
// Read lookup cache
|
|
115
|
+
const lookupContent = fs.readFileSync(lookupPath, 'utf-8');
|
|
116
|
+
const lookup = JSON.parse(lookupContent);
|
|
117
|
+
// Warn if cache is stale (requirements changed since last prepare)
|
|
118
|
+
try {
|
|
119
|
+
const cacheStat = fs.statSync(lookupPath);
|
|
120
|
+
if (isCacheStale(requirementsDir, cacheStat.mtimeMs)) {
|
|
121
|
+
console.error('Warning: Cache may be stale — requirements have changed since last prepare. Run "dotrequirements prepare" to rebuild.\n');
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
// Ignore stat errors
|
|
126
|
+
}
|
|
127
|
+
// Read tracking entries
|
|
128
|
+
const trackingPath = path.join(cacheDir, 'tracking.jsonl');
|
|
129
|
+
const trackingExists = fs.existsSync(trackingPath);
|
|
130
|
+
const entries = readTrackingEntries(requirementsDir);
|
|
131
|
+
if (!trackingExists) {
|
|
132
|
+
console.error('Warning: No tracking data found. Run your tests first, or run "dotrequirements prepare" to start a new tracking session.\n');
|
|
133
|
+
}
|
|
134
|
+
else if (entries.length === 0) {
|
|
135
|
+
console.error('Warning: Tracking file is empty — no requirements were tracked during tests.\n');
|
|
136
|
+
}
|
|
137
|
+
const coverage = buildCoverageEntries(lookup, entries, options.requirement);
|
|
138
|
+
const format = options.format || 'console';
|
|
139
|
+
switch (format) {
|
|
140
|
+
case 'json':
|
|
141
|
+
console.log(formatJson(coverage));
|
|
142
|
+
break;
|
|
143
|
+
case 'markdown':
|
|
144
|
+
console.log(formatMarkdown(coverage));
|
|
145
|
+
break;
|
|
146
|
+
case 'console':
|
|
147
|
+
default:
|
|
148
|
+
console.log(formatConsole(coverage));
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
//# sourceMappingURL=report.js.map
|
package/dist/harness/cache.d.ts
CHANGED
|
@@ -32,6 +32,8 @@ export interface LookupCache {
|
|
|
32
32
|
id: string;
|
|
33
33
|
label: string;
|
|
34
34
|
content: string;
|
|
35
|
+
/** True if this key is a label-path alias for another entry (e.g. "REQ.given" → "REQ.0") */
|
|
36
|
+
isAlias?: boolean;
|
|
35
37
|
}>;
|
|
36
38
|
}
|
|
37
39
|
/**
|
|
@@ -69,6 +71,11 @@ export declare function findRequirementsFiles(projectRoot: string): string[];
|
|
|
69
71
|
* Write the lookup cache with all pre-parsed requirements
|
|
70
72
|
*/
|
|
71
73
|
export declare function writeLookupCache(requirementsDir: string, requirements: RequirementNode[]): void;
|
|
74
|
+
/**
|
|
75
|
+
* Check if any source file is newer than the cache file.
|
|
76
|
+
* Returns true if cache is stale and should be invalidated.
|
|
77
|
+
*/
|
|
78
|
+
export declare function isCacheStale(requirementsDir: string, cacheMtime: number): boolean;
|
|
72
79
|
/**
|
|
73
80
|
* Read the lookup cache, returning null if not found, invalid, or stale.
|
|
74
81
|
* Cache is considered stale if any .requirements.md file is newer than the cache.
|
package/dist/harness/cache.js
CHANGED
|
@@ -84,19 +84,37 @@ export function writeLookupCache(requirementsDir, requirements) {
|
|
|
84
84
|
generatedAt: new Date().toISOString(),
|
|
85
85
|
requirements: {},
|
|
86
86
|
};
|
|
87
|
-
// Recursively flatten the requirement trees
|
|
88
|
-
function flatten(node) {
|
|
89
|
-
|
|
87
|
+
// Recursively flatten the requirement trees, adding both numeric and label paths
|
|
88
|
+
function flatten(node, labelPath) {
|
|
89
|
+
const entry = {
|
|
90
90
|
id: node.id,
|
|
91
91
|
label: node.label,
|
|
92
92
|
content: node.content,
|
|
93
93
|
};
|
|
94
|
+
// Always add the numeric path (e.g. "AUTH-LOGIN.0")
|
|
95
|
+
lookup.requirements[node.id] = entry;
|
|
96
|
+
// Add label path as an alias if it differs from the numeric path
|
|
97
|
+
if (labelPath && labelPath !== node.id) {
|
|
98
|
+
lookup.requirements[labelPath] = { ...entry, isAlias: true };
|
|
99
|
+
}
|
|
100
|
+
// Track label occurrences for disambiguation (e.g. given#0, given#1)
|
|
101
|
+
const labelCounts = new Map();
|
|
94
102
|
for (const child of node.children) {
|
|
95
|
-
|
|
103
|
+
let childLabelPath = null;
|
|
104
|
+
if (child.label && child.label !== 'requirementHeader') {
|
|
105
|
+
const normalizedLabel = child.label.toLowerCase().replace(/\s+/g, '-');
|
|
106
|
+
const count = labelCounts.get(normalizedLabel) || 0;
|
|
107
|
+
labelCounts.set(normalizedLabel, count + 1);
|
|
108
|
+
const parentPath = labelPath || node.id;
|
|
109
|
+
childLabelPath = count === 0
|
|
110
|
+
? `${parentPath}.${normalizedLabel}`
|
|
111
|
+
: `${parentPath}.${normalizedLabel}#${count}`;
|
|
112
|
+
}
|
|
113
|
+
flatten(child, childLabelPath);
|
|
96
114
|
}
|
|
97
115
|
}
|
|
98
116
|
for (const req of requirements) {
|
|
99
|
-
flatten(req);
|
|
117
|
+
flatten(req, null);
|
|
100
118
|
}
|
|
101
119
|
fs.writeFileSync(lookupPath, JSON.stringify(lookup, null, 2));
|
|
102
120
|
}
|
|
@@ -104,7 +122,7 @@ export function writeLookupCache(requirementsDir, requirements) {
|
|
|
104
122
|
* Check if any source file is newer than the cache file.
|
|
105
123
|
* Returns true if cache is stale and should be invalidated.
|
|
106
124
|
*/
|
|
107
|
-
function isCacheStale(requirementsDir, cacheMtime) {
|
|
125
|
+
export function isCacheStale(requirementsDir, cacheMtime) {
|
|
108
126
|
const projectRoot = path.dirname(requirementsDir);
|
|
109
127
|
const sourceFiles = findRequirementsFiles(projectRoot);
|
|
110
128
|
for (const file of sourceFiles) {
|
package/dist/harness/finalize.js
CHANGED
|
@@ -47,7 +47,7 @@ function getCurrentBranch(cwd) {
|
|
|
47
47
|
*/
|
|
48
48
|
function printLocalReport(testedKeys, lookup, options) {
|
|
49
49
|
const { showSummary, showTestedList, showUntestedList } = options;
|
|
50
|
-
const allKeys = lookup ? Object.keys(lookup.requirements) : [];
|
|
50
|
+
const allKeys = lookup ? Object.keys(lookup.requirements).filter(k => !lookup.requirements[k].isAlias) : [];
|
|
51
51
|
const untestedKeys = allKeys.filter(key => !testedKeys.includes(key));
|
|
52
52
|
const total = allKeys.length;
|
|
53
53
|
const tested = testedKeys.length;
|
|
@@ -278,10 +278,26 @@ export async function finalize(options = {}) {
|
|
|
278
278
|
const entries = readTrackingEntries(requirementsDir);
|
|
279
279
|
// Aggregate by requirement key
|
|
280
280
|
const aggregated = aggregateEntries(entries);
|
|
281
|
-
const testedKeys = Array.from(aggregated.keys());
|
|
282
281
|
// Read lookup cache for report
|
|
283
282
|
const lookup = readLookupCache(requirementsDir);
|
|
284
|
-
|
|
283
|
+
// Normalize alias keys to canonical numeric keys.
|
|
284
|
+
// Non-JS consumers may write label paths (e.g. "AUTH-LOGIN.given") to tracking.jsonl.
|
|
285
|
+
// We resolve those to their canonical numeric key (e.g. "AUTH-LOGIN.0") so that
|
|
286
|
+
// coverage counting, display, and cloud reporting all use consistent keys.
|
|
287
|
+
if (lookup) {
|
|
288
|
+
for (const [key, trackingEntries] of Array.from(aggregated.entries())) {
|
|
289
|
+
const entry = lookup.requirements[key];
|
|
290
|
+
if (entry?.isAlias) {
|
|
291
|
+
const canonicalKey = entry.id;
|
|
292
|
+
// Merge into canonical key's entries
|
|
293
|
+
const existing = aggregated.get(canonicalKey) || [];
|
|
294
|
+
aggregated.set(canonicalKey, [...existing, ...trackingEntries]);
|
|
295
|
+
aggregated.delete(key);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
const testedKeys = Array.from(aggregated.keys());
|
|
300
|
+
const totalRequirements = lookup ? Object.keys(lookup.requirements).filter(k => !lookup.requirements[k].isAlias).length : 0;
|
|
285
301
|
const coveragePercent = totalRequirements > 0
|
|
286
302
|
? (testedKeys.length / totalRequirements) * 100
|
|
287
303
|
: 0;
|