create-qa-architect 5.13.5 → 5.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,570 @@
1
+ /**
2
+ * Ship Check — unified release readiness report
3
+ *
4
+ * Orchestrates existing Pro-tier checks (lint, tests, security, coverage,
5
+ * bundle, lighthouse, env, ci-cost, docs) and produces a single
6
+ * "can I ship?" report in human / JSON / markdown formats.
7
+ *
8
+ * Gated behind Pro tier (proxy: hasFeature('shipCheck')).
9
+ *
10
+ * All process invocations use spawnSync with argument arrays (no shell).
11
+ */
12
+
13
+ const fs = require('fs')
14
+ const path = require('path')
15
+ const { spawnSync } = require('child_process')
16
+ const {
17
+ hasFeature,
18
+ showUpgradeMessage,
19
+ ensureLicenseFresh,
20
+ } = require('../licensing')
21
+
22
+ const VERDICT = {
23
+ SHIP: 'SHIP',
24
+ REVIEW: 'REVIEW',
25
+ BLOCK: 'BLOCK',
26
+ }
27
+
28
+ const STATUS = {
29
+ PASS: 'pass',
30
+ WARN: 'warn',
31
+ FAIL: 'fail',
32
+ SKIP: 'skip',
33
+ }
34
+
35
+ const STATUS_ICON = {
36
+ pass: '✅',
37
+ warn: '⚠️',
38
+ fail: '❌',
39
+ skip: '⏭️',
40
+ }
41
+
42
+ function readPackageJson(projectPath) {
43
+ const pkgPath = path.join(projectPath, 'package.json')
44
+ if (!fs.existsSync(pkgPath)) return null
45
+ try {
46
+ return JSON.parse(fs.readFileSync(pkgPath, 'utf8'))
47
+ } catch {
48
+ return null
49
+ }
50
+ }
51
+
52
+ function hasNpmScript(pkg, scriptName) {
53
+ return Boolean(pkg && pkg.scripts && pkg.scripts[scriptName])
54
+ }
55
+
56
+ function runNpmScript(projectPath, scriptName, timeoutMs) {
57
+ // No shell: spawnSync with argv array.
58
+ const result = spawnSync('npm', ['run', '--silent', scriptName], {
59
+ cwd: projectPath,
60
+ encoding: 'utf8',
61
+ timeout: timeoutMs,
62
+ stdio: ['ignore', 'pipe', 'pipe'],
63
+ shell: false,
64
+ })
65
+
66
+ if (result.error && result.error.code === 'ETIMEDOUT') {
67
+ return {
68
+ status: STATUS.WARN,
69
+ summary: `Timed out after ${Math.round(timeoutMs / 1000)}s`,
70
+ details: '',
71
+ }
72
+ }
73
+
74
+ const ok = result.status === 0
75
+ const output = `${result.stdout || ''}${result.stderr || ''}`.trim()
76
+ return {
77
+ status: ok ? STATUS.PASS : STATUS.FAIL,
78
+ summary: ok
79
+ ? `${scriptName} passed`
80
+ : `${scriptName} exited with ${result.status}`,
81
+ details: output.slice(-500),
82
+ }
83
+ }
84
+
85
+ function checkLint(projectPath, pkg) {
86
+ if (!hasNpmScript(pkg, 'lint')) {
87
+ return {
88
+ name: 'Lint',
89
+ status: STATUS.SKIP,
90
+ summary: 'No lint script configured',
91
+ }
92
+ }
93
+ const r = runNpmScript(projectPath, 'lint', 60_000)
94
+ return { name: 'Lint', ...r }
95
+ }
96
+
97
+ function checkTests(projectPath, pkg, options) {
98
+ if (options.skipTests) {
99
+ return {
100
+ name: 'Tests',
101
+ status: STATUS.SKIP,
102
+ summary: 'Skipped (--skip-tests)',
103
+ }
104
+ }
105
+ if (!hasNpmScript(pkg, 'test')) {
106
+ return {
107
+ name: 'Tests',
108
+ status: STATUS.SKIP,
109
+ summary: 'No test script configured',
110
+ }
111
+ }
112
+ const r = runNpmScript(projectPath, 'test', 300_000)
113
+ return { name: 'Tests', ...r }
114
+ }
115
+
116
+ function checkSecurity(projectPath, pkg) {
117
+ if (hasNpmScript(pkg, 'security:secrets')) {
118
+ const r = runNpmScript(projectPath, 'security:secrets', 60_000)
119
+ return { name: 'Security (secrets)', ...r }
120
+ }
121
+
122
+ if (!pkg) {
123
+ return {
124
+ name: 'Security',
125
+ status: STATUS.SKIP,
126
+ summary: 'No package.json found',
127
+ }
128
+ }
129
+
130
+ const result = spawnSync(
131
+ 'npm',
132
+ ['audit', '--audit-level=high', '--omit=dev'],
133
+ {
134
+ cwd: projectPath,
135
+ encoding: 'utf8',
136
+ timeout: 60_000,
137
+ stdio: ['ignore', 'pipe', 'pipe'],
138
+ shell: false,
139
+ }
140
+ )
141
+ const ok = result.status === 0
142
+ return {
143
+ name: 'Security (npm audit)',
144
+ status: ok ? STATUS.PASS : STATUS.WARN,
145
+ summary: ok
146
+ ? 'No high/critical vulnerabilities'
147
+ : 'High or critical vulnerabilities detected',
148
+ details: (result.stdout || '').slice(-500),
149
+ }
150
+ }
151
+
152
+ function readCoverageSummary(projectPath) {
153
+ const candidates = [
154
+ path.join(projectPath, 'coverage', 'coverage-summary.json'),
155
+ path.join(projectPath, 'coverage', 'coverage-final.json'),
156
+ ]
157
+ for (const p of candidates) {
158
+ if (fs.existsSync(p)) {
159
+ try {
160
+ return { path: p, data: JSON.parse(fs.readFileSync(p, 'utf8')) }
161
+ } catch {
162
+ // try next
163
+ }
164
+ }
165
+ }
166
+ return null
167
+ }
168
+
169
+ function readCoverageThresholds(projectPath) {
170
+ const defaults = { lines: 75, functions: 70, branches: 65 }
171
+ const rcPath = path.join(projectPath, '.qualityrc.json')
172
+ if (!fs.existsSync(rcPath)) return defaults
173
+ try {
174
+ const rc = JSON.parse(fs.readFileSync(rcPath, 'utf8'))
175
+ return rc.coverage ? { ...defaults, ...rc.coverage } : defaults
176
+ } catch {
177
+ return defaults
178
+ }
179
+ }
180
+
181
+ function compareThresholds(pcts, thresholds) {
182
+ const failed = []
183
+ for (const key of ['lines', 'functions', 'branches']) {
184
+ if (pcts[key] < thresholds[key]) {
185
+ failed.push(`${key} ${pcts[key]}% < ${thresholds[key]}%`)
186
+ }
187
+ }
188
+ return failed
189
+ }
190
+
191
+ function checkCoverage(projectPath) {
192
+ const summary = readCoverageSummary(projectPath)
193
+ if (!summary || !summary.data || !summary.data.total) {
194
+ return {
195
+ name: 'Coverage',
196
+ status: STATUS.SKIP,
197
+ summary: 'No coverage report found (run `npm run test:coverage`)',
198
+ }
199
+ }
200
+
201
+ const total = summary.data.total
202
+ const pcts = {
203
+ lines: (total.lines && total.lines.pct) || 0,
204
+ functions: (total.functions && total.functions.pct) || 0,
205
+ branches: (total.branches && total.branches.pct) || 0,
206
+ }
207
+ const thresholds = readCoverageThresholds(projectPath)
208
+ const failed = compareThresholds(pcts, thresholds)
209
+
210
+ return {
211
+ name: 'Coverage',
212
+ status: failed.length === 0 ? STATUS.PASS : STATUS.FAIL,
213
+ summary:
214
+ failed.length === 0
215
+ ? `lines ${pcts.lines}% / functions ${pcts.functions}% / branches ${pcts.branches}%`
216
+ : `Below threshold: ${failed.join(', ')}`,
217
+ }
218
+ }
219
+
220
+ function checkBundleSize(projectPath, pkg) {
221
+ const hasScript = hasNpmScript(pkg, 'size') || hasNpmScript(pkg, 'size-limit')
222
+ const hasConfig =
223
+ fs.existsSync(path.join(projectPath, '.size-limit.json')) ||
224
+ fs.existsSync(path.join(projectPath, '.size-limit.js')) ||
225
+ (pkg && pkg['size-limit'])
226
+
227
+ if (!hasScript && !hasConfig) {
228
+ return {
229
+ name: 'Bundle size',
230
+ status: STATUS.SKIP,
231
+ summary: 'size-limit not configured',
232
+ }
233
+ }
234
+
235
+ if (hasScript) {
236
+ const scriptName = hasNpmScript(pkg, 'size') ? 'size' : 'size-limit'
237
+ const r = runNpmScript(projectPath, scriptName, 120_000)
238
+ return { name: 'Bundle size', ...r }
239
+ }
240
+
241
+ return {
242
+ name: 'Bundle size',
243
+ status: STATUS.WARN,
244
+ summary:
245
+ 'size-limit configured but no `size` script — add `"size": "size-limit"`',
246
+ }
247
+ }
248
+
249
+ function checkLighthouse(projectPath) {
250
+ const cfg = path.join(projectPath, '.lighthouserc.js')
251
+ const cfgJson = path.join(projectPath, '.lighthouserc.json')
252
+ if (!fs.existsSync(cfg) && !fs.existsSync(cfgJson)) {
253
+ return {
254
+ name: 'Lighthouse thresholds',
255
+ status: STATUS.SKIP,
256
+ summary: 'Lighthouse CI not configured',
257
+ }
258
+ }
259
+ return {
260
+ name: 'Lighthouse thresholds',
261
+ status: STATUS.PASS,
262
+ summary: 'Lighthouse CI configured (run in CI for full report)',
263
+ }
264
+ }
265
+
266
+ function parseEnvKeys(content) {
267
+ const lines = content.split('\n')
268
+ const keys = []
269
+ for (const raw of lines) {
270
+ const line = raw.trim()
271
+ if (!line || line.startsWith('#')) continue
272
+ const eq = line.indexOf('=')
273
+ if (eq <= 0) continue
274
+ keys.push(line.slice(0, eq).trim())
275
+ }
276
+ return keys
277
+ }
278
+
279
+ function checkEnvVars(projectPath) {
280
+ const examplePath = path.join(projectPath, '.env.example')
281
+ const envPath = path.join(projectPath, '.env')
282
+
283
+ if (!fs.existsSync(examplePath)) {
284
+ return {
285
+ name: 'Env vars',
286
+ status: STATUS.SKIP,
287
+ summary: 'No .env.example file (skip)',
288
+ }
289
+ }
290
+
291
+ const exampleKeys = parseEnvKeys(fs.readFileSync(examplePath, 'utf8'))
292
+ if (exampleKeys.length === 0) {
293
+ return {
294
+ name: 'Env vars',
295
+ status: STATUS.WARN,
296
+ summary: '.env.example is empty',
297
+ }
298
+ }
299
+
300
+ if (!fs.existsSync(envPath)) {
301
+ return {
302
+ name: 'Env vars',
303
+ status: STATUS.WARN,
304
+ summary: `${exampleKeys.length} keys in .env.example, but no local .env (CI/prod may be configured)`,
305
+ }
306
+ }
307
+
308
+ const localKeys = parseEnvKeys(fs.readFileSync(envPath, 'utf8'))
309
+ const missing = exampleKeys.filter(k => !localKeys.includes(k))
310
+
311
+ if (missing.length === 0) {
312
+ return {
313
+ name: 'Env vars',
314
+ status: STATUS.PASS,
315
+ summary: `All ${exampleKeys.length} required keys present locally`,
316
+ }
317
+ }
318
+
319
+ return {
320
+ name: 'Env vars',
321
+ status: STATUS.WARN,
322
+ summary: `${missing.length} key(s) missing from local .env: ${missing.slice(0, 5).join(', ')}`,
323
+ }
324
+ }
325
+
326
+ function checkCiCost(projectPath) {
327
+ try {
328
+ const analyzeCi = require('./analyze-ci')
329
+ const workflows = analyzeCi.discoverWorkflows(projectPath)
330
+ if (workflows.length === 0) {
331
+ return {
332
+ name: 'CI cost',
333
+ status: STATUS.SKIP,
334
+ summary: 'No GitHub Actions workflows found',
335
+ }
336
+ }
337
+
338
+ const yaml = require('js-yaml')
339
+ const parsed = []
340
+ const skipped = []
341
+ for (const wf of workflows) {
342
+ try {
343
+ const content = fs.readFileSync(wf.path, 'utf8')
344
+ parsed.push({
345
+ name: wf.name,
346
+ path: wf.path,
347
+ parsed: yaml.load(content),
348
+ })
349
+ } catch (err) {
350
+ // Track unparseable workflows — they could mask real CI cost
351
+ // problems if we silently dropped them.
352
+ skipped.push(`${wf.name} (${err.message})`)
353
+ }
354
+ }
355
+
356
+ const commitStats = analyzeCi.getCommitFrequency(projectPath)
357
+ const costs = analyzeCi.calculateMonthlyCosts(
358
+ parsed,
359
+ commitStats.commitsPerDay
360
+ )
361
+ const minutes = Math.round(costs.totalMinutes || 0)
362
+ const cost = (costs.totalCost || 0).toFixed(2)
363
+
364
+ if (skipped.length > 0) {
365
+ return {
366
+ name: 'CI cost',
367
+ status: STATUS.WARN,
368
+ summary: `~${minutes} min/mo, ~$${cost}/mo across ${parsed.length} workflow(s); skipped ${skipped.length} unparseable: ${skipped.slice(0, 3).join(', ')}`,
369
+ }
370
+ }
371
+
372
+ return {
373
+ name: 'CI cost',
374
+ status: STATUS.PASS,
375
+ summary: `~${minutes} min/mo, ~$${cost}/mo across ${parsed.length} workflow(s)`,
376
+ }
377
+ } catch (err) {
378
+ return {
379
+ name: 'CI cost',
380
+ status: STATUS.SKIP,
381
+ summary: `Could not analyze CI cost: ${err.message}`,
382
+ }
383
+ }
384
+ }
385
+
386
+ function checkDocs(projectPath) {
387
+ const readme = path.join(projectPath, 'README.md')
388
+ if (!fs.existsSync(readme)) {
389
+ return {
390
+ name: 'Docs',
391
+ status: STATUS.WARN,
392
+ summary: 'No README.md found',
393
+ }
394
+ }
395
+ const content = fs.readFileSync(readme, 'utf8')
396
+ if (content.trim().length < 200) {
397
+ return {
398
+ name: 'Docs',
399
+ status: STATUS.WARN,
400
+ summary: 'README.md is very short (< 200 chars)',
401
+ }
402
+ }
403
+ return { name: 'Docs', status: STATUS.PASS, summary: 'README.md present' }
404
+ }
405
+
406
+ function computeVerdict(results) {
407
+ if (results.some(r => r.status === STATUS.FAIL)) return VERDICT.BLOCK
408
+ if (results.some(r => r.status === STATUS.WARN)) return VERDICT.REVIEW
409
+ return VERDICT.SHIP
410
+ }
411
+
412
+ function gitInfo(projectPath, args) {
413
+ // No shell: spawnSync with argv array.
414
+ const result = spawnSync('git', args, {
415
+ cwd: projectPath,
416
+ encoding: 'utf8',
417
+ stdio: ['ignore', 'pipe', 'ignore'],
418
+ shell: false,
419
+ })
420
+ if (result.status === 0 && result.stdout) {
421
+ return result.stdout.trim()
422
+ }
423
+ return null
424
+ }
425
+
426
+ function getCurrentBranch(projectPath) {
427
+ return gitInfo(projectPath, ['rev-parse', '--abbrev-ref', 'HEAD'])
428
+ }
429
+
430
+ function getCurrentCommit(projectPath) {
431
+ return gitInfo(projectPath, ['rev-parse', '--short', 'HEAD'])
432
+ }
433
+
434
+ function countByStatus(results) {
435
+ const counts = { pass: 0, warn: 0, fail: 0, skip: 0 }
436
+ for (const r of results) {
437
+ if (counts[r.status] !== undefined) counts[r.status]++
438
+ }
439
+ return counts
440
+ }
441
+
442
+ function buildMarkdown(report) {
443
+ const lines = []
444
+ lines.push(`# Ship Check — ${report.verdict}`)
445
+ lines.push('')
446
+ if (report.branch || report.commit) {
447
+ const parts = []
448
+ if (report.branch) parts.push(`branch \`${report.branch}\``)
449
+ if (report.commit) parts.push(`commit \`${report.commit}\``)
450
+ lines.push(`_${parts.join(' · ')}_`)
451
+ lines.push('')
452
+ }
453
+
454
+ const counts = countByStatus(report.results)
455
+ lines.push(
456
+ `**Summary:** ${counts.pass} passed · ${counts.warn} warnings · ${counts.fail} failures · ${counts.skip} skipped`
457
+ )
458
+ lines.push('')
459
+
460
+ lines.push('| Check | Status | Summary |')
461
+ lines.push('| --- | --- | --- |')
462
+ for (const r of report.results) {
463
+ const icon = STATUS_ICON[r.status] || ''
464
+ const summary = (r.summary || '').replace(/\|/g, '\\|')
465
+ lines.push(`| ${r.name} | ${icon} ${r.status} | ${summary} |`)
466
+ }
467
+ lines.push('')
468
+
469
+ if (report.verdict === VERDICT.BLOCK) {
470
+ lines.push('### ❌ Not ready to ship')
471
+ lines.push('Resolve the failures above before merging.')
472
+ } else if (report.verdict === VERDICT.REVIEW) {
473
+ lines.push('### ⚠️ Ship with review')
474
+ lines.push('No hard failures, but warnings should be acknowledged.')
475
+ } else {
476
+ lines.push('### ✅ Ready to ship')
477
+ }
478
+
479
+ return `${lines.join('\n')}\n`
480
+ }
481
+
482
+ function buildHumanReport(report) {
483
+ const lines = []
484
+ lines.push('')
485
+ lines.push('🚀 Ship Check')
486
+ lines.push('─'.repeat(60))
487
+ for (const r of report.results) {
488
+ const icon = STATUS_ICON[r.status] || ' '
489
+ lines.push(`${icon} ${r.name.padEnd(22)} ${r.summary || ''}`)
490
+ }
491
+ lines.push('─'.repeat(60))
492
+ const counts = countByStatus(report.results)
493
+ lines.push(
494
+ `Summary: ${counts.pass} passed · ${counts.warn} warn · ${counts.fail} fail · ${counts.skip} skip`
495
+ )
496
+ lines.push('')
497
+ if (report.verdict === VERDICT.SHIP) {
498
+ lines.push('✅ Verdict: SHIP — ready to merge')
499
+ } else if (report.verdict === VERDICT.REVIEW) {
500
+ lines.push('⚠️ Verdict: REVIEW — warnings, but no failures')
501
+ } else {
502
+ lines.push('❌ Verdict: BLOCK — resolve failures before merging')
503
+ }
504
+ lines.push('')
505
+ return lines.join('\n')
506
+ }
507
+
508
+ function runShipCheck(projectPath, options = {}) {
509
+ const pkg = readPackageJson(projectPath)
510
+ const results = [
511
+ checkLint(projectPath, pkg),
512
+ checkTests(projectPath, pkg, options),
513
+ checkSecurity(projectPath, pkg),
514
+ checkCoverage(projectPath),
515
+ checkBundleSize(projectPath, pkg),
516
+ checkLighthouse(projectPath),
517
+ checkEnvVars(projectPath),
518
+ checkCiCost(projectPath),
519
+ checkDocs(projectPath),
520
+ ]
521
+
522
+ return {
523
+ verdict: computeVerdict(results),
524
+ branch: getCurrentBranch(projectPath),
525
+ commit: getCurrentCommit(projectPath),
526
+ generatedAt: new Date().toISOString(),
527
+ results,
528
+ }
529
+ }
530
+
531
+ async function handleShipCheck(options = {}) {
532
+ await ensureLicenseFresh()
533
+ if (!hasFeature('shipCheck')) {
534
+ showUpgradeMessage('Ship check (release readiness report)')
535
+ process.exit(1)
536
+ }
537
+
538
+ const projectPath = options.projectPath || process.cwd()
539
+ const report = runShipCheck(projectPath, options)
540
+
541
+ if (options.json) {
542
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`)
543
+ } else {
544
+ process.stdout.write(buildHumanReport(report))
545
+ }
546
+
547
+ if (options.outPath) {
548
+ const md = buildMarkdown(report)
549
+ fs.writeFileSync(options.outPath, md, 'utf8')
550
+ if (!options.json) {
551
+ process.stdout.write(
552
+ `\n📄 Markdown report written to ${options.outPath}\n`
553
+ )
554
+ }
555
+ }
556
+
557
+ const blocked = report.verdict === VERDICT.BLOCK
558
+ process.exit(blocked ? 1 : 0)
559
+ }
560
+
561
+ module.exports = {
562
+ runShipCheck,
563
+ handleShipCheck,
564
+ buildMarkdown,
565
+ buildHumanReport,
566
+ computeVerdict,
567
+ parseEnvKeys,
568
+ VERDICT,
569
+ STATUS,
570
+ }