create-qa-architect 5.0.6 → 5.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 (37) hide show
  1. package/.github/workflows/auto-release.yml +49 -0
  2. package/.github/workflows/dependabot-auto-merge.yml +32 -0
  3. package/LICENSE +3 -3
  4. package/README.md +54 -15
  5. package/docs/ADOPTION-SUMMARY.md +41 -0
  6. package/docs/ARCHITECTURE-REVIEW.md +67 -0
  7. package/docs/ARCHITECTURE.md +29 -41
  8. package/docs/CODE-REVIEW.md +100 -0
  9. package/docs/PREFLIGHT_REPORT.md +32 -40
  10. package/docs/REQUIREMENTS.md +148 -0
  11. package/docs/SECURITY-AUDIT.md +68 -0
  12. package/docs/TESTING.md +3 -4
  13. package/docs/test-trace-matrix.md +28 -0
  14. package/lib/billing-dashboard.html +6 -12
  15. package/lib/commands/deps.js +245 -0
  16. package/lib/commands/index.js +25 -0
  17. package/lib/commands/validate.js +85 -0
  18. package/lib/error-reporter.js +13 -1
  19. package/lib/github-api.js +108 -13
  20. package/lib/license-signing.js +110 -0
  21. package/lib/license-validator.js +359 -71
  22. package/lib/licensing.js +343 -111
  23. package/lib/prelaunch-validator.js +828 -0
  24. package/lib/quality-tools-generator.js +495 -0
  25. package/lib/result-types.js +112 -0
  26. package/lib/security-enhancements.js +1 -1
  27. package/lib/smart-strategy-generator.js +28 -9
  28. package/lib/template-loader.js +52 -19
  29. package/lib/validation/cache-manager.js +36 -6
  30. package/lib/validation/config-security.js +82 -15
  31. package/lib/validation/workflow-validation.js +49 -23
  32. package/package.json +8 -10
  33. package/scripts/check-test-coverage.sh +46 -0
  34. package/setup.js +356 -285
  35. package/templates/QUALITY_TROUBLESHOOTING.md +32 -33
  36. package/templates/scripts/smart-test-strategy.sh +1 -1
  37. package/create-saas-monetization.js +0 -1513
@@ -34,7 +34,11 @@ class TemplateLoader {
34
34
  try {
35
35
  const stats = fs.statSync(templatePath)
36
36
  return stats.isDirectory()
37
- } catch {
37
+ } catch (error) {
38
+ // Silent failure fix: Log unexpected errors in debug mode
39
+ if (process.env.DEBUG && error.code !== 'ENOENT') {
40
+ console.warn(`⚠️ Template path check failed: ${error.message}`)
41
+ }
38
42
  return false
39
43
  }
40
44
  }
@@ -51,7 +55,13 @@ class TemplateLoader {
51
55
 
52
56
  const stats = fs.statSync(templateDir)
53
57
  return stats.isDirectory()
54
- } catch {
58
+ } catch (error) {
59
+ // Silent failure fix: Log unexpected errors in debug mode
60
+ if (process.env.DEBUG && error.code !== 'ENOENT') {
61
+ console.warn(
62
+ `⚠️ Template structure validation failed: ${error.message}`
63
+ )
64
+ }
55
65
  return false
56
66
  }
57
67
  }
