@paths.design/caws-cli 9.2.0 → 9.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/dist/commands/specs.js +28 -15
- package/dist/commands/status.js +1 -1
- package/dist/commands/verify-acs.js +471 -0
- package/dist/index.js +13 -1
- package/dist/parallel/parallel-manager.js +5 -12
- package/dist/scaffold/cursor-hooks.js +0 -1
- package/dist/scaffold/git-hooks.js +18 -1
- package/dist/templates/.caws/tools/README.md +4 -7
- package/dist/templates/.caws/tools/scope-guard.js +115 -171
- package/dist/templates/.claude/hooks/audit.sh +25 -0
- package/dist/templates/.claude/hooks/block-dangerous.sh +39 -0
- package/dist/templates/.claude/hooks/lite-sprawl-check.sh +30 -2
- package/dist/templates/.claude/hooks/naming-check.sh +5 -2
- package/dist/templates/.claude/hooks/scope-guard.sh +66 -4
- package/dist/templates/.claude/hooks/session-log.sh +38 -5
- package/dist/templates/.claude/rules/worktree-isolation.md +4 -1
- package/dist/templates/.cursor/README.md +0 -9
- package/dist/templates/.cursor/hooks/audit.sh +1 -1
- package/dist/templates/.cursor/hooks/block-dangerous.sh +1 -0
- package/dist/templates/.cursor/hooks/scan-secrets.sh +8 -3
- package/dist/templates/.cursor/hooks.json +0 -8
- package/dist/templates/.vscode/launch.json +0 -12
- package/dist/utils/detection.js +38 -0
- package/dist/utils/project-analysis.js +0 -1
- package/dist/utils/spec-resolver.js +23 -10
- package/dist/worktree/worktree-manager.js +160 -6
- package/package.json +1 -1
- package/templates/.caws/tools/README.md +4 -7
- package/templates/.caws/tools/scope-guard.js +115 -171
- package/templates/.claude/hooks/audit.sh +25 -0
- package/templates/.claude/hooks/block-dangerous.sh +39 -0
- package/templates/.claude/hooks/lite-sprawl-check.sh +30 -2
- package/templates/.claude/hooks/naming-check.sh +5 -2
- package/templates/.claude/hooks/scope-guard.sh +66 -4
- package/templates/.claude/hooks/session-log.sh +38 -5
- package/templates/.claude/rules/worktree-isolation.md +4 -1
- package/templates/.cursor/README.md +0 -9
- package/templates/.cursor/hooks/audit.sh +1 -1
- package/templates/.cursor/hooks/block-dangerous.sh +1 -0
- package/templates/.cursor/hooks/scan-secrets.sh +8 -3
- package/templates/.cursor/hooks.json +0 -8
- package/templates/.vscode/launch.json +0 -12
- package/templates/.cursor/hooks/caws-tool-validation.sh +0 -121
package/dist/commands/specs.js
CHANGED
|
@@ -14,10 +14,19 @@ const { SPEC_TYPES } = require('../constants/spec-types');
|
|
|
14
14
|
|
|
15
15
|
// Import suggestFeatureBreakdown from spec-resolver
|
|
16
16
|
const { suggestFeatureBreakdown } = require('../utils/spec-resolver');
|
|
17
|
+
const { findProjectRoot } = require('../utils/detection');
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
|
-
* Specs directory structure
|
|
20
|
+
* Specs directory structure — anchored to the CAWS project root,
|
|
21
|
+
* not process.cwd(), so the CLI works from subdirectories and monorepos.
|
|
20
22
|
*/
|
|
23
|
+
function getSpecsDir() {
|
|
24
|
+
return path.join(findProjectRoot(), '.caws', 'specs');
|
|
25
|
+
}
|
|
26
|
+
function getSpecsRegistry() {
|
|
27
|
+
return path.join(findProjectRoot(), '.caws', 'specs', 'registry.json');
|
|
28
|
+
}
|
|
29
|
+
// Legacy constants kept for backward compatibility in tests
|
|
21
30
|
const SPECS_DIR = '.caws/specs';
|
|
22
31
|
const SPECS_REGISTRY = '.caws/specs/registry.json';
|
|
23
32
|
|
|
@@ -27,7 +36,8 @@ const SPECS_REGISTRY = '.caws/specs/registry.json';
|
|
|
27
36
|
*/
|
|
28
37
|
async function loadSpecsRegistry() {
|
|
29
38
|
try {
|
|
30
|
-
|
|
39
|
+
const registryPath = getSpecsRegistry();
|
|
40
|
+
if (!(await fs.pathExists(registryPath))) {
|
|
31
41
|
return {
|
|
32
42
|
version: '1.0.0',
|
|
33
43
|
specs: {},
|
|
@@ -35,7 +45,7 @@ async function loadSpecsRegistry() {
|
|
|
35
45
|
};
|
|
36
46
|
}
|
|
37
47
|
|
|
38
|
-
const registry = JSON.parse(await fs.readFile(
|
|
48
|
+
const registry = JSON.parse(await fs.readFile(registryPath, 'utf8'));
|
|
39
49
|
return registry;
|
|
40
50
|
} catch (error) {
|
|
41
51
|
return {
|
|
@@ -52,9 +62,10 @@ async function loadSpecsRegistry() {
|
|
|
52
62
|
* @returns {Promise<void>}
|
|
53
63
|
*/
|
|
54
64
|
async function saveSpecsRegistry(registry) {
|
|
55
|
-
|
|
65
|
+
const registryPath = getSpecsRegistry();
|
|
66
|
+
await fs.ensureDir(path.dirname(registryPath));
|
|
56
67
|
registry.lastUpdated = new Date().toISOString();
|
|
57
|
-
await fs.writeFile(
|
|
68
|
+
await fs.writeFile(registryPath, JSON.stringify(registry, null, 2));
|
|
58
69
|
}
|
|
59
70
|
|
|
60
71
|
/**
|
|
@@ -62,16 +73,17 @@ async function saveSpecsRegistry(registry) {
|
|
|
62
73
|
* @returns {Promise<Array>} Array of spec file info
|
|
63
74
|
*/
|
|
64
75
|
async function listSpecFiles() {
|
|
65
|
-
|
|
76
|
+
const specsDir = getSpecsDir();
|
|
77
|
+
if (!(await fs.pathExists(specsDir))) {
|
|
66
78
|
return [];
|
|
67
79
|
}
|
|
68
80
|
|
|
69
|
-
const files = await fs.readdir(
|
|
81
|
+
const files = await fs.readdir(specsDir, { recursive: true });
|
|
70
82
|
const yamlFiles = files.filter((file) => file.endsWith('.yaml') || file.endsWith('.yml'));
|
|
71
83
|
|
|
72
84
|
const specs = [];
|
|
73
85
|
for (const file of yamlFiles) {
|
|
74
|
-
const filePath = path.join(
|
|
86
|
+
const filePath = path.join(specsDir, file);
|
|
75
87
|
try {
|
|
76
88
|
const content = await fs.readFile(filePath, 'utf8');
|
|
77
89
|
const spec = yaml.load(content);
|
|
@@ -121,7 +133,8 @@ async function createSpec(id, options = {}) {
|
|
|
121
133
|
}
|
|
122
134
|
|
|
123
135
|
// Check for existing spec
|
|
124
|
-
const
|
|
136
|
+
const specsDir = getSpecsDir();
|
|
137
|
+
const existingSpecPath = path.join(specsDir, `${id}.yaml`);
|
|
125
138
|
const specExists = await fs.pathExists(existingSpecPath);
|
|
126
139
|
|
|
127
140
|
// Handle conflict resolution
|
|
@@ -185,7 +198,7 @@ async function createSpec(id, options = {}) {
|
|
|
185
198
|
}
|
|
186
199
|
|
|
187
200
|
// Ensure specs directory exists
|
|
188
|
-
await fs.ensureDir(
|
|
201
|
+
await fs.ensureDir(specsDir);
|
|
189
202
|
|
|
190
203
|
// Generate spec content with all required fields
|
|
191
204
|
// Merge template carefully to preserve required fields and structure
|
|
@@ -260,7 +273,7 @@ async function createSpec(id, options = {}) {
|
|
|
260
273
|
|
|
261
274
|
// Create file path
|
|
262
275
|
const fileName = `${id}.yaml`;
|
|
263
|
-
const filePath = path.join(
|
|
276
|
+
const filePath = path.join(specsDir, fileName);
|
|
264
277
|
|
|
265
278
|
// Write spec file
|
|
266
279
|
const yamlContent = yaml.dump(specContent, { indent: 2 });
|
|
@@ -340,7 +353,7 @@ async function loadSpec(id) {
|
|
|
340
353
|
return null;
|
|
341
354
|
}
|
|
342
355
|
|
|
343
|
-
const specPath = path.join(
|
|
356
|
+
const specPath = path.join(getSpecsDir(), registry.specs[id].path);
|
|
344
357
|
|
|
345
358
|
try {
|
|
346
359
|
const content = await fs.readFile(specPath, 'utf8');
|
|
@@ -389,7 +402,7 @@ async function updateSpec(id, updates = {}) {
|
|
|
389
402
|
await saveSpecsRegistry(registry);
|
|
390
403
|
|
|
391
404
|
// Write back to file
|
|
392
|
-
const specPath = path.join(
|
|
405
|
+
const specPath = path.join(getSpecsDir(), registry.specs[id].path);
|
|
393
406
|
await fs.writeFile(specPath, yaml.dump(updatedSpec, { indent: 2 }));
|
|
394
407
|
|
|
395
408
|
return true;
|
|
@@ -534,7 +547,7 @@ async function deleteSpec(id) {
|
|
|
534
547
|
return false;
|
|
535
548
|
}
|
|
536
549
|
|
|
537
|
-
const specPath = path.join(
|
|
550
|
+
const specPath = path.join(getSpecsDir(), registry.specs[id].path);
|
|
538
551
|
|
|
539
552
|
// Remove file
|
|
540
553
|
await fs.remove(specPath);
|
|
@@ -673,7 +686,7 @@ async function migrateFromLegacy(options = {}, createSpecFn = createSpec) {
|
|
|
673
686
|
const yaml = require('js-yaml');
|
|
674
687
|
const chalk = require('chalk');
|
|
675
688
|
|
|
676
|
-
const legacyPath = path.join(
|
|
689
|
+
const legacyPath = path.join(findProjectRoot(), '.caws', 'working-spec.yaml');
|
|
677
690
|
|
|
678
691
|
if (!(await fs.pathExists(legacyPath))) {
|
|
679
692
|
throw new Error('No legacy working-spec.yaml found to migrate');
|
package/dist/commands/status.js
CHANGED
|
@@ -184,7 +184,7 @@ async function loadWaiverStatus() {
|
|
|
184
184
|
async function checkQualityGates() {
|
|
185
185
|
return {
|
|
186
186
|
checked: false,
|
|
187
|
-
message: 'Run: caws quality-gates
|
|
187
|
+
message: 'Run: caws quality-gates for full gate status',
|
|
188
188
|
};
|
|
189
189
|
}
|
|
190
190
|
|
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Verify Acceptance Criteria Command
|
|
3
|
+
* Mechanically verifies that ACs in CAWS specs are backed by real test evidence.
|
|
4
|
+
* Language-agnostic: detects test runner from project structure.
|
|
5
|
+
* @author @darianrosebrook
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const fs = require('fs-extra');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const yaml = require('js-yaml');
|
|
11
|
+
const chalk = require('chalk');
|
|
12
|
+
const { execFileSync } = require('child_process');
|
|
13
|
+
const { findProjectRoot } = require('../utils/detection');
|
|
14
|
+
|
|
15
|
+
const SPECS_DIR = '.caws/specs';
|
|
16
|
+
const TERMINAL_STATUSES = new Set(['completed', 'closed', 'archived']);
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Detect the project's test runner from config files.
|
|
20
|
+
* Returns a runner ID used to determine collect/run commands.
|
|
21
|
+
* @param {string} projectRoot
|
|
22
|
+
* @returns {'pytest'|'jest'|'vitest'|'cargo'|'go'|'unknown'}
|
|
23
|
+
*/
|
|
24
|
+
function detectTestRunner(projectRoot) {
|
|
25
|
+
const checks = [
|
|
26
|
+
{ files: ['pytest.ini', 'conftest.py', 'setup.cfg'], content: [['pyproject.toml', '[tool.pytest']], runner: 'pytest' },
|
|
27
|
+
{ files: ['vitest.config.ts', 'vitest.config.js', 'vitest.config.mts'], runner: 'vitest' },
|
|
28
|
+
{ files: ['jest.config.js', 'jest.config.ts', 'jest.config.mjs', 'jest.config.cjs'], content: [['package.json', '"jest"']], runner: 'jest' },
|
|
29
|
+
{ files: ['Cargo.toml'], runner: 'cargo' },
|
|
30
|
+
{ files: ['go.mod'], runner: 'go' },
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
for (const check of checks) {
|
|
34
|
+
for (const f of (check.files || [])) {
|
|
35
|
+
if (fs.existsSync(path.join(projectRoot, f))) return check.runner;
|
|
36
|
+
}
|
|
37
|
+
for (const [f, needle] of (check.content || [])) {
|
|
38
|
+
const fp = path.join(projectRoot, f);
|
|
39
|
+
if (fs.existsSync(fp)) {
|
|
40
|
+
try {
|
|
41
|
+
if (fs.readFileSync(fp, 'utf8').includes(needle)) return check.runner;
|
|
42
|
+
} catch (_) { /* ignore unreadable config */ }
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return 'unknown';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Collect (verify existence of) a test nodeid.
|
|
51
|
+
* @param {string} nodeid
|
|
52
|
+
* @param {string} runner
|
|
53
|
+
* @param {string} projectRoot
|
|
54
|
+
* @returns {{found: boolean, detail: string}}
|
|
55
|
+
*/
|
|
56
|
+
function collectNodeid(nodeid, runner, projectRoot) {
|
|
57
|
+
try {
|
|
58
|
+
switch (runner) {
|
|
59
|
+
case 'pytest': {
|
|
60
|
+
const out = execFileSync('python3', ['-m', 'pytest', '--collect-only', '-q', nodeid], {
|
|
61
|
+
cwd: projectRoot, encoding: 'utf8', timeout: 30000, stdio: ['pipe', 'pipe', 'pipe'],
|
|
62
|
+
});
|
|
63
|
+
const lines = out.trim().split('\n').filter(l => l && !l.startsWith('='));
|
|
64
|
+
return { found: lines.length > 0, detail: `${lines.length} test(s) collected` };
|
|
65
|
+
}
|
|
66
|
+
case 'jest':
|
|
67
|
+
case 'vitest': {
|
|
68
|
+
// For jest/vitest, split nodeid into file path and optional test name
|
|
69
|
+
const parts = nodeid.split('::');
|
|
70
|
+
const testFile = parts[0];
|
|
71
|
+
if (!fs.existsSync(path.join(projectRoot, testFile))) {
|
|
72
|
+
return { found: false, detail: `test file not found: ${testFile}` };
|
|
73
|
+
}
|
|
74
|
+
if (parts.length > 1) {
|
|
75
|
+
const content = fs.readFileSync(path.join(projectRoot, testFile), 'utf8');
|
|
76
|
+
const testName = parts[parts.length - 1];
|
|
77
|
+
if (!content.includes(testName)) {
|
|
78
|
+
return { found: false, detail: `test name '${testName}' not found in ${testFile}` };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return { found: true, detail: `test file exists: ${testFile}` };
|
|
82
|
+
}
|
|
83
|
+
case 'cargo': {
|
|
84
|
+
execFileSync('cargo', ['test', nodeid, '--no-run'], {
|
|
85
|
+
cwd: projectRoot, encoding: 'utf8', timeout: 60000, stdio: ['pipe', 'pipe', 'pipe'],
|
|
86
|
+
});
|
|
87
|
+
return { found: true, detail: 'compiled successfully' };
|
|
88
|
+
}
|
|
89
|
+
case 'go': {
|
|
90
|
+
const parts = nodeid.split('::');
|
|
91
|
+
const pkg = parts[0] || './...';
|
|
92
|
+
const testName = parts[1] || '';
|
|
93
|
+
const args = ['test', '-list', testName || '.*', pkg];
|
|
94
|
+
const out = execFileSync('go', args, {
|
|
95
|
+
cwd: projectRoot, encoding: 'utf8', timeout: 30000, stdio: ['pipe', 'pipe', 'pipe'],
|
|
96
|
+
});
|
|
97
|
+
const found = testName ? out.includes(testName) : out.trim().length > 0;
|
|
98
|
+
return { found, detail: found ? 'test found' : 'test not found' };
|
|
99
|
+
}
|
|
100
|
+
default: {
|
|
101
|
+
// Unknown runner — check if the file exists
|
|
102
|
+
const testFile = nodeid.split('::')[0];
|
|
103
|
+
if (fs.existsSync(path.join(projectRoot, testFile))) {
|
|
104
|
+
return { found: true, detail: 'test file exists (runner unknown, cannot collect)' };
|
|
105
|
+
}
|
|
106
|
+
return { found: false, detail: `test file not found: ${testFile}` };
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
} catch (err) {
|
|
110
|
+
const msg = err.stderr ? err.stderr.toString().split('\n')[0] : err.message;
|
|
111
|
+
return { found: false, detail: msg.slice(0, 120) };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Run a test nodeid and check if it passes.
|
|
117
|
+
* @param {string} nodeid
|
|
118
|
+
* @param {string} runner
|
|
119
|
+
* @param {string} projectRoot
|
|
120
|
+
* @returns {{passed: boolean, detail: string}}
|
|
121
|
+
*/
|
|
122
|
+
function runNodeid(nodeid, runner, projectRoot) {
|
|
123
|
+
try {
|
|
124
|
+
switch (runner) {
|
|
125
|
+
case 'pytest': {
|
|
126
|
+
execFileSync('python3', ['-m', 'pytest', '-x', '--tb=short', nodeid], {
|
|
127
|
+
cwd: projectRoot, encoding: 'utf8', timeout: 120000, stdio: ['pipe', 'pipe', 'pipe'],
|
|
128
|
+
});
|
|
129
|
+
return { passed: true, detail: 'tests passed' };
|
|
130
|
+
}
|
|
131
|
+
case 'jest': {
|
|
132
|
+
const parts = nodeid.split('::');
|
|
133
|
+
const args = ['jest', '--testPathPattern', parts[0]];
|
|
134
|
+
if (parts.length > 1) args.push('--testNamePattern', parts[parts.length - 1]);
|
|
135
|
+
execFileSync('npx', args, {
|
|
136
|
+
cwd: projectRoot, encoding: 'utf8', timeout: 120000, stdio: ['pipe', 'pipe', 'pipe'],
|
|
137
|
+
});
|
|
138
|
+
return { passed: true, detail: 'tests passed' };
|
|
139
|
+
}
|
|
140
|
+
case 'vitest': {
|
|
141
|
+
const parts = nodeid.split('::');
|
|
142
|
+
const args = ['vitest', 'run', parts[0]];
|
|
143
|
+
if (parts.length > 1) args.push('--testNamePattern', parts[parts.length - 1]);
|
|
144
|
+
execFileSync('npx', args, {
|
|
145
|
+
cwd: projectRoot, encoding: 'utf8', timeout: 120000, stdio: ['pipe', 'pipe', 'pipe'],
|
|
146
|
+
});
|
|
147
|
+
return { passed: true, detail: 'tests passed' };
|
|
148
|
+
}
|
|
149
|
+
case 'cargo': {
|
|
150
|
+
execFileSync('cargo', ['test', nodeid], {
|
|
151
|
+
cwd: projectRoot, encoding: 'utf8', timeout: 120000, stdio: ['pipe', 'pipe', 'pipe'],
|
|
152
|
+
});
|
|
153
|
+
return { passed: true, detail: 'tests passed' };
|
|
154
|
+
}
|
|
155
|
+
case 'go': {
|
|
156
|
+
const parts = nodeid.split('::');
|
|
157
|
+
const pkg = parts[0] || './...';
|
|
158
|
+
const testName = parts[1] || '.*';
|
|
159
|
+
execFileSync('go', ['test', '-run', testName, '-v', pkg], {
|
|
160
|
+
cwd: projectRoot, encoding: 'utf8', timeout: 120000, stdio: ['pipe', 'pipe', 'pipe'],
|
|
161
|
+
});
|
|
162
|
+
return { passed: true, detail: 'tests passed' };
|
|
163
|
+
}
|
|
164
|
+
default:
|
|
165
|
+
return { passed: false, detail: 'unknown test runner — cannot execute' };
|
|
166
|
+
}
|
|
167
|
+
} catch (err) {
|
|
168
|
+
const msg = err.stderr ? err.stderr.toString().split('\n').slice(-3).join(' ') : err.message;
|
|
169
|
+
return { passed: false, detail: msg.slice(0, 200) };
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Run a test_command and check exit code.
|
|
175
|
+
* @param {string} command
|
|
176
|
+
* @param {string} projectRoot
|
|
177
|
+
* @returns {{passed: boolean, detail: string}}
|
|
178
|
+
*/
|
|
179
|
+
function runTestCommand(command, projectRoot) {
|
|
180
|
+
try {
|
|
181
|
+
execFileSync('sh', ['-c', command], {
|
|
182
|
+
cwd: projectRoot, encoding: 'utf8', timeout: 120000, stdio: ['pipe', 'pipe', 'pipe'],
|
|
183
|
+
});
|
|
184
|
+
return { passed: true, detail: 'exit code 0' };
|
|
185
|
+
} catch (err) {
|
|
186
|
+
const msg = err.stderr ? err.stderr.toString().split('\n').slice(-2).join(' ') : err.message;
|
|
187
|
+
return { passed: false, detail: `exit code ${err.status || 'unknown'}: ${msg.slice(0, 150)}` };
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Check if an evidence artifact exists.
|
|
193
|
+
* @param {string} evidence
|
|
194
|
+
* @param {string} projectRoot
|
|
195
|
+
* @returns {{found: boolean, detail: string}}
|
|
196
|
+
*/
|
|
197
|
+
function checkEvidence(evidence, projectRoot) {
|
|
198
|
+
// If it looks like a file path, check directly
|
|
199
|
+
if (evidence.includes('/') || evidence.includes('.')) {
|
|
200
|
+
const fp = path.join(projectRoot, evidence);
|
|
201
|
+
if (fs.existsSync(fp)) {
|
|
202
|
+
return { found: true, detail: `artifact found: ${evidence}` };
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Search for the evidence ID in common test output directories
|
|
207
|
+
const searchDirs = ['test-results', 'test-scenarios', 'coverage', '.caws/evidence'];
|
|
208
|
+
for (const dir of searchDirs) {
|
|
209
|
+
const dp = path.join(projectRoot, dir);
|
|
210
|
+
if (fs.existsSync(dp)) {
|
|
211
|
+
try {
|
|
212
|
+
const files = execFileSync('find', [dp, '-name', `*${evidence}*`, '-type', 'f'], {
|
|
213
|
+
encoding: 'utf8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'],
|
|
214
|
+
}).trim();
|
|
215
|
+
if (files) {
|
|
216
|
+
return { found: true, detail: `found: ${files.split('\n')[0]}` };
|
|
217
|
+
}
|
|
218
|
+
} catch (_) { /* ignore unreadable config */ }
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return { found: false, detail: `artifact not found for: ${evidence}` };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Extract and merge acceptance criteria from a spec.
|
|
227
|
+
* Handles both `acceptance` and `acceptance_criteria` fields.
|
|
228
|
+
* @param {Object} spec
|
|
229
|
+
* @returns {Array<{id: string, description: string, test_nodeids?: string[], test_command?: string, evidence?: string, narrative?: string}>}
|
|
230
|
+
*/
|
|
231
|
+
function extractACs(spec) {
|
|
232
|
+
const byId = new Map();
|
|
233
|
+
|
|
234
|
+
// Load from `acceptance` field (given/when/then format)
|
|
235
|
+
for (const ac of (spec.acceptance || [])) {
|
|
236
|
+
if (!ac.id) continue;
|
|
237
|
+
const entry = {
|
|
238
|
+
id: ac.id,
|
|
239
|
+
description: ac.then || ac.description || '',
|
|
240
|
+
test_nodeids: ac.test_nodeids || null,
|
|
241
|
+
test_command: ac.test_command || null,
|
|
242
|
+
evidence: ac.evidence || null,
|
|
243
|
+
narrative: ac.test || (ac.given ? `Given ${ac.given}, when ${ac.when}, then ${ac.then}` : null),
|
|
244
|
+
};
|
|
245
|
+
byId.set(ac.id, entry);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Load from `acceptance_criteria` field (id/description format) — overrides mechanical fields
|
|
249
|
+
for (const ac of (spec.acceptance_criteria || [])) {
|
|
250
|
+
if (!ac.id) continue;
|
|
251
|
+
const existing = byId.get(ac.id) || { id: ac.id };
|
|
252
|
+
existing.description = ac.description || existing.description || '';
|
|
253
|
+
if (ac.test_nodeids) existing.test_nodeids = ac.test_nodeids;
|
|
254
|
+
if (ac.test_command) existing.test_command = ac.test_command;
|
|
255
|
+
if (ac.evidence) existing.evidence = ac.evidence;
|
|
256
|
+
if (ac.test) existing.narrative = ac.test;
|
|
257
|
+
byId.set(ac.id, existing);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return Array.from(byId.values());
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Verify all ACs in a spec.
|
|
265
|
+
* @param {Object} spec
|
|
266
|
+
* @param {string} projectRoot
|
|
267
|
+
* @param {Object} options
|
|
268
|
+
* @param {boolean} options.run - Actually run tests (vs collect-only)
|
|
269
|
+
* @param {string} options.runner - Test runner override
|
|
270
|
+
* @returns {{specId: string, title: string, results: Array}}
|
|
271
|
+
*/
|
|
272
|
+
function verifySpec(spec, projectRoot, options = {}) {
|
|
273
|
+
const runner = options.runner || detectTestRunner(projectRoot);
|
|
274
|
+
const acs = extractACs(spec);
|
|
275
|
+
const results = [];
|
|
276
|
+
|
|
277
|
+
for (const ac of acs) {
|
|
278
|
+
const result = { id: ac.id, description: ac.description, method: null, status: null, detail: '' };
|
|
279
|
+
|
|
280
|
+
if (ac.test_command) {
|
|
281
|
+
result.method = 'test_command';
|
|
282
|
+
const { passed, detail } = runTestCommand(ac.test_command, projectRoot);
|
|
283
|
+
result.status = passed ? 'PASS' : 'FAIL';
|
|
284
|
+
result.detail = detail;
|
|
285
|
+
} else if (ac.test_nodeids && ac.test_nodeids.length > 0) {
|
|
286
|
+
result.method = 'test_nodeids';
|
|
287
|
+
const nodeResults = [];
|
|
288
|
+
for (const nodeid of ac.test_nodeids) {
|
|
289
|
+
if (options.run) {
|
|
290
|
+
nodeResults.push(runNodeid(nodeid, runner, projectRoot));
|
|
291
|
+
} else {
|
|
292
|
+
const { found, detail } = collectNodeid(nodeid, runner, projectRoot);
|
|
293
|
+
nodeResults.push({ passed: found, detail });
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
const allOk = nodeResults.every(r => r.passed);
|
|
297
|
+
result.status = allOk ? 'PASS' : 'FAIL';
|
|
298
|
+
result.detail = nodeResults.map(r => r.detail).join('; ');
|
|
299
|
+
} else if (ac.evidence) {
|
|
300
|
+
result.method = 'evidence';
|
|
301
|
+
const { found, detail } = checkEvidence(ac.evidence, projectRoot);
|
|
302
|
+
result.status = found ? 'PASS' : 'FAIL';
|
|
303
|
+
result.detail = detail;
|
|
304
|
+
} else {
|
|
305
|
+
result.method = 'narrative';
|
|
306
|
+
result.status = 'unchecked';
|
|
307
|
+
result.detail = ac.narrative || 'no mechanical verification available';
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
results.push(result);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return {
|
|
314
|
+
specId: spec.id || 'unknown',
|
|
315
|
+
title: spec.title || '',
|
|
316
|
+
runner,
|
|
317
|
+
results,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Load all active specs from .caws/specs/
|
|
323
|
+
* @param {string} projectRoot
|
|
324
|
+
* @param {string} [targetSpecId] - If provided, only load this spec
|
|
325
|
+
* @returns {Array<{path: string, spec: Object}>}
|
|
326
|
+
*/
|
|
327
|
+
function loadSpecs(projectRoot, targetSpecId) {
|
|
328
|
+
const specs = [];
|
|
329
|
+
const specsDir = path.join(projectRoot, SPECS_DIR);
|
|
330
|
+
|
|
331
|
+
if (targetSpecId) {
|
|
332
|
+
const specPath = path.join(specsDir, `${targetSpecId}.yaml`);
|
|
333
|
+
if (!fs.existsSync(specPath)) {
|
|
334
|
+
const ymlPath = path.join(specsDir, `${targetSpecId}.yml`);
|
|
335
|
+
if (!fs.existsSync(ymlPath)) {
|
|
336
|
+
throw new Error(`Spec '${targetSpecId}' not found at ${specPath}`);
|
|
337
|
+
}
|
|
338
|
+
specs.push({ path: ymlPath, spec: yaml.load(fs.readFileSync(ymlPath, 'utf8')) });
|
|
339
|
+
} else {
|
|
340
|
+
specs.push({ path: specPath, spec: yaml.load(fs.readFileSync(specPath, 'utf8')) });
|
|
341
|
+
}
|
|
342
|
+
return specs;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Also check working-spec.yaml
|
|
346
|
+
const workingSpec = path.join(projectRoot, '.caws/working-spec.yaml');
|
|
347
|
+
if (fs.existsSync(workingSpec)) {
|
|
348
|
+
try {
|
|
349
|
+
const s = yaml.load(fs.readFileSync(workingSpec, 'utf8'));
|
|
350
|
+
if (s && !TERMINAL_STATUSES.has(s.status)) {
|
|
351
|
+
specs.push({ path: workingSpec, spec: s });
|
|
352
|
+
}
|
|
353
|
+
} catch (_) { /* ignore unreadable config */ }
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (fs.existsSync(specsDir)) {
|
|
357
|
+
for (const f of fs.readdirSync(specsDir).filter(f => f.endsWith('.yaml') || f.endsWith('.yml'))) {
|
|
358
|
+
try {
|
|
359
|
+
const s = yaml.load(fs.readFileSync(path.join(specsDir, f), 'utf8'));
|
|
360
|
+
if (s && !TERMINAL_STATUSES.has(s.status)) {
|
|
361
|
+
specs.push({ path: path.join(specsDir, f), spec: s });
|
|
362
|
+
}
|
|
363
|
+
} catch (_) { /* ignore unreadable config */ }
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return specs;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Main command handler
|
|
372
|
+
*/
|
|
373
|
+
async function verifyAcsCommand(options = {}) {
|
|
374
|
+
const projectRoot = findProjectRoot();
|
|
375
|
+
|
|
376
|
+
// Check for CAWS project
|
|
377
|
+
if (!fs.existsSync(path.join(projectRoot, '.caws'))) {
|
|
378
|
+
console.error(chalk.red('Not a CAWS project (no .caws/ directory found)'));
|
|
379
|
+
process.exit(1);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const specs = loadSpecs(projectRoot, options.specId);
|
|
383
|
+
|
|
384
|
+
if (specs.length === 0) {
|
|
385
|
+
console.log(chalk.yellow('No active specs found.'));
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const allResults = [];
|
|
390
|
+
let totalAcs = 0, totalMechanical = 0, totalPass = 0, totalFail = 0, totalUnchecked = 0;
|
|
391
|
+
|
|
392
|
+
for (const { spec } of specs) {
|
|
393
|
+
const acs = extractACs(spec);
|
|
394
|
+
if (acs.length === 0) continue;
|
|
395
|
+
|
|
396
|
+
const result = verifySpec(spec, projectRoot, {
|
|
397
|
+
run: options.run || false,
|
|
398
|
+
runner: options.runner,
|
|
399
|
+
});
|
|
400
|
+
allResults.push(result);
|
|
401
|
+
|
|
402
|
+
// Tally
|
|
403
|
+
for (const r of result.results) {
|
|
404
|
+
totalAcs++;
|
|
405
|
+
if (r.status === 'PASS') { totalPass++; totalMechanical++; }
|
|
406
|
+
else if (r.status === 'FAIL') { totalFail++; totalMechanical++; }
|
|
407
|
+
else { totalUnchecked++; }
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Output
|
|
412
|
+
if (options.format === 'json') {
|
|
413
|
+
console.log(JSON.stringify({
|
|
414
|
+
summary: { total: totalAcs, mechanical: totalMechanical, pass: totalPass, fail: totalFail, unchecked: totalUnchecked },
|
|
415
|
+
specs: allResults,
|
|
416
|
+
}, null, 2));
|
|
417
|
+
process.exit(totalFail > 0 ? 1 : 0);
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Table output
|
|
422
|
+
for (const result of allResults) {
|
|
423
|
+
console.log(chalk.cyan(`\n## ${result.specId} — ${result.title}`));
|
|
424
|
+
console.log(chalk.gray(` Test runner: ${result.runner} | Mode: ${options.run ? 'run' : 'collect-only'}`));
|
|
425
|
+
console.log();
|
|
426
|
+
|
|
427
|
+
const widths = { id: 8, desc: 40, method: 14, status: 10 };
|
|
428
|
+
console.log(
|
|
429
|
+
chalk.gray(
|
|
430
|
+
` ${'AC'.padEnd(widths.id)}${'Description'.padEnd(widths.desc)}${'Method'.padEnd(widths.method)}${'Status'.padEnd(widths.status)}Detail`
|
|
431
|
+
)
|
|
432
|
+
);
|
|
433
|
+
console.log(chalk.gray(' ' + '-'.repeat(widths.id + widths.desc + widths.method + widths.status + 20)));
|
|
434
|
+
|
|
435
|
+
for (const r of result.results) {
|
|
436
|
+
const statusColor = r.status === 'PASS' ? chalk.green : r.status === 'FAIL' ? chalk.red : chalk.yellow;
|
|
437
|
+
const desc = r.description.length > widths.desc - 2
|
|
438
|
+
? r.description.slice(0, widths.desc - 5) + '...'
|
|
439
|
+
: r.description;
|
|
440
|
+
const detail = r.detail.length > 60 ? r.detail.slice(0, 57) + '...' : r.detail;
|
|
441
|
+
|
|
442
|
+
console.log(
|
|
443
|
+
` ${chalk.white(r.id.padEnd(widths.id))}${desc.padEnd(widths.desc)}${(r.method || '').padEnd(widths.method)}${statusColor(r.status.padEnd(widths.status))}${chalk.gray(detail)}`
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Summary
|
|
449
|
+
console.log(chalk.cyan('\n## Summary'));
|
|
450
|
+
console.log(` Total ACs: ${totalAcs} | Mechanical: ${totalMechanical} | ${chalk.green(`Pass: ${totalPass}`)} | ${chalk.red(`Fail: ${totalFail}`)} | ${chalk.yellow(`Unchecked: ${totalUnchecked}`)}`);
|
|
451
|
+
|
|
452
|
+
if (totalFail > 0) {
|
|
453
|
+
console.log(chalk.red('\n Some acceptance criteria failed verification.'));
|
|
454
|
+
process.exit(1);
|
|
455
|
+
} else if (totalMechanical === 0 && totalAcs > 0) {
|
|
456
|
+
console.log(chalk.yellow('\n No ACs have mechanical verification. Consider adding test_nodeids or test_command.'));
|
|
457
|
+
} else {
|
|
458
|
+
console.log(chalk.green('\n All mechanically-verifiable ACs passed.'));
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
module.exports = {
|
|
463
|
+
verifyAcsCommand,
|
|
464
|
+
verifySpec,
|
|
465
|
+
extractACs,
|
|
466
|
+
detectTestRunner,
|
|
467
|
+
collectNodeid,
|
|
468
|
+
runNodeid,
|
|
469
|
+
runTestCommand,
|
|
470
|
+
checkEvidence,
|
|
471
|
+
};
|
package/dist/index.js
CHANGED
|
@@ -52,6 +52,7 @@ const { planCommand } = require('./commands/plan');
|
|
|
52
52
|
const { worktreeCommand } = require('./commands/worktree');
|
|
53
53
|
const { sessionCommand } = require('./commands/session');
|
|
54
54
|
const { parallelCommand } = require('./commands/parallel');
|
|
55
|
+
const { verifyAcsCommand } = require('./commands/verify-acs');
|
|
55
56
|
|
|
56
57
|
// Import scaffold functionality
|
|
57
58
|
const { scaffoldProject, setScaffoldDependencies } = require('./scaffold');
|
|
@@ -463,7 +464,7 @@ parallelCmd
|
|
|
463
464
|
parallelCmd
|
|
464
465
|
.command('merge')
|
|
465
466
|
.description('Merge all parallel branches back to base')
|
|
466
|
-
.option('--strategy <strategy>', 'Merge strategy:
|
|
467
|
+
.option('--strategy <strategy>', 'Merge strategy: merge or squash', 'merge')
|
|
467
468
|
.option('--dry-run', 'Preview merge without executing', false)
|
|
468
469
|
.option('--force', 'Force merge even with detected conflicts', false)
|
|
469
470
|
.action((options) => parallelCommand('merge', options));
|
|
@@ -490,6 +491,16 @@ program
|
|
|
490
491
|
.option('--fix', 'Apply automatic fixes', false)
|
|
491
492
|
.action(diagnoseCommand);
|
|
492
493
|
|
|
494
|
+
// Verify Acceptance Criteria command
|
|
495
|
+
program
|
|
496
|
+
.command('verify-acs')
|
|
497
|
+
.description('Verify acceptance criteria in specs are backed by test evidence')
|
|
498
|
+
.option('--spec-id <id>', 'Verify only this spec')
|
|
499
|
+
.option('--run', 'Actually run tests (default: collect-only)', false)
|
|
500
|
+
.option('--runner <runner>', 'Force test runner (pytest, jest, vitest, cargo, go)')
|
|
501
|
+
.option('--format <format>', 'Output format (text, json)', 'text')
|
|
502
|
+
.action(verifyAcsCommand);
|
|
503
|
+
|
|
493
504
|
// Evaluate command
|
|
494
505
|
program
|
|
495
506
|
.command('evaluate [spec-file]')
|
|
@@ -746,6 +757,7 @@ program.exitOverride((err) => {
|
|
|
746
757
|
'worktree',
|
|
747
758
|
'session',
|
|
748
759
|
'parallel',
|
|
760
|
+
'verify-acs',
|
|
749
761
|
];
|
|
750
762
|
const similar = findSimilarCommand(commandName, validCommands);
|
|
751
763
|
|
|
@@ -20,7 +20,9 @@ const {
|
|
|
20
20
|
// session-manager available if needed: require('../session/session-manager')
|
|
21
21
|
|
|
22
22
|
const PARALLEL_REGISTRY = '.caws/parallel.json';
|
|
23
|
-
|
|
23
|
+
// 'rebase' removed: it rewrites branch history, which is unsafe when
|
|
24
|
+
// worktrees are still active and other agents may depend on those commits.
|
|
25
|
+
const VALID_STRATEGIES = ['merge', 'squash'];
|
|
24
26
|
const NAME_REGEX = /^[a-zA-Z0-9_-]+$/;
|
|
25
27
|
|
|
26
28
|
/**
|
|
@@ -353,12 +355,7 @@ function mergeParallel(options = {}) {
|
|
|
353
355
|
|
|
354
356
|
for (const agent of activeAgents) {
|
|
355
357
|
try {
|
|
356
|
-
if (strategy === '
|
|
357
|
-
execFileSync('git', ['rebase', agent.branch], {
|
|
358
|
-
cwd: root,
|
|
359
|
-
stdio: 'pipe',
|
|
360
|
-
});
|
|
361
|
-
} else if (strategy === 'squash') {
|
|
358
|
+
if (strategy === 'squash') {
|
|
362
359
|
execFileSync('git', ['merge', '--squash', agent.branch], {
|
|
363
360
|
cwd: root,
|
|
364
361
|
stdio: 'pipe',
|
|
@@ -380,11 +377,7 @@ function mergeParallel(options = {}) {
|
|
|
380
377
|
try {
|
|
381
378
|
execFileSync('git', ['merge', '--abort'], { cwd: root, stdio: 'pipe' });
|
|
382
379
|
} catch {
|
|
383
|
-
|
|
384
|
-
execFileSync('git', ['rebase', '--abort'], { cwd: root, stdio: 'pipe' });
|
|
385
|
-
} catch {
|
|
386
|
-
// Already clean
|
|
387
|
-
}
|
|
380
|
+
// Already clean
|
|
388
381
|
}
|
|
389
382
|
failed.push({ name: agent.name, error: err.message });
|
|
390
383
|
}
|