@popoverai/dotrequirements 0.20.3 → 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 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';
@@ -75,7 +78,25 @@ program
75
78
  .command('browsertest <requirement-key> [url]')
76
79
  .description('Run browser-based acceptance test for a requirement')
77
80
  .option('--json', 'Output results as JSON')
81
+ .option('--useAgent', 'Encourage Claude to use the Agent tool for multi-step tasks')
78
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));
79
100
  program
80
101
  .command('mcp')
81
102
  .description('Start the MCP (Model Context Protocol) server for AI assistant integration')
@@ -1,5 +1,6 @@
1
1
  interface BrowserTestOptions {
2
2
  json?: boolean;
3
+ useAgent?: boolean;
3
4
  }
4
5
  export declare function browserTestCommand(requirementKey: string, url: string | undefined, options: BrowserTestOptions): Promise<void>;
5
6
  export {};
@@ -82,7 +82,7 @@ export async function browserTestCommand(requirementKey, url, options) {
82
82
  console.log(`Testing ${requirementKey} against ${targetURL}...\n`);
83
83
  }
84
84
  try {
85
- const { stdout } = await execFileAsync('npx', ['@popoverai/browser-automation', 'test', targetURL, ...assertions], { env, maxBuffer: 10 * 1024 * 1024 });
85
+ const { stdout } = await execFileAsync('npx', ['@popoverai/browser-automation', 'test', ...(options.useAgent ? ['--useAgent'] : []), targetURL, ...assertions], { env, maxBuffer: 10 * 1024 * 1024 });
86
86
  // 9. Parse results
87
87
  let results;
88
88
  try {
@@ -0,0 +1,7 @@
1
+ interface FinalizeOptions {
2
+ push?: boolean;
3
+ quiet?: boolean;
4
+ }
5
+ export declare function finalizeCommand(options: FinalizeOptions): Promise<void>;
6
+ export {};
7
+ //# sourceMappingURL=finalize.d.ts.map
@@ -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,6 @@
1
+ interface PrepareOptions {
2
+ quiet?: boolean;
3
+ }
4
+ export declare function prepareCommand(options: PrepareOptions): Promise<void>;
5
+ export {};
6
+ //# sourceMappingURL=prepare.d.ts.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,7 @@
1
+ interface ReportOptions {
2
+ format?: 'console' | 'json' | 'markdown';
3
+ requirement?: string;
4
+ }
5
+ export declare function reportCommand(options: ReportOptions): Promise<void>;
6
+ export {};
7
+ //# sourceMappingURL=report.d.ts.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
@@ -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.
@@ -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
- lookup.requirements[node.id] = {
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
- flatten(child);
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) {
@@ -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
- const totalRequirements = lookup ? Object.keys(lookup.requirements).length : 0;
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@popoverai/dotrequirements",
3
- "version": "0.20.3",
3
+ "version": "0.21.0",
4
4
  "description": "Requirements tracking CLI, test harness, and MCP server",
5
5
  "type": "module",
6
6
  "bin": {