create-qa-architect 5.0.7 → 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.
- package/.github/workflows/auto-release.yml +49 -0
- package/README.md +46 -1
- package/docs/ADOPTION-SUMMARY.md +41 -0
- package/docs/ARCHITECTURE-REVIEW.md +67 -0
- package/docs/ARCHITECTURE.md +29 -45
- package/docs/CODE-REVIEW.md +100 -0
- package/docs/REQUIREMENTS.md +148 -0
- package/docs/SECURITY-AUDIT.md +68 -0
- package/docs/test-trace-matrix.md +28 -0
- package/lib/commands/deps.js +245 -0
- package/lib/commands/index.js +25 -0
- package/lib/commands/validate.js +85 -0
- package/lib/error-reporter.js +13 -1
- package/lib/github-api.js +108 -13
- package/lib/license-signing.js +110 -0
- package/lib/license-validator.js +359 -71
- package/lib/licensing.js +333 -99
- package/lib/prelaunch-validator.js +828 -0
- package/lib/quality-tools-generator.js +495 -0
- package/lib/result-types.js +112 -0
- package/lib/security-enhancements.js +1 -1
- package/lib/smart-strategy-generator.js +28 -9
- package/lib/template-loader.js +52 -19
- package/lib/validation/cache-manager.js +36 -6
- package/lib/validation/config-security.js +78 -15
- package/lib/validation/workflow-validation.js +28 -7
- package/package.json +2 -4
- package/scripts/check-test-coverage.sh +46 -0
- package/setup.js +350 -284
- package/create-saas-monetization.js +0 -1513
package/lib/template-loader.js
CHANGED
|
@@ -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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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(
|
|
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
|
|
@@ -213,20 +213,37 @@ class ConfigSecurityScanner {
|
|
|
213
213
|
/**
|
|
214
214
|
* Download file from URL to target path
|
|
215
215
|
*/
|
|
216
|
-
downloadFile(url, targetPath) {
|
|
217
|
-
|
|
218
|
-
const file = fs.createWriteStream(targetPath)
|
|
216
|
+
downloadFile(url, targetPath, redirectCount = 0) {
|
|
217
|
+
const MAX_REDIRECTS = 5
|
|
219
218
|
|
|
219
|
+
return new Promise((resolve, reject) => {
|
|
220
220
|
https
|
|
221
221
|
.get(url, response => {
|
|
222
|
-
if (
|
|
223
|
-
|
|
224
|
-
|
|
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)
|
|
225
240
|
.then(resolve)
|
|
226
241
|
.catch(reject)
|
|
242
|
+
return
|
|
227
243
|
}
|
|
228
244
|
|
|
229
245
|
if (response.statusCode !== 200) {
|
|
246
|
+
response.resume()
|
|
230
247
|
reject(
|
|
231
248
|
new Error(
|
|
232
249
|
`HTTP ${response.statusCode}: ${response.statusMessage}`
|
|
@@ -235,6 +252,7 @@ class ConfigSecurityScanner {
|
|
|
235
252
|
return
|
|
236
253
|
}
|
|
237
254
|
|
|
255
|
+
const file = fs.createWriteStream(targetPath)
|
|
238
256
|
response.pipe(file)
|
|
239
257
|
|
|
240
258
|
file.on('finish', () => {
|
|
@@ -351,6 +369,7 @@ class ConfigSecurityScanner {
|
|
|
351
369
|
execSync(auditCmd, {
|
|
352
370
|
stdio: 'pipe',
|
|
353
371
|
timeout: 60000, // 60 second timeout for audit operations
|
|
372
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
354
373
|
encoding: 'utf8',
|
|
355
374
|
})
|
|
356
375
|
spinner.succeed('npm audit completed - no high/critical vulnerabilities')
|
|
@@ -368,11 +387,13 @@ class ConfigSecurityScanner {
|
|
|
368
387
|
const auditResult = JSON.parse(error.stdout.toString())
|
|
369
388
|
if (auditResult.metadata && auditResult.metadata.vulnerabilities) {
|
|
370
389
|
const vulns = auditResult.metadata.vulnerabilities
|
|
371
|
-
const total = vulns.high + vulns.critical
|
|
390
|
+
const total = vulns.high + vulns.critical
|
|
372
391
|
if (total > 0) {
|
|
373
|
-
spinner.fail(
|
|
392
|
+
spinner.fail(
|
|
393
|
+
`npm audit found ${total} high/critical vulnerabilities`
|
|
394
|
+
)
|
|
374
395
|
this.issues.push(
|
|
375
|
-
`${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.`
|
|
376
397
|
)
|
|
377
398
|
} else {
|
|
378
399
|
spinner.succeed(
|
|
@@ -405,16 +426,34 @@ class ConfigSecurityScanner {
|
|
|
405
426
|
|
|
406
427
|
// Build command - handle npx vs direct binary execution
|
|
407
428
|
const isNpxCommand = gitleaksBinary.startsWith('npx ')
|
|
408
|
-
const
|
|
409
|
-
|
|
410
|
-
|
|
429
|
+
const args = ['detect', '--source', '.', '--redact']
|
|
430
|
+
const command = isNpxCommand ? 'npx' : gitleaksBinary
|
|
431
|
+
const commandArgs = isNpxCommand ? ['gitleaks', ...args] : args
|
|
411
432
|
|
|
412
433
|
// Run gitleaks with --redact to prevent secret exposure and timeout for safety
|
|
413
|
-
|
|
434
|
+
const result = spawnSync(command, commandArgs, {
|
|
414
435
|
stdio: 'pipe',
|
|
415
436
|
timeout: 30000, // 30 second timeout to prevent hangs
|
|
416
437
|
encoding: 'utf8',
|
|
417
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
|
+
}
|
|
418
457
|
spinner.succeed('gitleaks scan completed - no secrets detected')
|
|
419
458
|
} catch (error) {
|
|
420
459
|
if (error.signal === 'SIGTERM') {
|
|
@@ -676,14 +715,38 @@ class ConfigSecurityScanner {
|
|
|
676
715
|
const secretPattern =
|
|
677
716
|
/(?:SECRET|PASSWORD|KEY|TOKEN)\s*=\s*["']?[^"\s']+/gi
|
|
678
717
|
if (secretPattern.test(envStatement)) {
|
|
718
|
+
const redacted = this.redactDockerEnv(envStatement)
|
|
679
719
|
this.issues.push(
|
|
680
|
-
`Dockerfile: Hardcoded secret in ENV statement: ${
|
|
720
|
+
`Dockerfile: Hardcoded secret in ENV statement: ${redacted}`
|
|
681
721
|
)
|
|
682
722
|
}
|
|
683
723
|
}
|
|
684
724
|
}
|
|
685
725
|
}
|
|
686
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
|
+
|
|
687
750
|
/**
|
|
688
751
|
* Check .gitignore for security-sensitive files
|
|
689
752
|
*/
|
|
@@ -95,15 +95,36 @@ class WorkflowValidator {
|
|
|
95
95
|
for (const file of workflowFiles) {
|
|
96
96
|
const filePath = path.join(workflowDir, file)
|
|
97
97
|
const content = fs.readFileSync(filePath, 'utf8')
|
|
98
|
-
const results = linter(content, filePath) || []
|
|
99
98
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
+
)
|
|
105
110
|
)
|
|
106
|
-
|
|
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
|
|
107
128
|
}
|
|
108
129
|
}
|
|
109
130
|
|
package/package.json
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-qa-architect",
|
|
3
|
-
"version": "5.
|
|
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/",
|
|
@@ -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
|