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.
- package/.github/workflows/claude-md-validation.yml +0 -3
- package/.semgrep/defensive-patterns.yaml +356 -0
- package/.semgrep/vibe-audit-rules.yaml +332 -0
- package/LICENSE +192 -39
- package/README.md +98 -46
- package/config/requirements-dev.txt +2 -2
- package/docs/CI-COST-ANALYSIS.md +10 -10
- package/docs/POLAR-DEPLOYMENT.md +157 -0
- package/docs/{STRIPE-LIVE-MODE-DEPLOYMENT.md → _archive/STRIPE-LIVE-MODE-DEPLOYMENT.md} +24 -21
- package/docs/plans/PLAN-vibe-code-auditor.md +130 -0
- package/docs/plans/POLAR-MIGRATION.md +111 -0
- package/docs/plans/pro-features-2026-05.md +159 -0
- package/eslint.config.cjs +1 -0
- package/lib/commands/analyze-ci.js +20 -11
- package/lib/commands/audit.js +668 -0
- package/lib/commands/ci-doctor.js +341 -0
- package/lib/commands/history-scan.js +342 -0
- package/lib/commands/index.js +8 -0
- package/lib/commands/pr-check.js +484 -0
- package/lib/commands/prelaunch-setup.js +4 -0
- package/lib/commands/ship-check.js +570 -0
- package/lib/license-validator.js +200 -6
- package/lib/licensing.js +99 -11
- package/package.json +7 -6
- package/scripts/deploy-consumers.sh +10 -5
- package/scripts/risk-policy-gate.js +410 -0
- package/setup.js +132 -4
- /package/lib/{billing-dashboard.html → _archive/billing-dashboard.html} +0 -0
|
@@ -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
|
+
}
|