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,410 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Risk Policy Gate - Carson's Code Factory Pattern
5
+ *
6
+ * Validates PR changes against risk-aware merge policy before expensive CI.
7
+ * Implements Carson's "gate preflight before expensive CI" pattern.
8
+ */
9
+
10
+ const fs = require('fs')
11
+ const path = require('path')
12
+ const { execFileSync } = require('child_process')
13
+ const picomatch = require('picomatch')
14
+
15
+ // Load harness configuration
16
+ const CONFIG_PATH = path.join(__dirname, '..', 'harness-config.json')
17
+
18
+ function loadConfig() {
19
+ if (!fs.existsSync(CONFIG_PATH)) {
20
+ console.error('❌ harness-config.json not found')
21
+ process.exit(1)
22
+ }
23
+
24
+ try {
25
+ return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'))
26
+ } catch (error) {
27
+ console.error('❌ Invalid harness-config.json:', error.message)
28
+ process.exit(1)
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Default git runner — uses execFileSync (no shell, no concat). Returns trimmed
34
+ * stdout, throws with `.failed=true` on non-zero exit. Tests inject their own.
35
+ */
36
+ function defaultGitRunner(args) {
37
+ try {
38
+ return execFileSync('git', args, {
39
+ encoding: 'utf8',
40
+ stdio: ['ignore', 'pipe', 'pipe'],
41
+ }).trim()
42
+ } catch (error) {
43
+ const err = new Error(`git ${args.join(' ')} failed: ${error.message}`)
44
+ err.failed = true
45
+ throw err
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Resolve the base ref for diffing against HEAD.
51
+ *
52
+ * Algorithm (pure — only the injected runner touches git):
53
+ * 1. CI path: GITHUB_BASE_REF + GITHUB_HEAD_REF set → `origin/<base>`
54
+ * 2. CLI: --base <ref> → use it; fail closed if not resolvable
55
+ * 3. HEAD detached → fail closed
56
+ * 4. Try in order: origin/main, origin/master, main, master
57
+ * 5. None resolvable → fail closed
58
+ *
59
+ * Returns { mode: 'ci'|'local', base: '<ref>' }
60
+ * Throws Error with `.reason` for any fail-closed condition.
61
+ */
62
+ function resolveBase({
63
+ env = process.env,
64
+ baseArg = null,
65
+ gitRunner = defaultGitRunner,
66
+ } = {}) {
67
+ // Step 1: CI path
68
+ if (env.GITHUB_BASE_REF && env.GITHUB_HEAD_REF) {
69
+ return { mode: 'ci', base: `origin/${env.GITHUB_BASE_REF}` }
70
+ }
71
+
72
+ // Step 2: explicit --base
73
+ if (baseArg) {
74
+ if (!refExists(baseArg, gitRunner)) {
75
+ const err = new Error(`--base ${baseArg} is not resolvable in this repo`)
76
+ err.reason = 'base-not-resolvable'
77
+ throw err
78
+ }
79
+ return { mode: 'local', base: baseArg }
80
+ }
81
+
82
+ // Step 3: detached HEAD
83
+ if (isHeadDetached(gitRunner)) {
84
+ const err = new Error('HEAD is detached; pass --base <ref> explicitly')
85
+ err.reason = 'detached-head'
86
+ throw err
87
+ }
88
+
89
+ // Step 4: candidate base order
90
+ const candidates = ['origin/main', 'origin/master', 'main', 'master']
91
+ for (const ref of candidates) {
92
+ if (refExists(ref, gitRunner)) {
93
+ return { mode: 'local', base: ref }
94
+ }
95
+ }
96
+
97
+ // Step 5: nothing resolved
98
+ const err = new Error(
99
+ 'No base ref found (tried origin/main, origin/master, main, master); pass --base explicitly'
100
+ )
101
+ err.reason = 'no-base'
102
+ throw err
103
+ }
104
+
105
+ function refExists(ref, gitRunner) {
106
+ try {
107
+ gitRunner(['rev-parse', '--verify', '--quiet', `${ref}^{commit}`])
108
+ return true
109
+ } catch {
110
+ return false
111
+ }
112
+ }
113
+
114
+ function isHeadDetached(gitRunner) {
115
+ try {
116
+ gitRunner(['symbolic-ref', '--quiet', 'HEAD'])
117
+ return false
118
+ } catch {
119
+ return true
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Step 6: merge-base + Step 7: union of branch diff + staged + unstaged.
125
+ * Throws with `.reason='no-merge-base'` when merge-base cannot be computed
126
+ * (unrelated history or shallow clone too short).
127
+ */
128
+ function getChangedFilesForBase(base, gitRunner = defaultGitRunner) {
129
+ let mergeBase
130
+ try {
131
+ mergeBase = gitRunner(['merge-base', 'HEAD', base])
132
+ } catch {
133
+ const err = new Error(
134
+ `Could not compute merge-base between HEAD and ${base} ` +
135
+ '(unrelated history or shallow clone too short); deepen clone or pass --base explicitly'
136
+ )
137
+ err.reason = 'no-merge-base'
138
+ throw err
139
+ }
140
+
141
+ if (!mergeBase) {
142
+ const err = new Error(`merge-base HEAD ${base} returned empty`)
143
+ err.reason = 'no-merge-base'
144
+ throw err
145
+ }
146
+
147
+ const branch = gitRunner(['diff', '--name-only', `${mergeBase}...HEAD`])
148
+ const staged = gitRunner(['diff', '--cached', '--name-only'])
149
+ const unstaged = gitRunner(['diff', '--name-only'])
150
+
151
+ return [
152
+ ...new Set(
153
+ [branch, staged, unstaged]
154
+ .flatMap(out => out.split('\n'))
155
+ .filter(f => f.length > 0)
156
+ ),
157
+ ]
158
+ }
159
+
160
+ /**
161
+ * Top-level wrapper used by main(). Resolves base + diffs in one call.
162
+ * Any failure throws an Error with `.reason` — caller decides exit message.
163
+ */
164
+ function getChangedFiles({
165
+ env = process.env,
166
+ baseArg = null,
167
+ gitRunner = defaultGitRunner,
168
+ } = {}) {
169
+ const resolved = resolveBase({ env, baseArg, gitRunner })
170
+ const files = getChangedFilesForBase(resolved.base, gitRunner)
171
+ return { files, resolved }
172
+ }
173
+
174
+ function parseCliArgs(argv) {
175
+ const result = { baseArg: null }
176
+ for (let i = 0; i < argv.length; i++) {
177
+ if (argv[i] === '--base' && i + 1 < argv.length) {
178
+ result.baseArg = argv[i + 1]
179
+ i++
180
+ } else if (argv[i].startsWith('--base=')) {
181
+ result.baseArg = argv[i].slice('--base='.length)
182
+ }
183
+ }
184
+ return result
185
+ }
186
+
187
+ const matcherCache = new Map()
188
+ function getMatcher(pattern) {
189
+ let matcher = matcherCache.get(pattern)
190
+ if (!matcher) {
191
+ try {
192
+ matcher = picomatch(pattern, { dot: true })
193
+ } catch {
194
+ console.warn(`Invalid pattern: ${pattern}`)
195
+ matcher = () => false
196
+ }
197
+ matcherCache.set(pattern, matcher)
198
+ }
199
+ return matcher
200
+ }
201
+
202
+ function matchesPattern(filepath, patterns) {
203
+ return patterns.some(pattern => getMatcher(pattern)(filepath))
204
+ }
205
+
206
+ function calculateRiskTier(filepath, config) {
207
+ const { riskTierRules } = config
208
+
209
+ if (!riskTierRules || typeof riskTierRules !== 'object') {
210
+ return 'low'
211
+ }
212
+
213
+ // Check in order of decreasing risk - use allowlist of known tiers
214
+ const validTiers = ['critical', 'high', 'medium', 'low']
215
+ for (const tier of validTiers) {
216
+ if (riskTierRules[tier] && Array.isArray(riskTierRules[tier])) {
217
+ if (matchesPattern(filepath, riskTierRules[tier])) {
218
+ return tier
219
+ }
220
+ }
221
+ }
222
+
223
+ return 'low' // default
224
+ }
225
+
226
+ function validateRequiredChecks(riskTier, config) {
227
+ if (!config.mergePolicy) {
228
+ return {
229
+ valid: false,
230
+ error: 'No mergePolicy defined in config',
231
+ }
232
+ }
233
+
234
+ const policy = config.mergePolicy[riskTier]
235
+ if (!policy) {
236
+ return {
237
+ valid: false,
238
+ error: `No merge policy defined for risk tier: ${riskTier}`,
239
+ }
240
+ }
241
+
242
+ const { requiredChecks } = policy
243
+ const missingChecks = []
244
+
245
+ for (const check of requiredChecks) {
246
+ if (!config.checkDefinitions[check]) {
247
+ missingChecks.push(check)
248
+ }
249
+ }
250
+
251
+ if (missingChecks.length > 0) {
252
+ return {
253
+ valid: false,
254
+ error: `Missing check definitions: ${missingChecks.join(', ')}`,
255
+ }
256
+ }
257
+
258
+ return { valid: true }
259
+ }
260
+
261
+ function analyzeRisks(changedFiles, config) {
262
+ const riskOrder = ['low', 'medium', 'high', 'critical']
263
+ const riskAnalysis = {}
264
+ let highestRisk = 'low'
265
+
266
+ for (const file of changedFiles) {
267
+ const risk = calculateRiskTier(file, config)
268
+ if (!riskAnalysis[risk]) riskAnalysis[risk] = []
269
+ riskAnalysis[risk].push(file)
270
+
271
+ if (riskOrder.indexOf(risk) > riskOrder.indexOf(highestRisk)) {
272
+ highestRisk = risk
273
+ }
274
+ }
275
+
276
+ return { riskAnalysis, highestRisk }
277
+ }
278
+
279
+ function printRiskAnalysis(riskAnalysis) {
280
+ const TIER_EMOJI = { critical: '🔴', high: '🟠', medium: '🟡', low: '🟢' }
281
+ console.log('📊 Risk Analysis:')
282
+ for (const tier of ['critical', 'high', 'medium', 'low']) {
283
+ const files = riskAnalysis[tier]
284
+ if (!files || files.length === 0) continue
285
+ console.log(
286
+ ` ${TIER_EMOJI[tier]} ${tier.toUpperCase()}: ${files.length} files`
287
+ )
288
+ const preview = files.length <= 3 ? files : files.slice(0, 2)
289
+ preview.forEach(f => console.log(` - ${f}`))
290
+ if (files.length > 3) console.log(` ... and ${files.length - 2} more`)
291
+ }
292
+ console.log('')
293
+ }
294
+
295
+ function printPolicyRequirements(highestRisk, policy, config) {
296
+ console.log(`🎯 Merge Policy: ${highestRisk.toUpperCase()} tier requirements`)
297
+ console.log(' Required checks:')
298
+ policy.requiredChecks.forEach(check => {
299
+ const def = config.checkDefinitions[check]
300
+ console.log(` ✓ ${check} (${def.description})`)
301
+ })
302
+ console.log(` Review requirement: ${policy.reviewRequirement}`)
303
+ console.log(` Evidence requirement: ${policy.evidenceRequirement}`)
304
+ console.log('')
305
+ }
306
+
307
+ function checkDocsDrift(changedFiles, config) {
308
+ if (!config.docsDriftRules?.enabled) return
309
+ const affected = changedFiles.filter(file =>
310
+ config.docsDriftRules.watchPaths.some(pattern =>
311
+ matchesPattern(file, [pattern])
312
+ )
313
+ )
314
+ if (affected.length === 0) return
315
+ console.log('📝 Docs drift check:')
316
+ console.log(' Changed files that may require doc updates:')
317
+ affected.forEach(file => console.log(` - ${file}`))
318
+ console.log(' Required updates:')
319
+ config.docsDriftRules.requiredUpdates.forEach(path =>
320
+ console.log(` - ${path}`)
321
+ )
322
+ console.log('')
323
+ }
324
+
325
+ function writeGithubOutput(summary) {
326
+ const outputPath = process.env.GITHUB_OUTPUT
327
+ if (!outputPath || typeof outputPath !== 'string' || outputPath.length === 0)
328
+ return
329
+ try {
330
+ Object.entries(summary).forEach(([key, value]) => {
331
+ fs.appendFileSync(outputPath, `${key}=${value}\n`, { encoding: 'utf8' })
332
+ })
333
+ } catch (error) {
334
+ console.warn('Failed to write GitHub Actions output:', error.message)
335
+ }
336
+ }
337
+
338
+ function main() {
339
+ console.log('🔍 Risk Policy Gate - Validating PR changes...\n')
340
+
341
+ const config = loadConfig()
342
+ const { baseArg } = parseCliArgs(process.argv.slice(2))
343
+
344
+ let changedFiles
345
+ let resolved
346
+ try {
347
+ ;({ files: changedFiles, resolved } = getChangedFiles({ baseArg }))
348
+ } catch (error) {
349
+ // Fail-closed for any base-resolution / merge-base failure.
350
+ console.error(`❌ Failed to determine changed files: ${error.message}`)
351
+ if (error.reason) {
352
+ console.error(` Reason: ${error.reason}`)
353
+ }
354
+ process.exit(1)
355
+ }
356
+
357
+ console.log(`🔎 Base resolution: mode=${resolved.mode} base=${resolved.base}`)
358
+
359
+ if (changedFiles.length === 0) {
360
+ console.log('✅ No changed files detected - policy gate passed')
361
+ return
362
+ }
363
+
364
+ console.log(`📁 Changed files (${changedFiles.length}):`)
365
+ changedFiles.forEach(file => console.log(` ${file}`))
366
+ console.log('')
367
+
368
+ const { riskAnalysis, highestRisk } = analyzeRisks(changedFiles, config)
369
+ printRiskAnalysis(riskAnalysis)
370
+
371
+ const validation = validateRequiredChecks(highestRisk, config)
372
+ if (!validation.valid) {
373
+ console.error(`❌ Policy validation failed: ${validation.error}`)
374
+ process.exit(1)
375
+ }
376
+
377
+ const policy = config.mergePolicy[highestRisk]
378
+ printPolicyRequirements(highestRisk, policy, config)
379
+ checkDocsDrift(changedFiles, config)
380
+
381
+ if (process.env.GITHUB_OUTPUT) {
382
+ writeGithubOutput({
383
+ highestRisk,
384
+ requiredChecks: policy.requiredChecks.join(','),
385
+ reviewRequired: policy.reviewRequirement !== 'none',
386
+ changedFileCount: changedFiles.length,
387
+ resolvedBase: resolved.base,
388
+ resolutionMode: resolved.mode,
389
+ })
390
+ }
391
+
392
+ console.log('✅ Risk policy gate passed')
393
+ console.log(
394
+ `📈 Proceeding with ${highestRisk.toUpperCase()} tier requirements`
395
+ )
396
+ }
397
+
398
+ if (require.main === module) {
399
+ main()
400
+ }
401
+
402
+ module.exports = {
403
+ calculateRiskTier,
404
+ validateRequiredChecks,
405
+ matchesPattern,
406
+ resolveBase,
407
+ getChangedFilesForBase,
408
+ getChangedFiles,
409
+ parseCliArgs,
410
+ }
package/setup.js CHANGED
@@ -151,6 +151,7 @@ const {
151
151
  showUpgradeMessage,
152
152
  checkUsageCaps,
153
153
  incrementUsage,
154
+ ensureLicenseFresh,
154
155
  } = require('./lib/licensing')
155
156
 
156
157
  // Smart Test Strategy Generator (Pro/Team/Enterprise feature)
@@ -455,6 +456,77 @@ const validateAndSanitizeInput = input => {
455
456
  return sanitized
456
457
  }
457
458
 
459
+ /**
460
+ * Detect which (if any) Pro release-confidence command was invoked.
461
+ * Returns the command name, or null if none.
462
+ */
463
+ function detectProCommand(sanitizedArgs) {
464
+ if (sanitizedArgs.includes('--ship-check')) return 'ship-check'
465
+ if (sanitizedArgs.includes('--pr-check')) return 'pr-check'
466
+ if (sanitizedArgs.includes('--history-scan')) return 'history-scan'
467
+ if (sanitizedArgs.includes('--audit')) return 'audit'
468
+ return null
469
+ }
470
+
471
+ /**
472
+ * Dispatch to the matching Pro command handler. Always exits the process
473
+ * (the handlers manage their own exit codes).
474
+ */
475
+ async function runProCommand(command, sanitizedArgs, rawArgs) {
476
+ try {
477
+ const cmdOptions = parseProCommandOptions(sanitizedArgs, rawArgs)
478
+ if (command === 'ship-check') {
479
+ const { handleShipCheck } = require('./lib/commands/ship-check')
480
+ await handleShipCheck(cmdOptions)
481
+ } else if (command === 'pr-check') {
482
+ const { handlePrCheck } = require('./lib/commands/pr-check')
483
+ await handlePrCheck(cmdOptions)
484
+ } else if (command === 'audit') {
485
+ const { handleAudit } = require('./lib/commands/audit')
486
+ await handleAudit(cmdOptions)
487
+ } else {
488
+ const { handleHistoryScan } = require('./lib/commands/history-scan')
489
+ await handleHistoryScan(cmdOptions)
490
+ }
491
+ process.exit(0)
492
+ } catch (error) {
493
+ console.error(`Command error: ${error.message}`)
494
+ process.exit(1)
495
+ }
496
+ }
497
+
498
+ /**
499
+ * Extract options for Pro release-confidence commands (ship-check, pr-check,
500
+ * history-scan). Kept separate from parseArguments() to avoid bloating the
501
+ * main parser's cyclomatic complexity.
502
+ *
503
+ * @param {string[]} sanitizedArgs - Args after validateAndSanitizeInput
504
+ * @param {string[]} rawArgs - Original args (for path values that may include `..`)
505
+ * @returns {{json:boolean, skipTests:boolean, noFail:boolean, base:string|null, depth:string|null, outPath:string|null}}
506
+ */
507
+ function parseProCommandOptions(sanitizedArgs, rawArgs) {
508
+ const pickValue = (flag, source) => {
509
+ const idx = source.findIndex(arg => arg === flag)
510
+ if (idx === -1) return null
511
+ return source[idx + 1] || null
512
+ }
513
+
514
+ const baseValue = pickValue('--base', sanitizedArgs)
515
+ const depthValue = pickValue('--depth', sanitizedArgs)
516
+ const outValue = pickValue('--out', rawArgs)
517
+
518
+ return {
519
+ json: sanitizedArgs.includes('--json'),
520
+ skipTests: sanitizedArgs.includes('--skip-tests'),
521
+ noFail: sanitizedArgs.includes('--no-fail'),
522
+ fix: sanitizedArgs.includes('--fix'),
523
+ base: baseValue,
524
+ depth: depthValue,
525
+ outPath: outValue ? path.resolve(outValue) : null,
526
+ projectPath: process.cwd(),
527
+ }
528
+ }
529
+
458
530
  /**
459
531
  * Parse CLI arguments and return configuration object
460
532
  * @param {string[]} rawArgs - Raw command line arguments
@@ -711,6 +783,35 @@ WORKFLOW TIERS (GitHub Actions optimization):
711
783
  --matrix Enable Node.js version matrix testing (20 + 22)
712
784
  Use for npm libraries/CLI tools that support multiple Node versions
713
785
  --analyze-ci Analyze GitHub Actions usage and get optimization tips (Pro)
786
+ --analyze-ci --doctor Add CI Doctor: flaky tests, duplicated jobs, waste detection (Pro)
787
+
788
+ VIBE-CODE SECURITY AUDIT (Free):
789
+ --audit Scan codebase for security vulnerabilities in AI-generated code
790
+ Runs semgrep SAST (injection, auth, XSS, misconfigs) + npm CVE audit
791
+ Output: Critical/High/Medium/Low findings with file:line + fix guidance
792
+ --audit --json Emit JSON output (for CI integration)
793
+ --audit --out <path> Write markdown report to file (PR-comment-ready)
794
+ --audit --no-fail Always exit 0 (report-only, don't block CI)
795
+
796
+ AUDIT PRO (Pro):
797
+ --audit --fix Generate Claude Code prompts for each Critical/High finding
798
+ (paste directly into Claude Code to fix issues one by one)
799
+
800
+ Requires: semgrep (pip install semgrep / brew install semgrep)
801
+ Pro also adds: hallucinated package detection (npm registry check)
802
+
803
+ RELEASE CONFIDENCE (Pro):
804
+ --ship-check Unified release-readiness report (lint, tests, security,
805
+ coverage, bundle, env, CI cost, docs) with SHIP/REVIEW/BLOCK verdict
806
+ --pr-check Diff-aware risk classifier — flags high-risk file changes and
807
+ missing tests, emits PR-comment-ready markdown
808
+ --history-scan Full git-history secrets audit (gitleaks --log-opts=--all)
809
+ --base <branch> Base branch for --pr-check (default: main, falls back to master)
810
+ --depth <N> Limit --history-scan to last N commits (default: full history)
811
+ --skip-tests Skip running tests in --ship-check (e.g. when slow)
812
+ --json Emit JSON output for --ship-check/--pr-check/--history-scan
813
+ --out <path> Write markdown report to <path> (PR-comment-ready)
814
+ --no-fail Always exit 0 from --pr-check (report-only mode)
714
815
 
715
816
  VALIDATION OPTIONS:
716
817
  --validate Run comprehensive validation (same as --comprehensive)
@@ -723,7 +824,7 @@ VALIDATION OPTIONS:
723
824
 
724
825
  LICENSE, TELEMETRY & ERROR REPORTING:
725
826
  --license-status Show current license tier and available features
726
- --activate-license Activate Pro license key from Stripe purchase
827
+ --activate-license Activate Pro license key from purchase
727
828
  --telemetry-status Show telemetry status and opt-in instructions
728
829
  --error-reporting-status Show error reporting status and privacy information
729
830
 
@@ -750,7 +851,7 @@ EXAMPLES:
750
851
  → Show current license tier and upgrade options
751
852
 
752
853
  npx create-qa-architect@latest --activate-license
753
- → Activate Pro license after Stripe purchase
854
+ → Activate Pro license after purchase
754
855
 
755
856
  npx create-qa-architect@latest --telemetry-status
756
857
  → Show telemetry status and privacy information
@@ -788,6 +889,19 @@ EXAMPLES:
788
889
  npx create-qa-architect@latest --update --workflow-minimal
789
890
  → Convert existing comprehensive workflow to minimal (reduce CI costs)
790
891
 
892
+ npx create-qa-architect@latest --audit
893
+ → Scan for security vulnerabilities in AI-generated code (free)
894
+ → Requires: semgrep (pip install semgrep)
895
+
896
+ npx create-qa-architect@latest --audit --out audit-report.md
897
+ → Run audit and write markdown report to file (for PR comments or docs)
898
+
899
+ npx create-qa-architect@latest --audit --json
900
+ → Emit JSON output for CI integration or tooling
901
+
902
+ npx create-qa-architect@latest --audit --fix
903
+ → Run audit + generate Claude Code prompts for each finding (Pro)
904
+
791
905
  npx create-qa-architect@latest --analyze-ci
792
906
  → Analyze your GitHub Actions usage and get cost optimization recommendations (Pro)
793
907
 
@@ -805,6 +919,13 @@ HELP:
805
919
  process.exit(0)
806
920
  }
807
921
 
922
+ // Pro release-confidence commands must run BEFORE handleDryRun() so they
923
+ // don't get a "🚀 Setting up Quality Automation..." banner in their output.
924
+ const proCommand = detectProCommand(sanitizedArgs)
925
+ if (proCommand) {
926
+ return runProCommand(proCommand, sanitizedArgs, args)
927
+ }
928
+
808
929
  // Handle dry-run mode and show mode banner
809
930
  handleDryRun({ isDryRun, isUpdateMode, isDependencyMonitoringMode })
810
931
 
@@ -826,12 +947,13 @@ HELP:
826
947
  handleMaturityCheck()
827
948
  }
828
949
 
829
- // Handle CI cost analysis command
950
+ // Handle CI cost analysis command (with optional --doctor expansion)
830
951
  if (isAnalyzeCiMode) {
831
952
  return (async () => {
832
953
  try {
833
954
  const { handleAnalyzeCi } = require('./lib/commands/analyze-ci')
834
- await handleAnalyzeCi()
955
+ const doctor = sanitizedArgs.includes('--doctor')
956
+ await handleAnalyzeCi({ doctor })
835
957
  process.exit(0)
836
958
  } catch (error) {
837
959
  console.error('CI cost analysis error:', error.message)
@@ -1134,6 +1256,12 @@ HELP:
1134
1256
  process.exit(1)
1135
1257
  }
1136
1258
 
1259
+ // Re-check the signed registry before any Pro feature lookup (quality
1260
+ // tooling, Smart Test Strategy, etc.) so a revoked/cancelled subscription
1261
+ // stops unlocking Pro here too — not only on the standalone Pro commands.
1262
+ // Fails open offline; only a fresh, signature-verified fetch downgrades.
1263
+ await ensureLicenseFresh()
1264
+
1137
1265
  // Enforce FREE tier repo limit (1 private repo)
1138
1266
  // Must happen before any file modifications
1139
1267
  const license = getLicenseInfo()