create-qa-architect 5.12.0 → 5.13.2
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/dependabot.yml +10 -30
- package/.github/workflows/claude-md-validation.yml +5 -7
- package/.github/workflows/dependabot-auto-merge.yml +1 -0
- package/.github/workflows/quality.yml +26 -12
- package/.github/workflows/release.yml +2 -1
- package/.github/workflows/stale-prs.yml +42 -0
- package/.github/workflows/weekly-gitleaks-verification.yml +6 -4
- package/LICENSE +3 -3
- package/README.md +19 -20
- package/config/quality-config.schema.json +1 -1
- package/docs/CI-COST-ANALYSIS.md +8 -8
- package/docs/DEPLOYMENT.md +1 -1
- package/docs/DEVELOPMENT-WORKFLOW.md +2 -2
- package/docs/TURBOREPO-SUPPORT.md +3 -3
- package/docs/dev_guide/CONVENTIONS.md +132 -0
- package/eslint.config.cjs +25 -0
- package/lib/blob-storage.js +57 -0
- package/lib/commands/analyze-ci.js +267 -27
- package/lib/commands/deps.js +5 -5
- package/lib/commands/license-commands.js +2 -2
- package/lib/commands/maturity-check.js +20 -2
- package/lib/dependency-monitoring-basic.js +4 -4
- package/lib/dependency-monitoring-premium.js +5 -5
- package/lib/license-validator.js +1 -1
- package/lib/licensing.js +3 -3
- package/lib/smart-strategy-generator.js +1 -1
- package/lib/validation/documentation.js +2 -0
- package/lib/workflow-config.js +106 -61
- package/package.json +51 -21
- package/scripts/deploy-consumers.sh +369 -0
- package/scripts/pattern-check.sh +607 -0
- package/scripts/run-semgrep.sh +244 -0
- package/scripts/smart-test-strategy.sh +1 -1
- package/setup.js +62 -32
- package/templates/CLAUDE_WORKFLOW_POLICY.md +3 -3
- package/templates/scripts/smart-test-strategy.sh +1 -1
- package/.github/workflows/auto-release.yml +0 -39
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# Dev Guide — QA Architect
|
|
2
|
+
|
|
3
|
+
> Load at session start. Replaces blind codebase exploration.
|
|
4
|
+
> **Last updated:** 2026-03-08
|
|
5
|
+
|
|
6
|
+
## What This Project Does
|
|
7
|
+
|
|
8
|
+
QA Architect (`create-qa-architect`) is a CLI tool that bootstraps quality automation for JavaScript/TypeScript, Python, and shell script projects. One command installs ESLint, Prettier, Husky, lint-staged, and GitHub Actions. Pro tier adds Gitleaks security scanning, Smart Test Strategy, and multi-language support.
|
|
9
|
+
|
|
10
|
+
**Tech stack:** Node.js (CommonJS, no build step) · Vanilla JS (no framework) · GitHub Actions templates · c8 for coverage · Playwright for E2E
|
|
11
|
+
|
|
12
|
+
**Entry point:** `setup.js` — CLI argument parsing and orchestration. Run as `npx create-qa-architect` or `node setup.js`.
|
|
13
|
+
|
|
14
|
+
**npm package:** `create-qa-architect` v5.13.2 (published via GitHub trusted publishing)
|
|
15
|
+
|
|
16
|
+
## Directory Structure
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
qa-architect/
|
|
20
|
+
├── setup.js # Main CLI entry — arg parsing + orchestration
|
|
21
|
+
├── lib/ # Business logic modules
|
|
22
|
+
│ ├── licensing.js # Freemium tier system (FREE/PRO), feature gates, usage caps
|
|
23
|
+
│ ├── project-maturity.js# Detects project maturity stage (minimal → production-ready)
|
|
24
|
+
│ ├── workflow-config.js # CI workflow generation + tier transformations
|
|
25
|
+
│ ├── smart-strategy-generator.js # Risk-based test selection (Pro feature)
|
|
26
|
+
│ ├── template-loader.js # Custom template merging
|
|
27
|
+
│ ├── commands/ # Command handlers (validate, deps, analyze-ci)
|
|
28
|
+
│ ├── validation/ # Validators (security, docs, config)
|
|
29
|
+
│ └── interactive/ # TTY prompt system
|
|
30
|
+
├── templates/ # Config file templates deployed to consumer repos
|
|
31
|
+
│ ├── ci/ # GitHub Actions workflow templates
|
|
32
|
+
│ └── scripts/ # Helper scripts deployed to consumers
|
|
33
|
+
├── config/ # Language-specific configs (Python, Shell)
|
|
34
|
+
├── scripts/ # Dev/ops scripts (deploy-consumers, e2e tests, etc.)
|
|
35
|
+
├── tests/ # 40+ test files (Node's assert module, no test runner)
|
|
36
|
+
├── docs/ # Dev guides and plans
|
|
37
|
+
│ ├── dev_guide/ # This file and other dev references
|
|
38
|
+
│ └── plans/ # Agent planning docs (/bs:plan output)
|
|
39
|
+
└── .claude/ # Claude Code workspace metadata
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Key Files
|
|
43
|
+
|
|
44
|
+
| File | Role |
|
|
45
|
+
| --------------------------------------------- | ----------------------------------------------------------- |
|
|
46
|
+
| `setup.js:390-500` | Main entry — arg parsing, interactive mode, command routing |
|
|
47
|
+
| `setup.js:985-2143` | `runMainSetup()` — core setup flow |
|
|
48
|
+
| `lib/licensing.js` | All tier logic, usage caps, feature gates |
|
|
49
|
+
| `lib/project-maturity.js` | Maturity detection algorithm |
|
|
50
|
+
| `lib/workflow-config.js` | CI workflow generation, mode detection, matrix injection |
|
|
51
|
+
| `lib/template-loader.js` | Custom template merging |
|
|
52
|
+
| `config/defaults.js` | Default scripts, dependencies, lint-staged config |
|
|
53
|
+
| `scripts/deploy-consumers.sh` | Auto-discovers + deploys to all consumer repos |
|
|
54
|
+
| `tests/consumer-workflow-integration.test.js` | Gates what can appear in consumer CI output |
|
|
55
|
+
|
|
56
|
+
## Conventions
|
|
57
|
+
|
|
58
|
+
**Language:** Plain JavaScript (CommonJS). No TypeScript in the main source. `QAA_DEVELOPER=true` env var bypasses license checks in tests.
|
|
59
|
+
|
|
60
|
+
**Naming:** kebab-case files, camelCase functions/vars. Test files: `tests/[feature].test.js`.
|
|
61
|
+
|
|
62
|
+
**Feature addition pattern:**
|
|
63
|
+
|
|
64
|
+
1. Add feature gate check in `lib/licensing.js` if Pro-only
|
|
65
|
+
2. Implement in appropriate `lib/` module
|
|
66
|
+
3. Wire into `setup.js` argument parsing if it needs a CLI flag
|
|
67
|
+
4. Add test file: `tests/[feature].test.js` using Node `assert` module
|
|
68
|
+
5. Add to the `npm test` chain in `package.json`
|
|
69
|
+
|
|
70
|
+
**Template-as-Product contract** — `quality.yml` is BOTH qa-architect's own CI AND the template deployed to 15+ consumer repos. Rules:
|
|
71
|
+
|
|
72
|
+
- Never reference `node_modules/create-qa-architect` in templates — consumers use `npx @latest`
|
|
73
|
+
- Never use `\s*` in YAML cleanup regexes — use `[ \t]*` (avoids newline collapse)
|
|
74
|
+
- Conditional content uses `# {{NAME_BEGIN/END}}` section markers, stripped by `stripSection()`
|
|
75
|
+
- `CONSUMER_FORBIDDEN_CONTENT` in `consumer-workflow-integration.test.js` is a hard gate
|
|
76
|
+
|
|
77
|
+
**Workflow tiers:**
|
|
78
|
+
|
|
79
|
+
- Minimal (default): single Node 22, weekly security, path filters (~$0-5/mo)
|
|
80
|
+
- Standard: single Node 22, tests on main only (~$5-10/mo)
|
|
81
|
+
- Comprehensive: matrix every commit (~$100-350/mo)
|
|
82
|
+
|
|
83
|
+
**Testing approach:** Tests use real filesystem with temp directories (no mocks). `createTempGitRepo()` is the standard test setup helper.
|
|
84
|
+
|
|
85
|
+
**Publishing:** Never run `npm publish` manually. GitHub Actions handles publishing via trusted publishing when `package.json` version changes on `main`.
|
|
86
|
+
|
|
87
|
+
## Running the Project
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
# Install dependencies
|
|
91
|
+
npm install
|
|
92
|
+
|
|
93
|
+
# Run all tests (40+ files, ~2-3 min)
|
|
94
|
+
QAA_DEVELOPER=true npm test
|
|
95
|
+
|
|
96
|
+
# Fast unit tests only
|
|
97
|
+
npm run test:unit
|
|
98
|
+
|
|
99
|
+
# Single test file
|
|
100
|
+
QAA_DEVELOPER=true node tests/licensing.test.js
|
|
101
|
+
|
|
102
|
+
# CLI smoke test (dry run — no changes)
|
|
103
|
+
node setup.js --dry-run
|
|
104
|
+
|
|
105
|
+
# Validate before release
|
|
106
|
+
npm run prerelease
|
|
107
|
+
|
|
108
|
+
# Deploy to consumer repos (after publishing)
|
|
109
|
+
./scripts/deploy-consumers.sh # validate only
|
|
110
|
+
./scripts/deploy-consumers.sh --push # regenerate + commit + push
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Agent Gotchas
|
|
114
|
+
|
|
115
|
+
- **`QAA_DEVELOPER=true`** must be set for most tests — it bypasses license checks. Without it, tests fail with license errors.
|
|
116
|
+
- **Never `npm publish` manually** — GitHub trusted publishing only. Use `npm version patch/minor/major` + push tags.
|
|
117
|
+
- **Template changes affect 15+ consumer repos** — always run `tests/consumer-workflow-integration.test.js` after template edits.
|
|
118
|
+
- **`\s` in YAML regexes will collapse newlines** — use `[ \t]*` for any whitespace-trimming regex on YAML content.
|
|
119
|
+
- **Coverage thresholds:** 75% lines, 70% functions, 65% branches (enforced by `c8`).
|
|
120
|
+
- **No Vitest/Jest** — tests use Node's built-in `assert` module and are run directly with `node tests/*.test.js`.
|
|
121
|
+
- **Pre-push hook** runs `test:patterns`, `test:commands`, `test:changed` — these must all pass before push.
|
|
122
|
+
- **`.claude` directory** already exists (has prior workspace data) — do not overwrite its contents.
|
|
123
|
+
|
|
124
|
+
## Active Development Areas
|
|
125
|
+
|
|
126
|
+
From recent git log:
|
|
127
|
+
|
|
128
|
+
- Dependency updates (Dependabot active)
|
|
129
|
+
- CI cost optimization (minute budget guardrails, monthly vs weekly security scans)
|
|
130
|
+
- Staged rollout / canary deployment for consumer updates
|
|
131
|
+
- Vercel Blob integration for webhook handler (replacing filesystem storage)
|
|
132
|
+
- Documentation consistency improvements
|
package/eslint.config.cjs
CHANGED
|
@@ -37,6 +37,11 @@ if (security) {
|
|
|
37
37
|
|
|
38
38
|
// Base rules configuration
|
|
39
39
|
const baseRules = {
|
|
40
|
+
// Complexity gates (AI quality)
|
|
41
|
+
complexity: ['warn', 15],
|
|
42
|
+
'max-depth': ['warn', 4],
|
|
43
|
+
'max-params': ['warn', 5],
|
|
44
|
+
|
|
40
45
|
// XSS Prevention patterns - critical for web applications
|
|
41
46
|
'no-eval': 'error',
|
|
42
47
|
'no-implied-eval': 'error',
|
|
@@ -112,4 +117,24 @@ if (tsPlugin && tsParser) {
|
|
|
112
117
|
})
|
|
113
118
|
}
|
|
114
119
|
|
|
120
|
+
// Import verification (eslint-plugin-n)
|
|
121
|
+
let nPlugin = null
|
|
122
|
+
try {
|
|
123
|
+
nPlugin = require('eslint-plugin-n')
|
|
124
|
+
} catch {
|
|
125
|
+
// eslint-plugin-n not installed
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (nPlugin) {
|
|
129
|
+
configs.push({
|
|
130
|
+
files: ['**/*.{js,mjs,cjs}'],
|
|
131
|
+
plugins: { n: nPlugin },
|
|
132
|
+
rules: {
|
|
133
|
+
'n/no-missing-require': 'error',
|
|
134
|
+
'n/no-missing-import': 'off', // Often handled by bundlers
|
|
135
|
+
'n/no-unpublished-require': 'off',
|
|
136
|
+
},
|
|
137
|
+
})
|
|
138
|
+
}
|
|
139
|
+
|
|
115
140
|
module.exports = configs
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { put, head } = require('@vercel/blob')
|
|
4
|
+
|
|
5
|
+
const BLOB_PREFIX = 'licenses/'
|
|
6
|
+
|
|
7
|
+
const BLOB_PATHS = {
|
|
8
|
+
private: `${BLOB_PREFIX}legitimate-licenses.json`,
|
|
9
|
+
public: `${BLOB_PREFIX}legitimate-licenses.public.json`,
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Load JSON from a Vercel Blob path.
|
|
14
|
+
* Returns null ONLY if the blob does not exist (first-run).
|
|
15
|
+
* Throws on infrastructure errors so callers can distinguish
|
|
16
|
+
* "empty" from "broken".
|
|
17
|
+
*/
|
|
18
|
+
async function loadBlob(blobPath) {
|
|
19
|
+
let metadata
|
|
20
|
+
try {
|
|
21
|
+
metadata = await head(blobPath)
|
|
22
|
+
} catch (error) {
|
|
23
|
+
if (error.code === 'blob_not_found' || error.name === 'BlobNotFoundError') {
|
|
24
|
+
return null
|
|
25
|
+
}
|
|
26
|
+
throw new Error(`Blob head failed for ${blobPath}: ${error.message}`)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const response = await fetch(metadata.url)
|
|
30
|
+
if (!response.ok) {
|
|
31
|
+
throw new Error(
|
|
32
|
+
`Blob fetch failed for ${blobPath}: HTTP ${response.status}`
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
return await response.json()
|
|
38
|
+
} catch (error) {
|
|
39
|
+
throw new Error(`Blob JSON parse failed for ${blobPath}: ${error.message}`)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Save JSON to a Vercel Blob path.
|
|
45
|
+
* Throws on failure so callers know the write did not persist.
|
|
46
|
+
*/
|
|
47
|
+
async function saveBlob(blobPath, data) {
|
|
48
|
+
const content = JSON.stringify(data, null, 2)
|
|
49
|
+
return put(blobPath, content, {
|
|
50
|
+
access: /** @type {const} */ ('public'),
|
|
51
|
+
addRandomSuffix: false,
|
|
52
|
+
allowOverwrite: true,
|
|
53
|
+
contentType: 'application/json',
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
module.exports = { loadBlob, saveBlob, BLOB_PATHS }
|
|
@@ -13,6 +13,11 @@ const { execSync } = require('child_process')
|
|
|
13
13
|
const yaml = require('js-yaml')
|
|
14
14
|
const { showProgress } = require('../ui-helpers')
|
|
15
15
|
|
|
16
|
+
const DAYS_PER_MONTH = 30
|
|
17
|
+
const DEFAULT_PULL_REQUEST_FACTOR = 0.8
|
|
18
|
+
const DEFAULT_MANUAL_RUNS_PER_MONTH = 1
|
|
19
|
+
const DEFAULT_RELEASE_RUNS_PER_MONTH = 1
|
|
20
|
+
|
|
16
21
|
/**
|
|
17
22
|
* Discover all GitHub Actions workflow files in the project
|
|
18
23
|
* @param {string} projectPath - Root path of the project
|
|
@@ -151,25 +156,238 @@ function getCommitFrequency(projectPath, days = 30) {
|
|
|
151
156
|
}
|
|
152
157
|
}
|
|
153
158
|
|
|
159
|
+
/**
|
|
160
|
+
* Normalize a GitHub Actions `on` declaration into a trigger object.
|
|
161
|
+
* @param {string|string[]|object} onConfig - Workflow `on` section
|
|
162
|
+
* @returns {object} Normalized trigger object
|
|
163
|
+
*/
|
|
164
|
+
function normalizeTriggers(onConfig) {
|
|
165
|
+
if (!onConfig) return {}
|
|
166
|
+
if (typeof onConfig === 'string') return { [onConfig]: true }
|
|
167
|
+
if (Array.isArray(onConfig)) {
|
|
168
|
+
return onConfig.reduce((acc, eventName) => {
|
|
169
|
+
if (typeof eventName === 'string') {
|
|
170
|
+
acc[eventName] = true
|
|
171
|
+
}
|
|
172
|
+
return acc
|
|
173
|
+
}, {})
|
|
174
|
+
}
|
|
175
|
+
if (typeof onConfig === 'object') return onConfig
|
|
176
|
+
return {}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Count cron field slots for rough monthly frequency estimation.
|
|
181
|
+
* @param {string} field - Cron field expression
|
|
182
|
+
* @param {number} maxSlots - Max slots in field (minute=60, hour=24)
|
|
183
|
+
* @returns {number} Estimated slot count
|
|
184
|
+
*/
|
|
185
|
+
function countCronFieldSlots(field, maxSlots) {
|
|
186
|
+
if (!field || field === '*') return 1
|
|
187
|
+
|
|
188
|
+
if (field.includes(',')) {
|
|
189
|
+
return field
|
|
190
|
+
.split(',')
|
|
191
|
+
.map(part => part.trim())
|
|
192
|
+
.reduce((sum, part) => sum + countCronFieldSlots(part, maxSlots), 0)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (field.includes('/')) {
|
|
196
|
+
const [base, stepRaw] = field.split('/')
|
|
197
|
+
const step = Number(stepRaw)
|
|
198
|
+
if (!Number.isFinite(step) || step <= 0) return 1
|
|
199
|
+
|
|
200
|
+
if (!base || base === '*') {
|
|
201
|
+
return Math.max(1, Math.ceil(maxSlots / step))
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (base.includes('-')) {
|
|
205
|
+
const [startRaw, endRaw] = base.split('-')
|
|
206
|
+
const start = Number(startRaw)
|
|
207
|
+
const end = Number(endRaw)
|
|
208
|
+
if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) {
|
|
209
|
+
return 1
|
|
210
|
+
}
|
|
211
|
+
return Math.max(1, Math.ceil((end - start + 1) / step))
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (field.includes('-')) {
|
|
216
|
+
const [startRaw, endRaw] = field.split('-')
|
|
217
|
+
const start = Number(startRaw)
|
|
218
|
+
const end = Number(endRaw)
|
|
219
|
+
if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) {
|
|
220
|
+
return 1
|
|
221
|
+
}
|
|
222
|
+
return Math.max(1, end - start + 1)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return 1
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Estimate monthly runs from schedule cron expressions.
|
|
230
|
+
* @param {Array|object} scheduleConfig - Workflow schedule config
|
|
231
|
+
* @returns {number} Estimated runs per month
|
|
232
|
+
*/
|
|
233
|
+
function estimateScheduleRunsPerMonth(scheduleConfig) {
|
|
234
|
+
const schedules = Array.isArray(scheduleConfig)
|
|
235
|
+
? scheduleConfig
|
|
236
|
+
: scheduleConfig
|
|
237
|
+
? [scheduleConfig]
|
|
238
|
+
: []
|
|
239
|
+
|
|
240
|
+
if (schedules.length === 0) return 0
|
|
241
|
+
|
|
242
|
+
return schedules.reduce((total, entry) => {
|
|
243
|
+
const cron =
|
|
244
|
+
entry && typeof entry.cron === 'string' ? entry.cron.trim() : ''
|
|
245
|
+
const parts = cron.split(/\s+/)
|
|
246
|
+
if (parts.length !== 5) {
|
|
247
|
+
return total + 4
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const [
|
|
251
|
+
minuteField,
|
|
252
|
+
hourField,
|
|
253
|
+
dayOfMonthField,
|
|
254
|
+
monthField,
|
|
255
|
+
dayOfWeekField,
|
|
256
|
+
] = parts
|
|
257
|
+
|
|
258
|
+
const minuteSlots = countCronFieldSlots(minuteField, 60)
|
|
259
|
+
const hourSlots = countCronFieldSlots(hourField, 24)
|
|
260
|
+
const timeSlots = Math.max(1, minuteSlots * hourSlots)
|
|
261
|
+
|
|
262
|
+
let baseRuns = DAYS_PER_MONTH
|
|
263
|
+
if (dayOfWeekField !== '*') {
|
|
264
|
+
const dowSlots = countCronFieldSlots(dayOfWeekField, 7)
|
|
265
|
+
baseRuns = Math.ceil(dowSlots * 4.3) // ~4.3 weeks/month
|
|
266
|
+
} else if (dayOfMonthField !== '*') {
|
|
267
|
+
baseRuns = 1
|
|
268
|
+
} else if (monthField !== '*') {
|
|
269
|
+
baseRuns = 1
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return total + Math.max(1, Math.ceil(baseRuns * timeSlots))
|
|
273
|
+
}, 0)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Estimate workflow runs/month from trigger type.
|
|
278
|
+
* @param {object} workflow - Parsed workflow object
|
|
279
|
+
* @param {number} commitsPerDay - Average commits/day
|
|
280
|
+
* @param {object} [options={}] - Estimation tuning
|
|
281
|
+
* @returns {number} Estimated runs per month
|
|
282
|
+
*/
|
|
283
|
+
function estimateWorkflowRunsPerMonth(workflow, commitsPerDay, options = {}) {
|
|
284
|
+
const triggers = normalizeTriggers(workflow && workflow.on)
|
|
285
|
+
const commitsPerMonth = Math.ceil(commitsPerDay * DAYS_PER_MONTH)
|
|
286
|
+
const pullRequestFactor =
|
|
287
|
+
options.pullRequestFactor ?? DEFAULT_PULL_REQUEST_FACTOR
|
|
288
|
+
const manualRunsPerMonth =
|
|
289
|
+
options.manualRunsPerMonth ?? DEFAULT_MANUAL_RUNS_PER_MONTH
|
|
290
|
+
const releaseRunsPerMonth =
|
|
291
|
+
options.releaseRunsPerMonth ?? DEFAULT_RELEASE_RUNS_PER_MONTH
|
|
292
|
+
|
|
293
|
+
const hasPush = Object.prototype.hasOwnProperty.call(triggers, 'push')
|
|
294
|
+
const hasPullRequest = Object.prototype.hasOwnProperty.call(
|
|
295
|
+
triggers,
|
|
296
|
+
'pull_request'
|
|
297
|
+
)
|
|
298
|
+
const hasSchedule = Object.prototype.hasOwnProperty.call(triggers, 'schedule')
|
|
299
|
+
const hasWorkflowDispatch = Object.prototype.hasOwnProperty.call(
|
|
300
|
+
triggers,
|
|
301
|
+
'workflow_dispatch'
|
|
302
|
+
)
|
|
303
|
+
const hasRelease = Object.prototype.hasOwnProperty.call(triggers, 'release')
|
|
304
|
+
const hasCreate = Object.prototype.hasOwnProperty.call(triggers, 'create')
|
|
305
|
+
|
|
306
|
+
const pushConfig =
|
|
307
|
+
hasPush && typeof triggers.push === 'object' ? triggers.push : null
|
|
308
|
+
const pushIsTagOnly =
|
|
309
|
+
!!pushConfig &&
|
|
310
|
+
Array.isArray(pushConfig.tags) &&
|
|
311
|
+
pushConfig.tags.length > 0 &&
|
|
312
|
+
!pushConfig.branches &&
|
|
313
|
+
!pushConfig['branches-ignore']
|
|
314
|
+
const hasCommitPush = hasPush && !pushIsTagOnly
|
|
315
|
+
const hasTagPush = hasPush && pushIsTagOnly
|
|
316
|
+
|
|
317
|
+
let runsPerMonth = 0
|
|
318
|
+
|
|
319
|
+
if (hasCommitPush) {
|
|
320
|
+
runsPerMonth += commitsPerMonth
|
|
321
|
+
}
|
|
322
|
+
if (hasPullRequest) {
|
|
323
|
+
runsPerMonth += Math.ceil(commitsPerMonth * pullRequestFactor)
|
|
324
|
+
}
|
|
325
|
+
if (hasSchedule) {
|
|
326
|
+
runsPerMonth += estimateScheduleRunsPerMonth(triggers.schedule)
|
|
327
|
+
}
|
|
328
|
+
if (hasTagPush || hasRelease || hasCreate) {
|
|
329
|
+
runsPerMonth += releaseRunsPerMonth
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const hasOnlyManualTrigger =
|
|
333
|
+
hasWorkflowDispatch &&
|
|
334
|
+
!hasCommitPush &&
|
|
335
|
+
!hasPullRequest &&
|
|
336
|
+
!hasSchedule &&
|
|
337
|
+
!hasTagPush &&
|
|
338
|
+
!hasRelease &&
|
|
339
|
+
!hasCreate
|
|
340
|
+
if (hasOnlyManualTrigger) {
|
|
341
|
+
runsPerMonth += manualRunsPerMonth
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Fallback for unusual trigger configurations.
|
|
345
|
+
if (runsPerMonth === 0) {
|
|
346
|
+
runsPerMonth = commitsPerMonth
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
return Math.max(1, Math.ceil(runsPerMonth))
|
|
350
|
+
}
|
|
351
|
+
|
|
154
352
|
/**
|
|
155
353
|
* Calculate monthly CI costs based on workflow usage
|
|
156
354
|
* @param {Array} workflows - Array of workflow analysis results
|
|
157
355
|
* @param {number} commitsPerDay - Average commits per day
|
|
356
|
+
* @param {object} [options={}] - Estimation tuning options
|
|
158
357
|
* @returns {object} Cost breakdown and recommendations
|
|
159
358
|
*/
|
|
160
|
-
function calculateMonthlyCosts(workflows, commitsPerDay) {
|
|
161
|
-
const
|
|
162
|
-
|
|
359
|
+
function calculateMonthlyCosts(workflows, commitsPerDay, options = {}) {
|
|
360
|
+
const enrichedWorkflows = workflows.map(wf => {
|
|
361
|
+
const runsPerMonth = estimateWorkflowRunsPerMonth(
|
|
362
|
+
wf.parsed || wf,
|
|
363
|
+
commitsPerDay,
|
|
364
|
+
options
|
|
365
|
+
)
|
|
366
|
+
const minutesPerMonth = Math.ceil(wf.estimatedDuration * runsPerMonth)
|
|
367
|
+
return {
|
|
368
|
+
...wf,
|
|
369
|
+
runsPerMonth,
|
|
370
|
+
minutesPerMonth,
|
|
371
|
+
}
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
const totalWorkflowRunsPerMonth = enrichedWorkflows.reduce(
|
|
375
|
+
(total, wf) => total + wf.runsPerMonth,
|
|
376
|
+
0
|
|
377
|
+
)
|
|
378
|
+
const minutesPerMonth = enrichedWorkflows.reduce(
|
|
379
|
+
(total, wf) => total + wf.minutesPerMonth,
|
|
163
380
|
0
|
|
164
381
|
)
|
|
165
|
-
const
|
|
166
|
-
const
|
|
167
|
-
const minutesPerMonth = Math.ceil(minutesPerDay * 30)
|
|
382
|
+
const minutesPerDay = minutesPerMonth / DAYS_PER_MONTH
|
|
383
|
+
const workflowRunsPerDay = totalWorkflowRunsPerMonth / DAYS_PER_MONTH
|
|
168
384
|
|
|
169
385
|
// GitHub Actions pricing (as of 2024)
|
|
170
386
|
const FREE_TIER_MINUTES = 2000 // Free tier monthly limit
|
|
171
387
|
const TEAM_TIER_MINUTES = 3000 // Team tier monthly limit
|
|
172
388
|
const COST_PER_MINUTE = 0.008 // $0.008/min for private repos
|
|
389
|
+
const TARGET_BUDGET_MINUTES = 1000
|
|
390
|
+
const STRETCH_BUDGET_MINUTES = 1500
|
|
173
391
|
|
|
174
392
|
const freeOverage = Math.max(0, minutesPerMonth - FREE_TIER_MINUTES)
|
|
175
393
|
const teamOverage = Math.max(0, minutesPerMonth - TEAM_TIER_MINUTES)
|
|
@@ -181,12 +399,18 @@ function calculateMonthlyCosts(workflows, commitsPerDay) {
|
|
|
181
399
|
minutesPerMonth,
|
|
182
400
|
minutesPerDay,
|
|
183
401
|
workflowRunsPerDay,
|
|
184
|
-
breakdown:
|
|
402
|
+
breakdown: enrichedWorkflows.map(wf => ({
|
|
185
403
|
name: wf.name,
|
|
186
404
|
minutesPerRun: wf.estimatedDuration,
|
|
187
|
-
runsPerMonth:
|
|
188
|
-
minutesPerMonth:
|
|
405
|
+
runsPerMonth: wf.runsPerMonth,
|
|
406
|
+
minutesPerMonth: wf.minutesPerMonth,
|
|
189
407
|
})),
|
|
408
|
+
budgets: {
|
|
409
|
+
target: TARGET_BUDGET_MINUTES,
|
|
410
|
+
stretch: STRETCH_BUDGET_MINUTES,
|
|
411
|
+
withinTarget: minutesPerMonth <= TARGET_BUDGET_MINUTES,
|
|
412
|
+
withinStretch: minutesPerMonth <= STRETCH_BUDGET_MINUTES,
|
|
413
|
+
},
|
|
190
414
|
tiers: {
|
|
191
415
|
free: {
|
|
192
416
|
limit: FREE_TIER_MINUTES,
|
|
@@ -217,6 +441,7 @@ function analyzeOptimizations(workflows, commitsPerDay) {
|
|
|
217
441
|
for (const wf of workflows) {
|
|
218
442
|
const workflow = wf.parsed
|
|
219
443
|
const workflowName = wf.name
|
|
444
|
+
const runsPerMonth = estimateWorkflowRunsPerMonth(workflow, commitsPerDay)
|
|
220
445
|
|
|
221
446
|
if (!workflow.jobs) continue
|
|
222
447
|
|
|
@@ -243,7 +468,7 @@ function analyzeOptimizations(workflows, commitsPerDay) {
|
|
|
243
468
|
if (hasInstall && !hasCaching) {
|
|
244
469
|
// Estimate 2-5 min savings per run
|
|
245
470
|
const savingsPerRun = 3
|
|
246
|
-
const savingsPerMonth = Math.ceil(savingsPerRun *
|
|
471
|
+
const savingsPerMonth = Math.ceil(savingsPerRun * runsPerMonth)
|
|
247
472
|
|
|
248
473
|
recommendations.push({
|
|
249
474
|
type: 'caching',
|
|
@@ -267,7 +492,7 @@ function analyzeOptimizations(workflows, commitsPerDay) {
|
|
|
267
492
|
const currentMinutes = wf.estimatedDuration
|
|
268
493
|
const reductionFactor = 0.5
|
|
269
494
|
const savingsPerMonth = Math.ceil(
|
|
270
|
-
currentMinutes * reductionFactor *
|
|
495
|
+
currentMinutes * reductionFactor * runsPerMonth
|
|
271
496
|
)
|
|
272
497
|
|
|
273
498
|
recommendations.push({
|
|
@@ -287,14 +512,18 @@ function analyzeOptimizations(workflows, commitsPerDay) {
|
|
|
287
512
|
|
|
288
513
|
// 3. Detect high-frequency scheduled workflows
|
|
289
514
|
if (workflow.on) {
|
|
290
|
-
const triggers =
|
|
291
|
-
const hasSchedule =
|
|
292
|
-
triggers
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
515
|
+
const triggers = normalizeTriggers(workflow.on)
|
|
516
|
+
const hasSchedule = Object.prototype.hasOwnProperty.call(
|
|
517
|
+
triggers,
|
|
518
|
+
'schedule'
|
|
519
|
+
)
|
|
520
|
+
const scheduledRuns = hasSchedule
|
|
521
|
+
? estimateScheduleRunsPerMonth(triggers.schedule)
|
|
522
|
+
: 0
|
|
523
|
+
|
|
524
|
+
if (scheduledRuns >= 20) {
|
|
525
|
+
const currentRuns = scheduledRuns
|
|
526
|
+
const proposedRuns = 4 // Weekly
|
|
298
527
|
const savingsPerMonth = Math.ceil(
|
|
299
528
|
wf.estimatedDuration * (currentRuns - proposedRuns)
|
|
300
529
|
)
|
|
@@ -303,7 +532,7 @@ function analyzeOptimizations(workflows, commitsPerDay) {
|
|
|
303
532
|
type: 'frequency',
|
|
304
533
|
workflow: workflowName,
|
|
305
534
|
title: 'Reduce schedule frequency',
|
|
306
|
-
description: `"${workflowName}" runs
|
|
535
|
+
description: `"${workflowName}" runs about ${currentRuns}x/month`,
|
|
307
536
|
action: 'Change to weekly schedule (4x/month)',
|
|
308
537
|
potentialSavings: savingsPerMonth,
|
|
309
538
|
savingsPerRun: 0,
|
|
@@ -311,8 +540,8 @@ function analyzeOptimizations(workflows, commitsPerDay) {
|
|
|
311
540
|
})
|
|
312
541
|
}
|
|
313
542
|
|
|
314
|
-
if (
|
|
315
|
-
const currentRuns =
|
|
543
|
+
if (scheduledRuns >= 4 && scheduledRuns < 20) {
|
|
544
|
+
const currentRuns = scheduledRuns
|
|
316
545
|
const proposedRuns = 1 // Monthly = 1 run/month
|
|
317
546
|
const savingsPerMonth = Math.ceil(
|
|
318
547
|
wf.estimatedDuration * (currentRuns - proposedRuns)
|
|
@@ -323,7 +552,7 @@ function analyzeOptimizations(workflows, commitsPerDay) {
|
|
|
323
552
|
type: 'frequency',
|
|
324
553
|
workflow: workflowName,
|
|
325
554
|
title: 'Reduce schedule frequency',
|
|
326
|
-
description: `"${workflowName}" runs
|
|
555
|
+
description: `"${workflowName}" runs about ${currentRuns}x/month`,
|
|
327
556
|
action: 'Change to monthly schedule (1x/month)',
|
|
328
557
|
potentialSavings: savingsPerMonth,
|
|
329
558
|
savingsPerRun: 0,
|
|
@@ -337,12 +566,15 @@ function analyzeOptimizations(workflows, commitsPerDay) {
|
|
|
337
566
|
if (workflow.on && typeof workflow.on === 'object') {
|
|
338
567
|
const hasPush = workflow.on.push || workflow.on.pull_request
|
|
339
568
|
const hasPathFilter =
|
|
340
|
-
(workflow.on.push &&
|
|
341
|
-
|
|
569
|
+
(workflow.on.push &&
|
|
570
|
+
(workflow.on.push.paths || workflow.on.push['paths-ignore'])) ||
|
|
571
|
+
(workflow.on.pull_request &&
|
|
572
|
+
(workflow.on.pull_request.paths ||
|
|
573
|
+
workflow.on.pull_request['paths-ignore']))
|
|
342
574
|
|
|
343
575
|
if (hasPush && !hasPathFilter && !workflowName.includes('release')) {
|
|
344
|
-
// Estimate 20% of
|
|
345
|
-
const wastedRuns =
|
|
576
|
+
// Estimate 20% of runs are docs-only/config-only changes
|
|
577
|
+
const wastedRuns = runsPerMonth * 0.2
|
|
346
578
|
const savingsPerMonth = Math.ceil(wf.estimatedDuration * wastedRuns)
|
|
347
579
|
|
|
348
580
|
if (savingsPerMonth > 50) {
|
|
@@ -401,6 +633,12 @@ function generateReport(analysis) {
|
|
|
401
633
|
` Commit frequency: ~${commitStats.commitsPerDay.toFixed(1)} commits/day`
|
|
402
634
|
)
|
|
403
635
|
console.log(` Workflows detected: ${workflows.length}`)
|
|
636
|
+
console.log(
|
|
637
|
+
` Budget target (<${costs.budgets.target} min): ${costs.budgets.withinTarget ? '✅' : '⚠️'}`
|
|
638
|
+
)
|
|
639
|
+
console.log(
|
|
640
|
+
` Stretch budget (<${costs.budgets.stretch} min): ${costs.budgets.withinStretch ? '✅' : '⚠️'}`
|
|
641
|
+
)
|
|
404
642
|
console.log('')
|
|
405
643
|
|
|
406
644
|
// Workflow breakdown
|
|
@@ -613,6 +851,8 @@ module.exports = {
|
|
|
613
851
|
handleAnalyzeCi,
|
|
614
852
|
discoverWorkflows,
|
|
615
853
|
estimateWorkflowDuration,
|
|
854
|
+
estimateScheduleRunsPerMonth,
|
|
855
|
+
estimateWorkflowRunsPerMonth,
|
|
616
856
|
getCommitFrequency,
|
|
617
857
|
calculateMonthlyCosts,
|
|
618
858
|
analyzeOptimizations,
|
package/lib/commands/deps.js
CHANGED
|
@@ -95,7 +95,7 @@ async function handleDependencyMonitoring() {
|
|
|
95
95
|
if (!capCheck.allowed) {
|
|
96
96
|
console.error(`❌ ${capCheck.reason}`)
|
|
97
97
|
console.error(
|
|
98
|
-
' Upgrade to Pro, Team, or Enterprise for unlimited runs: https://
|
|
98
|
+
' Upgrade to Pro, Team, or Enterprise for unlimited runs: https://buildproven.ai/qa-architect'
|
|
99
99
|
)
|
|
100
100
|
process.exit(1)
|
|
101
101
|
}
|
|
@@ -126,7 +126,7 @@ async function handleDependencyMonitoring() {
|
|
|
126
126
|
|
|
127
127
|
const configData = generatePremiumDependabotConfig({
|
|
128
128
|
projectPath,
|
|
129
|
-
schedule: '
|
|
129
|
+
schedule: 'monthly',
|
|
130
130
|
})
|
|
131
131
|
|
|
132
132
|
if (configData) {
|
|
@@ -173,7 +173,7 @@ async function handleDependencyMonitoring() {
|
|
|
173
173
|
|
|
174
174
|
const dependabotConfig = generateBasicDependabotConfig({
|
|
175
175
|
projectPath,
|
|
176
|
-
schedule: '
|
|
176
|
+
schedule: 'monthly',
|
|
177
177
|
})
|
|
178
178
|
|
|
179
179
|
if (dependabotConfig) {
|
|
@@ -184,7 +184,7 @@ async function handleDependencyMonitoring() {
|
|
|
184
184
|
console.log('\n🎉 Basic dependency monitoring setup complete!')
|
|
185
185
|
console.log('\n📋 What was added (Free Tier):')
|
|
186
186
|
console.log(' • Basic Dependabot configuration for npm packages')
|
|
187
|
-
console.log(' •
|
|
187
|
+
console.log(' • Monthly dependency updates')
|
|
188
188
|
console.log(' • GitHub Actions dependency monitoring')
|
|
189
189
|
|
|
190
190
|
// Show upgrade message for premium features
|
|
@@ -271,7 +271,7 @@ async function handleDependencyMonitoring() {
|
|
|
271
271
|
console.log(' 2. Enable "Dependabot alerts"')
|
|
272
272
|
console.log(' 3. Enable "Dependabot security updates"')
|
|
273
273
|
console.log(
|
|
274
|
-
`\n • Report issue: https://github.com/
|
|
274
|
+
`\n • Report issue: https://github.com/buildproven/qa-architect/issues/new?title=${errorId}`
|
|
275
275
|
)
|
|
276
276
|
}
|
|
277
277
|
|
|
@@ -39,11 +39,11 @@ async function handleLicenseActivation() {
|
|
|
39
39
|
console.log('\n❌ License activation failed.')
|
|
40
40
|
console.log('• Check your license key format (QAA-XXXX-XXXX-XXXX-XXXX)')
|
|
41
41
|
console.log('• Verify your email address')
|
|
42
|
-
console.log('• Contact support: support@
|
|
42
|
+
console.log('• Contact support: support@buildproven.ai')
|
|
43
43
|
}
|
|
44
44
|
} catch (error) {
|
|
45
45
|
console.error('\n❌ License activation error:', error.message)
|
|
46
|
-
console.log('Contact support for assistance: support@
|
|
46
|
+
console.log('Contact support for assistance: support@buildproven.ai')
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
process.exit(0)
|