@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.
Files changed (43) hide show
  1. package/dist/commands/specs.js +28 -15
  2. package/dist/commands/status.js +1 -1
  3. package/dist/commands/verify-acs.js +471 -0
  4. package/dist/index.js +13 -1
  5. package/dist/parallel/parallel-manager.js +5 -12
  6. package/dist/scaffold/cursor-hooks.js +0 -1
  7. package/dist/scaffold/git-hooks.js +18 -1
  8. package/dist/templates/.caws/tools/README.md +4 -7
  9. package/dist/templates/.caws/tools/scope-guard.js +115 -171
  10. package/dist/templates/.claude/hooks/audit.sh +25 -0
  11. package/dist/templates/.claude/hooks/block-dangerous.sh +39 -0
  12. package/dist/templates/.claude/hooks/lite-sprawl-check.sh +30 -2
  13. package/dist/templates/.claude/hooks/naming-check.sh +5 -2
  14. package/dist/templates/.claude/hooks/scope-guard.sh +66 -4
  15. package/dist/templates/.claude/hooks/session-log.sh +38 -5
  16. package/dist/templates/.claude/rules/worktree-isolation.md +4 -1
  17. package/dist/templates/.cursor/README.md +0 -9
  18. package/dist/templates/.cursor/hooks/audit.sh +1 -1
  19. package/dist/templates/.cursor/hooks/block-dangerous.sh +1 -0
  20. package/dist/templates/.cursor/hooks/scan-secrets.sh +8 -3
  21. package/dist/templates/.cursor/hooks.json +0 -8
  22. package/dist/templates/.vscode/launch.json +0 -12
  23. package/dist/utils/detection.js +38 -0
  24. package/dist/utils/project-analysis.js +0 -1
  25. package/dist/utils/spec-resolver.js +23 -10
  26. package/dist/worktree/worktree-manager.js +160 -6
  27. package/package.json +1 -1
  28. package/templates/.caws/tools/README.md +4 -7
  29. package/templates/.caws/tools/scope-guard.js +115 -171
  30. package/templates/.claude/hooks/audit.sh +25 -0
  31. package/templates/.claude/hooks/block-dangerous.sh +39 -0
  32. package/templates/.claude/hooks/lite-sprawl-check.sh +30 -2
  33. package/templates/.claude/hooks/naming-check.sh +5 -2
  34. package/templates/.claude/hooks/scope-guard.sh +66 -4
  35. package/templates/.claude/hooks/session-log.sh +38 -5
  36. package/templates/.claude/rules/worktree-isolation.md +4 -1
  37. package/templates/.cursor/README.md +0 -9
  38. package/templates/.cursor/hooks/audit.sh +1 -1
  39. package/templates/.cursor/hooks/block-dangerous.sh +1 -0
  40. package/templates/.cursor/hooks/scan-secrets.sh +8 -3
  41. package/templates/.cursor/hooks.json +0 -8
  42. package/templates/.vscode/launch.json +0 -12
  43. package/templates/.cursor/hooks/caws-tool-validation.sh +0 -121
@@ -106,7 +106,6 @@ async function scaffoldCursorHooks(projectDir, levels = ['safety', 'quality', 's
106
106
  { command: './.cursor/hooks/block-dangerous.sh' },
107
107
  { command: './.cursor/hooks/audit.sh' },
108
108
  ];
109
- hooksConfig.hooks.beforeMCPExecution = [{ command: './.cursor/hooks/audit.sh' }];
110
109
  hooksConfig.hooks.beforeReadFile = [{ command: './.cursor/hooks/scan-secrets.sh' }];
111
110
  }
112
111
 
@@ -408,8 +408,25 @@ elif [ -f "scripts/quality-gates/run-quality-gates.js" ]; then
408
408
  fi
409
409
  # Option 3: CAWS CLI validation
410
410
  elif command -v caws >/dev/null 2>&1; then