@@ -128,6 +138,7 @@ class TemplateLoader {
128
138
  async loadTemplates(dir, baseDir = dir, isPackageDir = false) {
129
139
  /** @type {Record<string, string>} */
130
140
  const templates = {}
141
+ const errors = []
131
142
 
132
143
  try {
133
144
  const entries = fs.readdirSync(dir, { withFileTypes: true })
@@ -144,29 +155,51 @@ class TemplateLoader {
144
155
  const fullPath = path.join(dir, entry.name)
145
156
  const relativePath = path.relative(baseDir, fullPath)
146
157
 
147
- if (entry.isDirectory()) {
148
- // Recursively load from subdirectories
149
- // After first level, we're no longer in package root
150
- const subTemplates = await this.loadTemplates(
151
- fullPath,
152
- baseDir,
153
- false
154
- )
155
- Object.assign(templates, subTemplates)
156
- } else if (entry.isFile()) {
157
- // Load file content asynchronously for better performance
158
- const content = await fs.promises.readFile(fullPath, 'utf8')
159
- templates[relativePath] = content
158
+ try {
159
+ if (entry.isDirectory()) {
160
+ // Recursively load from subdirectories
161
+ const subTemplates = await this.loadTemplates(
162
+ fullPath,
163
+ baseDir,
164
+ false
165
+ )
166
+ Object.assign(templates, subTemplates)
167
+ } else if (entry.isFile()) {
168
+ // DR6 fix: Handle individual file errors separately
169
+ const content = await fs.promises.readFile(fullPath, 'utf8')
170
+ templates[relativePath] = content
171
+ }
172
+ } catch (fileError) {
173
+ // Track individual file errors
174
+ errors.push({ file: relativePath, error: fileError.message })
175
+
176
+ if (this.strict) {
177
+ throw new Error(
178
+ `Failed to load template ${relativePath}: ${fileError.message}`
179
+ )
180
+ } else if (this.verbose) {
181
+ console.warn(
182
+ `⚠️ Skipping template ${relativePath}: ${fileError.message}`
183
+ )
184
+ }
160
185
  }
161
186
  }
162
187
  } catch (error) {
163
- if (this.verbose) {
164
- console.warn(
165
- `⚠️ Warning: Could not load templates from ${dir}: ${error.message}`
166
- )
188
+ const message = `Could not load templates from ${dir}: ${error.message}`
189
+
190
+ if (this.strict) {
191
+ throw new Error(message)
192
+ } else if (this.verbose) {
193
+ console.warn(`⚠️ ${message}`)
167
194
  }
168
195
  }
169
196
 
197
+ // DR6 fix: Alert if critical templates are missing
198
+ if (errors.length > 0 && this.verbose) {
199
+ console.warn(`⚠️ ${errors.length} template file(s) failed to load`)
200
+ console.warn(' This may result in incomplete configuration')
201
+ }
202
+
170
203
  return templates
171
204
  }
172
205
 
@@ -40,8 +40,14 @@ class CacheManager {
40
40
  try {
41
41
  const content = fs.readFileSync(filePath, 'utf8')
42
42
  return crypto.createHash('sha256').update(content).digest('hex')
43
- } catch {
43
+ } catch (error) {
44
44
  // If file doesn't exist or can't be read, return timestamp-based key
45
+ // Silent failure fix: Log unexpected errors in debug mode
46
+ if (process.env.DEBUG && error.code !== 'ENOENT') {
47
+ console.warn(
48
+ `⚠️ Cache key generation failed for ${filePath}: ${error.message}`
49
+ )
50
+ }
45
51
  return crypto
46
52
  .createHash('sha256')
47
53
  .update(`${filePath}-${Date.now()}`)
@@ -89,8 +95,12 @@ class CacheManager {
89
95
  }
90
96
 
91
97
  return cached.result
92
- } catch {
98
+ } catch (error) {
93
99
  // If cache file is corrupted or unreadable, treat as cache miss
100
+ // Silent failure fix: Log unexpected errors in debug mode
101
+ if (process.env.DEBUG && error.code !== 'ENOENT') {
102
+ console.warn(`⚠️ Cache read failed: ${error.message}`)
103
+ }
94
104
  return null
95
105
  }
96
106
  }
@@ -127,24 +137,44 @@ class CacheManager {
127
137
  */
128
138
  clear() {
129
139
  if (!this.enabled) {
130
- return
140
+ return { success: true, cleared: 0 }
131
141
  }
132
142
 
143
+ const results = { success: true, cleared: 0, errors: [] }
144
+
133
145
  try {
134
146
  if (fs.existsSync(this.cacheDir)) {
135
147
  const files = fs.readdirSync(this.cacheDir)
136
148
  files.forEach(file => {
137
149
  if (file.endsWith('.json')) {
138
- fs.unlinkSync(path.join(this.cacheDir, file))
150
+ try {
151
+ fs.unlinkSync(path.join(this.cacheDir, file))
152
+ results.cleared++
153
+ } catch (error) {
154
+ // DR11 fix: Track individual file errors
155
+ results.errors.push({ file, error: error.message })
156
+ results.success = false
157
+ }
139
158
  }
140
159
  })
141
160
  }
142
161
  } catch (error) {
143
- // Silently fail if cache clear fails
162
+ // DR11 fix: Report directory-level failures
163
+ results.success = false
164
+ results.errors.push({ directory: this.cacheDir, error: error.message })
165
+
144
166
  if (this.verbose) {
145
- console.warn(`Cache clear failed: ${error.message}`)
167
+ console.warn(`❌ Cache clear failed: ${error.message}`)
146
168
  }
147
169
  }
170
+
171
+ // DR11 fix: Warn if any failures occurred
172
+ if (!results.success && this.verbose) {
173
+ console.warn(`⚠️ Failed to clear ${results.errors.length} cache file(s)`)
174
+ console.warn(' Some cached validation results may be stale')
175
+ }
176
+
177
+ return results
148
178
  }
149
179
 
150
180
  /**
@@ -5,7 +5,7 @@ const path = require('path')
5
5
  const os = require('os')
6
6
  const crypto = require('crypto')
7
7
  const https = require('https')
8
- const { execSync } = require('child_process')
8
+ const { execSync, spawnSync } = require('child_process')
9
9
  const { showProgress } = require('../ui-helpers')
10
10
 
11
11
  // Pinned gitleaks version for reproducible security scanning
@@ -15,6 +15,8 @@ const GITLEAKS_VERSION = '8.28.0'
15
15
  const GITLEAKS_CHECKSUMS = {
16
16
  'linux-x64':
17
17
  '5fd1b3b0073269484d40078662e921d07427340ab9e6ed526ccd215a565b3298',
18
+ 'linux-arm64':
19
+ '3770c7ebeb625e3e96c183525ca18285a01aedef2d75a2c41ceb3e141af2e8b7',
18
20
  'darwin-x64':
19
21
  'cf09ad7a85683d90221db8324f036f23c8c29107145e1fc4a0dffbfa9e89c09a',
20
22
  'darwin-arm64':
@@ -200,6 +202,7 @@ class ConfigSecurityScanner {
200
202
  'darwin-x64': 'darwin_x64',
201
203
  'darwin-arm64': 'darwin_arm64',
202
204
  'linux-x64': 'linux_x64',
205
+ 'linux-arm64': 'linux_arm64',
203
206
  'win32-x64': 'windows_x64',
204
207
  }
205
208
 
@@ -210,20 +213,37 @@ class ConfigSecurityScanner {
210
213
  /**
211
214
  * Download file from URL to target path
212
215
  */
213
- downloadFile(url, targetPath) {
214
- return new Promise((resolve, reject) => {
215
- const file = fs.createWriteStream(targetPath)
216
+ downloadFile(url, targetPath, redirectCount = 0) {
217
+ const MAX_REDIRECTS = 5
216
218
 
219
+ return new Promise((resolve, reject) => {
217
220
  https
218
221
  .get(url, response => {
219
- if (response.statusCode === 302 || response.statusCode === 301) {
220
- // Follow redirect
221
- return this.downloadFile(response.headers.location, targetPath)
222
+ if (
223
+ response.statusCode === 302 ||
224
+ response.statusCode === 301 ||
225
+ response.statusCode === 303 ||
226
+ response.statusCode === 307 ||
227
+ response.statusCode === 308
228
+ ) {
229
+ if (redirectCount >= MAX_REDIRECTS) {
230
+ reject(new Error('Too many redirects while downloading gitleaks'))
231
+ return
232
+ }
233
+ const nextUrl = response.headers.location
234
+ if (!nextUrl) {
235
+ reject(new Error('Redirect without location header'))
236
+ return
237
+ }
238
+ response.resume()
239
+ this.downloadFile(nextUrl, targetPath, redirectCount + 1)
222
240
  .then(resolve)
223
241
  .catch(reject)
242
+ return
224
243
  }
225
244
 
226
245
  if (response.statusCode !== 200) {
246
+ response.resume()
227
247
  reject(
228
248
  new Error(
229
249
  `HTTP ${response.statusCode}: ${response.statusMessage}`
@@ -232,6 +252,7 @@ class ConfigSecurityScanner {
232
252
  return
233
253
  }
234
254
 
255
+ const file = fs.createWriteStream(targetPath)
235
256
  response.pipe(file)
236
257
 
237
258
  file.on('finish', () => {
@@ -348,8 +369,10 @@ class ConfigSecurityScanner {
348
369
  execSync(auditCmd, {
349
370
  stdio: 'pipe',
350
371
  timeout: 60000, // 60 second timeout for audit operations
372
+ maxBuffer: 10 * 1024 * 1024,
351
373
  encoding: 'utf8',
352
374
  })
375
+ spinner.succeed('npm audit completed - no high/critical vulnerabilities')
353
376
  } catch (error) {
354
377
  if (error.signal === 'SIGTERM') {
355
378
  // Timeout occurred
@@ -364,11 +387,13 @@ class ConfigSecurityScanner {
364
387
  const auditResult = JSON.parse(error.stdout.toString())
365
388
  if (auditResult.metadata && auditResult.metadata.vulnerabilities) {
366
389
  const vulns = auditResult.metadata.vulnerabilities
367
- const total = vulns.high + vulns.critical + vulns.moderate
390
+ const total = vulns.high + vulns.critical
368
391
  if (total > 0) {
369
- spinner.fail(`npm audit found ${total} vulnerabilities`)
392
+ spinner.fail(
393
+ `npm audit found ${total} high/critical vulnerabilities`
394
+ )
370
395
  this.issues.push(
371
- `${packageManager} audit: ${total} vulnerabilities found (${vulns.high} high, ${vulns.critical} critical). Run '${packageManager} audit fix' to resolve.`
396
+ `${packageManager} audit: ${total} high/critical vulnerabilities found (${vulns.high} high, ${vulns.critical} critical). Run '${packageManager} audit fix' to resolve.`
372
397
  )
373
398
  } else {
374
399
  spinner.succeed(
@@ -401,16 +426,34 @@ class ConfigSecurityScanner {
401
426
 
402
427
  // Build command - handle npx vs direct binary execution
403
428
  const isNpxCommand = gitleaksBinary.startsWith('npx ')
404
- const command = isNpxCommand
405
- ? `${gitleaksBinary} detect --source . --redact`
406
- : `"${gitleaksBinary}" detect --source . --redact`
429
+ const args = ['detect', '--source', '.', '--redact']
430
+ const command = isNpxCommand ? 'npx' : gitleaksBinary
431
+ const commandArgs = isNpxCommand ? ['gitleaks', ...args] : args
407
432
 
408
433
  // Run gitleaks with --redact to prevent secret exposure and timeout for safety
409
- execSync(command, {
434
+ const result = spawnSync(command, commandArgs, {
410
435
  stdio: 'pipe',
411
436
  timeout: 30000, // 30 second timeout to prevent hangs
412
437
  encoding: 'utf8',
413
438
  })
439
+
440
+ if (result.error) {
441
+ const error = result.error
442
+ error.stdout = result.stdout
443
+ error.stderr = result.stderr
444
+ error.status = result.status
445
+ error.signal = result.signal
446
+ throw error
447
+ }
448
+
449
+ if (result.status !== 0) {
450
+ const error = new Error('gitleaks exited with non-zero status')
451
+ error.status = result.status
452
+ error.stdout = result.stdout
453
+ error.stderr = result.stderr
454
+ error.signal = result.signal
455
+ throw error
456
+ }
414
457
  spinner.succeed('gitleaks scan completed - no secrets detected')
415
458
  } catch (error) {
416
459
  if (error.signal === 'SIGTERM') {
@@ -672,14 +715,38 @@ class ConfigSecurityScanner {
672
715
  const secretPattern =
673
716
  /(?:SECRET|PASSWORD|KEY|TOKEN)\s*=\s*["']?[^"\s']+/gi
674
717
  if (secretPattern.test(envStatement)) {
718
+ const redacted = this.redactDockerEnv(envStatement)
675
719
  this.issues.push(
676
- `Dockerfile: Hardcoded secret in ENV statement: ${envStatement.trim()}`
720
+ `Dockerfile: Hardcoded secret in ENV statement: ${redacted}`
677
721
  )
678
722
  }
679
723
  }
680
724
  }
681
725
  }
682
726
 
727
+ redactDockerEnv(statement) {
728
+ const trimmed = statement.trim()
729
+ const match = trimmed.match(/^ENV\s+(.+)$/i)
730
+ if (!match) return trimmed
731
+
732
+ const body = match[1].trim()
733
+ if (body.includes('=')) {
734
+ const redactedBody = body.replace(
735
+ /=\s*(?:'[^']*'|"[^"]*"|[^ \t\r\n]+)/g,
736
+ '=[REDACTED]'
737
+ )
738
+ return `ENV ${redactedBody}`
739
+ }
740
+
741
+ const parts = body.split(/\s+/)
742
+ if (parts.length >= 2) {
743
+ parts[1] = '[REDACTED]'
744
+ return `ENV ${parts.join(' ')}`
745
+ }
746
+
747
+ return `ENV ${body}`
748
+ }
749
+
683
750
  /**
684
751
  * Check .gitignore for security-sensitive files
685
752
  */
@@ -2,7 +2,6 @@
2
2
 
3
3
  const fs = require('fs')
4
4
  const path = require('path')
5
- const { execSync } = require('child_process')
6
5
  const { showProgress } = require('../ui-helpers')
7
6
 
8
7
  /**
@@ -85,32 +84,59 @@ class WorkflowValidator {
85
84
  const spinner = showProgress('Running actionlint on workflow files...')
86
85
 
87
86
  try {
88
- // Run actionlint via npx (works with local and global installs, cross-platform)
89
- execSync('npx actionlint', { stdio: 'pipe', cwd: process.cwd() })
90
- spinner.succeed('actionlint validation passed')
91
- } catch (error) {
92
- if (error.stdout || error.stderr) {
93
- const output = error.stdout
94
- ? error.stdout.toString()
95
- : error.stderr.toString()
96
- const lines = output
97
- .trim()
98
- .split('\n')
99
- .filter(line => line.trim())
100
-
101
- if (lines.length > 0) {
102
- spinner.fail(`actionlint found ${lines.length} issue(s)`)
103
- lines.forEach(line => {
104
- if (line.trim()) {
105
- this.issues.push(`actionlint: ${line.trim()}`)
106
- }
107
- })
108
- } else {
109
- spinner.succeed('actionlint validation passed')
87
+ const { createLinter } = require('actionlint')
88
+ const workflowFiles = fs
89
+ .readdirSync(workflowDir)
90
+ .filter(file => file.endsWith('.yml') || file.endsWith('.yaml'))
91
+
92
+ const linter = await createLinter()
93
+ let issueCount = 0
94
+
95
+ for (const file of workflowFiles) {
96
+ const filePath = path.join(workflowDir, file)
97
+ const content = fs.readFileSync(filePath, 'utf8')
98
+
99
+ try {
100
+ const results = linter(content, filePath) || []
101
+
102
+ if (Array.isArray(results) && results.length > 0) {
103
+ // Filter out false positives for 'vars' context (valid GitHub feature, not recognized by actionlint WASM)
104
+ const filteredResults = results.filter(
105
+ result =>
106
+ !(
107
+ result.kind === 'expression' &&
108
+ result.message?.includes('undefined variable "vars"')
109
+ )
110
+ )
111
+ issueCount += filteredResults.length
112
+ filteredResults.forEach(result => {
113
+ this.issues.push(
114
+ `actionlint: ${result.file}:${result.line}:${result.column} ${result.kind} - ${result.message}`
115
+ )
116
+ })
117
+ }
118
+ } catch (lintError) {
119
+ // WASM actionlint has limits on file complexity - skip silently for large files
120
+ if (
121
+ lintError.message?.includes('unreachable') &&
122
+ content.length > 10000
123
+ ) {
124
+ // Large file exceeded WASM limits - not a validation failure
125
+ continue
126
+ }
127
+ throw lintError
110
128
  }
129
+ }
130
+
131
+ if (issueCount > 0) {
132
+ spinner.fail(`actionlint found ${issueCount} issue(s)`)
111
133
  } else {
112
134
  spinner.succeed('actionlint validation passed')
113
135
  }
136
+ } catch (error) {
137
+ spinner.fail('actionlint failed to run')
138
+ const reason = error?.message || 'Unknown error'
139
+ this.issues.push(`actionlint: Failed to run - ${reason}`)
114
140
  }
115
141
  }
116
142
 
package/package.json CHANGED
@@ -1,11 +1,10 @@
1
1
  {
2
2
  "name": "create-qa-architect",
3
- "version": "5.0.6",
3
+ "version": "5.3.1",
4
4
  "description": "QA Architect - Bootstrap quality automation for JavaScript/TypeScript and Python projects with GitHub Actions, pre-commit hooks, linting, formatting, and smart test strategy",
5
5
  "main": "setup.js",
6
6
  "bin": {
7
- "create-qa-architect": "./setup.js",
8
- "create-saas-monetization": "./create-saas-monetization.js"
7
+ "create-qa-architect": "./setup.js"
9
8
  },
10
9
  "scripts": {
11
10
  "prepare": "husky",
@@ -66,7 +65,6 @@
66
65
  "license": "SEE LICENSE IN LICENSE",
67
66
  "files": [
68
67
  "setup.js",
69
- "create-saas-monetization.js",
70
68
  "config/",
71
69
  "docs/",
72
70
  "lib/",
@@ -91,7 +89,7 @@
91
89
  "bugs": {
92
90
  "url": "https://github.com/vibebuildlab/qa-architect/issues"
93
91
  },
94
- "homepage": "https://vibebuildlab.com/tools/qa-architect",
92
+ "homepage": "https://vibebuildlab.com/qa-architect",
95
93
  "engines": {
96
94
  "node": ">=20"
97
95
  },
@@ -110,13 +108,13 @@
110
108
  "actionlint": "^2.0.6",
111
109
  "typescript": "^5",
112
110
  "c8": "^10.1.2",
113
- "eslint": "^9.12.0",
111
+ "eslint": "^9.39.2",
114
112
  "eslint-plugin-security": "^3.0.1",
115
113
  "globals": "^15.9.0",
116
114
  "husky": "^9.1.4",
117
115
  "lint-staged": "^15.2.10",
118
- "prettier": "^3.3.3",
119
- "stylelint": "^16.8.0",
116
+ "prettier": "^3.7.4",
117
+ "stylelint": "^16.26.1",
120
118
  "stylelint-config-standard": "^37.0.0"
121
119
  },
122
120
  "volta": {
@@ -159,11 +157,11 @@
159
157
  ]
160
158
  },
161
159
  "dependencies": {
162
- "@npmcli/package-json": "^7.0.1",
160
+ "@npmcli/package-json": "^7.0.4",
163
161
  "ajv": "^8.17.1",
164
162
  "ajv-formats": "^3.0.1",
165
163
  "js-yaml": "^4.1.0",
166
- "markdownlint-cli2": "^0.19.0",
164
+ "markdownlint-cli2": "^0.20.0",
167
165
  "ora": "^8.1.1",
168
166
  "tar": "^7.4.3"
169
167
  }
@@ -0,0 +1,46 @@
1
+ #!/bin/bash
2
+ # Check test coverage for new features
3
+ # Run manually or via pre-commit hook (warning only)
4
+
5
+ set -e
6
+
7
+ YELLOW='\033[1;33m'
8
+ NC='\033[0m'
9
+
10
+ warnings=0
11
+
12
+ echo "🔍 Checking test coverage..."
13
+
14
+ # Find source files without corresponding test files
15
+ for file in $(find src -name "*.ts" -o -name "*.tsx" 2>/dev/null | grep -v "\.test\." | grep -v "\.d\.ts" || true); do
16
+ # Skip layout, page, and config files (Next.js conventions)
17
+ if [[ "$file" == *"/layout."* ]] || [[ "$file" == *"/page."* ]] || [[ "$file" == *".config."* ]]; then
18
+ continue
19
+ fi
20
+
21
+ # Skip API routes (tested via integration tests)
22
+ if [[ "$file" == *"/api/"* ]]; then
23
+ continue
24
+ fi
25
+
26
+ # Check for corresponding test file
27
+ test_file="${file%.*}.test.${file##*.}"
28
+
29
+ if [ ! -f "$test_file" ] && [ ! -f "tests/unit/$(basename ${file%.*}).test.ts" ]; then
30
+ # Only warn for lib/ and components/ (core business logic)
31
+ if [[ "$file" == *"/lib/"* ]] || [[ "$file" == *"/components/"* ]]; then
32
+ echo -e "${YELLOW}⚠️ May need test: $file${NC}"
33
+ ((warnings++)) || true
34
+ fi
35
+ fi
36
+ done
37
+
38
+ if [ $warnings -gt 0 ]; then
39
+ echo ""
40
+ echo -e "${YELLOW}⚠️ $warnings file(s) in lib/ or components/ may need tests${NC}"
41
+ echo " Run: pnpm test:coverage to check actual coverage"
42
+ else
43
+ echo "✅ All core files appear to have tests"
44
+ fi
45
+
46
+ exit 0