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,484 @@
1
+ /**
2
+ * PR Check — diff-aware risk classifier for AI-assisted changes.
3
+ *
4
+ * Analyzes `git diff <base>...HEAD`, classifies risk per file, flags
5
+ * missing tests for non-test source changes, and emits a PR-comment-ready
6
+ * markdown report with a SHIP / REVIEW / BLOCK verdict.
7
+ *
8
+ * Gated behind Pro tier (hasFeature('prCheck')).
9
+ *
10
+ * All git invocations use spawnSync with argv 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 RISK = {
23
+ HIGH: 'HIGH',
24
+ MEDIUM: 'MEDIUM',
25
+ LOW: 'LOW',
26
+ }
27
+
28
+ const RISK_ICON = {
29
+ HIGH: '🔴',
30
+ MEDIUM: '🟡',
31
+ LOW: '🟢',
32
+ }
33
+
34
+ const VERDICT = {
35
+ SHIP: 'SHIP',
36
+ REVIEW: 'REVIEW',
37
+ BLOCK: 'BLOCK',
38
+ }
39
+
40
+ // File-path patterns mapped to risk levels.
41
+ // Order matters: first match wins (so high-risk patterns are checked first).
42
+ const HIGH_RISK_PATTERNS = [
43
+ /(^|\/)\.env($|\.)/i,
44
+ /(^|\/)auth[^/]*\.(js|ts|jsx|tsx|py)$/i,
45
+ /(^|\/)(login|logout|session|jwt|oauth)[^/]*\.(js|ts|jsx|tsx|py)$/i,
46
+ /(^|\/)(crypto|hash|password)[^/]*\.(js|ts|jsx|tsx|py)$/i,
47
+ /(^|\/)(payment|stripe|billing|invoice|checkout)[^/]*\.(js|ts|jsx|tsx|py)$/i,
48
+ /(^|\/)webhook[^/]*\.(js|ts|jsx|tsx|py)$/i,
49
+ /(^|\/)migrations?\//i,
50
+ /\.sql$/i,
51
+ /(^|\/)(secrets?|tokens?|keys?)[^/]*\.(js|ts|jsx|tsx|py)$/i,
52
+ /(^|\/)\.github\/workflows\//i,
53
+ /(^|\/)license[^/]*\.(js|ts|jsx|tsx|py)$/i,
54
+ ]
55
+
56
+ const MEDIUM_RISK_PATTERNS = [
57
+ /^package(-lock)?\.json$/i,
58
+ /^pnpm-lock\.yaml$/i,
59
+ /^yarn\.lock$/i,
60
+ /^requirements.*\.txt$/i,
61
+ /^pyproject\.toml$/i,
62
+ /^Cargo\.(toml|lock)$/i,
63
+ /^Gemfile(\.lock)?$/i,
64
+ /^tsconfig.*\.json$/i,
65
+ /^eslint\.config\.(c?js|ts)$/i,
66
+ /^\.eslintrc.*$/i,
67
+ /(^|\/)config\//i,
68
+ /(^|\/)index\.(js|ts|jsx|tsx)$/i,
69
+ /^Dockerfile$/i,
70
+ /(^|\/)docker-compose.*\.ya?ml$/i,
71
+ ]
72
+
73
+ const LOW_RISK_PATTERNS = [
74
+ /\.(md|mdx)$/i,
75
+ /(^|\/)docs\//i,
76
+ /(^|\/)CHANGELOG/i,
77
+ /\.test\.(js|ts|jsx|tsx|py)$/i,
78
+ /\.spec\.(js|ts|jsx|tsx|py)$/i,
79
+ /(^|\/)tests?\//i,
80
+ /(^|\/)__tests__\//i,
81
+ /\.(png|jpg|jpeg|gif|svg|webp|ico)$/i,
82
+ ]
83
+
84
+ function matchesAny(filePath, patterns) {
85
+ return patterns.some(re => re.test(filePath))
86
+ }
87
+
88
+ // Paths under these prefixes are vendor / generated and should never count
89
+ // as a high-risk change, even if a segment of the path looks scary
90
+ // (e.g. `node_modules/foo/migrations/bar.js`).
91
+ const VENDOR_PREFIXES = [
92
+ /(^|\/)node_modules\//,
93
+ /(^|\/)vendor\//,
94
+ /(^|\/)\.venv\//,
95
+ /(^|\/)dist\//,
96
+ /(^|\/)build\//,
97
+ /(^|\/)\.next\//,
98
+ /(^|\/)coverage\//,
99
+ ]
100
+
101
+ function isVendored(filePath) {
102
+ return VENDOR_PREFIXES.some(re => re.test(filePath))
103
+ }
104
+
105
+ function classifyFile(filePath) {
106
+ if (isVendored(filePath)) {
107
+ return { risk: RISK.LOW, reason: 'vendored/generated path' }
108
+ }
109
+ if (matchesAny(filePath, HIGH_RISK_PATTERNS)) {
110
+ return {
111
+ risk: RISK.HIGH,
112
+ reason: 'security/auth/payment/migration surface',
113
+ }
114
+ }
115
+ if (matchesAny(filePath, LOW_RISK_PATTERNS)) {
116
+ return { risk: RISK.LOW, reason: 'docs/tests/assets only' }
117
+ }
118
+ if (matchesAny(filePath, MEDIUM_RISK_PATTERNS)) {
119
+ return {
120
+ risk: RISK.MEDIUM,
121
+ reason: 'config/dependency/public-API surface',
122
+ }
123
+ }
124
+ return { risk: RISK.MEDIUM, reason: 'source change' }
125
+ }
126
+
127
+ function gitSpawn(projectPath, args, timeoutMs) {
128
+ return spawnSync('git', args, {
129
+ cwd: projectPath,
130
+ encoding: 'utf8',
131
+ timeout: timeoutMs || 30_000,
132
+ stdio: ['ignore', 'pipe', 'pipe'],
133
+ shell: false,
134
+ })
135
+ }
136
+
137
+ function inGitRepo(projectPath) {
138
+ const r = gitSpawn(projectPath, ['rev-parse', '--is-inside-work-tree'])
139
+ return r.status === 0 && (r.stdout || '').trim() === 'true'
140
+ }
141
+
142
+ // Permit `name`, `name/with/slashes`, `name-1.2.3`. Reject leading dashes
143
+ // (git would treat them as option flags) and obvious shell metacharacters
144
+ // even though we never invoke a shell.
145
+ const SAFE_REF_PATTERN = /^[A-Za-z0-9._/-]+$/
146
+
147
+ function isSafeRef(value) {
148
+ if (!value || typeof value !== 'string') return false
149
+ if (value.startsWith('-')) return false
150
+ if (value.length > 200) return false
151
+ return SAFE_REF_PATTERN.test(value)
152
+ }
153
+
154
+ function detectBaseBranch(projectPath, override) {
155
+ if (override) {
156
+ if (!isSafeRef(override)) return null
157
+ // Confirm the override actually exists as a ref before we trust it.
158
+ const r = gitSpawn(projectPath, [
159
+ 'rev-parse',
160
+ '--verify',
161
+ '--quiet',
162
+ `${override}^{commit}`,
163
+ ])
164
+ if (r.status === 0) return override
165
+ return null
166
+ }
167
+
168
+ for (const candidate of ['main', 'master']) {
169
+ const r = gitSpawn(projectPath, [
170
+ 'rev-parse',
171
+ '--verify',
172
+ '--quiet',
173
+ candidate,
174
+ ])
175
+ if (r.status === 0) return candidate
176
+ }
177
+ return null
178
+ }
179
+
180
+ function getCurrentBranch(projectPath) {
181
+ const r = gitSpawn(projectPath, ['rev-parse', '--abbrev-ref', 'HEAD'])
182
+ return r.status === 0 ? (r.stdout || '').trim() : null
183
+ }
184
+
185
+ function getChangedFiles(projectPath, baseRef) {
186
+ // `--name-status` gives us A/M/D/R per file.
187
+ const r = gitSpawn(projectPath, [
188
+ 'diff',
189
+ '--name-status',
190
+ `${baseRef}...HEAD`,
191
+ ])
192
+ if (r.status !== 0) return null
193
+
194
+ const out = (r.stdout || '').trim()
195
+ if (!out) return []
196
+
197
+ const files = []
198
+ for (const line of out.split('\n')) {
199
+ // Format: "M\tpath" or "R100\told\tnew"
200
+ const parts = line.split('\t')
201
+ if (parts.length < 2) continue
202
+ const code = parts[0]
203
+ const filePath = parts[parts.length - 1] // for renames, the new path
204
+ files.push({ code, path: filePath })
205
+ }
206
+ return files
207
+ }
208
+
209
+ function isTestFile(filePath) {
210
+ return /\.(test|spec)\.(js|ts|jsx|tsx|py)$/i.test(filePath)
211
+ }
212
+
213
+ function isSourceCodeFile(filePath) {
214
+ if (isTestFile(filePath)) return false
215
+ return /\.(js|ts|jsx|tsx|py|rs|rb|go|java)$/i.test(filePath)
216
+ }
217
+
218
+ function findMissingTests(changedFiles) {
219
+ const changedTestPaths = new Set(
220
+ changedFiles.filter(f => isTestFile(f.path)).map(f => f.path)
221
+ )
222
+ const changedSourceFiles = changedFiles.filter(
223
+ f => isSourceCodeFile(f.path) && f.code !== 'D'
224
+ )
225
+
226
+ const missing = []
227
+ for (const src of changedSourceFiles) {
228
+ if (hasMatchingTest(src.path, changedTestPaths)) continue
229
+ missing.push(src.path)
230
+ }
231
+ return missing
232
+ }
233
+
234
+ function hasMatchingTest(srcPath, changedTestPaths) {
235
+ const base = path.basename(srcPath).replace(/\.[^.]+$/, '')
236
+ for (const testPath of changedTestPaths) {
237
+ if (testPath.includes(base)) return true
238
+ }
239
+ return false
240
+ }
241
+
242
+ function classifyAll(changedFiles) {
243
+ return changedFiles.map(f => ({
244
+ path: f.path,
245
+ code: f.code,
246
+ ...classifyFile(f.path),
247
+ }))
248
+ }
249
+
250
+ function summarizeRisks(classified) {
251
+ const counts = { HIGH: 0, MEDIUM: 0, LOW: 0 }
252
+ for (const c of classified) {
253
+ counts[c.risk]++
254
+ }
255
+ return counts
256
+ }
257
+
258
+ function computeVerdict(classified, missingTests) {
259
+ const counts = summarizeRisks(classified)
260
+ // BLOCK if any HIGH-risk file is in the missing-tests set. Adding an
261
+ // unrelated README.md change must not downgrade verdict from BLOCK to
262
+ // REVIEW — the high-risk file still has no test.
263
+ const missingSet = new Set(missingTests)
264
+ const highRiskMissingTest = classified.some(
265
+ f => f.risk === RISK.HIGH && missingSet.has(f.path)
266
+ )
267
+ if (counts.HIGH > 0 && highRiskMissingTest) {
268
+ return VERDICT.BLOCK
269
+ }
270
+ if (counts.HIGH > 0 || missingTests.length > 0) return VERDICT.REVIEW
271
+ if (counts.MEDIUM > 0) return VERDICT.REVIEW
272
+ return VERDICT.SHIP
273
+ }
274
+
275
+ function buildMarkdown(report) {
276
+ const lines = []
277
+ lines.push(`# PR Risk Check — ${report.verdict}`)
278
+ lines.push('')
279
+ if (report.baseRef && report.headRef) {
280
+ lines.push(`_${report.headRef} vs \`${report.baseRef}\`_`)
281
+ lines.push('')
282
+ }
283
+
284
+ const counts = report.riskCounts
285
+ lines.push(
286
+ `**Risk summary:** ${RISK_ICON.HIGH} ${counts.HIGH} high · ${RISK_ICON.MEDIUM} ${counts.MEDIUM} medium · ${RISK_ICON.LOW} ${counts.LOW} low`
287
+ )
288
+ if (report.missingTests.length > 0) {
289
+ lines.push(
290
+ `**Missing tests:** ${report.missingTests.length} source file(s) changed without matching test changes`
291
+ )
292
+ }
293
+ lines.push('')
294
+
295
+ const high = report.files.filter(f => f.risk === RISK.HIGH)
296
+ if (high.length > 0) {
297
+ lines.push('### 🔴 High-risk changes')
298
+ for (const f of high) {
299
+ lines.push(`- \`${f.path}\` — ${f.reason}`)
300
+ }
301
+ lines.push('')
302
+ }
303
+
304
+ if (report.missingTests.length > 0) {
305
+ lines.push('### ⚠️ Source changes without matching tests')
306
+ for (const p of report.missingTests.slice(0, 20)) {
307
+ lines.push(`- \`${p}\``)
308
+ }
309
+ if (report.missingTests.length > 20) {
310
+ lines.push(`- _…and ${report.missingTests.length - 20} more_`)
311
+ }
312
+ lines.push('')
313
+ }
314
+
315
+ lines.push('### All changed files')
316
+ lines.push('| Risk | File | Reason |')
317
+ lines.push('| --- | --- | --- |')
318
+ for (const f of report.files) {
319
+ const icon = RISK_ICON[f.risk] || ''
320
+ lines.push(`| ${icon} ${f.risk} | \`${f.path}\` | ${f.reason} |`)
321
+ }
322
+ lines.push('')
323
+
324
+ if (report.verdict === VERDICT.BLOCK) {
325
+ lines.push('### ❌ Block')
326
+ lines.push(
327
+ 'High-risk changes detected with no accompanying test changes. Add tests or get explicit reviewer sign-off.'
328
+ )
329
+ } else if (report.verdict === VERDICT.REVIEW) {
330
+ lines.push('### ⚠️ Needs review')
331
+ lines.push(
332
+ 'Non-trivial changes — request a careful review focused on the high-risk files above.'
333
+ )
334
+ } else {
335
+ lines.push('### ✅ Low-risk diff')
336
+ lines.push('Looks like docs / tests / minor changes only.')
337
+ }
338
+
339
+ return `${lines.join('\n')}\n`
340
+ }
341
+
342
+ function buildHumanReport(report) {
343
+ const lines = []
344
+ lines.push('')
345
+ lines.push('🔍 PR Risk Check')
346
+ lines.push('─'.repeat(60))
347
+ if (report.baseRef) {
348
+ lines.push(`Base: ${report.baseRef} Head: ${report.headRef || 'HEAD'}`)
349
+ }
350
+
351
+ const counts = report.riskCounts
352
+ lines.push(
353
+ `Risk: ${RISK_ICON.HIGH} ${counts.HIGH} high ${RISK_ICON.MEDIUM} ${counts.MEDIUM} medium ${RISK_ICON.LOW} ${counts.LOW} low`
354
+ )
355
+ lines.push(`Files changed: ${report.files.length}`)
356
+ if (report.missingTests.length > 0) {
357
+ lines.push(`Missing tests: ${report.missingTests.length} source file(s)`)
358
+ }
359
+ lines.push('─'.repeat(60))
360
+
361
+ for (const f of report.files) {
362
+ const icon = RISK_ICON[f.risk] || ' '
363
+ lines.push(`${icon} ${f.risk.padEnd(6)} ${f.path}`)
364
+ }
365
+ lines.push('─'.repeat(60))
366
+
367
+ if (report.verdict === VERDICT.SHIP) {
368
+ lines.push('✅ Verdict: SHIP — low-risk diff')
369
+ } else if (report.verdict === VERDICT.REVIEW) {
370
+ lines.push('⚠️ Verdict: REVIEW — request careful review')
371
+ } else {
372
+ lines.push('❌ Verdict: BLOCK — add tests or get explicit sign-off')
373
+ }
374
+ lines.push('')
375
+ return lines.join('\n')
376
+ }
377
+
378
+ function runPrCheck(projectPath, options = {}) {
379
+ if (!inGitRepo(projectPath)) {
380
+ return { error: 'Not a git repository' }
381
+ }
382
+
383
+ const baseRef = detectBaseBranch(projectPath, options.base)
384
+ if (!baseRef) {
385
+ return {
386
+ error:
387
+ 'Could not detect base branch (no main or master found). Pass --base <branch>.',
388
+ }
389
+ }
390
+
391
+ const headRef = getCurrentBranch(projectPath)
392
+ const changed = getChangedFiles(projectPath, baseRef)
393
+ if (changed === null) {
394
+ return { error: `Failed to compute git diff against ${baseRef}` }
395
+ }
396
+
397
+ if (changed.length === 0) {
398
+ return {
399
+ verdict: VERDICT.SHIP,
400
+ baseRef,
401
+ headRef,
402
+ files: [],
403
+ missingTests: [],
404
+ riskCounts: { HIGH: 0, MEDIUM: 0, LOW: 0 },
405
+ empty: true,
406
+ generatedAt: new Date().toISOString(),
407
+ }
408
+ }
409
+
410
+ const classified = classifyAll(changed)
411
+ const missingTests = findMissingTests(changed)
412
+ const riskCounts = summarizeRisks(classified)
413
+ const verdict = computeVerdict(classified, missingTests)
414
+
415
+ return {
416
+ verdict,
417
+ baseRef,
418
+ headRef,
419
+ files: classified,
420
+ missingTests,
421
+ riskCounts,
422
+ empty: false,
423
+ generatedAt: new Date().toISOString(),
424
+ }
425
+ }
426
+
427
+ async function handlePrCheck(options = {}) {
428
+ await ensureLicenseFresh()
429
+ if (!hasFeature('prCheck')) {
430
+ showUpgradeMessage('PR Risk Check (diff-aware risk classifier)')
431
+ process.exit(1)
432
+ }
433
+
434
+ const projectPath = options.projectPath || process.cwd()
435
+ const report = runPrCheck(projectPath, options)
436
+
437
+ if (report.error) {
438
+ process.stderr.write(`❌ ${report.error}\n`)
439
+ process.exit(1)
440
+ }
441
+
442
+ if (report.empty) {
443
+ if (options.json) {
444
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`)
445
+ } else {
446
+ process.stdout.write(
447
+ `\n✅ No changes vs \`${report.baseRef}\` — nothing to review.\n\n`
448
+ )
449
+ }
450
+ process.exit(0)
451
+ }
452
+
453
+ if (options.json) {
454
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`)
455
+ } else {
456
+ process.stdout.write(buildHumanReport(report))
457
+ }
458
+
459
+ if (options.outPath) {
460
+ fs.writeFileSync(options.outPath, buildMarkdown(report), 'utf8')
461
+ if (!options.json) {
462
+ process.stdout.write(
463
+ `\n📄 Markdown report written to ${options.outPath}\n`
464
+ )
465
+ }
466
+ }
467
+
468
+ if (options.noFail) {
469
+ process.exit(0)
470
+ }
471
+ process.exit(report.verdict === VERDICT.BLOCK ? 1 : 0)
472
+ }
473
+
474
+ module.exports = {
475
+ runPrCheck,
476
+ handlePrCheck,
477
+ classifyFile,
478
+ findMissingTests,
479
+ computeVerdict,
480
+ buildMarkdown,
481
+ buildHumanReport,
482
+ RISK,
483
+ VERDICT,
484
+ }
@@ -8,6 +8,7 @@ const {
8
8
  getLicenseInfo,
9
9
  hasFeature,
10
10
  showUpgradeMessage,
11
+ ensureLicenseFresh,
11
12
  } = require('../licensing')
12
13
 
13
14
  /**
@@ -35,6 +36,9 @@ async function handlePrelaunchSetup(options) {
35
36
  const projectPath = process.cwd()
36
37
  const PackageJson = checkNodeVersionAndLoadPackageJson()
37
38
  const pkgJson = await PackageJson.load(projectPath)
39
+ // Re-check the signed registry before unlocking Pro pre-launch features so
40
+ // a revoked/cancelled subscription stops unlocking them (fails open offline).
41
+ await ensureLicenseFresh()
38
42
  const license = getLicenseInfo()
39
43
  const isPro = license.tier === 'PRO'
40
44