411
+ # In a worktree, validate only the associated spec to avoid false positives
412
+ CAWS_VALIDATE_ARGS="--quiet"
413
+ WORKTREE_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")
414
+ if [ -f ".caws/worktrees.json" ] && command -v node >/dev/null 2>&1; then
415
+ SPEC_ID=$(node -e "
416
+ try {
417
+ var reg = JSON.parse(require('fs').readFileSync('.caws/worktrees.json', 'utf8'));
418
+ var wt = Object.values(reg.worktrees || {}).find(function(w) {
419
+ return w.branch === '$WORKTREE_BRANCH';
420
+ });
421
+ if (wt && wt.specId) console.log(wt.specId);
422
+ } catch(e) {}
423
+ " 2>/dev/null || echo "")
424
+ if [ -n "$SPEC_ID" ]; then
425
+ CAWS_VALIDATE_ARGS="--quiet --spec-id $SPEC_ID"
426
+ fi
427
+ fi
411
428
  echo "Running CAWS CLI validation..."
412
- if caws validate --quiet 2>/dev/null; then
429
+ if caws validate $CAWS_VALIDATE_ARGS 2>/dev/null; then
413
430
  echo "CAWS validation passed"
414
431
  QUALITY_GATES_RAN=true
415
432
  else
@@ -4,18 +4,15 @@ This directory contains CAWS-specific tools that aren't available in the CLI.
4
4
 
5
5
  ## scope-guard.js
6
6
 
7
- Enforces that experimental code stays within designated sandbox areas. Used by Cursor hooks for scope validation.
7
+ Checks whether a file is within scope of active working-spec and feature specs. Used by Cursor hooks for scope validation on file attachments.
8
8
 
9
9
  ```bash
10
- # Validate experimental code containment
11
- node .caws/tools/scope-guard.js validate
10
+ # Check if a file is in scope
11
+ node .caws/tools/scope-guard.js check src/index.js
12
12
 
13
- # Check containment status
14
- node .caws/tools/scope-guard.js check .caws/working-spec.yaml
13
+ # Exit code 0 = in scope, 1 = out of scope
15
14
  ```
16
15
 
17
16
  **Usage in Cursor Hooks:**
18
17
 
19
18
  The `.cursor/hooks/scope-guard.sh` hook automatically uses this tool to validate file attachments against working spec scope boundaries.
20
-
21
-
@@ -1,208 +1,152 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  /**
4
- * @fileoverview CAWS Scope Guard
5
- * Enforces that experimental code stays within designated sandbox areas
4
+ * @fileoverview CAWS Scope Guard (file-level)
5
+ * Checks whether a given file path is within scope of active specs.
6
+ * Used by Cursor hooks for scope validation on file attachments.
6
7
  * @author @darianrosebrook
7
8
  */
8
9
 
9
10
  const fs = require('fs');
10
- const { execSync } = require('child_process');
11
+ const path = require('path');
11
12
 
12
13
  /**
13
- * Check if experimental code is properly contained
14
- * @param {string} workingSpecPath - Path to working spec file
15
- * @returns {Object} Scope validation results
14
+ * Convert a glob pattern to a RegExp, handling **, *, ?, [abc], {a,b}
16
15
  */
17
- function checkExperimentalContainment(workingSpecPath = '.caws/working-spec.yaml') {
18
- try {
19
- if (!fs.existsSync(workingSpecPath)) {
20
- console.error('❌ Working spec not found:', workingSpecPath);
21
- return { valid: false, errors: ['Working spec not found'] };
22
- }
23
-
24
- const yaml = require('js-yaml');
25
- const spec = yaml.load(fs.readFileSync(workingSpecPath, 'utf8'));
26
-
27
- const results = {
28
- valid: true,
29
- errors: [],
30
- warnings: [],
31
- experimentalFiles: [],
32
- nonExperimentalFiles: [],
33
- };
34
-
35
- // Only check if experimental mode is enabled
36
- if (!spec.experimental_mode?.enabled) {
37
- console.log('ℹ️ Experimental mode not enabled - skipping containment check');
38
- return results;
16
+ function globToRegex(pattern) {
17
+ let i = 0, re = '';
18
+ while (i < pattern.length) {
19
+ const c = pattern[i];
20
+ if (c === '*' && pattern[i + 1] === '*') {
21
+ re += '.*'; i += 2;
22
+ if (pattern[i] === '/') i++; // skip trailing slash after **
23
+ } else if (c === '*') {
24
+ re += '[^/]*'; i++;
25
+ } else if (c === '?') {
26
+ re += '[^/]'; i++;
27
+ } else if (c === '[') {
28
+ const end = pattern.indexOf(']', i);
29
+ if (end > i) { re += pattern.slice(i, end + 1); i = end + 1; }
30
+ else { re += '\\['; i++; }
31
+ } else if (c === '{') {
32
+ const end = pattern.indexOf('}', i);
33
+ if (end > i) {
34
+ const alts = pattern.slice(i + 1, end).split(',').map(a => a.trim());
35
+ re += '(?:' + alts.join('|') + ')'; i = end + 1;
36
+ } else { re += '\\{'; i++; }
37
+ } else if ('.+^$|()'.includes(c)) {
38
+ re += '\\' + c; i++;
39
+ } else {
40
+ re += c; i++;
39
41
  }
42
+ }
43
+ return new RegExp(re);
44
+ }
40
45
 
41
- const sandboxLocation = spec.experimental_mode.sandbox_location || 'experimental/';
42
- console.log(`🔍 Checking containment for experimental code in: ${sandboxLocation}`);
46
+ const TERMINAL = new Set(['completed', 'closed', 'archived']);
43
47
 
44
- // Get list of changed files (this would typically come from git diff)
45
- const changedFiles = getChangedFiles();
48
+ /**
49
+ * Check if a file is within scope of active specs.
50
+ * @param {string} filePath - Relative path from project root
51
+ * @param {string} projectDir - Project root directory
52
+ * @returns {{inScope: boolean, reason: string}}
53
+ */
54
+ function checkFileScope(filePath, projectDir) {
55
+ // Smart allowlist: root-level files, .caws/, .claude/ always pass
56
+ if (!filePath.includes('/') || filePath.startsWith('.caws/') || filePath.startsWith('.claude/')) {
57
+ return { inScope: true, reason: 'allowlisted path' };
58
+ }
46
59
 
47
- if (changedFiles.length === 0) {
48
- console.log('ℹ️ No files changed - skipping scope check');
49
- return results;
50
- }
60
+ const specFile = path.join(projectDir, '.caws/working-spec.yaml');
61
+ const specsDir = path.join(projectDir, '.caws/specs');
51
62
 
52
- // Check each changed file
53
- changedFiles.forEach((file) => {
54
- const isInSandbox =
55
- file.startsWith(sandboxLocation) ||
56
- file.includes(`/${sandboxLocation}`) ||
57
- file.includes(sandboxLocation);
58
-
59
- if (isInSandbox) {
60
- results.experimentalFiles.push(file);
61
- console.log(`✅ Experimental file properly contained: ${file}`);
62
- } else {
63
- results.nonExperimentalFiles.push(file);
64
- results.valid = false;
65
- results.errors.push(`Experimental code found outside sandbox: ${file}`);
66
- console.error(`❌ Experimental code outside sandbox: ${file}`);
67
- }
68
- });
69
-
70
- // Check if experimental files actually exist
71
- results.experimentalFiles.forEach((file) => {
72
- if (!fs.existsSync(file)) {
73
- results.warnings.push(`Experimental file not found (may have been deleted): ${file}`);
74
- console.warn(`⚠️ Experimental file not found: ${file}`);
75
- }
76
- });
63
+ if (!fs.existsSync(specFile) && !fs.existsSync(specsDir)) {
64
+ return { inScope: true, reason: 'no specs found' };
65
+ }
77
66
 
78
- return results;
79
- } catch (error) {
80
- console.error('❌ Error checking experimental containment:', error.message);
81
- return { valid: false, errors: [error.message] };
67
+ // Load all active specs
68
+ let yaml;
69
+ try { yaml = require('js-yaml'); } catch (_) {
70
+ return { inScope: true, reason: 'js-yaml not available' };
82
71
  }
83
- }
84
72
 
85
- /**
86
- * Get list of changed files from git
87
- * @returns {Array} List of changed file paths
88
- */
89
- function getChangedFiles() {
90
- try {
91
- // Get files that are staged or modified
92
- const staged = execSync('git diff --cached --name-only', { encoding: 'utf8' })
93
- .split('\n')
94
- .filter((file) => file.trim());
95
-
96
- const modified = execSync('git diff --name-only', { encoding: 'utf8' })
97
- .split('\n')
98
- .filter((file) => file.trim());
99
-
100
- // Combine and deduplicate
101
- const allFiles = [...new Set([...staged, ...modified])];
102
-
103
- // Filter out deleted files (they might still be in the diff)
104
- return allFiles.filter((file) => {
105
- try {
106
- return fs.existsSync(file);
107
- } catch {
108
- return false;
73
+ const specs = [];
74
+
75
+ if (fs.existsSync(specFile)) {
76
+ try {
77
+ const s = yaml.load(fs.readFileSync(specFile, 'utf8'));
78
+ if (s && !TERMINAL.has(s.status)) {
79
+ specs.push({ source: 'working-spec', spec: s });
109
80
  }
110
- });
111
- } catch (error) {
112
- console.warn('⚠️ Could not get changed files from git:', error.message);
113
- return [];
81
+ } catch (_) {}
114
82
  }
115
- }
116
83
 
117
- /**
118
- * Validate that experimental code follows containment rules
119
- * @param {string} workingSpecPath - Path to working spec file
120
- */
121
- function validateExperimentalScope(workingSpecPath = '.caws/working-spec.yaml') {
122
- console.log('🔍 Validating experimental code containment...');
123
-
124
- const results = checkExperimentalContainment(workingSpecPath);
125
-
126
- if (!results.valid) {
127
- console.error('\n❌ Experimental containment validation failed:');
128
- results.errors.forEach((error) => {
129
- console.error(` - ${error}`);
130
- });
131
-
132
- if (results.warnings.length > 0) {
133
- console.warn('\n⚠️ Warnings:');
134
- results.warnings.forEach((warning) => {
135
- console.warn(` - ${warning}`);
136
- });
84
+ if (fs.existsSync(specsDir)) {
85
+ for (const f of fs.readdirSync(specsDir).filter(f => f.endsWith('.yaml') || f.endsWith('.yml'))) {
86
+ try {
87
+ const s = yaml.load(fs.readFileSync(path.join(specsDir, f), 'utf8'));
88
+ if (s && !TERMINAL.has(s.status)) {
89
+ specs.push({ source: f, spec: s });
90
+ }
91
+ } catch (_) {}
137
92
  }
93
+ }
138
94
 
139
- console.error('\n💡 To fix containment issues:');
140
- console.error(' 1. Move experimental code to the designated sandbox location');
141
- console.error(' 2. Update the sandbox_location in your working spec');
142
- console.error(' 3. Or disable experimental mode if this is production code');
95
+ if (specs.length === 0) {
96
+ return { inScope: true, reason: 'no active specs' };
97
+ }
143
98
 
144
- process.exit(1);
99
+ // Check scope.out — any match blocks
100
+ for (const { source, spec } of specs) {
101
+ for (const pattern of (spec.scope?.out || [])) {
102
+ if (globToRegex(pattern).test(filePath)) {
103
+ return { inScope: false, reason: `out-of-scope in ${source} (pattern: ${pattern})` };
104
+ }
105
+ }
145
106
  }
146
107
 
147
- if (results.warnings.length > 0) {
148
- console.warn('\n⚠️ Experimental containment warnings:');
149
- results.warnings.forEach((warning) => {
150
- console.warn(` - ${warning}`);
151
- });
108
+ // Union all scope.in must match at least one
109
+ const allIn = specs.flatMap(({ spec }) => spec.scope?.in || []);
110
+ if (allIn.length > 0) {
111
+ const found = allIn.some(pattern => globToRegex(pattern).test(filePath));
112
+ if (!found) {
113
+ return { inScope: false, reason: 'not in any active spec scope.in' };
114
+ }
152
115
  }
153
116
 
154
- console.log('✅ Experimental code containment validated');
155
- console.log(` - Files in sandbox: ${results.experimentalFiles.length}`);
156
- console.log(` - Files outside sandbox: ${results.nonExperimentalFiles.length}`);
117
+ return { inScope: true, reason: 'in scope' };
157
118
  }
158
119
 
159
120
  // CLI interface
160
121
  if (require.main === module) {
161
122
  const command = process.argv[2];
162
- const specPath = process.argv[3] || '.caws/working-spec.yaml';
163
-
164
- switch (command) {
165
- case 'validate':
166
- validateExperimentalScope(specPath);
167
- break;
168
-
169
- case 'check':
170
- const results = checkExperimentalContainment(specPath);
171
- console.log('\n📊 Containment Check Results:');
172
- console.log(` Valid: ${results.valid}`);
173
- console.log(` Experimental files: ${results.experimentalFiles.length}`);
174
- console.log(` Non-experimental files: ${results.nonExperimentalFiles.length}`);
175
- console.log(` Errors: ${results.errors.length}`);
176
- console.log(` Warnings: ${results.warnings.length}`);
177
-
178
- if (results.errors.length > 0) {
179
- console.log('\n❌ Errors:');
180
- results.errors.forEach((error) => console.log(` - ${error}`));
181
- }
182
-
183
- if (results.warnings.length > 0) {
184
- console.log('\n⚠️ Warnings:');
185
- results.warnings.forEach((warning) => console.log(` - ${warning}`));
186
- }
187
-
188
- process.exit(results.valid ? 0 : 1);
189
- break;
190
-
191
- default:
192
- console.log('CAWS Scope Guard');
193
- console.log('Usage:');
194
- console.log(' node scope-guard.js validate [spec-path]');
195
- console.log(' node scope-guard.js check [spec-path]');
196
- console.log('');
197
- console.log('Examples:');
198
- console.log(' node scope-guard.js validate');
199
- console.log(' node scope-guard.js check .caws/working-spec.yaml');
123
+ const filePath = process.argv[3];
124
+
125
+ if (command === 'check' && filePath) {
126
+ // Resolve relative to cwd
127
+ const projectDir = process.cwd();
128
+ const rel = filePath.startsWith(projectDir)
129
+ ? filePath.slice(projectDir.length + 1)
130
+ : filePath;
131
+
132
+ const result = checkFileScope(rel, projectDir);
133
+ if (result.inScope) {
134
+ console.log(`in_scope: ${result.reason}`);
135
+ process.exit(0);
136
+ } else {
137
+ console.error(`out_of_scope: ${result.reason}`);
200
138
  process.exit(1);
139
+ }
140
+ } else {
141
+ console.log('CAWS Scope Guard');
142
+ console.log('Usage:');
143
+ console.log(' node scope-guard.js check <file-path>');
144
+ console.log('');
145
+ console.log('Examples:');
146
+ console.log(' node scope-guard.js check src/index.js');
147
+ console.log(' node scope-guard.js check packages/cli/lib/main.ts');
148
+ process.exit(1);
201
149
  }
202
150
  }
203
151
 
204
- module.exports = {
205
- checkExperimentalContainment,
206
- validateExperimentalScope,
207
- getChangedFiles,
208
- };
152
+ module.exports = { checkFileScope, globToRegex };
@@ -88,6 +88,31 @@ case "$EVENT_TYPE" in
88
88
  ;;
89
89
  esac
90
90
 
91
+ # --- Log rotation ---
92
+ # Keep main audit.log under 10MB; keep date-logs for 30 days
93
+ rotate_logs() {
94
+ # Rotate main audit.log at 10MB
95
+ if [[ -f "$LOG_FILE" ]]; then
96
+ local size
97
+ size=$(wc -c < "$LOG_FILE" 2>/dev/null | tr -d ' ')
98
+ if [[ "$size" -gt 10485760 ]]; then
99
+ # Keep last rotated copy, discard older
100
+ [[ -f "${LOG_FILE}.1" ]] && rm -f "${LOG_FILE}.1"
101
+ mv "$LOG_FILE" "${LOG_FILE}.1"
102
+ fi
103
+ fi
104
+
105
+ # Prune date-based logs older than 30 days
106
+ if [[ -d "$LOG_DIR" ]]; then
107
+ find "$LOG_DIR" -name 'audit-*.log' -type f -mtime +30 -delete 2>/dev/null || true
108
+ fi
109
+ }
110
+
111
+ # Run rotation check ~1% of the time (avoid stat overhead on every tool call)
112
+ if [[ $(( RANDOM % 100 )) -eq 0 ]]; then
113
+ rotate_logs
114
+ fi
115
+
91
116
  # Append to log files
92
117
  echo "$LOG_ENTRY" >> "$LOG_FILE"
93
118
  echo "$LOG_ENTRY" >> "$DATE_LOG_FILE"
@@ -75,6 +75,8 @@ DANGEROUS_PATTERNS=(
75
75
  'git clean -f'
76
76
  'git checkout \.'
77
77
  'git restore \.'
78
+ '(^|&&|\|\||;|\|)\s*git rebase'
79
+ '(^|&&|\|\||;|\|)\s*git cherry-pick'
78
80
 
79
81
  # Virtual environment creation (prevents venv sprawl)
80
82
  'python -m venv'
@@ -91,6 +93,43 @@ for pattern in "${DANGEROUS_PATTERNS[@]}"; do
91
93
  continue
92
94
  fi
93
95
 
96
+ # Allow git rebase/cherry-pick only when no worktrees are active
97
+ if [[ "$pattern" == *"git rebase"* ]] || [[ "$pattern" == *"git cherry-pick"* ]]; then
98
+ PROJECT_DIR="${CLAUDE_PROJECT_DIR:-.}"
99
+ # Resolve to main repo root if we're in a worktree
100
+ if command -v git >/dev/null 2>&1; then
101
+ GIT_COMMON=$(cd "$PROJECT_DIR" && git rev-parse --git-common-dir 2>/dev/null || echo "")
102
+ if [[ -n "$GIT_COMMON" ]] && [[ "$GIT_COMMON" != ".git" ]]; then
103
+ CANDIDATE=$(cd "$PROJECT_DIR" && cd "$GIT_COMMON/.." 2>/dev/null && pwd || echo "")
104
+ if [[ -n "$CANDIDATE" ]] && [[ -d "$CANDIDATE/.caws" ]]; then
105
+ PROJECT_DIR="$CANDIDATE"
106
+ fi
107
+ fi
108
+ fi
109
+ WT_FILE="$PROJECT_DIR/.caws/worktrees.json"
110
+ if [[ -f "$WT_FILE" ]] && command -v node >/dev/null 2>&1; then
111
+ ACTIVE_COUNT=$(node -e "
112
+ try {
113
+ var r = JSON.parse(require('fs').readFileSync('$WT_FILE','utf8'));
114
+ var c = Object.values(r.worktrees||{}).filter(function(w){return w.status==='active';}).length;
115
+ console.log(c);
116
+ } catch(e) { console.log(0); }
117
+ " 2>/dev/null || echo "0")
118
+ if [[ "$ACTIVE_COUNT" -gt 0 ]]; then
119
+ # Extract the specific git subcommand for the message
120
+ GIT_SUBCMD="git operation"
121
+ [[ "$pattern" == *"git rebase"* ]] && GIT_SUBCMD="git rebase"
122
+ [[ "$pattern" == *"git cherry-pick"* ]] && GIT_SUBCMD="git cherry-pick"
123
+ echo "BLOCKED: $GIT_SUBCMD is forbidden while $ACTIVE_COUNT worktree(s) are active." >&2
124
+ echo "This can replay or rewrite commits across worktree boundaries." >&2
125
+ echo "Command was: $COMMAND" >&2
126
+ exit 2
127
+ fi
128
+ fi
129
+ # No active worktrees — allow
130
+ continue
131
+ fi
132
+
94
133
  # Allow venv commands if target matches designated venv path from scope.json
95
134
  if echo "$pattern" | grep -qE '(python.*venv|virtualenv|conda create)'; then
96
135
  PROJECT_DIR="${CLAUDE_PROJECT_DIR:-.}"
@@ -49,9 +49,37 @@ if command -v node >/dev/null 2>&1; then
49
49
  const basename = '$BASENAME';
50
50
  const banned = scope.bannedPatterns || {};
51
51
 
52
+ function globToRegex(pattern) {
53
+ let i = 0, re = '';
54
+ while (i < pattern.length) {
55
+ const c = pattern[i];
56
+ if (c === '*' && pattern[i+1] === '*') {
57
+ re += '.*'; i += 2;
58
+ if (pattern[i] === '/') i++;
59
+ } else if (c === '*') {
60
+ re += '[^/]*'; i++;
61
+ } else if (c === '?') {
62
+ re += '[^/]'; i++;
63
+ } else if (c === '[') {
64
+ const end = pattern.indexOf(']', i);
65
+ if (end > i) { re += pattern.slice(i, end + 1); i = end + 1; }
66
+ else { re += '\\\\['; i++; }
67
+ } else if (c === '{') {
68
+ const end = pattern.indexOf('}', i);
69
+ if (end > i) {
70
+ const alts = pattern.slice(i + 1, end).split(',').map(a => a.trim());
71
+ re += '(?:' + alts.join('|') + ')'; i = end + 1;
72
+ } else { re += '\\\\{'; i++; }
73
+ } else if ('.+^$|()'.includes(c)) {
74
+ re += '\\\\' + c; i++;
75
+ } else {
76
+ re += c; i++;
77
+ }
78
+ }
79
+ return new RegExp('^' + re + '$');
80
+ }
52
81
  function matchGlob(str, pattern) {
53
- const regex = new RegExp('^' + pattern.replace(/\\*/g, '.*').replace(/\\?/g, '.') + '$');
54
- return regex.test(str);
82
+ return globToRegex(pattern).test(str);
55
83
  }
56
84
 
57
85
  // Check banned file patterns
@@ -51,9 +51,12 @@ BANNED_MODIFIERS=(
51
51
  # Convert filename to lowercase for checking
52
52
  FILENAME_LOWER=$(echo "$FILENAME" | tr '[:upper:]' '[:lower:]')
53
53
 
54
- # Check for banned modifiers
54
+ # Check for banned modifiers (word-boundary aware)
55
55
  for modifier in "${BANNED_MODIFIERS[@]}"; do
56
- if [[ "$FILENAME_LOWER" == *"$modifier"* ]]; then
56
+ # Match modifier preceded by start-of-string, hyphen, underscore, or dot
57
+ # and followed by end-of-string, hyphen, underscore, or dot
58
+ # Prevents false positives like "old" in "gold_oracle" or "new" in "renewable"
59
+ if [[ "$FILENAME_LOWER" =~ (^|[-_.])"$modifier"([-_.]|$) ]]; then
57
60
  # Special case: allow test files that follow conventions
58
61
  if [[ "$modifier" == "test-" ]] || [[ "$modifier" == "-test" ]] || [[ "$modifier" == "_test" ]]; then
59
62
  if [[ "$FILENAME_LOWER" =~ \.(test|spec)\.(js|ts|jsx|tsx|py|go|rs)$ ]]; then
@@ -44,6 +44,37 @@ if [[ ! -f "$SPEC_FILE" ]] && [[ -f "$SCOPE_FILE" ]]; then
44
44
  LITE_CHECK=$(node -e "
45
45
  const fs = require('fs');
46
46
  const path = require('path');
47
+
48
+ function globToRegex(pattern) {
49
+ let i = 0, re = '';
50
+ while (i < pattern.length) {
51
+ const c = pattern[i];
52
+ if (c === '*' && pattern[i+1] === '*') {
53
+ re += '.*'; i += 2;
54
+ if (pattern[i] === '/') i++;
55
+ } else if (c === '*') {
56
+ re += '[^/]*'; i++;
57
+ } else if (c === '?') {
58
+ re += '[^/]'; i++;
59
+ } else if (c === '[') {
60
+ const end = pattern.indexOf(']', i);
61
+ if (end > i) { re += pattern.slice(i, end + 1); i = end + 1; }
62
+ else { re += '\\\\['; i++; }
63
+ } else if (c === '{') {
64
+ const end = pattern.indexOf('}', i);
65
+ if (end > i) {
66
+ const alts = pattern.slice(i + 1, end).split(',').map(a => a.trim());
67
+ re += '(?:' + alts.join('|') + ')'; i = end + 1;
68
+ } else { re += '\\\\{'; i++; }
69
+ } else if ('.+^$|()'.includes(c)) {
70
+ re += '\\\\' + c; i++;
71
+ } else {
72
+ re += c; i++;
73
+ }
74
+ }
75
+ return new RegExp(re);
76
+ }
77
+
47
78
  try {
48
79
  const scope = JSON.parse(fs.readFileSync('$SCOPE_FILE', 'utf8'));
49
80
  const filePath = '$REL_PATH';
@@ -54,7 +85,7 @@ if [[ ! -f "$SPEC_FILE" ]] && [[ -f "$SCOPE_FILE" ]]; then
54
85
  const basename = path.basename(filePath);
55
86
  const bannedFiles = banned.files || [];
56
87
  for (const pattern of bannedFiles) {
57
- const regex = new RegExp(pattern.replace(/\\*/g, '.*').replace(/\\?/g, '.'));
88
+ const regex = globToRegex(pattern);
58
89
  if (regex.test(basename)) {
59
90
  console.log('banned:' + pattern);
60
91
  process.exit(0);
@@ -64,7 +95,7 @@ if [[ ! -f "$SPEC_FILE" ]] && [[ -f "$SCOPE_FILE" ]]; then
64
95
  // Check banned doc patterns
65
96
  const bannedDocs = banned.docs || [];
66
97
  for (const pattern of bannedDocs) {
67
- const regex = new RegExp(pattern.replace(/\\*/g, '.*').replace(/\\?/g, '.'));
98
+ const regex = globToRegex(pattern);
68
99
  if (regex.test(basename)) {
69
100
  console.log('banned:' + pattern);
70
101
  process.exit(0);
@@ -129,6 +160,37 @@ if command -v node >/dev/null 2>&1; then
129
160
  const fs = require('fs');
130
161
  const path = require('path');
131
162
 
163
+ // Convert glob pattern to regex, handling **, *, ?, [abc], {a,b}
164
+ function globToRegex(pattern) {
165
+ let i = 0, re = '';
166
+ while (i < pattern.length) {
167
+ const c = pattern[i];
168
+ if (c === '*' && pattern[i+1] === '*') {
169
+ re += '.*'; i += 2;
170
+ if (pattern[i] === '/') i++; // skip trailing slash after **
171
+ } else if (c === '*') {
172
+ re += '[^/]*'; i++;
173
+ } else if (c === '?') {
174
+ re += '[^/]'; i++;
175
+ } else if (c === '[') {
176
+ const end = pattern.indexOf(']', i);
177
+ if (end > i) { re += pattern.slice(i, end + 1); i = end + 1; }
178
+ else { re += '\\\\['; i++; }
179
+ } else if (c === '{') {
180
+ const end = pattern.indexOf('}', i);
181
+ if (end > i) {
182
+ const alts = pattern.slice(i + 1, end).split(',').map(a => a.trim());
183
+ re += '(?:' + alts.join('|') + ')'; i = end + 1;
184
+ } else { re += '\\\\{'; i++; }
185
+ } else if ('.+^$|()'.includes(c)) {
186
+ re += '\\\\' + c; i++;
187
+ } else {
188
+ re += c; i++;
189
+ }
190
+ }
191
+ return new RegExp(re);
192
+ }
193
+
132
194
  try {
133
195
  const filePath = '$REL_PATH';
134
196
 
@@ -177,7 +239,7 @@ if command -v node >/dev/null 2>&1; then
177
239
  // Check scope.out across ALL active specs — any match blocks
178
240
  for (const { source, spec } of specs) {
179
241
  for (const pattern of (spec.scope?.out || [])) {
180
- const regex = new RegExp(pattern.replace(/\\*/g, '.*').replace(/\\?/g, '.'));
242
+ const regex = globToRegex(pattern);
181
243
  if (regex.test(filePath)) {
182
244
  console.log('out_of_scope:' + source + ':' + pattern);
183
245
  process.exit(0);
@@ -190,7 +252,7 @@ if command -v node >/dev/null 2>&1; then
190
252
  if (allInScope.length > 0) {
191
253
  let found = false;
192
254
  for (const pattern of allInScope) {
193
- const regex = new RegExp(pattern.replace(/\\*/g, '.*').replace(/\\?/g, '.'));
255
+ const regex = globToRegex(pattern);
194
256
  if (regex.test(filePath)) {
195
257
  found = true;
196
258
  break;