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,341 @@
1
+ /**
2
+ * CI Doctor — workflow waste + flaky-test detection.
3
+ *
4
+ * Extends --analyze-ci with deeper diagnostic checks:
5
+ * - Duplicated jobs (same runs-on + steps signature)
6
+ * - Workflows triggered on every push without `paths:` filters
7
+ * - Oversized matrix (>10 cells)
8
+ * - Unnecessary scheduled runs (more frequent than weekly)
9
+ * - Flaky tests (parsed from `gh run list` if gh CLI is authenticated)
10
+ *
11
+ * Gated behind hasFeature('ciDoctor'). Invoked via `--analyze-ci --doctor`.
12
+ *
13
+ * All process invocations use spawnSync with argv arrays (no shell).
14
+ */
15
+
16
+ const crypto = require('crypto')
17
+ const { spawnSync } = require('child_process')
18
+
19
+ const SEVERITY = {
20
+ HIGH: 'high',
21
+ MEDIUM: 'medium',
22
+ LOW: 'low',
23
+ }
24
+
25
+ const SEVERITY_ICON = {
26
+ high: '🔴',
27
+ medium: '🟡',
28
+ low: '🟢',
29
+ }
30
+
31
+ const MATRIX_CELL_THRESHOLD = 10
32
+ const FLAKY_THRESHOLD_PCT = 90
33
+ const FLAKY_MIN_RUNS = 5
34
+
35
+ function fingerprintSteps(steps) {
36
+ if (!Array.isArray(steps)) return ''
37
+ const sig = steps
38
+ .map(step => {
39
+ if (step.uses) return `uses:${step.uses}`
40
+ if (step.run) return `run:${step.run.trim().slice(0, 200)}`
41
+ return 'unknown'
42
+ })
43
+ .join('|')
44
+ return crypto.createHash('sha256').update(sig).digest('hex').slice(0, 12)
45
+ }
46
+
47
+ function detectDuplicatedJobs(workflows) {
48
+ const findings = []
49
+ const seen = new Map()
50
+
51
+ for (const wf of workflows) {
52
+ const jobs = (wf.parsed && wf.parsed.jobs) || {}
53
+ for (const [jobName, job] of Object.entries(jobs)) {
54
+ if (!job || !Array.isArray(job.steps)) continue
55
+ const fp = `${job['runs-on'] || ''}|${fingerprintSteps(job.steps)}`
56
+ if (seen.has(fp)) {
57
+ const prev = seen.get(fp)
58
+ findings.push({
59
+ id: 'duplicated-job',
60
+ severity: SEVERITY.MEDIUM,
61
+ title: 'Duplicated job',
62
+ location: `${wf.name}#${jobName}`,
63
+ description: `Job "${jobName}" has the same runs-on + steps signature as "${prev.job}" in ${prev.workflow}.`,
64
+ fix: 'Extract into a reusable workflow (`workflow_call`) or composite action.',
65
+ })
66
+ } else {
67
+ seen.set(fp, { workflow: wf.name, job: jobName })
68
+ }
69
+ }
70
+ }
71
+ return findings
72
+ }
73
+
74
+ function workflowTriggersOnPush(parsed) {
75
+ if (!parsed || !parsed.on) return false
76
+ if (parsed.on === 'push') return true
77
+ if (typeof parsed.on === 'object' && parsed.on !== null) {
78
+ return Object.prototype.hasOwnProperty.call(parsed.on, 'push')
79
+ }
80
+ if (Array.isArray(parsed.on)) return parsed.on.includes('push')
81
+ return false
82
+ }
83
+
84
+ function pushHasPathFilter(parsed) {
85
+ if (!parsed || !parsed.on || typeof parsed.on !== 'object') return false
86
+ const push = parsed.on.push
87
+ if (!push || typeof push !== 'object') return false
88
+ return Boolean(push.paths || push['paths-ignore'])
89
+ }
90
+
91
+ function detectMissingPathFilters(workflows) {
92
+ const findings = []
93
+ for (const wf of workflows) {
94
+ const parsed = wf.parsed
95
+ if (!workflowTriggersOnPush(parsed)) continue
96
+ if (pushHasPathFilter(parsed)) continue
97
+ findings.push({
98
+ id: 'missing-path-filter',
99
+ severity: SEVERITY.MEDIUM,
100
+ title: 'Missing path filter',
101
+ location: wf.name,
102
+ description:
103
+ 'Workflow runs on every push without `paths:` or `paths-ignore:` filters.',
104
+ fix: 'Add `paths:` filter so docs/test-only changes do not trigger this workflow.',
105
+ })
106
+ }
107
+ return findings
108
+ }
109
+
110
+ function matrixCellCount(matrix) {
111
+ if (!matrix || typeof matrix !== 'object') return 0
112
+ // Use the existing logic from analyze-ci if available; fall back to local.
113
+ let product = 1
114
+ let hasAxis = false
115
+ for (const [key, value] of Object.entries(matrix)) {
116
+ if (key === 'include' || key === 'exclude') continue
117
+ if (Array.isArray(value)) {
118
+ hasAxis = true
119
+ product *= value.length || 1
120
+ }
121
+ }
122
+ if (!hasAxis) return 0
123
+ if (Array.isArray(matrix.include)) product += matrix.include.length
124
+ if (Array.isArray(matrix.exclude)) product -= matrix.exclude.length
125
+ return Math.max(product, 0)
126
+ }
127
+
128
+ function detectExpensiveMatrix(workflows) {
129
+ const findings = []
130
+ for (const wf of workflows) {
131
+ const jobs = (wf.parsed && wf.parsed.jobs) || {}
132
+ for (const [jobName, job] of Object.entries(jobs)) {
133
+ if (!job || !job.strategy || !job.strategy.matrix) continue
134
+ const cells = matrixCellCount(job.strategy.matrix)
135
+ if (cells > MATRIX_CELL_THRESHOLD) {
136
+ findings.push({
137
+ id: 'expensive-matrix',
138
+ severity: SEVERITY.HIGH,
139
+ title: 'Oversized matrix',
140
+ location: `${wf.name}#${jobName}`,
141
+ description: `Matrix has ${cells} combinations (threshold: ${MATRIX_CELL_THRESHOLD}).`,
142
+ fix: 'Prune axes or use `include`/`exclude` to keep only the combinations that matter.',
143
+ })
144
+ }
145
+ }
146
+ }
147
+ return findings
148
+ }
149
+
150
+ function parseCronFrequency(cron) {
151
+ // Returns approximate runs per week for a cron expression.
152
+ // We only need to flag "more frequent than weekly".
153
+ // Cron: minute hour dom month dow
154
+ if (typeof cron !== 'string') return 0
155
+ const parts = cron.trim().split(/\s+/)
156
+ if (parts.length < 5) return 0
157
+ const [minute, hour, dom, , dow] = parts
158
+
159
+ // If every minute or every hour, very frequent.
160
+ if (minute === '*' || /\*\//.test(minute)) return 7 * 24 * 60
161
+ if (hour === '*' || /\*\//.test(hour)) return 7 * 24
162
+ // Daily if dom and dow are unrestricted.
163
+ if (dom === '*' && dow === '*') return 7
164
+ // Weekly if dow is a specific day.
165
+ if (dow !== '*') return 1
166
+ return 1
167
+ }
168
+
169
+ function detectUnnecessarySchedules(workflows) {
170
+ const findings = []
171
+ for (const wf of workflows) {
172
+ const on = wf.parsed && wf.parsed.on
173
+ if (!on || typeof on !== 'object') continue
174
+ const schedule = on.schedule
175
+ if (!Array.isArray(schedule)) continue
176
+ for (const entry of schedule) {
177
+ if (!entry || !entry.cron) continue
178
+ const runsPerWeek = parseCronFrequency(entry.cron)
179
+ if (runsPerWeek > 1) {
180
+ findings.push({
181
+ id: 'frequent-schedule',
182
+ severity: SEVERITY.LOW,
183
+ title: 'Frequent scheduled run',
184
+ location: `${wf.name} (cron: ${entry.cron})`,
185
+ description: `Cron runs ~${runsPerWeek}× per week. Most maintenance jobs only need weekly.`,
186
+ fix: 'Reduce frequency unless this is load-bearing (e.g. dependency updates can be weekly).',
187
+ })
188
+ }
189
+ }
190
+ }
191
+ return findings
192
+ }
193
+
194
+ function ghCliAvailable() {
195
+ const r = spawnSync('gh', ['--version'], {
196
+ encoding: 'utf8',
197
+ stdio: ['ignore', 'pipe', 'pipe'],
198
+ shell: false,
199
+ timeout: 5_000,
200
+ })
201
+ return r.status === 0
202
+ }
203
+
204
+ function ghAuthenticated() {
205
+ const r = spawnSync('gh', ['auth', 'status'], {
206
+ encoding: 'utf8',
207
+ stdio: ['ignore', 'pipe', 'pipe'],
208
+ shell: false,
209
+ timeout: 5_000,
210
+ })
211
+ return r.status === 0
212
+ }
213
+
214
+ function fetchRecentRuns(projectPath, limit) {
215
+ const r = spawnSync(
216
+ 'gh',
217
+ [
218
+ 'run',
219
+ 'list',
220
+ '--limit',
221
+ String(limit),
222
+ '--json',
223
+ 'workflowName,conclusion,name,status',
224
+ ],
225
+ {
226
+ cwd: projectPath,
227
+ encoding: 'utf8',
228
+ stdio: ['ignore', 'pipe', 'pipe'],
229
+ shell: false,
230
+ timeout: 15_000,
231
+ }
232
+ )
233
+ if (r.status !== 0) return null
234
+ try {
235
+ return JSON.parse(r.stdout || '[]')
236
+ } catch {
237
+ return null
238
+ }
239
+ }
240
+
241
+ function tallyRunsByWorkflow(runs) {
242
+ const byWorkflow = new Map()
243
+ for (const run of runs) {
244
+ const key = run.workflowName || run.name
245
+ if (!key || run.status !== 'completed') continue
246
+ if (!byWorkflow.has(key)) {
247
+ byWorkflow.set(key, { total: 0, success: 0 })
248
+ }
249
+ const bucket = byWorkflow.get(key)
250
+ bucket.total++
251
+ if (run.conclusion === 'success') bucket.success++
252
+ }
253
+ return byWorkflow
254
+ }
255
+
256
+ function flakyFinding(workflowName, total, success) {
257
+ const successPct = Math.round((success / total) * 100)
258
+ if (successPct >= FLAKY_THRESHOLD_PCT) return null
259
+ return {
260
+ id: 'flaky-workflow',
261
+ severity: SEVERITY.HIGH,
262
+ title: 'Flaky workflow',
263
+ location: workflowName,
264
+ description: `${success}/${total} recent runs succeeded (${successPct}%, threshold ${FLAKY_THRESHOLD_PCT}%).`,
265
+ fix: 'Identify the flaky job/test — retry-with-backoff is a smell. Fix root cause (timing, external service, shared state).',
266
+ }
267
+ }
268
+
269
+ function detectFlakyWorkflows(projectPath, options) {
270
+ if (options && options.skipFlakyCheck) return []
271
+ if (!ghCliAvailable() || !ghAuthenticated()) return []
272
+
273
+ const runs = fetchRecentRuns(projectPath, 50)
274
+ if (!runs || runs.length === 0) return []
275
+
276
+ const byWorkflow = tallyRunsByWorkflow(runs)
277
+ const findings = []
278
+ for (const [workflowName, { total, success }] of byWorkflow.entries()) {
279
+ if (total < FLAKY_MIN_RUNS) continue
280
+ const finding = flakyFinding(workflowName, total, success)
281
+ if (finding) findings.push(finding)
282
+ }
283
+ return findings
284
+ }
285
+
286
+ function runDoctorChecks(workflows, projectPath, options = {}) {
287
+ const findings = []
288
+ findings.push(...detectDuplicatedJobs(workflows))
289
+ findings.push(...detectMissingPathFilters(workflows))
290
+ findings.push(...detectExpensiveMatrix(workflows))
291
+ findings.push(...detectUnnecessarySchedules(workflows))
292
+ if (projectPath) {
293
+ findings.push(...detectFlakyWorkflows(projectPath, options))
294
+ }
295
+ // Order: HIGH first, then MEDIUM, then LOW.
296
+ const order = { high: 0, medium: 1, low: 2 }
297
+ findings.sort((a, b) => order[a.severity] - order[b.severity])
298
+ return findings
299
+ }
300
+
301
+ function buildDoctorReport(findings) {
302
+ const lines = []
303
+ lines.push('')
304
+ lines.push('🩺 CI Doctor')
305
+ lines.push('─'.repeat(60))
306
+ if (findings.length === 0) {
307
+ lines.push('✅ No CI health issues detected.')
308
+ lines.push('')
309
+ return lines.join('\n')
310
+ }
311
+
312
+ const counts = { high: 0, medium: 0, low: 0 }
313
+ for (const f of findings) counts[f.severity]++
314
+ lines.push(
315
+ `Findings: ${counts.high} high · ${counts.medium} medium · ${counts.low} low`
316
+ )
317
+ lines.push('─'.repeat(60))
318
+
319
+ for (const f of findings) {
320
+ const icon = SEVERITY_ICON[f.severity] || ' '
321
+ lines.push(`${icon} ${f.title} — ${f.location}`)
322
+ lines.push(` ${f.description}`)
323
+ lines.push(` Fix: ${f.fix}`)
324
+ lines.push('')
325
+ }
326
+ return lines.join('\n')
327
+ }
328
+
329
+ module.exports = {
330
+ runDoctorChecks,
331
+ buildDoctorReport,
332
+ detectDuplicatedJobs,
333
+ detectMissingPathFilters,
334
+ detectExpensiveMatrix,
335
+ detectUnnecessarySchedules,
336
+ detectFlakyWorkflows,
337
+ matrixCellCount,
338
+ parseCronFrequency,
339
+ fingerprintSteps,
340
+ SEVERITY,
341
+ }
@@ -0,0 +1,342 @@
1
+ /**
2
+ * Historical Secrets Scan — full git-history audit via gitleaks.
3
+ *
4
+ * Runs gitleaks against the project's git history (default: all commits,
5
+ * with a configurable --depth limit), parses JSON output, deduplicates,
6
+ * and reports leaked secrets with commit SHAs and file paths.
7
+ *
8
+ * Gated behind Pro tier (hasFeature('historicalSecretsScan'), with
9
+ * fallback to hasFeature('securityScanning') for backwards compatibility).
10
+ *
11
+ * All process invocations use spawnSync with argv arrays (no shell).
12
+ */
13
+
14
+ const fs = require('fs')
15
+ const os = require('os')
16
+ const path = require('path')
17
+ const { spawnSync } = require('child_process')
18
+ const {
19
+ hasFeature,
20
+ showUpgradeMessage,
21
+ ensureLicenseFresh,
22
+ } = require('../licensing')
23
+ const { ConfigSecurityScanner } = require('../validation/config-security')
24
+
25
+ const SAFE_DEPTH_MAX = 10_000
26
+
27
+ function parseDepth(input) {
28
+ if (!input) return null
29
+ const n = parseInt(input, 10)
30
+ if (!Number.isFinite(n) || n <= 0) return null
31
+ return Math.min(n, SAFE_DEPTH_MAX)
32
+ }
33
+
34
+ function inGitRepo(projectPath) {
35
+ const r = spawnSync(
36
+ 'git',
37
+ ['-C', projectPath, 'rev-parse', '--is-inside-work-tree'],
38
+ {
39
+ encoding: 'utf8',
40
+ stdio: ['ignore', 'pipe', 'pipe'],
41
+ shell: false,
42
+ timeout: 10_000,
43
+ }
44
+ )
45
+ return r.status === 0 && (r.stdout || '').trim() === 'true'
46
+ }
47
+
48
+ function countCommits(projectPath) {
49
+ const r = spawnSync(
50
+ 'git',
51
+ ['-C', projectPath, 'rev-list', '--count', 'HEAD'],
52
+ {
53
+ encoding: 'utf8',
54
+ stdio: ['ignore', 'pipe', 'pipe'],
55
+ shell: false,
56
+ timeout: 10_000,
57
+ }
58
+ )
59
+ if (r.status !== 0) return null
60
+ const n = parseInt((r.stdout || '').trim(), 10)
61
+ return Number.isFinite(n) ? n : null
62
+ }
63
+
64
+ function buildGitleaksArgs(reportPath, depth) {
65
+ // Note: argv array, no shell. `--log-opts` value is a single argv element.
66
+ // We always pass --log-opts so gitleaks scans history (not just the
67
+ // working tree). Use `--max-count=N` for depth limits — `HEAD~N..HEAD`
68
+ // produces a git fatal error on shallow repos with fewer than N commits.
69
+ const args = [
70
+ 'detect',
71
+ '--no-banner',
72
+ '--redact',
73
+ '--report-format',
74
+ 'json',
75
+ '--report-path',
76
+ reportPath,
77
+ ]
78
+
79
+ if (depth) {
80
+ args.push(`--log-opts=--max-count=${depth}`)
81
+ } else {
82
+ args.push('--log-opts=--all')
83
+ }
84
+ return args
85
+ }
86
+
87
+ function dedupeFindings(findings) {
88
+ const seen = new Set()
89
+ const out = []
90
+ for (const f of findings) {
91
+ const key = [f.Commit || f.commit, f.File || f.file, f.Secret || f.secret]
92
+ .map(v => String(v || ''))
93
+ .join('|')
94
+ if (seen.has(key)) continue
95
+ seen.add(key)
96
+ out.push(f)
97
+ }
98
+ return out
99
+ }
100
+
101
+ function normalizeFinding(f) {
102
+ return {
103
+ commit: f.Commit || f.commit || '',
104
+ file: f.File || f.file || '',
105
+ line: f.StartLine || f.startLine || null,
106
+ secretType: f.RuleID || f.ruleId || f.Description || f.description || '',
107
+ author: f.Author || f.author || '',
108
+ date: f.Date || f.date || '',
109
+ }
110
+ }
111
+
112
+ function countBySecretType(normalized) {
113
+ const counts = {}
114
+ for (const f of normalized) {
115
+ const key = f.secretType || 'unknown'
116
+ counts[key] = (counts[key] || 0) + 1
117
+ }
118
+ return counts
119
+ }
120
+
121
+ function oldestExposures(normalized, limit) {
122
+ // Sort by date ascending (oldest first); fall back to commit string compare.
123
+ const sorted = [...normalized].sort((a, b) => {
124
+ if (a.date && b.date) return a.date.localeCompare(b.date)
125
+ return String(a.commit).localeCompare(String(b.commit))
126
+ })
127
+ return sorted.slice(0, limit)
128
+ }
129
+
130
+ function buildHumanReport(report) {
131
+ const lines = []
132
+ lines.push('')
133
+ lines.push('🔐 Historical Secrets Scan')
134
+ lines.push('─'.repeat(60))
135
+ lines.push(`Scope: ${report.scope}`)
136
+ lines.push(`Commits scanned: ${report.commitsScanned ?? 'unknown'}`)
137
+ lines.push(`Findings: ${report.findings.length}`)
138
+ lines.push('─'.repeat(60))
139
+
140
+ if (report.findings.length === 0) {
141
+ lines.push('✅ No secrets detected in git history.')
142
+ lines.push('')
143
+ return lines.join('\n')
144
+ }
145
+
146
+ const counts = countBySecretType(report.findings)
147
+ lines.push('By secret type:')
148
+ for (const [type, count] of Object.entries(counts)) {
149
+ lines.push(` • ${type}: ${count}`)
150
+ }
151
+ lines.push('')
152
+
153
+ const oldest = oldestExposures(report.findings, 10)
154
+ lines.push('Top 10 oldest exposures:')
155
+ for (const f of oldest) {
156
+ const date = f.date ? f.date.slice(0, 10) : 'unknown'
157
+ const sha = (f.commit || '').slice(0, 8)
158
+ lines.push(` • ${date} ${sha} ${f.secretType} ${f.file}`)
159
+ }
160
+ lines.push('')
161
+ lines.push('❌ Secrets found in history. Rotate exposed credentials and')
162
+ lines.push(' consider rewriting history with git-filter-repo or BFG.')
163
+ lines.push('')
164
+ return lines.join('\n')
165
+ }
166
+
167
+ function buildMarkdown(report) {
168
+ const lines = []
169
+ lines.push('# Historical Secrets Scan')
170
+ lines.push('')
171
+ lines.push(`- **Scope:** ${report.scope}`)
172
+ lines.push(`- **Commits scanned:** ${report.commitsScanned ?? 'unknown'}`)
173
+ lines.push(`- **Findings:** ${report.findings.length}`)
174
+ lines.push('')
175
+
176
+ if (report.findings.length === 0) {
177
+ lines.push('✅ No secrets detected in git history.')
178
+ lines.push('')
179
+ return `${lines.join('\n')}\n`
180
+ }
181
+
182
+ const counts = countBySecretType(report.findings)
183
+ lines.push('## By secret type')
184
+ lines.push('')
185
+ for (const [type, count] of Object.entries(counts)) {
186
+ lines.push(`- \`${type}\`: ${count}`)
187
+ }
188
+ lines.push('')
189
+
190
+ const oldest = oldestExposures(report.findings, 10)
191
+ lines.push('## Top 10 oldest exposures')
192
+ lines.push('')
193
+ lines.push('| Date | Commit | Secret type | File |')
194
+ lines.push('| --- | --- | --- | --- |')
195
+ for (const f of oldest) {
196
+ const date = f.date ? f.date.slice(0, 10) : 'unknown'
197
+ const sha = (f.commit || '').slice(0, 8)
198
+ lines.push(`| ${date} | \`${sha}\` | ${f.secretType} | \`${f.file}\` |`)
199
+ }
200
+ lines.push('')
201
+ lines.push('### Next steps')
202
+ lines.push('1. **Rotate every exposed credential immediately.**')
203
+ lines.push('2. Confirm logs/services for unauthorized use of leaked keys.')
204
+ lines.push(
205
+ '3. Rewrite history with `git-filter-repo` or BFG if you need to purge.'
206
+ )
207
+ lines.push('4. Add a pre-commit `gitleaks` hook to prevent recurrence.')
208
+ lines.push('')
209
+ return `${lines.join('\n')}\n`
210
+ }
211
+
212
+ /**
213
+ * Validate a completed gitleaks invocation. Returns an error string when
214
+ * the run is untrustworthy (so we never silently report "clean"), or null
215
+ * when the result can be parsed.
216
+ */
217
+ function validateGitleaksRun(result, tmpReport) {
218
+ const stderr = (result.stderr || '').trim()
219
+ if (result.status !== 0 && result.status !== 1) {
220
+ return `gitleaks failed (exit ${result.status}): ${stderr.slice(0, 500)}`
221
+ }
222
+ if (/^fatal:/m.test(stderr)) {
223
+ return `git rev-walk failed during scan: ${stderr.slice(0, 500)}`
224
+ }
225
+ if (!fs.existsSync(tmpReport) && result.status === 1) {
226
+ return `gitleaks reported leaks but produced no report file (stderr: ${stderr.slice(0, 300)})`
227
+ }
228
+ return null
229
+ }
230
+
231
+ /**
232
+ * Read and parse the gitleaks JSON report, cleaning up the temp file.
233
+ * Returns { findings } on success or { error } on parse failure.
234
+ */
235
+ function readGitleaksReport(tmpReport) {
236
+ if (!fs.existsSync(tmpReport)) return { findings: [] }
237
+ try {
238
+ const content = fs.readFileSync(tmpReport, 'utf8').trim()
239
+ if (!content) return { findings: [] }
240
+ const parsed = JSON.parse(content)
241
+ return { findings: Array.isArray(parsed) ? parsed : [] }
242
+ } catch (err) {
243
+ return { error: `Failed to parse gitleaks report: ${err.message}` }
244
+ } finally {
245
+ try {
246
+ fs.unlinkSync(tmpReport)
247
+ } catch {
248
+ // cleanup is best-effort
249
+ }
250
+ }
251
+ }
252
+
253
+ async function runHistoryScan(projectPath, options = {}) {
254
+ if (!inGitRepo(projectPath)) {
255
+ return { error: 'Not a git repository' }
256
+ }
257
+
258
+ const depth = parseDepth(options.depth)
259
+ const scope = depth
260
+ ? `last ${depth} commit(s) of HEAD`
261
+ : 'full git history (--all)'
262
+
263
+ const scanner = new ConfigSecurityScanner({ quiet: true })
264
+ let binary
265
+ try {
266
+ binary = await scanner.resolveGitleaksBinary()
267
+ } catch (err) {
268
+ return { error: `Failed to resolve gitleaks binary: ${err.message}` }
269
+ }
270
+
271
+ const tmpReport = path.join(
272
+ os.tmpdir(),
273
+ `qaa-history-scan-${Date.now()}.json`
274
+ )
275
+ const args = buildGitleaksArgs(tmpReport, depth)
276
+
277
+ const result = spawnSync(binary, args, {
278
+ cwd: projectPath,
279
+ encoding: 'utf8',
280
+ stdio: ['ignore', 'pipe', 'pipe'],
281
+ shell: false,
282
+ timeout: options.timeoutMs || 10 * 60 * 1000,
283
+ })
284
+
285
+ const runError = validateGitleaksRun(result, tmpReport)
286
+ if (runError) return { error: runError }
287
+
288
+ const parseResult = readGitleaksReport(tmpReport)
289
+ if (parseResult.error) return { error: parseResult.error }
290
+
291
+ const normalized = dedupeFindings(parseResult.findings).map(normalizeFinding)
292
+ return {
293
+ scope,
294
+ commitsScanned: countCommits(projectPath),
295
+ findings: normalized,
296
+ generatedAt: new Date().toISOString(),
297
+ }
298
+ }
299
+
300
+ async function handleHistoryScan(options = {}) {
301
+ await ensureLicenseFresh()
302
+ if (!hasFeature('historicalSecretsScan') && !hasFeature('securityScanning')) {
303
+ showUpgradeMessage('Historical secrets scan')
304
+ process.exit(1)
305
+ }
306
+
307
+ const projectPath = options.projectPath || process.cwd()
308
+ const report = await runHistoryScan(projectPath, options)
309
+
310
+ if (report.error) {
311
+ process.stderr.write(`❌ ${report.error}\n`)
312
+ process.exit(1)
313
+ }
314
+
315
+ if (options.json) {
316
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`)
317
+ } else {
318
+ process.stdout.write(buildHumanReport(report))
319
+ }
320
+
321
+ if (options.outPath) {
322
+ fs.writeFileSync(options.outPath, buildMarkdown(report), 'utf8')
323
+ if (!options.json) {
324
+ process.stdout.write(
325
+ `\n📄 Markdown report written to ${options.outPath}\n`
326
+ )
327
+ }
328
+ }
329
+
330
+ process.exit(report.findings.length > 0 ? 1 : 0)
331
+ }
332
+
333
+ module.exports = {
334
+ runHistoryScan,
335
+ handleHistoryScan,
336
+ buildHumanReport,
337
+ buildMarkdown,
338
+ buildGitleaksArgs,
339
+ dedupeFindings,
340
+ normalizeFinding,
341
+ parseDepth,
342
+ }
@@ -13,6 +13,9 @@ const {
13
13
  detectRubyProject,
14
14
  } = require('./deps')
15
15
  const { handleAnalyzeCi } = require('./analyze-ci')
16
+ const { handleShipCheck } = require('./ship-check')
17
+ const { handlePrCheck } = require('./pr-check')
18
+ const { handleHistoryScan } = require('./history-scan')
16
19
 
17
20
  module.exports = {
18
21
  // Validation commands
@@ -26,4 +29,9 @@ module.exports = {
26
29
 
27
30
  // CI/CD optimization commands
28
31
  handleAnalyzeCi,
32
+
33
+ // Pro release-confidence commands
34
+ handleShipCheck,
35
+ handlePrCheck,
36
+ handleHistoryScan,
29
37
  }