From fdb32e2c535e541e1d7336099aed94a180711bcb Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 26 Feb 2026 14:44:00 +0700 Subject: [PATCH] fix(maintainability): make gate parallel-pr friendly --- .github/workflows/ci.yml | 2 +- .github/workflows/dev-release.yml | 2 +- .github/workflows/release.yml | 2 +- CLAUDE.md | 30 +++++- docs/project-roadmap.md | 6 ++ package.json | 4 +- scripts/maintainability-check.js | 163 ++++++++++++++++++++++++++++++ 7 files changed, 202 insertions(+), 7 deletions(-) create mode 100644 scripts/maintainability-check.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 76254243..e4041af2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,5 +30,5 @@ jobs: - name: Build package run: bun run build:all - - name: Validate (typecheck + lint + tests) + - name: Validate (typecheck + lint + format + maintainability [warn on PR] + tests) run: bun run validate diff --git a/.github/workflows/dev-release.yml b/.github/workflows/dev-release.yml index c32b9f91..35db2a30 100644 --- a/.github/workflows/dev-release.yml +++ b/.github/workflows/dev-release.yml @@ -42,7 +42,7 @@ jobs: - name: Build run: bun run build:all - - name: Validate (typecheck + lint + tests) + - name: Validate (typecheck + lint + format + maintainability [strict] + tests) run: bun run validate - name: Release diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 04485546..1d91bfe3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -41,7 +41,7 @@ jobs: - name: Build package run: bun run build:all - - name: Validate (typecheck + lint + tests) + - name: Validate (typecheck + lint + format + maintainability [strict] + tests) run: bun run validate - name: Release diff --git a/CLAUDE.md b/CLAUDE.md index 80f46d55..8c5be4d5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,6 +43,7 @@ CLI wrapper for instant switching between multiple provider accounts and alterna | Mistake | Consequence | Correct Action | |---------|-------------|----------------| | Running `validate` without `format` first | format:check fails | Run `bun run format` BEFORE validate | +| Assuming maintainability check is always strict | PR/feature branches run warning mode by default | Use `bun run maintainability:check:strict` before merge when touching debt-sensitive code | | Using `chore:` for dev→main PR | No npm release triggered | Use `feat:` or `fix:` prefix | | Committing directly to `main` or `dev` | Bypasses CI/review | Always use PRs | | Manual version bump or git tag | Conflicts with semantic-release | Let CI handle versioning | @@ -59,7 +60,7 @@ Quality gates MUST pass before pushing. **Both projects have identical workflow. # Main project (from repo root) bun run format # Step 1: Fix formatting bun run lint:fix # Step 2: Fix lint issues -bun run validate # Step 3: Full test gate (must pass) +bun run validate # Step 3: Full gate (typecheck + lint + format + maintainability + tests) bun run validate:ci-parity # Step 4: CI parity gate (build + validate + base branch check) # UI project (if UI changed) @@ -78,7 +79,7 @@ bun run validate # Step 3: Final check (must pass) | Project | Command | Runs | |---------|---------|------| -| Main | `bun run validate` | typecheck + lint:fix + format:check + test:all | +| Main | `bun run validate` | typecheck + lint:fix + format:check + maintainability:check + test:all | | UI | `bun run validate` | typecheck + lint:fix + format:check | ### ESLint Rules (ALL errors) @@ -105,10 +106,31 @@ bun run validate # Step 3: Final check (must pass) ### Automatic Enforcement - `prepublishOnly` / `prepack` runs `build:all` + `validate` + `sync-version.js` -- CI/CD runs `bun run validate` on every PR +- CI/CD runs `bun run validate` on every PR (maintainability is warning mode on PR events) - husky `pre-commit` runs quick lint/type/format checks - husky `pre-push` runs `bun run validate:ci-parity` to block CI drift before push +### Maintainability Baseline Gate + +- Baseline file: `docs/metrics/maintainability-baseline.json` +- Metric collector/check script: `scripts/maintainability-baseline.js` +- Branch-aware gate wrapper: `scripts/maintainability-check.js` +- Enforcement path: `bun run maintainability:check` (included in `bun run validate`) +- Gate modes: + - `strict`: protected branches (`main`, `dev`, `hotfix/*`, `kai/hotfix-*`) and equivalent CI refs + - `warn`: pull request CI and non-protected local branches (non-blocking for parallel PR workflow) + - override commands: + - `bun run maintainability:check:strict` + - `bun run maintainability:check:warn` +- Gated metrics (must not increase vs baseline): + - `processExitReferenceCount` + - `synchronousFsApiReferenceCount` + - `largeFileCountOver350Loc` +- Baseline update policy: + 1. Prefer reducing the metric and keeping the baseline unchanged. + 2. On protected-branch integration (strict mode), if increase is intentional and accepted, run `bun run maintainability:baseline`. + 3. Commit both the code change and `docs/metrics/maintainability-baseline.json`, and state reason in PR description. + ## Critical Constraints (NEVER VIOLATE) 1. **NO EMOJIS in CLI output** - Terminal output uses ASCII only: [OK], [!], [X], [i] @@ -359,6 +381,8 @@ rm -rf ~/.ccs # Clean environment - [ ] `bun run validate` — all checks pass - [ ] `bun run validate:ci-parity` — CI parity passed (also enforced by pre-push hook) - [ ] `cd ui && bun run format && bun run validate` — if UI changed +- [ ] If touching debt-sensitive code, run `bun run maintainability:check:strict` before opening/merging PR +- [ ] If strict mode fails and increase is intentional: `bun run maintainability:baseline` and commit `docs/metrics/maintainability-baseline.json` **Code:** - [ ] Conventional commit format (`feat:`, `fix:`, etc.) diff --git a/docs/project-roadmap.md b/docs/project-roadmap.md index 07598a41..2ef26085 100644 --- a/docs/project-roadmap.md +++ b/docs/project-roadmap.md @@ -203,15 +203,21 @@ All criteria achieved: ## Maintainability Gate (Issue #539 Foundation) - Baseline metrics artifact: `docs/metrics/maintainability-baseline.json` +- Branch-aware gate wrapper: `scripts/maintainability-check.js` - Generate or refresh baseline: - `bun run maintainability:baseline` - `npm run maintainability:baseline` - Run regression check gate: - `bun run maintainability:check` - `npm run maintainability:check` + - `bun run maintainability:check:strict` (force strict locally) The baseline/check scripts enumerate git-tracked files under `src` for deterministic results and fail fast if git file listing is unavailable. +Default gate behavior: +- strict mode on protected branches (`main`, `dev`, `hotfix/*`, `kai/hotfix-*`) +- warning mode on PR CI and non-protected branches (parallel PR friendly) + The check mode supports a maintainability regression gate that blocks increases in: - `process.exit` references - synchronous fs API references diff --git a/package.json b/package.json index 77951c30..c14f6700 100644 --- a/package.json +++ b/package.json @@ -69,7 +69,9 @@ "validate:ci-parity": "bash scripts/ci-parity-gate.sh", "verify:bundle": "node scripts/verify-bundle.js", "maintainability:baseline": "node scripts/maintainability-baseline.js --out docs/metrics/maintainability-baseline.json", - "maintainability:check": "node scripts/maintainability-baseline.js --check docs/metrics/maintainability-baseline.json", + "maintainability:check": "node scripts/maintainability-check.js", + "maintainability:check:strict": "node scripts/maintainability-check.js --strict", + "maintainability:check:warn": "node scripts/maintainability-check.js --warn", "test": "bun run build && bun run test:all", "test:ci": "bun run test:all", "test:all": "bun test tests/unit tests/integration tests/npm", diff --git a/scripts/maintainability-check.js b/scripts/maintainability-check.js new file mode 100644 index 00000000..8971ef84 --- /dev/null +++ b/scripts/maintainability-check.js @@ -0,0 +1,163 @@ +#!/usr/bin/env node + +const { execFileSync, spawnSync } = require('child_process'); +const path = require('path'); + +const PROJECT_ROOT = path.resolve(__dirname, '..'); +const BASELINE_FILE = path.join('docs', 'metrics', 'maintainability-baseline.json'); +const BASELINE_SCRIPT = path.join(PROJECT_ROOT, 'scripts', 'maintainability-baseline.js'); + +const PROTECTED_BRANCHES = new Set(['main', 'dev']); +const HOTFIX_PREFIXES = ['hotfix/', 'kai/hotfix-']; + +function hasFlag(name) { + return process.argv.slice(2).includes(name); +} + +function detectBranchName() { + try { + return execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { + cwd: PROJECT_ROOT, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + } catch { + return ''; + } +} + +function isProtectedBranch(branchName) { + if (!branchName) { + return false; + } + + if (PROTECTED_BRANCHES.has(branchName)) { + return true; + } + + return HOTFIX_PREFIXES.some(prefix => branchName.startsWith(prefix)); +} + +function detectMode() { + if (hasFlag('--strict')) { + return 'strict'; + } + + if (hasFlag('--warn')) { + return 'warn'; + } + + if (hasFlag('--off')) { + return 'off'; + } + + const explicitMode = (process.env.CCS_MAINTAINABILITY_MODE || '').toLowerCase().trim(); + if (explicitMode === 'strict' || explicitMode === 'warn' || explicitMode === 'off') { + return explicitMode; + } + + const eventName = process.env.GITHUB_EVENT_NAME || ''; + if (eventName === 'pull_request' || eventName === 'pull_request_target') { + return 'warn'; + } + + const gitHubRef = process.env.GITHUB_REF || ''; + if (gitHubRef.startsWith('refs/heads/')) { + const branchFromRef = gitHubRef.slice('refs/heads/'.length); + if (isProtectedBranch(branchFromRef)) { + return 'strict'; + } + } + + return isProtectedBranch(detectBranchName()) ? 'strict' : 'warn'; +} + +function runBaselineCheck() { + return spawnSync('node', [BASELINE_SCRIPT, '--check', BASELINE_FILE], { + cwd: PROJECT_ROOT, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); +} + +function writeStreams(result) { + if (result.stdout) { + process.stdout.write(result.stdout); + } + if (result.stderr) { + process.stderr.write(result.stderr); + } +} + +function tryParseJson(stdout) { + if (!stdout) { + return null; + } + + try { + return JSON.parse(stdout); + } catch { + return null; + } +} + +function formatViolations(violations) { + if (!Array.isArray(violations) || violations.length === 0) { + return []; + } + + return violations.map(violation => { + if (!violation || typeof violation !== 'object') { + return '- unknown violation'; + } + + const metric = violation.metric || 'unknown'; + const baseline = typeof violation.baseline === 'number' ? violation.baseline : 'n/a'; + const current = typeof violation.current === 'number' ? violation.current : 'n/a'; + return `- ${metric}: baseline=${baseline}, current=${current}`; + }); +} + +function main() { + const mode = detectMode(); + + if (mode === 'off') { + console.log('[i] Maintainability gate disabled (mode=off).'); + process.exit(0); + } + + const result = runBaselineCheck(); + if (mode === 'strict') { + writeStreams(result); + process.exit(result.status === null ? 1 : result.status); + } + + if (result.status === 0) { + writeStreams(result); + process.exit(0); + } + + const parsed = tryParseJson(result.stdout); + const branchName = detectBranchName(); + + console.log('[!] Maintainability regression detected (warning-only mode).'); + if (branchName) { + console.log(`[i] Branch: ${branchName}`); + } + + if (parsed && Array.isArray(parsed.violations) && parsed.violations.length > 0) { + console.log('[i] Violations:'); + for (const line of formatViolations(parsed.violations)) { + console.log(line); + } + } else { + writeStreams(result); + } + + console.log('[i] This is non-blocking on PR/feature branches to support parallel workflow.'); + console.log( + '[i] Use strict mode when needed: bun run maintainability:check:strict' + ); +} + +main();