diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 706b6c22f9..2cdbc4dbd3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,12 +38,17 @@ jobs: with: persist-credentials: false fetch-depth: 0 + - uses: actions/setup-node@v6 + with: + node-version: ${{ env.CI_NODE_VERSION }} - id: classify env: EVENT_NAME: ${{ github.event_name }} BASE_SHA: ${{ github.event.pull_request.base.sha }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: | + # Single source of truth: scripts/quality/classify-pr-changes.mjs + # (unit-tested). Push/dispatch always enable every lane. if [ "$EVENT_NAME" != "pull_request" ]; then { echo "code=true" @@ -54,51 +59,18 @@ jobs: exit 0 fi - code=false - docs=false - i18n=false - workflow=false - git diff --name-only "$BASE_SHA" "$HEAD_SHA" > changed-files.txt - - while IFS= read -r file; do - case "$file" in - .github/workflows/*|.zizmor.yml) - workflow=true - code=true - ;; - docs/*|*.md) - docs=true - ;; - src/i18n/*|src/i18n/messages/*|scripts/i18n/*|config/i18n.json) - i18n=true - code=true - ;; - src/*|open-sse/*|bin/*|electron/*|tests/*|scripts/*|package.json|package-lock.json|tsconfig*.json|next.config.*|vitest*.config.*|playwright.config.*) - code=true - ;; - db/*|config/*) - code=true - ;; - *) - code=true - ;; - esac - done < changed-files.txt - - { - echo "code=$code" - echo "docs=$docs" - echo "i18n=$i18n" - echo "workflow=$workflow" - } >> "$GITHUB_OUTPUT" + node scripts/quality/classify-pr-changes.mjs changed-files.txt >> "$GITHUB_OUTPUT" lint: name: Lint runs-on: ubuntu-latest + needs: changes # P3 (plano mestre): a release-PR viva fica DRAFT o ciclo inteiro — jobs pesados pulam # drafts (ciclo v3.8.44: 123 runs pesados re-disparados por merges na release, 88 cancelados). - if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }} + # Path filter: pure docs / pure message-catalog PRs skip the code lint bag + typecheck. + # Existence reason of this job is code regression; docs/i18n have dedicated jobs. + if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false && needs.changes.outputs.code == 'true') }} env: # tsx gates below (known-symbols, route-guard-membership) import modules that # open SQLite on load; provide DB env so a fresh CI DB initializes cleanly. @@ -116,7 +88,27 @@ jobs: - uses: ./.github/actions/npm-ci-retry - run: npm run check:node-runtime - run: npm run audit:deps - - run: npm run lint + - name: Restore ESLint file cache + uses: actions/cache@v6 + with: + path: | + .eslintcache + .eslintcache-complexity + key: eslint-${{ runner.os }}-${{ hashFiles('eslint.config.mjs', 'eslint.complexity-ratchets.config.mjs', 'config/quality/eslint-suppressions.json', 'package-lock.json') }} + restore-keys: | + eslint-${{ runner.os }}- + # Single ESLint inventory (JSON) — quality-gate reuses the artifact instead of + # a second cold full-tree pass for eslintWarnings ratchet counts. + - name: ESLint (JSON report) + run: npm run lint:json + - name: Upload ESLint results + if: always() + uses: actions/upload-artifact@v7 + with: + name: eslint-results + path: .artifacts/eslint-results.json + if-no-files-found: warn + retention-days: 7 - run: npm run check:cycles - run: npm run check:route-validation:t06 - run: npm run check:any-budget:t11 @@ -137,16 +129,16 @@ jobs: # check:docs-sync is run by the docs-sync-strict job (via check:docs-all) and the # husky pre-commit hook; the standalone copy here was redundant (ROI dedup). - run: npm run typecheck:core - # typecheck:noimplicit:core is a forward-looking gate (noImplicitAny). - # Run informationally for now — many pre-existing call sites still need - # explicit annotations; track in a dedicated follow-up. - - run: npm run typecheck:noimplicit:core - continue-on-error: true + # typecheck:noimplicit:core dropped from this job (2026-07 optimize): + # it was advisory (continue-on-error) and largely subsumed by the blocking + # check:type-coverage ratchet in quality-gate. Local: npm run typecheck:noimplicit:core. quality-gate: name: Quality Ratchet runs-on: ubuntu-latest - needs: test-coverage + # needs lint so eslint-results artifact is available (same inventory as the + # blocking lint step). Allow lint failure so other ratchets still run. + needs: [changes, test-coverage, lint] # Run even when test-coverage was SKIPPED/FAILED (e.g. a single flaky Coverage # Shard breaks the shard→coverage→ratchet chain). The DETERMINISTIC ratchets # (eslint / complexity / cognitive-complexity / duplication / codeql) do NOT need @@ -155,7 +147,8 @@ jobs: # release PR #4854, where the drift cascade only surfaced post-merge in #5029). # The coverage.* metrics degrade gracefully: the download is continue-on-error and # the ratchet runs with --allow-missing, so absent coverage is skipped, not failed. - if: ${{ !cancelled() && (github.event_name != 'pull_request' || github.event.pull_request.draft == false) }} + # Path filter: code-only — pure docs/i18n PRs have nothing for these ratchets to guard. + if: ${{ !cancelled() && (github.event_name != 'pull_request' || (github.event.pull_request.draft == false && needs.changes.outputs.code == 'true' && (needs.lint.result == 'success' || needs.lint.result == 'failure'))) }} # security-events: read lets the CodeQL ratchet read open code-scanning alerts # via `gh api .../code-scanning/alerts`. contents: read keeps checkout working. permissions: @@ -170,6 +163,15 @@ jobs: node-version: ${{ env.CI_NODE_VERSION }} cache: npm - uses: ./.github/actions/npm-ci-retry + - name: Restore ESLint file cache + uses: actions/cache@v6 + with: + path: | + .eslintcache + .eslintcache-complexity + key: eslint-${{ runner.os }}-${{ hashFiles('eslint.config.mjs', 'eslint.complexity-ratchets.config.mjs', 'config/quality/eslint-suppressions.json', 'package-lock.json') }} + restore-keys: | + eslint-${{ runner.os }}- # Coverage mergeada (coverage-summary.json) p/ o ratchet de cobertura. # continue-on-error: o artifact pode não existir se a job test-coverage foi # SKIPPED (shard flaky). Nesse caso collect-metrics pula coverage.* (ausente sem @@ -180,6 +182,13 @@ jobs: with: name: coverage-report path: coverage/ + # Prefer lint job's ESLint JSON (one inventory, two consumers). + - name: Download ESLint results + continue-on-error: true + uses: actions/download-artifact@v8 + with: + name: eslint-results + path: .artifacts/ - run: npm run quality:collect # Catraca: falha se qualquer métrica regredir vs quality-baseline.json (commitado). # Hoje: contagem de warnings do ESLint. Fase 4 estende com cobertura (lida do @@ -201,15 +210,13 @@ jobs: # para não pesar no caminho crítico do lint. - name: Duplication ratchet run: npm run check:duplication - - name: Complexity ratchet - run: npm run check:complexity - # Fase 7 INT: dead-code, cognitive-complexity, type-coverage promovidos de - # advisory (quality-extended) para BLOQUEANTES aqui. Os 3 leem seus baseline - # de quality-baseline.json e saem 1 em regressão. + # Complexity + cognitive: one ESLint walk, two independent baselines (by ruleId). + - name: Complexity + cognitive ratchets + run: npm run check:complexity-ratchets + # Fase 7 INT: dead-code, type-coverage promovidos de advisory (quality-extended) + # para BLOQUEANTES aqui. cognitive-complexity is folded into the step above. - name: Dead-code ratchet (knip) run: npm run check:dead-code - - name: Cognitive complexity ratchet (sonarjs) - run: npm run check:cognitive-complexity - name: Type coverage ratchet run: npm run check:type-coverage - name: Compression budget ratchet (F2.4 / N4) @@ -247,9 +254,11 @@ jobs: quality-extended: name: Quality Gates (Extended) runs-on: ubuntu-latest + needs: changes # P3 (plano mestre): a release-PR viva fica DRAFT o ciclo inteiro — jobs pesados pulam # drafts (ciclo v3.8.44: 123 runs pesados re-disparados por merges na release, 88 cancelados). - if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }} + # Path filter: code-only (scanners/ratchets target production surface). + if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false && needs.changes.outputs.code == 'true') }} steps: # fetch-depth: 0 — the OpenAPI breaking-change gate (oasdiff) reads the base # spec via `git show :docs/openapi.yaml`; a shallow clone @@ -357,9 +366,11 @@ jobs: docs-sync-strict: name: Docs Sync (Strict) runs-on: ubuntu-latest + needs: changes # P3 (plano mestre): a release-PR viva fica DRAFT o ciclo inteiro — jobs pesados pulam # drafts (ciclo v3.8.44: 123 runs pesados re-disparados por merges na release, 88 cancelados). - if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }} + # Run when docs OR code change: API/route code can break doc/OpenAPI contract gates. + if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false && (needs.changes.outputs.docs == 'true' || needs.changes.outputs.code == 'true')) }} steps: - uses: actions/checkout@v7 with: @@ -379,19 +390,20 @@ jobs: run: npm run check:openapi-coverage - name: OpenAPI security-tier consistency (advisory) run: npm run check:openapi-security-tiers - - name: OpenAPI spec paths resolve to real routes (anti-hallucination) - run: npm run check:openapi-routes - - name: Doc /api refs resolve to real routes (anti-hallucination) - run: npm run check:docs-symbols + # One FS inventory of src/app/api for both anti-hallucination directions. + - name: API docs refs (openapi + prose → routes) + run: npm run check:api-docs-refs - name: i18n translation drift (warn) run: node scripts/i18n/check-translation-drift.mjs --warn docs-lint: name: Docs Lint (prose — advisory) runs-on: ubuntu-latest + needs: changes # P3 (plano mestre): a release-PR viva fica DRAFT o ciclo inteiro — jobs pesados pulam # drafts (ciclo v3.8.44: 123 runs pesados re-disparados por merges na release, 88 cancelados). - if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }} + # Prose/markdown only — skip when the PR has no doc surface. + if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false && needs.changes.outputs.docs == 'true') }} # Advisory (warning-first): prose/markdown style must not block merges while the # existing doc corpus is brought up to style. Promote to blocking once it converges. continue-on-error: true @@ -418,9 +430,11 @@ jobs: i18n-ui-coverage: name: i18n UI Coverage runs-on: ubuntu-latest + needs: changes # P3 (plano mestre): a release-PR viva fica DRAFT o ciclo inteiro — jobs pesados pulam # drafts (ciclo v3.8.44: 123 runs pesados re-disparados por merges na release, 88 cancelados). - if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }} + # UI keys move with dashboard code OR message catalogs. + if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false && (needs.changes.outputs.i18n == 'true' || needs.changes.outputs.code == 'true')) }} steps: - uses: actions/checkout@v7 with: @@ -440,9 +454,11 @@ jobs: i18n: name: i18n Validation (all languages) runs-on: ubuntu-latest + needs: changes # P3 (plano mestre): a release-PR viva fica DRAFT o ciclo inteiro — jobs pesados pulam # drafts (ciclo v3.8.44: 123 runs pesados re-disparados por merges na release, 88 cancelados). - if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }} + # Message-catalog / i18n-tooling only — pure code without i18n surface skips this lane. + if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false && needs.changes.outputs.i18n == 'true') }} continue-on-error: true steps: - uses: actions/checkout@v7 @@ -961,7 +977,7 @@ jobs: - uses: ./.github/actions/npm-ci-retry - run: npm run check:node-runtime - name: Cache Playwright browsers - uses: actions/cache@v6.1.0 + uses: actions/cache@v6 with: path: ~/.cache/ms-playwright key: playwright-chromium-${{ runner.os }}-${{ hashFiles('package-lock.json') }} diff --git a/.github/workflows/nightly-release-green.yml b/.github/workflows/nightly-release-green.yml index 421c82091a..0ba8e9301d 100644 --- a/.github/workflows/nightly-release-green.yml +++ b/.github/workflows/nightly-release-green.yml @@ -142,7 +142,7 @@ jobs: - name: Upload report artifact if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: release-green-report path: | diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 04d69911ec..f6cdbe4d98 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -21,8 +21,68 @@ env: CI_NODE_VERSION: "24" jobs: + # Same classifier as ci.yml (scripts/quality/classify-pr-changes.mjs) so PR→release + # path filters share existence reasons: code / docs / i18n / workflow. + changes: + name: Change Classification + runs-on: ubuntu-latest + outputs: + code: ${{ steps.classify.outputs.code }} + docs: ${{ steps.classify.outputs.docs }} + i18n: ${{ steps.classify.outputs.i18n }} + workflow: ${{ steps.classify.outputs.workflow }} + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + fetch-depth: 0 + - uses: actions/setup-node@v6 + with: + node-version: ${{ env.CI_NODE_VERSION }} + - id: classify + env: + EVENT_NAME: ${{ github.event_name }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + if [ "$EVENT_NAME" != "pull_request" ]; then + { + echo "code=true" + echo "docs=true" + echo "i18n=true" + echo "workflow=true" + } >> "$GITHUB_OUTPUT" + exit 0 + fi + git diff --name-only "$BASE_SHA" "$HEAD_SHA" > changed-files.txt + node scripts/quality/classify-pr-changes.mjs changed-files.txt >> "$GITHUB_OUTPUT" + + # Docs/OpenAPI contract gates only — existence reason is doc accuracy + route refs. + # Split out of fast-gates so pure-docs PRs skip typecheck/unit while still validating docs. + docs-gates: + name: Docs Gates (fast-path) + needs: changes + if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false && (needs.changes.outputs.docs == 'true' || needs.changes.outputs.code == 'true')) }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/setup-node@v6 + with: + node-version: ${{ env.CI_NODE_VERSION }} + cache: npm + - run: npm ci + # One walk of src/app/api for openapi-routes + docs-symbols (both still fail independently). + - run: npm run check:api-docs-refs + - name: Docs accuracy (fabricated-docs + i18n mirrors, strict) + run: npm run check:docs-all + fast-gates: name: Fast Quality Gates + needs: changes + # Code surface only — pure docs/i18n PRs skip this bag (docs-gates covers docs). + if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false && needs.changes.outputs.code == 'true') }} # Dynamic runner (same rule as ci.yml): use the self-hosted VPS pool only when the # release captain has USE_VPS_RUNNER=true AND this is not a fork PR (own-origin # branches only — a fork PR must never execute on the LAN runner). Var unset/false @@ -44,12 +104,18 @@ jobs: node-version: ${{ env.CI_NODE_VERSION }} cache: npm - run: npm ci + - name: Restore ESLint file cache + uses: actions/cache@v6 + with: + path: | + .eslintcache + .eslintcache-complexity + key: eslint-${{ runner.os }}-${{ hashFiles('eslint.config.mjs', 'eslint.complexity-ratchets.config.mjs', 'config/quality/eslint-suppressions.json', 'package-lock.json') }} + restore-keys: | + eslint-${{ runner.os }}- - run: npm run check:provider-consistency - run: npm run check:fetch-targets - - run: npm run check:openapi-routes - - run: npm run check:docs-symbols - - name: Docs accuracy (fabricated-docs + i18n mirrors, strict) - run: npm run check:docs-all + # docs-all / openapi-routes / docs-symbols live in docs-gates (path-filtered). - run: npm run check:deps - run: npm run check:file-size - run: npm run check:error-helper @@ -72,25 +138,21 @@ jobs: # leaking into the npm tarball (v3.8.36: 6 ops bin/*.sh) per-PR instead of only on # the release PR's heavy Package Artifact job. - run: npm run check:pack-policy - # Complexity + cognitive-complexity ratchets on the fast-path (PR→release) so - # cycle drift is rebaselined PER-PR instead of cascading onto the release PR's - # Quality Ratchet (v3.8.36: +30 complexity / +15 cognitive surfaced only post-merge). - - run: npm run check:complexity - - run: npm run check:cognitive-complexity + # Complexity + cognitive-complexity: ONE ESLint walk (both baselines still + # enforced separately by ruleId). Avoids two cold tree walks on fast-path. + - run: npm run check:complexity-ratchets - name: Typecheck (core) run: npm run typecheck:core # TIA: build the impact map at runtime (gitignored, ~21MB) and run only the - # unit tests impacted by this PR's changed files. Fail-safe runs the FULL - # unit suite on hub/unmapped changes — TIA accelerates, never replaces, the net. + # unit tests impacted by this PR's changed files. On hub/unmapped changes the + # selector returns __RUN_ALL__ — full-suite authority is the parallel + # `fast-unit` 4-shard job (test:unit:ci:shard; was 2-shard, #6781), NOT an + # unsharded re-run here. Stacking unsharded test:unit:ci on top of fast-unit + # doubled wall time (~16 min extra on ubuntu-latest) without extra coverage. # - # BLOCKING (flipped 2026-06-17). The pre-existing release unit test-debt that kept - # this advisory was cleared: #4030 (16 Zod/registry reds, lossless restore) and - # #4063 (the last red — the LiveWS boot test — root-caused as a real event-loop - # stall in the WS sidecar, fixed + relocated to the integration suite). A full - # ci.yml run on release/v3.8.28 then showed all 8 unit shards green, so PR->release - # now blocks on unit-test regressions in the impacted set (typecheck:core already - # blocked above). Fail-safe still runs the FULL unit suite on hub/unmapped changes. - - name: Impacted unit tests (TIA, fail-safe full; blocking) + # BLOCKING for the *impacted subset* (flipped 2026-06-17). Fail-safe full + # coverage remains required via `Unit Tests fast-path` (fast-unit). + - name: Impacted unit tests (TIA subset; blocking) env: GITHUB_BASE_REF: ${{ github.base_ref }} run: | @@ -104,7 +166,9 @@ jobs: # which must not happen on a blocking gate. DATA_DIR isolation keeps the parallel # run race-free regardless of concurrency. if echo "$SEL" | grep -q "__RUN_ALL__"; then - echo "Fail-safe: running FULL unit suite (CI concurrency)"; npm run test:unit:ci; exit $? + echo "Fail-safe: __RUN_ALL__ — deferring FULL unit suite to fast-unit (4-shard)." + echo "Not re-running unsharded test:unit:ci here (duplicate of fast-unit coverage)." + exit 0 fi echo "Running impacted tests:"; echo "$SEL" mapfile -t FILES <<< "$SEL" @@ -131,6 +195,8 @@ jobs: fast-vitest: name: Vitest (fast-path) + needs: changes + if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false && needs.changes.outputs.code == 'true') }} # Dynamic runner — see fast-gates (own-origin + flag; fork/unset → ubuntu-latest). runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)) && fromJSON('["self-hosted","omni-release"]') || 'ubuntu-latest' }} env: @@ -150,11 +216,13 @@ jobs: fast-unit: name: Unit Tests fast-path (${{ matrix.shard }}/4) + needs: changes + if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false && needs.changes.outputs.code == 'true') }} # Dynamic runner — see fast-gates (own-origin + flag; fork/unset → ubuntu-latest). - # This is the heaviest fast-path job; 4-way sharding (was 2) halves the critical - # path again (~8.5min → ~4.5min on ubuntu-latest; ~2min on the 8-slot runner box). - # Node's native --test-shard=N/total takes any denominator — only this matrix and - # the TEST_SHARD env below encode the shard count. + # This is the heaviest fast-path job; 4-way sharding (was 2, #6781) halves the + # critical path again (~8.5min → ~4.5min on ubuntu-latest; ~2min on the 8-slot + # runner box). Node's native --test-shard=N/total takes any denominator — only + # this matrix and the TEST_SHARD env below encode the shard count. runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)) && fromJSON('["self-hosted","omni-release"]') || 'ubuntu-latest' }} strategy: fail-fast: false @@ -194,6 +262,8 @@ jobs: # contribuidor NUNCA é bloqueado nem cobrado). lint-guard: name: No new ESLint warnings + needs: changes + if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false && needs.changes.outputs.code == 'true') }} runs-on: ubuntu-latest continue-on-error: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true }} steps: @@ -205,8 +275,18 @@ jobs: node-version: ${{ env.CI_NODE_VERSION }} cache: npm - run: npm ci + - name: Restore ESLint file cache + uses: actions/cache@v6 + with: + path: | + .eslintcache + .eslintcache-complexity + key: eslint-${{ runner.os }}-${{ hashFiles('eslint.config.mjs', 'eslint.complexity-ratchets.config.mjs', 'config/quality/eslint-suppressions.json', 'package-lock.json') }} + restore-keys: | + eslint-${{ runner.os }}- - name: ESLint (baseline congelado — warning novo = vermelho) - run: npx eslint . --suppressions-location config/quality/eslint-suppressions.json --max-warnings 0 + # lint:json writes the report; --max-warnings 0 keeps no-new-warnings policy. + run: npm run lint:json -- --max-warnings 0 # Merge-integrity: pega no PR os dois vazamentos crônicos de merge que hoje só # explodem na release-PR. (1) CHANGELOG-eat — o auto-resolve do merge come @@ -221,6 +301,8 @@ jobs: # nunca é bloqueado. merge-integrity: name: Merge integrity (changelog + generated skills) + # Always on non-draft PRs — CHANGELOG/skills can break on docs-only merges too. + if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }} runs-on: ubuntu-latest continue-on-error: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true }} env: diff --git a/.gitignore b/.gitignore index a91758f691..67d69b3d5d 100644 --- a/.gitignore +++ b/.gitignore @@ -234,3 +234,11 @@ omniroute.md mise.toml _artifacts/ .claude-flow/ + +# ESLint file cache (npm run lint --cache / complexity ratchets) +.eslintcache +.eslintcache-complexity + + +# CI/local quality artifacts (eslint-results.json, etc.) +.artifacts/ diff --git a/.husky/pre-push b/.husky/pre-push index 32b08fb504..6ac944236b 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -1,7 +1,9 @@ #!/usr/bin/env sh -# .husky/pre-push — fast deterministic gates (<10s total) -# Intentionally excludes test:unit (slow; covered by CI pre-push remote run). -# Activated: 2026-06-13 (6A.12 — replaced commented-out test:unit stub) +# .husky/pre-push — intentionally light. +# any-budget + tracked-artifacts already run on pre-commit; re-running them on +# every push only doubles local wall time for the same existence reason (CI still +# enforces both). Keep this hook as a PATH/npm sanity check + reminder. +# Intentionally excludes test:unit / typecheck (slow; covered by CI). if ! command -v npm >/dev/null 2>&1; then echo "⚠️ npm not found in PATH — skipping pre-push hooks" @@ -9,4 +11,5 @@ if ! command -v npm >/dev/null 2>&1; then exit 0 fi -npm run check:any-budget:t11 && npm run check:tracked-artifacts +# No-op success: real local gates live in pre-commit; CI owns the rest. +exit 0 diff --git a/CLAUDE.md b/CLAUDE.md index d642dfbb6c..cafcce7ff0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -428,8 +428,10 @@ git push -u origin feat/your-feature **Husky hooks**: -- **pre-commit**: lint-staged + `check-docs-sync` + `check:any-budget:t11` -- **pre-push**: fast deterministic gates (`check:any-budget:t11` + `check:tracked-artifacts`); intentionally excludes `test:unit` (slow — covered by the CI `test-unit` job). Activated 2026-06-13 (Quality Gates Fase 6A.12). +- **pre-commit**: lint-staged + `check-docs-sync` + `check:any-budget:t11` + `check:tracked-artifacts` +- **pre-push**: intentionally light (PATH/npm sanity only). `any-budget` + `tracked-artifacts` + already run on pre-commit; re-running them on every push was pure double-pay. CI still + enforces both. (Was Fase 6A.12 full pre-push gate; folded into pre-commit in #6716.) ### Worktree isolation (MANDATORY for every development task) diff --git a/docs/architecture/QUALITY_GATES.md b/docs/architecture/QUALITY_GATES.md index 0b0c977012..3686b9eef4 100644 --- a/docs/architecture/QUALITY_GATES.md +++ b/docs/architecture/QUALITY_GATES.md @@ -34,7 +34,7 @@ Runs on every PR to `main`. Blocks merge on failure. | `audit:deps` | `npm audit` (root + electron) — no high/critical advisories (overlaps osv `check:vuln-ratchet`; see Rationalization Backlog) | Yes | | `check:lockfile` | `package-lock.json` integrity — https registry, integrity hashes, no host overrides | Yes | | `check:licenses` | SPDX license allowlist for production dependencies | Yes | -| `check:tracked-artifacts` | No build artifacts / committed `node_modules` symlinks (also runs in husky pre-push) | Yes | +| `check:tracked-artifacts` | No build artifacts / committed `node_modules` symlinks (also runs in husky pre-commit; pre-push is intentionally light — #6716) | Yes | | `check:file-size` | No source file exceeds the per-extension cap (ratchet: frozen large files in `frozen` list) | Yes | | `check:error-helper` | Error responses in executors/handlers use `buildErrorBody()` / `sanitizeErrorMessage()` (Hard Rule #12) | Yes | | `check:migration-numbering` | Migration SQL files are sequentially numbered, no gaps or duplicates | Yes | @@ -259,9 +259,10 @@ several "obvious" merges turned out to hide debt and are **not** clean drop-ins. - **`check:docs-sync` runs twice** — standalone in the `lint` job and again inside `check:docs-all` (`docs-sync-strict`) and the husky pre-commit hook. ✅ **DONE** — standalone `lint` invocation removed. - **CVE scanning** — ❌ **NOT a clean merge.** `audit:deps` hard-fails on any high/critical CVE; `check:vuln-ratchet` (osv) only fails on a _regression_ vs baseline (currently 1 MODERATE). Different semantics — dropping `audit:deps` would lose the absolute high/critical gate. Keep both. - **Cycle detection** — ❌ **NOT a clean merge.** `check:circular-deps` (dpdm) reports **91 cycles** (that is why it is advisory); it cannot be promoted to blocking without first resolving them, and it has a broader scope than the green, curated `check:cycles`. Keep `check:cycles` blocking; resolving the 91 dpdm cycles is its own backlog. -- **Complexity** — ⏳ valid but real surgery. `check:complexity` (core ESLint) + `check:cognitive-complexity` (sonarjs) are two ESLint passes over `src` + `open-sse`; merging into one config emitting both metrics needs careful ratchet re-wiring. Deferred. -- **`/api` anti-hallucination** — ⏳ valid but script surgery. `check:openapi-routes` (spec→route) + `check:docs-symbols` (prose→route) share resolution logic; collapsing them is a non-trivial script change. Deferred. +- **Complexity** — ✅ **DONE** (`check:complexity-ratchets` / `eslint.complexity-ratchets.config.mjs`): one ESLint walk, counts by ruleId so cyclomatic+max-lines and cognitive baselines stay independent; individual `check:complexity` / `check:cognitive-complexity` remain for local `--update`. +- **`/api` anti-hallucination** — ✅ **DONE** (`check:api-docs-refs` + `scripts/check/lib/apiRoutes.mjs`): one FS inventory of `src/app/api`, openapi-routes + docs-symbols still report independently; individuals remain for local runs. - **`check:node-runtime` runs in 11 jobs** — ⚠️ **low ROI.** Each is a separate runner and the check is <1s; total savings ~10s, against losing a cheap per-job guard. Not worth the churn. +- **`typecheck:noimplicit:core` on CI lint** — ✅ **removed from lint job** (was advisory `continue-on-error`); blocking type surface is `typecheck:core` + `check:type-coverage`. Local script retained. ### Flip / decide (operator policy) diff --git a/eslint.complexity-ratchets.config.mjs b/eslint.complexity-ratchets.config.mjs new file mode 100644 index 0000000000..af29755fdf --- /dev/null +++ b/eslint.complexity-ratchets.config.mjs @@ -0,0 +1,76 @@ +// eslint.complexity-ratchets.config.mjs +// STANDALONE flat config for BOTH complexity ratchets in ONE ESLint walk: +// - ESLint core: complexity + max-lines-per-function (src, open-sse, electron, bin) +// - sonarjs/cognitive-complexity (src, open-sse only) +// +// Existence reason: two independent baselines (complexity-baseline.json + +// quality-baseline metrics.cognitiveComplexity) must stay isolatable from the +// main lint warning budget — but they do NOT need two cold tree walks. +// +// Counts are taken by ruleId in the check scripts (never file.errorCount), so +// cognitive violations cannot inflate the cyclomatic/max-lines ratchet. +import tseslint from "typescript-eslint"; +import sonarjs from "eslint-plugin-sonarjs"; + +const SHARED_LANGUAGE = { + parser: tseslint.parser, + parserOptions: { + ecmaVersion: 2022, + sourceType: "module", + ecmaFeatures: { jsx: true }, + }, +}; + +const SHARED_LINTER = { + noInlineConfig: true, + reportUnusedDisableDirectives: "off", +}; + +const SHARED_IGNORES = { + ignores: [ + "**/*.test.ts", + "**/*.test.tsx", + "**/__tests__/**", + "**/*.d.ts", + "node_modules/**", + "electron/node_modules/**", + "electron/dist-electron/**", + ".next/**", + ".build/**", + "dist/**", + "coverage/**", + ], +}; + +/** @type {import("eslint").Linter.Config[]} */ +const config = [ + { + files: [ + "src/**/*.{ts,tsx}", + "open-sse/**/*.{ts,tsx}", + "electron/**/*.{ts,tsx}", + "bin/**/*.{ts,tsx}", + ], + languageOptions: SHARED_LANGUAGE, + linterOptions: SHARED_LINTER, + rules: { + complexity: ["error", 15], + "max-lines-per-function": [ + "error", + { max: 80, skipBlankLines: true, skipComments: true }, + ], + }, + }, + { + files: ["src/**/*.{ts,tsx}", "open-sse/**/*.{ts,tsx}"], + languageOptions: SHARED_LANGUAGE, + plugins: { sonarjs }, + linterOptions: SHARED_LINTER, + rules: { + "sonarjs/cognitive-complexity": ["error", 15], + }, + }, + SHARED_IGNORES, +]; + +export default config; diff --git a/package.json b/package.json index 92d759d7eb..2d6fbb04c2 100644 --- a/package.json +++ b/package.json @@ -87,7 +87,8 @@ "build:release": "rm -rf .build dist && OMNIROUTE_BUILD_SHA=$(git rev-parse --short HEAD) npm run build && npm run build:cli && node scripts/build/write-build-sha.mjs", "build:native:tproxy": "cd src/mitm/tproxy/native && npx --yes node-gyp rebuild", "start": "node scripts/dev/run-next.mjs start", - "lint": "eslint . --suppressions-location config/quality/eslint-suppressions.json", + "lint": "eslint . --cache --cache-location .eslintcache --suppressions-location config/quality/eslint-suppressions.json", + "lint:json": "node scripts/quality/run-eslint-json.mjs", "lint:md": "npx --yes markdownlint-cli2 \"docs/**/*.md\" \"*.md\" \"!docs/i18n\" \"!docs/research\"", "lint:prose": "vale docs", "electron:dev": "concurrently \"npm run dev\" \"wait-on http://localhost:20128 && cd electron && npm run dev\"", @@ -136,6 +137,7 @@ "check:provider-assets": "node scripts/check/check-provider-assets.mjs", "check:fetch-targets": "node scripts/check/check-fetch-targets.mjs", "check:openapi-routes": "node scripts/check/check-openapi-routes.mjs", + "check:api-docs-refs": "node scripts/check/check-api-docs-refs.mjs", "check:openapi-breaking": "node scripts/check/check-openapi-breaking.mjs", "check:deps": "node scripts/check/check-deps.mjs", "check:file-size": "node scripts/check/check-file-size.mjs", @@ -159,6 +161,7 @@ "check:complexity": "node scripts/check/check-complexity.mjs", "check:dead-code": "node scripts/check/check-dead-code.mjs", "check:cognitive-complexity": "node scripts/check/check-cognitive-complexity.mjs", + "check:complexity-ratchets": "node scripts/check/check-complexity-ratchets.mjs", "check:release-green": "node scripts/quality/validate-release-green.mjs", "check:type-coverage": "node scripts/check/check-type-coverage.mjs", "check:lockfile": "node scripts/check/check-lockfile.mjs", diff --git a/scripts/check/check-api-docs-refs.mjs b/scripts/check/check-api-docs-refs.mjs new file mode 100644 index 0000000000..44b4df7609 --- /dev/null +++ b/scripts/check/check-api-docs-refs.mjs @@ -0,0 +1,32 @@ +#!/usr/bin/env node +/** + * Combined anti-hallucination gate: OpenAPI paths + docs prose /api refs. + * + * Existence reasons (both still enforced): + * - openapi-routes: invented/obsolete paths in docs/openapi.yaml + * - docs-symbols: invented/obsolete /api paths in docs markdown * + * Shared walk of src/app/api (lib/apiRoutes.mjs) — one filesystem inventory, + * two independent failure messages. Prefer this on docs-gates CI; individual + * scripts remain for targeted local runs. + */ +import { pathToFileURL } from "node:url"; +import { collectApiRouteFiles, collectApiRouteUrlPaths } from "./lib/apiRoutes.mjs"; +import { runOpenapiRoutesCheck } from "./check-openapi-routes.mjs"; +import { runDocsSymbolsCheck } from "./check-docs-symbols.mjs"; + +function main() { + const implPaths = collectApiRouteUrlPaths(); + const routeFiles = collectApiRouteFiles(); + + const openapi = runOpenapiRoutesCheck({ implPaths }); + const docs = runDocsSymbolsCheck({ routeFiles }); + + if (openapi.ok) console.log(openapi.message); + else console.error(openapi.message); + if (docs.ok) console.log(docs.message); + else console.error(docs.message); + + process.exit(openapi.ok && docs.ok ? 0 : 1); +} + +if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main(); diff --git a/scripts/check/check-cognitive-complexity.mjs b/scripts/check/check-cognitive-complexity.mjs index 663fd8eb44..0d54bb2b52 100644 --- a/scripts/check/check-cognitive-complexity.mjs +++ b/scripts/check/check-cognitive-complexity.mjs @@ -1,32 +1,21 @@ #!/usr/bin/env node // scripts/check/check-cognitive-complexity.mjs // Ratchet bloqueante para complexidade cognitiva (sonarjs/cognitive-complexity). -// Fase 7 INT: promovido de ADVISORY para RATCHET. -// -// Roda o ESLint sobre src+open-sse usando um config flat STANDALONE -// (eslint.sonarjs.config.mjs) que liga APENAS `sonarjs/cognitive-complexity` — -// mantendo a contagem ISOLADA do orçamento de warnings do lint principal. -// -// Lê o baseline de quality-baseline.json (metrics.cognitiveComplexity). -// Falha com exit 1 se a contagem SUBIR. Suporta --update. -// -// Saída canônica: cognitiveComplexity=N (parseable por collect-metrics.mjs) -// -// Uso: -// node scripts/check/check-cognitive-complexity.mjs -// node scripts/check/check-cognitive-complexity.mjs --quiet # só a linha canônica -// node scripts/check/check-cognitive-complexity.mjs --update # ratcheta baseline se melhorou -import { execFileSync } from "node:child_process"; +// Shares ESLint walk with check-complexity via complexityEslintReport.mjs. import fs from "node:fs"; import path from "node:path"; import { pathToFileURL } from "node:url"; +import { + countCognitiveViolations, + getComplexityEslintReport, +} from "./complexityEslintReport.mjs"; + +// Re-export for existing unit tests. +export { countCognitiveViolations }; const ROOT = process.cwd(); const QUIET = process.argv.includes("--quiet"); const UPDATE = process.argv.includes("--update"); -const CONFIG_PATH = path.join(ROOT, "eslint.sonarjs.config.mjs"); - -const ESLINT_BIN = path.join(ROOT, "node_modules", ".bin", "eslint"); const BASELINE_PATH = path.resolve( process.argv.includes("--baseline") @@ -34,43 +23,8 @@ const BASELINE_PATH = path.resolve( : path.join(ROOT, "config/quality/quality-baseline.json") ); -const ESLINT_ARGS = [ - "--no-config-lookup", - "--config", - CONFIG_PATH, - "--format", - "json", - "src", - "open-sse", -]; - -/** - * Parses the ESLint JSON output (array of file results) and counts total - * `sonarjs/cognitive-complexity` violations. - * - * Exported so unit tests can call it directly with synthetic data. - * - * @param {Array<{messages: Array<{ruleId: string}>}>} report - * @returns {number} - */ -export function countCognitiveViolations(report) { - let count = 0; - for (const file of report) { - for (const msg of file.messages) { - if (msg.ruleId === "sonarjs/cognitive-complexity") { - count++; - } - } - } - return count; -} - /** * Avalia a contagem atual de violações cognitivas contra o baseline. - * Direction: down (contagem só pode CAIR). - * - * Exported for unit testing. - * * @param {number} current * @param {number} baseline * @returns {{ regressed: boolean, improved: boolean }} @@ -82,22 +36,6 @@ export function evaluateCognitiveComplexity(current, baseline) { }; } -function runEslint() { - let stdout; - try { - stdout = execFileSync(ESLINT_BIN, ESLINT_ARGS, { - encoding: "utf8", - maxBuffer: 64 * 1024 * 1024, - }); - } catch (err) { - // ESLint exits non-zero when there are lint errors; the JSON report is still - // in stdout. Re-throw only if there is no parseable output. - stdout = err.stdout ? String(err.stdout) : ""; - if (!stdout.trim()) throw err; - } - return JSON.parse(stdout); -} - function main() { if (!fs.existsSync(BASELINE_PATH)) { process.stderr.write( @@ -116,10 +54,9 @@ function main() { } const baselineValue = baselineMetric.value; - const report = runEslint(); + const report = getComplexityEslintReport(); const count = countCognitiveViolations(report); - // Canonical machine-readable output consumed by collect-metrics.mjs and shell scripts. console.log(`cognitiveComplexity=${count}`); if (!QUIET) { diff --git a/scripts/check/check-complexity-ratchets.mjs b/scripts/check/check-complexity-ratchets.mjs new file mode 100644 index 0000000000..22bcc02fef --- /dev/null +++ b/scripts/check/check-complexity-ratchets.mjs @@ -0,0 +1,105 @@ +#!/usr/bin/env node +/** + * One ESLint walk → both complexity ratchets. + * + * Existence reasons (unchanged): + * - cyclomatic + max-lines vs complexity-baseline.json + * - cognitive-complexity vs quality-baseline metrics.cognitiveComplexity + * + * CI should call this instead of sequential check:complexity + check:cognitive + * so PR→release / quality-gate pay for one tree walk, not two. + */ +import fs from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { evaluateComplexity } from "./check-complexity.mjs"; +import { evaluateCognitiveComplexity } from "./check-cognitive-complexity.mjs"; +import { + countCognitiveViolations, + countComplexityViolations, + getComplexityEslintReport, +} from "./complexityEslintReport.mjs"; + +const ROOT = process.cwd(); +const UPDATE = process.argv.includes("--update"); + +const COMPLEXITY_BASELINE = path.resolve( + process.argv.includes("--baseline") + ? process.argv[process.argv.indexOf("--baseline") + 1] + : path.join(ROOT, "config/quality/complexity-baseline.json") +); +const QUALITY_BASELINE = path.join(ROOT, "config/quality/quality-baseline.json"); + +function main() { + if (!fs.existsSync(COMPLEXITY_BASELINE)) { + console.error(`[complexity-ratchets] FAIL — complexity-baseline.json ausente.`); + process.exit(2); + } + if (!fs.existsSync(QUALITY_BASELINE)) { + console.error(`[complexity-ratchets] FAIL — quality-baseline.json ausente.`); + process.exit(2); + } + + const report = getComplexityEslintReport(); + const complexityCount = countComplexityViolations(report); + const cognitiveCount = countCognitiveViolations(report); + + // Machine-readable lines for collect-metrics / scripts + console.log(`complexity=${complexityCount}`); + console.log(`cognitiveComplexity=${cognitiveCount}`); + + const complexityBaseline = JSON.parse(fs.readFileSync(COMPLEXITY_BASELINE, "utf8")); + const qualityBaseline = JSON.parse(fs.readFileSync(QUALITY_BASELINE, "utf8")); + const cognitiveMetric = qualityBaseline.metrics?.cognitiveComplexity; + if (!cognitiveMetric || typeof cognitiveMetric.value !== "number") { + console.error( + "[complexity-ratchets] FAIL — metrics.cognitiveComplexity ausente em quality-baseline.json." + ); + process.exit(2); + } + + const cyc = evaluateComplexity(complexityCount, complexityBaseline.count); + const cog = evaluateCognitiveComplexity(cognitiveCount, cognitiveMetric.value); + + if (UPDATE && cyc.improved) { + console.log( + `[complexity] baseline ratcheado: ${complexityCount} (era ${complexityBaseline.count})` + ); + complexityBaseline.count = complexityCount; + fs.writeFileSync(COMPLEXITY_BASELINE, JSON.stringify(complexityBaseline, null, 2) + "\n"); + } + if (UPDATE && cog.improved) { + console.log( + `[cognitive-complexity] baseline ratcheado: ${cognitiveCount} (era ${cognitiveMetric.value})` + ); + qualityBaseline.metrics.cognitiveComplexity.value = cognitiveCount; + fs.writeFileSync(QUALITY_BASELINE, JSON.stringify(qualityBaseline, null, 2) + "\n"); + } + + let failed = false; + if (cyc.regressed) { + console.error( + `[complexity] REGRESSÃO — ${complexityCount} violações > baseline ${complexityBaseline.count}` + ); + failed = true; + } else { + console.log( + `[complexity] OK — ${complexityCount} violações (baseline ${complexityBaseline.count})` + ); + } + + if (cog.regressed) { + console.error( + `[cognitive-complexity] REGRESSÃO — ${cognitiveCount} violações > baseline ${cognitiveMetric.value}` + ); + failed = true; + } else { + console.log( + `[cognitive-complexity] OK — ${cognitiveCount} violações (baseline ${cognitiveMetric.value})` + ); + } + + process.exit(failed ? 1 : 0); +} + +if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main(); diff --git a/scripts/check/check-complexity.mjs b/scripts/check/check-complexity.mjs index 2e0c89938b..af79c706dd 100644 --- a/scripts/check/check-complexity.mjs +++ b/scripts/check/check-complexity.mjs @@ -1,19 +1,17 @@ #!/usr/bin/env node // scripts/check/check-complexity.mjs -// Catraca de complexidade de código. Roda o ESLint sobre src+open-sse usando um config -// flat STANDALONE (eslint.complexity.config.mjs) que liga APENAS duas regras CORE do -// ESLint — `complexity` (ciclomática) e `max-lines-per-function` (tamanho de função) — -// e compara a contagem total de violações contra um baseline congelado -// (complexity-baseline.json). Falha se a contagem SUBIR. Completa a dimensão -// "complexity" do snapshot de qualidade, ao lado de duplicação/tamanho-de-arquivo. -// -// O config dedicado evita poluir a contagem de warnings do lint principal (ratcheada -// em exatamente 3482): este gate roda isolado, com seu próprio par de regras. --update -// ratcheta (a contagem só pode CAIR). +// Catraca de complexidade de código (cyclomatic + max-lines-per-function). +// Shares one ESLint walk with cognitive-complexity via complexityEslintReport.mjs +// / eslint.complexity-ratchets.config.mjs. Counts by ruleId so cognitive +// violations never inflate this baseline. import fs from "node:fs"; import path from "node:path"; -import { execFileSync } from "node:child_process"; import { pathToFileURL } from "node:url"; +import { + ESLINT_ARGS, + countComplexityViolations, + getComplexityEslintReport, +} from "./complexityEslintReport.mjs"; const ROOT = process.cwd(); const BASELINE_PATH = path.resolve( @@ -22,24 +20,9 @@ const BASELINE_PATH = path.resolve( : path.join(ROOT, "config/quality/complexity-baseline.json") ); const UPDATE = process.argv.includes("--update"); -const CONFIG_PATH = path.join(ROOT, "eslint.complexity.config.mjs"); -// Exported for the gate's own unit test (tests/unit/build/check-complexity.test.ts), which -// locks the scan scope to the one documented in eslint.complexity.config.mjs `files` and in -// complexity-baseline.json. The positional paths MUST match that scope (src+open-sse+electron+bin) -// — ESLint flat config only walks the directories passed here, so a `files` glob for bin/electron -// is inert unless the directory is also passed as a positional argument. -export const ESLINT_ARGS = [ - "eslint", - "--no-config-lookup", - "--config", - CONFIG_PATH, - "--format", - "json", - "src", - "open-sse", - "electron", - "bin", -]; + +// Re-export for tests that lock scan scope (src+open-sse+electron+bin). +export { ESLINT_ARGS }; /** Avalia a contagem atual de violações contra o baseline. */ export function evaluateComplexity(current, baseline) { @@ -50,20 +33,7 @@ export function evaluateComplexity(current, baseline) { } function measureComplexityCount() { - let stdout; - try { - stdout = execFileSync("npx", ["--yes", ...ESLINT_ARGS], { - encoding: "utf8", - maxBuffer: 64 * 1024 * 1024, - }); - } catch (err) { - // ESLint sai com código !=0 quando há erros (e nossas regras são "error"); o relatório - // JSON ainda vai no stdout. Só relançamos se não houver stdout parseável. - stdout = err.stdout ? String(err.stdout) : ""; - if (!stdout.trim()) throw err; - } - const report = JSON.parse(stdout); - return report.reduce((sum, file) => sum + file.errorCount, 0); + return countComplexityViolations(getComplexityEslintReport()); } function main() { diff --git a/scripts/check/check-docs-symbols.mjs b/scripts/check/check-docs-symbols.mjs index e5a97d7934..a3528b2483 100644 --- a/scripts/check/check-docs-symbols.mjs +++ b/scripts/check/check-docs-symbols.mjs @@ -19,11 +19,11 @@ import fs from "node:fs"; import path from "node:path"; import { pathToFileURL } from "node:url"; -import { assertNoStale } from "./lib/allowlist.mjs"; +import { reportStaleEntries } from "./lib/allowlist.mjs"; +import { collectApiRouteFiles } from "./lib/apiRoutes.mjs"; const ROOT = process.cwd(); const DOCS = path.join(ROOT, "docs"); -const API = path.join(ROOT, "src/app/api"); // Padrões que NÃO são rotas internas do OmniRoute (ruído estrutural, não drift). // Adicione aqui (com justificativa) em vez da allowlist quando uma categoria gera @@ -73,10 +73,9 @@ function walk(dir, filter, acc = []) { return acc; } -export function collectRouteFiles() { - return new Set( - walk(API, (n) => /^route\.tsx?$/.test(n)).map((p) => path.relative(ROOT, p).replace(/\\/g, "/")) - ); +/** @deprecated prefer collectApiRouteFiles from lib/apiRoutes.mjs — re-export for tests. */ +export function collectRouteFiles(root = ROOT) { + return collectApiRouteFiles(root); } /** Normaliza um segmento dinâmico ({param} / [param] / [...param] / :param) para wildcard. */ @@ -177,45 +176,67 @@ export function findStaleDocApiRefs(docPathsByFile, routeFiles, allowlist) { return misses; } -function main() { - const routeFiles = collectRouteFiles(); +/** + * @param {{ root?: string, routeFiles?: Set }} [opts] + * @returns {{ ok: boolean, exitCode: number, message: string }} + */ +export function runDocsSymbolsCheck(opts = {}) { + const root = opts.root || ROOT; + const docsDir = path.join(root, "docs"); + const routeFiles = opts.routeFiles || collectApiRouteFiles(root); // docs/i18n/** são espelhos auto-gerados das docs canônicas — validar só o canônico // evita 40× de ruído duplicado (e os mirrors herdam qualquer fix do canônico). // docs/superpowers/** são planos internos de implementação (snapshots históricos // de intenção — podem citar rotas planejadas/abandonadas), não claims sobre o // código atual; fora do escopo do gate (drift surgiu no ciclo v3.8.18). - const docFiles = walk(DOCS, (n) => /\.md$/.test(n)).filter((f) => { - const rel = path.relative(ROOT, f).replace(/\\/g, "/"); + const docFiles = walk(docsDir, (n) => /\.md$/.test(n)).filter((f) => { + const rel = path.relative(root, f).replace(/\\/g, "/"); return !rel.startsWith("docs/i18n/") && !rel.startsWith("docs/superpowers/"); }); const docPathsByFile = docFiles.map((f) => ({ - file: path.relative(ROOT, f).replace(/\\/g, "/"), + file: path.relative(root, f).replace(/\\/g, "/"), paths: extractDocApiPaths(fs.readFileSync(f, "utf8")), })); - // Live misses BEFORE allowlist filtering — used for stale-enforcement. - // The paths (not "file → path" strings) are the unit that the allowlist keys on. const allMisses = findStaleDocApiRefs(docPathsByFile, routeFiles, new Set()); const liveMissPaths = allMisses.map((m) => m.split(" → ")[1]); - assertNoStale(KNOWN_STALE_DOC_REFS, liveMissPaths, "check-docs-symbols"); - + const stale = reportStaleEntries(KNOWN_STALE_DOC_REFS, liveMissPaths, "check-docs-symbols"); const misses = findStaleDocApiRefs(docPathsByFile, routeFiles, KNOWN_STALE_DOC_REFS); + + const parts = []; + if (stale.length) { + parts.push( + `[check-docs-symbols] ${stale.length} entrada(s) obsoleta(s) na allowlist ` + + `— a violação foi corrigida; REMOVA a entrada para travar a correção:\n` + + stale.map((e) => ` ✗ ${e}`).join("\n") + ); + } if (misses.length) { - console.error( + parts.push( `[check-docs-symbols] ${misses.length} ref(s) /api em docs sem rota real:\n` + misses.map((m) => " ✗ " + m).join("\n") + `\n → crie o route.ts, corrija o path na doc, ou (se for upstream/placeholder)` + ` adicione um padrão a IGNORE com justificativa. NÃO adicione à allowlist sem` + ` confirmar que é drift pré-existente real.` ); - process.exitCode = 1; } - if (!process.exitCode) { - console.log( + if (parts.length) { + return { ok: false, exitCode: 1, message: parts.join("\n") }; + } + return { + ok: true, + exitCode: 0, + message: `[check-docs-symbols] OK — ${docFiles.length} docs canônicas, ` + - `${routeFiles.size} rotas conhecidas, ${KNOWN_STALE_DOC_REFS.size} stale congeladas` - ); - } + `${routeFiles.size} rotas conhecidas, ${KNOWN_STALE_DOC_REFS.size} stale congeladas`, + }; +} + +function main() { + const result = runDocsSymbolsCheck(); + if (result.ok) console.log(result.message); + else console.error(result.message); + process.exit(result.exitCode); } if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main(); diff --git a/scripts/check/check-env-doc-sync.mjs b/scripts/check/check-env-doc-sync.mjs index 0ecac07688..d6fea05d1e 100644 --- a/scripts/check/check-env-doc-sync.mjs +++ b/scripts/check/check-env-doc-sync.mjs @@ -51,6 +51,9 @@ const IGNORE_FROM_CODE = new Set([ "CI", "GITHUB_ACTIONS", "RUNNER_OS", + // Quality-gate harness knobs (optional cache/report paths for CI scripts — not product config). + "ESLINT_RESULTS_JSON", + "COMPLEXITY_ESLINT_REPORT", // Agent environment / system execution paths. "PROJECT_ROOT", "ARTIFACTS_DIR", diff --git a/scripts/check/check-openapi-coverage.mjs b/scripts/check/check-openapi-coverage.mjs index cc66d0fc6d..0a53f00159 100644 --- a/scripts/check/check-openapi-coverage.mjs +++ b/scripts/check/check-openapi-coverage.mjs @@ -10,9 +10,10 @@ import fs from "node:fs"; import path from "node:path"; import * as yaml from "js-yaml"; +import { apiRoot, collectApiRouteUrlPaths } from "./lib/apiRoutes.mjs"; const ROOT = process.cwd(); -const API_ROOT = path.join(ROOT, "src", "app", "api"); +const API_ROOT = apiRoot(ROOT); const OPENAPI_PATH = path.join(ROOT, "docs", "openapi.yaml"); // Floor recorded on 2026-05-26 for release/v3.8.4: 137/365 routes documented. // The original ≥99% target tracks the OpenAPI audit follow-up (#2701); @@ -21,30 +22,6 @@ const OPENAPI_PATH = path.join(ROOT, "docs", "openapi.yaml"); // instead of the absolute target. Raise this back to 99 once the backlog clears. const THRESHOLD = 36; -function collectRoutePaths(dir) { - const entries = fs.readdirSync(dir, { withFileTypes: true }); - const paths = []; - for (const entry of entries) { - const fullPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - paths.push(...collectRoutePaths(fullPath)); - continue; - } - if (entry.isFile() && entry.name === "route.ts") { - const apiPath = path - .dirname(fullPath) - .replace(API_ROOT, "") - .replace(/\[([^\]]+)\]/g, "{$1}"); - paths.push(`/api${apiPath}`); - } - } - return paths; -} - -function normalizePath(p) { - return p.replace(/\/\[\.\.\.([^\]]+)\]/g, "/{$1}").replace(/\[([^\]]+)\]/g, "{$1}"); -} - if (!fs.existsSync(API_ROOT)) { console.error(`[openapi-coverage] FAIL — API root not found: ${API_ROOT}`); process.exit(1); @@ -55,7 +32,7 @@ if (!fs.existsSync(OPENAPI_PATH)) { process.exit(1); } -const implementedPaths = collectRoutePaths(API_ROOT).map(normalizePath).sort(); +const implementedPaths = collectApiRouteUrlPaths(ROOT).sort(); const raw = yaml.load(fs.readFileSync(OPENAPI_PATH, "utf-8")); const documentedPaths = new Set(Object.keys(raw.paths || {})); diff --git a/scripts/check/check-openapi-routes.mjs b/scripts/check/check-openapi-routes.mjs index db4db2a702..453f687052 100644 --- a/scripts/check/check-openapi-routes.mjs +++ b/scripts/check/check-openapi-routes.mjs @@ -10,10 +10,10 @@ import fs from "node:fs"; import path from "node:path"; import { pathToFileURL } from "node:url"; import * as yaml from "js-yaml"; -import { assertNoStale } from "./lib/allowlist.mjs"; +import { reportStaleEntries } from "./lib/allowlist.mjs"; +import { apiRoot, collectApiRouteUrlPaths } from "./lib/apiRoutes.mjs"; const ROOT = process.cwd(); -const API_ROOT = path.join(ROOT, "src", "app", "api"); const OPENAPI_PATH = path.join(ROOT, "docs", "openapi.yaml"); // Entradas da spec sem rota real, congeladas para triagem (catraca: bloqueia NOVAS). @@ -33,51 +33,68 @@ export function findSpecPathsWithoutRoute(specPaths, implPaths) { return specPaths.filter((p) => !impl.has(normalizeParams(p))); } -function collectRoutePaths(dir) { - const paths = []; - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - const full = path.join(dir, entry.name); - if (entry.isDirectory()) { - paths.push(...collectRoutePaths(full)); - } else if (entry.isFile() && entry.name === "route.ts") { - const apiPath = path - .dirname(full) - .replace(API_ROOT, "") - .replace(/\/\[\.\.\.([^\]]+)\]/g, "/{$1}") - .replace(/\[([^\]]+)\]/g, "{$1}"); - paths.push(`/api${apiPath}`); - } +/** + * @param {{ root?: string, openapiPath?: string, implPaths?: string[] }} [opts] + * @returns {{ ok: boolean, exitCode: number, message: string }} + */ +export function runOpenapiRoutesCheck(opts = {}) { + const root = opts.root || ROOT; + const openapiPath = opts.openapiPath || path.join(root, "docs", "openapi.yaml"); + if (!fs.existsSync(openapiPath)) { + return { + ok: false, + exitCode: 1, + message: `[openapi-routes] FAIL — openapi.yaml não encontrado: ${openapiPath}`, + }; + } + if (!fs.existsSync(apiRoot(root))) { + return { + ok: false, + exitCode: 1, + message: `[openapi-routes] FAIL — API root not found: ${apiRoot(root)}`, + }; } - return paths; -} -function main() { - if (!fs.existsSync(OPENAPI_PATH)) { - console.error(`[openapi-routes] FAIL — openapi.yaml não encontrado: ${OPENAPI_PATH}`); - process.exit(1); - } - const raw = yaml.load(fs.readFileSync(OPENAPI_PATH, "utf-8")); + const raw = yaml.load(fs.readFileSync(openapiPath, "utf-8")); const specPaths = Object.keys(raw.paths || {}).filter((p) => p.startsWith("/api")); - const implPaths = collectRoutePaths(API_ROOT); + const implPaths = opts.implPaths || collectApiRouteUrlPaths(root); - // Live orphans BEFORE allowlist filtering (needed for stale-enforcement). const liveOrphans = findSpecPathsWithoutRoute(specPaths, implPaths); - assertNoStale(KNOWN_STALE_SPEC, liveOrphans, "openapi-routes"); - + const stale = reportStaleEntries(KNOWN_STALE_SPEC, liveOrphans, "openapi-routes"); const orphans = liveOrphans.filter((p) => !KNOWN_STALE_SPEC.has(p)); + + const parts = []; + if (stale.length) { + parts.push( + `[openapi-routes] ${stale.length} entrada(s) obsoleta(s) na allowlist ` + + `— a violação foi corrigida; REMOVA a entrada para travar a correção:\n` + + stale.map((e) => ` ✗ ${e}`).join("\n") + ); + } if (orphans.length) { - console.error( + parts.push( `[openapi-routes] ${orphans.length} path(s) documentado(s) sem rota real:\n` + orphans.map((p) => " ✗ " + p).join("\n") + `\n → crie a rota, corrija/remova a entrada na spec, ou adicione a KNOWN_STALE_SPEC com justificativa.` ); - process.exitCode = 1; } - if (!process.exitCode) { - console.log( - `[openapi-routes] OK — ${specPaths.length} paths na spec, todos com rota real (${implPaths.length} rotas)` - ); + if (parts.length) { + return { ok: false, exitCode: 1, message: parts.join("\n") }; } + return { + ok: true, + exitCode: 0, + message: `[openapi-routes] OK — ${specPaths.length} paths na spec, todos com rota real (${implPaths.length} rotas)`, + }; +} + +function main() { + // Keep assertNoStale side-effect path for CLI parity with other gates when + // runOpenapiRoutesCheck is not used alone — here we print structured result. + const result = runOpenapiRoutesCheck(); + if (result.ok) console.log(result.message); + else console.error(result.message); + process.exit(result.exitCode); } if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main(); diff --git a/scripts/check/complexityEslintReport.mjs b/scripts/check/complexityEslintReport.mjs new file mode 100644 index 0000000000..4c65efa3a3 --- /dev/null +++ b/scripts/check/complexityEslintReport.mjs @@ -0,0 +1,104 @@ +#!/usr/bin/env node +/** + * Shared ESLint runner for complexity + cognitive-complexity ratchets. + * One tree walk → JSON report; consumers count by ruleId (not errorCount). + */ +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const CONFIG_PATH = path.join(ROOT, "eslint.complexity-ratchets.config.mjs"); + +/** Positional dirs — must match config `files` scopes (see check-complexity tests). */ +export const ESLINT_SCAN_DIRS = ["src", "open-sse", "electron", "bin"]; + +const ESLINT_BIN = path.join( + ROOT, + "node_modules", + ".bin", + process.platform === "win32" ? "eslint.cmd" : "eslint" +); + +/** Args after the eslint binary (tests lock scan dirs on this array). */ +export const ESLINT_ARGS = [ + "--no-config-lookup", + "--config", + CONFIG_PATH, + "--format", + "json", + "--cache", + "--cache-location", + ".eslintcache-complexity", + ...ESLINT_SCAN_DIRS, +]; + +const COMPLEXITY_RULES = new Set(["complexity", "max-lines-per-function"]); + +/** + * @param {Array<{messages?: Array<{ruleId?: string}>}>} report + * @returns {number} + */ +export function countComplexityViolations(report) { + let count = 0; + for (const file of report) { + for (const msg of file.messages || []) { + if (COMPLEXITY_RULES.has(msg.ruleId)) count++; + } + } + return count; +} + +/** + * @param {Array<{messages?: Array<{ruleId?: string}>}>} report + * @returns {number} + */ +export function countCognitiveViolations(report) { + let count = 0; + for (const file of report) { + for (const msg of file.messages || []) { + if (msg.ruleId === "sonarjs/cognitive-complexity") count++; + } + } + return count; +} + +/** + * Run ESLint once (or reuse COMPLEXITY_ESLINT_REPORT / in-process cache). + * @returns {Array} + */ +export function getComplexityEslintReport() { + const fromEnv = process.env.COMPLEXITY_ESLINT_REPORT; + if (fromEnv && fs.existsSync(fromEnv)) { + return JSON.parse(fs.readFileSync(fromEnv, "utf8")); + } + if (getComplexityEslintReport._cache) return getComplexityEslintReport._cache; + + let stdout; + try { + // Prefer local bin (Windows-safe); shell only needed for .cmd shims. + stdout = execFileSync(ESLINT_BIN, ESLINT_ARGS, { + cwd: ROOT, + encoding: "utf8", + maxBuffer: 64 * 1024 * 1024, + shell: process.platform === "win32", + }); + } catch (err) { + stdout = err.stdout ? String(err.stdout) : ""; + if (!stdout.trim()) throw err; + } + const report = JSON.parse(stdout); + getComplexityEslintReport._cache = report; + + const outDir = path.join(ROOT, ".artifacts"); + try { + fs.mkdirSync(outDir, { recursive: true }); + fs.writeFileSync(path.join(outDir, "complexity-eslint.json"), stdout); + } catch { + // best-effort cache for sibling steps / local inspection + } + return report; +} + +getComplexityEslintReport._cache = null; diff --git a/scripts/check/lib/apiRoutes.mjs b/scripts/check/lib/apiRoutes.mjs new file mode 100644 index 0000000000..919c0b6ecf --- /dev/null +++ b/scripts/check/lib/apiRoutes.mjs @@ -0,0 +1,82 @@ +/** + * Shared filesystem inventory of Next.js App Router API routes. + * + * Existence reason: openapi-routes (spec→route), docs-symbols (prose→route), + * and openapi-coverage (route→spec %) all need the same walk of src/app/api. + * One collector keeps path normalization consistent and avoids triple walks + * when a combined gate runs them together. + */ +import fs from "node:fs"; +import path from "node:path"; + +/** + * @param {string} [root] repo root + * @returns {string} absolute path to src/app/api + */ +export function apiRoot(root = process.cwd()) { + return path.join(root, "src", "app", "api"); +} + +/** + * Convert a directory under src/app/api (the folder that contains route.ts) + * to an OpenAPI-style /api/... path. + * Dynamic segments: [id] → {id}, [...slug] → {slug}. + * + * @param {string} routeDir absolute directory containing route.ts + * @param {string} apiRootAbs absolute src/app/api + * @returns {string} + */ +export function toApiUrlPath(routeDir, apiRootAbs) { + const rel = path.relative(apiRootAbs, routeDir).replace(/\\/g, "/"); + if (!rel || rel === ".") return "/api"; + const normalized = rel + .replace(/\[\.\.\.([^\]]+)\]/g, "{$1}") + .replace(/\[([^\]]+)\]/g, "{$1}"); + return `/api/${normalized}`; +} + +/** + * Walk src/app/api for route.ts(x) → OpenAPI-style URL paths. + * @param {string} [root] + * @returns {string[]} + */ +export function collectApiRouteUrlPaths(root = process.cwd()) { + const API = apiRoot(root); + if (!fs.existsSync(API)) return []; + const out = []; + function walk(dir) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + walk(full); + } else if (entry.isFile() && /^route\.tsx?$/.test(entry.name)) { + out.push(toApiUrlPath(path.dirname(full), API)); + } + } + } + walk(API); + return out; +} + +/** + * Walk src/app/api → relative repo paths to route.ts (docs-symbols resolver). + * @param {string} [root] + * @returns {Set} + */ +export function collectApiRouteFiles(root = process.cwd()) { + const API = apiRoot(root); + const out = new Set(); + if (!fs.existsSync(API)) return out; + function walk(dir) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + walk(full); + } else if (entry.isFile() && /^route\.tsx?$/.test(entry.name)) { + out.add(path.relative(root, full).replace(/\\/g, "/")); + } + } + } + walk(API); + return out; +} diff --git a/scripts/quality/build-test-impact-map.mjs b/scripts/quality/build-test-impact-map.mjs index 223d23bf2b..0120f95dc6 100644 --- a/scripts/quality/build-test-impact-map.mjs +++ b/scripts/quality/build-test-impact-map.mjs @@ -57,10 +57,14 @@ function sourceDepsOf(entry) { // The TIA step runs the selected subset via `node --test`, so it must NOT include // vitest files (`.test.tsx`, `open-sse/**/__tests__`, `tests/unit/autoCombo`), nor // e2e/integration tests, which can't run under node:test (they 99-false-failed before). +// Mirror EXACTLY the package.json `test:unit` / `test:unit:ci` globs (incl. memory, +// usage, combo, dashboard, serial, and *.test.mjs). Drift here → false __RUN_ALL__. const testFiles = globSync( [ "tests/unit/*.test.ts", - "tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,executors,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui}/**/*.test.ts", + "tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts", + "tests/unit/**/*.test.mjs", + "tests/unit/dashboard/**/*.test.ts", // Quarentena serial (P0.3): também são node:test — a TIA precisa mapeá-los. "tests/unit/serial/**/*.test.ts", ], diff --git a/scripts/quality/classify-pr-changes.mjs b/scripts/quality/classify-pr-changes.mjs new file mode 100644 index 0000000000..619d5511a1 --- /dev/null +++ b/scripts/quality/classify-pr-changes.mjs @@ -0,0 +1,119 @@ +#!/usr/bin/env node +/** + * PR change classification for ci.yml path filters. + * + * Why this exists (not "skip work for free"): + * - code → typecheck, unit/vitest, lint bag, quality ratchets (code regressions) + * - docs → docs-sync / prose (doc/API contract regressions) + * - i18n → message/UI-key validation (translation regressions) + * - workflow → CI definition changes (always treat as code — gates protect the gates) + * + * Pure docs or pure message-catalog PRs should NOT pay full unit/lint wall time. + * Unknown paths default to code (fail-safe: better over-run than under-protect). + */ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +/** + * @param {string[]} files relative paths from git diff + * @returns {{ code: boolean, docs: boolean, i18n: boolean, workflow: boolean }} + */ +export function classifyPaths(files) { + let code = false; + let docs = false; + let i18n = false; + let workflow = false; + + for (const raw of files) { + const f = String(raw || "") + .trim() + .replace(/\\/g, "/"); + if (!f) continue; + + if (f.startsWith(".github/workflows/") || f === ".zizmor.yml") { + workflow = true; + // Workflow edits can weaken or remove gates — treat as code. + code = true; + continue; + } + + // Message catalogs only: translation content, not runtime TS. + if (f.startsWith("src/i18n/messages/")) { + i18n = true; + continue; + } + + // i18n tooling / non-message i18n source → also code (scripts, config, loaders). + if ( + f.startsWith("scripts/i18n/") || + f === "config/i18n.json" || + f.startsWith("src/i18n/") + ) { + i18n = true; + code = true; + continue; + } + + if (f.startsWith("docs/") || f.endsWith(".md")) { + docs = true; + continue; + } + + if ( + f.startsWith("src/") || + f.startsWith("open-sse/") || + f.startsWith("bin/") || + f.startsWith("electron/") || + f.startsWith("tests/") || + f.startsWith("scripts/") || + f.startsWith("db/") || + f.startsWith("config/") || + f === "package.json" || + f === "package-lock.json" || + /^tsconfig.*\.json$/.test(f) || + f.startsWith("next.config.") || + f.startsWith("vitest") || + f.startsWith("playwright.config.") + ) { + code = true; + continue; + } + + // Fail-safe: unknown path class → code (do not skip heavy gates by accident). + code = true; + } + + return { code, docs, i18n, workflow }; +} + +function main() { + const listPath = process.argv[2]; + let files; + if (listPath && listPath !== "-") { + files = fs + .readFileSync(listPath, "utf8") + .split(/\r?\n/) + .map((s) => s.trim()) + .filter(Boolean); + } else { + const stdin = fs.readFileSync(0, "utf8"); + files = stdin + .split(/\r?\n/) + .map((s) => s.trim()) + .filter(Boolean); + } + const c = classifyPaths(files); + // GitHub Actions output format (also human-readable key=value). + process.stdout.write( + `code=${c.code}\ndocs=${c.docs}\ni18n=${c.i18n}\nworkflow=${c.workflow}\n` + ); +} + +const isMain = + process.argv[1] && + path.resolve(fileURLToPath(import.meta.url)) === path.resolve(process.argv[1]); + +if (isMain) { + main(); +} diff --git a/scripts/quality/collect-metrics.mjs b/scripts/quality/collect-metrics.mjs index a1535cbcb0..f02617d2a5 100644 --- a/scripts/quality/collect-metrics.mjs +++ b/scripts/quality/collect-metrics.mjs @@ -23,21 +23,41 @@ const out = {}; // bruto (que driftava +41/+88 por ciclo e era rebaselinado às cegas na release). // O aperto do estoque acontece via --prune-suppressions na release. function eslintCounts() { - let stdout; - const args = ["eslint", ".", "--format", "json"]; - if (fs.existsSync(path.join(cwd, "config/quality/eslint-suppressions.json"))) { - args.push("--suppressions-location", "config/quality/eslint-suppressions.json"); + // Prefer a precomputed JSON report (same existence reason as lint job: inventory + // of net-new warnings vs suppressions). Avoids a second cold full-tree ESLint + // when CI/local already produced the report. + const cached = path.resolve( + cwd, + process.env.ESLINT_RESULTS_JSON || path.join(".artifacts", "eslint-results.json") + ); + let results; + if (fs.existsSync(cached)) { + results = JSON.parse(fs.readFileSync(cached, "utf8")); + } else { + let stdout; + const eslintBin = path.join( + cwd, + "node_modules", + ".bin", + process.platform === "win32" ? "eslint.cmd" : "eslint" + ); + const args = [".", "--format", "json", "--cache", "--cache-location", ".eslintcache"]; + if (fs.existsSync(path.join(cwd, "config/quality/eslint-suppressions.json"))) { + args.push("--suppressions-location", "config/quality/eslint-suppressions.json"); + } + try { + // Prefer local bin (Windows-safe .cmd); shell only when needed for the shim. + stdout = execFileSync(eslintBin, args, { + encoding: "utf8", + maxBuffer: 256 * 1024 * 1024, + shell: process.platform === "win32", + }); + } catch (e) { + // eslint sai com código != 0 quando há errors; o JSON ainda vem no stdout + stdout = e.stdout?.toString() || "[]"; + } + results = JSON.parse(stdout); } - try { - stdout = execFileSync("npx", args, { - encoding: "utf8", - maxBuffer: 256 * 1024 * 1024, - }); - } catch (e) { - // eslint sai com código != 0 quando há errors; o JSON ainda vem no stdout - stdout = e.stdout?.toString() || "[]"; - } - const results = JSON.parse(stdout); out.eslintWarnings = results.reduce((n, r) => n + (r.warningCount || 0), 0); out.eslintErrors = results.reduce((n, r) => n + (r.errorCount || 0), 0); } diff --git a/scripts/quality/run-all-gates.mjs b/scripts/quality/run-all-gates.mjs index 1a12cf7acf..409c8c1d29 100644 --- a/scripts/quality/run-all-gates.mjs +++ b/scripts/quality/run-all-gates.mjs @@ -32,14 +32,14 @@ const GATES = [ { name: "check:public-creds", cmd: ["node", "scripts/check/check-public-creds.mjs"] }, { name: "check:error-helper", cmd: ["node", "scripts/check/check-error-helper.mjs"] }, { name: "check:fetch-targets", cmd: ["node", "scripts/check/check-fetch-targets.mjs"] }, - { name: "check:openapi-routes", cmd: ["node", "scripts/check/check-openapi-routes.mjs"] }, + { name: "check:api-docs-refs", cmd: ["node", "scripts/check/check-api-docs-refs.mjs"] }, { name: "check:deps", cmd: ["node", "scripts/check/check-deps.mjs"] }, // Group C — moderate (<15s) { name: "check:db-rules", cmd: ["node", "scripts/check/check-db-rules.mjs"] }, { name: "check:file-size", cmd: ["node", "scripts/check/check-file-size.mjs"] }, - { name: "check:complexity", cmd: ["node", "scripts/check/check-complexity.mjs"] }, - { name: "check:docs-symbols", cmd: ["node", "scripts/check/check-docs-symbols.mjs"] }, + { name: "check:complexity-ratchets", cmd: ["node", "scripts/check/check-complexity-ratchets.mjs"] }, + // docs-symbols folded into check:api-docs-refs (Group B) { name: "check:known-symbols", cmd: ["node", "--import", "tsx", "scripts/check/check-known-symbols.ts"] }, { name: "check:route-guard-membership", cmd: ["node", "--import", "tsx", "scripts/check/check-route-guard-membership.ts"] }, { name: "check:test-discovery", cmd: ["node", "scripts/check/check-test-discovery.mjs"] }, diff --git a/scripts/quality/run-eslint-json.mjs b/scripts/quality/run-eslint-json.mjs new file mode 100644 index 0000000000..cbefa23e25 --- /dev/null +++ b/scripts/quality/run-eslint-json.mjs @@ -0,0 +1,61 @@ +#!/usr/bin/env node +/** + * Single ESLint pass that always writes a JSON report for quality:collect. + * + * Existence reason: one inventory of net-new issues (vs suppressions) should + * feed both the blocking lint gate and the eslintWarnings ratchet — not two + * cold full-tree walks on different runners. + * + * Exit code: ESLint's own (0 = clean, 1 = errors). Warnings do not fail by + * default (same as `npm run lint`); pass --max-warnings=0 for lint-guard. + */ +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const outFile = path.resolve( + root, + process.env.ESLINT_RESULTS_JSON || path.join(".artifacts", "eslint-results.json") +); + +fs.mkdirSync(path.dirname(outFile), { recursive: true }); + +const extra = process.argv.slice(2); +const eslintBin = path.join( + root, + "node_modules", + ".bin", + process.platform === "win32" ? "eslint.cmd" : "eslint" +); +const args = [ + ".", + "--cache", + "--cache-location", + ".eslintcache", + "--suppressions-location", + "config/quality/eslint-suppressions.json", + "--format", + "json", + "--output-file", + outFile, + ...extra, +]; + +const result = spawnSync(eslintBin, args, { + cwd: root, + encoding: "utf8", + shell: process.platform === "win32", + maxBuffer: 256 * 1024 * 1024, +}); + +if (result.stdout) process.stdout.write(result.stdout); +if (result.stderr) process.stderr.write(result.stderr); + +if (!fs.existsSync(outFile)) { + // ESLint may crash before writing; leave an empty array so collectors don't explode. + fs.writeFileSync(outFile, "[]\n"); +} + +process.exit(result.status === null ? 1 : result.status); diff --git a/scripts/quality/select-impacted-tests.mjs b/scripts/quality/select-impacted-tests.mjs index 709eba4f44..1908e0f3fc 100644 --- a/scripts/quality/select-impacted-tests.mjs +++ b/scripts/quality/select-impacted-tests.mjs @@ -9,9 +9,14 @@ const HUB_RE = /(setupPolyfill|tsconfig|package\.json|package-lock\.json|\.env|v // step can actually run via `node --test` — i.e. it mirrors the `npm run test:unit` glob. // This EXCLUDES vitest files (`.test.tsx`, `tests/unit/autoCombo/**`), e2e and integration // tests, and `src/**/__tests__`/`open-sse/**/__tests__`, which can't run under node:test. +// Keep in sync with package.json test:unit* braces + serial + dashboard + *.test.mjs. const UNIT_SUBDIRS = - "api|auth|authz|build|cli|cli-helper|compression|correctness|cors|dashboard|db|db-adapters|docs|gamification|guardrails|lib|mcp|runtime|security|services|settings|shared|ui"; -const TEST_RE = new RegExp(`^tests/unit/([^/]+\\.test\\.ts$|(${UNIT_SUBDIRS})/.*\\.test\\.ts$)`); + "api|auth|authz|build|cli|cli-helper|combo|compression|correctness|cors|dashboard|db|db-adapters|docs|gamification|guardrails|lib|mcp|memory|runtime|security|services|settings|shared|ui|usage|serial"; +// .ts: top-level + UNIT_SUBDIRS (mirrors package.json brace globs). +// .mjs: package.json uses tests/unit/**/*.test.mjs (any depth under tests/unit). +const TEST_RE = new RegExp( + `^tests/unit/([^/]+\\.test\\.(ts|mjs)$|(${UNIT_SUBDIRS})/.*\\.test\\.(ts|mjs)$|.*\\.test\\.mjs$)` +); export function selectImpacted({ changed, map }) { const out = new Set(); @@ -21,11 +26,10 @@ export function selectImpacted({ changed, map }) { out.add(f); continue; } - const isSource = - f.startsWith("src/") || - f.startsWith("open-sse/") || - f.startsWith("electron/") || - f.startsWith("bin/"); + // Impact map only indexes imports under src/ + open-sse/. electron/ and bin/ + // are not unit-mapped; treating them as unmapped used to force __RUN_ALL__ and + // a full unit suite for pure CLI/desktop PRs. Package/smoke jobs cover those. + const isSource = f.startsWith("src/") || f.startsWith("open-sse/"); if (!isSource) continue; const hits = map.sources[f]; if (!hits) return ["__RUN_ALL__"]; diff --git a/scripts/quality/validate-release-green.mjs b/scripts/quality/validate-release-green.mjs index 5160628da1..aecc88734a 100644 --- a/scripts/quality/validate-release-green.mjs +++ b/scripts/quality/validate-release-green.mjs @@ -399,23 +399,44 @@ async function main() { hardCmd("db-rules", "DB rules", npmCmd, ["run", "check:db-rules"]); hardCmd("public-creds", "Public creds", npmCmd, ["run", "check:public-creds"]); - // Cognitive-complexity (drift) + // Complexity + cognitive (one ESLint walk; both still recorded as drift) { - announce("Cognitive complexity (ratchet)"); - const { out } = run(npmCmd, ["run", "check:cognitive-complexity"]); - saveGateLog("cognitive", out); - const current = parseCognitiveCount(out); - const base = baselineValue("cognitiveComplexity"); - const over = isDrift(current, base); + announce("Complexity + cognitive ratchets (shared ESLint walk)"); + const { out } = run(npmCmd, ["run", "check:complexity-ratchets"]); + saveGateLog("complexity-ratchets", out); + const cogCurrent = parseCognitiveCount(out); + const cogBase = baselineValue("cognitiveComplexity"); + const cogOver = isDrift(cogCurrent, cogBase); + const cycMatch = /(?:^|\n)complexity=(\d+)/.exec(out); + const cycOkMatch = /\[complexity\] OK — (\d+)/.exec(out); + const cycRegMatch = /\[complexity\] REGRESSÃO — (\d+)/.exec(out); + const cycCurrent = cycMatch + ? Number(cycMatch[1]) + : cycOkMatch + ? Number(cycOkMatch[1]) + : cycRegMatch + ? Number(cycRegMatch[1]) + : null; + const cycRegressed = /\[complexity\] REGRESSÃO/.test(out); record({ id: "cognitive-complexity", label: "Cognitive complexity (ratchet)", kind: "drift", - ok: !over, + ok: !cogOver, detail: - current == null + cogCurrent == null ? "could not parse count" - : `${current} vs baseline ${base}${over ? ` (+${current - base} drift → rebaseline at release)` : ""}`, + : `${cogCurrent} vs baseline ${cogBase}${cogOver ? ` (+${cogCurrent - cogBase} drift → rebaseline at release)` : ""}`, + }); + record({ + id: "complexity", + label: "Cyclomatic complexity (ratchet)", + kind: "drift", + ok: !cycRegressed, + detail: + cycCurrent == null + ? firstFailureLine(out) || "measured via check:complexity-ratchets" + : `complexity=${cycCurrent} (shared walk with cognitive)${cycRegressed ? " REGRESSED" : ""}`, }); } @@ -457,7 +478,7 @@ async function main() { // fast-gates skip and that historically surfaced — one at a time, because the // CI Quality Ratchet job is fail-fast — only on the release PR. Running them all // here (drift, never blocking) means a single rebaseline pass at release. - driftCmd("complexity", "Cyclomatic complexity (ratchet)", npmCmd, ["run", "check:complexity"]); + // complexity recorded above with cognitive (check:complexity-ratchets) driftCmd("dead-code", "Dead-code (ratchet)", npmCmd, ["run", "check:dead-code"]); driftCmd("type-coverage", "Type coverage (ratchet)", npmCmd, ["run", "check:type-coverage"]); driftCmd("compression-budget", "Compression budget (ratchet)", npmCmd, [ diff --git a/tests/unit/api-routes-lib.test.ts b/tests/unit/api-routes-lib.test.ts new file mode 100644 index 0000000000..29c0abce49 --- /dev/null +++ b/tests/unit/api-routes-lib.test.ts @@ -0,0 +1,33 @@ +/** + * Shared API route collector — locks path normalization used by openapi + docs gates. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import path from "node:path"; +import { + collectApiRouteFiles, + collectApiRouteUrlPaths, + toApiUrlPath, +} from "../../scripts/check/lib/apiRoutes.mjs"; + +test("toApiUrlPath maps [id] and [...slug] to OpenAPI-style braces", () => { + const apiRoot = path.join("C:", "repo", "src", "app", "api"); + assert.equal( + toApiUrlPath(path.join(apiRoot, "providers", "[id]", "models"), apiRoot).replace(/\\/g, "/"), + "/api/providers/{id}/models" + ); + assert.equal( + toApiUrlPath(path.join(apiRoot, "files", "[...path]"), apiRoot).replace(/\\/g, "/"), + "/api/files/{path}" + ); +}); + +test("live repo has route files and matching URL paths", () => { + const files = collectApiRouteFiles(); + const urls = collectApiRouteUrlPaths(); + assert.ok(files.size > 50, `expected many route files, got ${files.size}`); + assert.ok(urls.length > 50, `expected many url paths, got ${urls.length}`); + assert.equal(files.size, urls.length, "each route file should yield one URL path"); + assert.ok([...files].every((f) => f.startsWith("src/app/api/") && /route\.tsx?$/.test(f))); + assert.ok(urls.every((u) => u.startsWith("/api"))); +}); diff --git a/tests/unit/build/check-complexity-rule-count.test.ts b/tests/unit/build/check-complexity-rule-count.test.ts new file mode 100644 index 0000000000..8d155bb9f0 --- /dev/null +++ b/tests/unit/build/check-complexity-rule-count.test.ts @@ -0,0 +1,30 @@ +/** + * Locks that complexity vs cognitive counts stay isolated when sharing one ESLint report. + * Existence reason: merging tree walks must NOT change either ratchet baseline semantics. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + countCognitiveViolations, + countComplexityViolations, +} from "../../../scripts/check/complexityEslintReport.mjs"; + +test("countComplexityViolations ignores cognitive-complexity messages", () => { + const report = [ + { + messages: [ + { ruleId: "complexity" }, + { ruleId: "max-lines-per-function" }, + { ruleId: "sonarjs/cognitive-complexity" }, + { ruleId: "sonarjs/cognitive-complexity" }, + ], + }, + ]; + assert.equal(countComplexityViolations(report), 2); + assert.equal(countCognitiveViolations(report), 2); +}); + +test("empty report → 0 for both counters", () => { + assert.equal(countComplexityViolations([]), 0); + assert.equal(countCognitiveViolations([]), 0); +}); diff --git a/tests/unit/classify-pr-changes.test.ts b/tests/unit/classify-pr-changes.test.ts new file mode 100644 index 0000000000..a2815786cc --- /dev/null +++ b/tests/unit/classify-pr-changes.test.ts @@ -0,0 +1,69 @@ +/** + * tests/unit/classify-pr-changes.test.ts + * + * Locks the *existence reason* of each change flag used by ci.yml path filters: + * - code → heavy static + unit/vitest (code regression surface) + * - docs → docs-sync / prose only + * - i18n → translation validation; pure messages must NOT force full unit + * - workflow → always code (CI is part of the safety net) + * - unknown → code (fail-safe over-run) + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { classifyPaths } from "../../scripts/quality/classify-pr-changes.mjs"; + +test("pure docs PR → docs only (no code unit/lint bag)", () => { + const c = classifyPaths(["docs/architecture/QUALITY_GATES.md", "README.md"]); + assert.deepEqual(c, { code: false, docs: true, i18n: false, workflow: false }); +}); + +test("openapi under docs/ → docs (contract gates live in docs-sync, not unit)", () => { + const c = classifyPaths(["docs/openapi.yaml"]); + assert.equal(c.docs, true); + assert.equal(c.code, false); +}); + +test("pure message catalog → i18n only (not full unit suite)", () => { + const c = classifyPaths(["src/i18n/messages/en.json", "src/i18n/messages/ko.json"]); + assert.deepEqual(c, { code: false, docs: false, i18n: true, workflow: false }); +}); + +test("i18n tooling/scripts → i18n + code (tooling can break runtime paths)", () => { + const c = classifyPaths(["scripts/i18n/check-ui-keys-coverage.mjs"]); + assert.equal(c.i18n, true); + assert.equal(c.code, true); +}); + +test("src/i18n loader TS (non-messages) → i18n + code", () => { + const c = classifyPaths(["src/i18n/request.ts"]); + assert.equal(c.i18n, true); + assert.equal(c.code, true); +}); + +test("workflow change → workflow + code (gates protect the gates)", () => { + const c = classifyPaths([".github/workflows/ci.yml"]); + assert.equal(c.workflow, true); + assert.equal(c.code, true); +}); + +test("production source → code", () => { + const c = classifyPaths(["open-sse/handlers/chatCore.ts", "src/lib/db/core.ts"]); + assert.deepEqual(c, { code: true, docs: false, i18n: false, workflow: false }); +}); + +test("mixed docs + code → both flags (jobs union their filters)", () => { + const c = classifyPaths(["docs/README.md", "src/lib/db/core.ts"]); + assert.equal(c.docs, true); + assert.equal(c.code, true); +}); + +test("unknown path → code fail-safe (never skip heavy gates by accident)", () => { + const c = classifyPaths(["weird/unclassified.bin"]); + assert.equal(c.code, true); +}); + +test("empty change list → all false (nothing to validate)", () => { + const c = classifyPaths([]); + assert.deepEqual(c, { code: false, docs: false, i18n: false, workflow: false }); +}); diff --git a/tests/unit/select-impacted-tests.test.ts b/tests/unit/select-impacted-tests.test.ts index 88692f0e9f..dd64c55f77 100644 --- a/tests/unit/select-impacted-tests.test.ts +++ b/tests/unit/select-impacted-tests.test.ts @@ -60,3 +60,65 @@ test("changed unit test in a curated subdir → run itself", () => { const sel = selectImpacted({ changed: ["tests/unit/db/migration.test.ts"], map: MAP }); assert.deepEqual(sel, ["tests/unit/db/migration.test.ts"]); }); + +// Parity with package.json test:unit braces — memory/usage/combo/serial were missing +// from UNIT_SUBDIRS and silently failed to self-select. +test("changed unit test under memory/ → run itself", () => { + const sel = selectImpacted({ + changed: ["tests/unit/memory/store.test.ts"], + map: MAP, + }); + assert.deepEqual(sel, ["tests/unit/memory/store.test.ts"]); +}); + +test("changed unit test under usage/ → run itself", () => { + const sel = selectImpacted({ + changed: ["tests/unit/usage/quota.test.ts"], + map: MAP, + }); + assert.deepEqual(sel, ["tests/unit/usage/quota.test.ts"]); +}); + +test("changed unit test under combo/ → run itself", () => { + const sel = selectImpacted({ + changed: ["tests/unit/combo/routing.test.ts"], + map: MAP, + }); + assert.deepEqual(sel, ["tests/unit/combo/routing.test.ts"]); +}); + +test("changed unit test under serial/ → run itself", () => { + const sel = selectImpacted({ + changed: ["tests/unit/serial/flaky-once.test.ts"], + map: MAP, + }); + assert.deepEqual(sel, ["tests/unit/serial/flaky-once.test.ts"]); +}); + +test("changed unit .test.mjs → run itself", () => { + const sel = selectImpacted({ + changed: ["tests/unit/example.test.mjs"], + map: MAP, + }); + assert.deepEqual(sel, ["tests/unit/example.test.mjs"]); +}); + +// package.json: tests/unit/**/*.test.mjs (any depth, not only UNIT_SUBDIRS). +test("nested .test.mjs outside UNIT_SUBDIRS → run itself", () => { + const sel = selectImpacted({ + changed: ["tests/unit/misc/nested/deep.test.mjs"], + map: MAP, + }); + assert.deepEqual(sel, ["tests/unit/misc/nested/deep.test.mjs"]); +}); + +// electron/bin are outside the import-graph map roots — must NOT force __RUN_ALL__. +test("changed electron/ file alone → empty (not unit fail-safe)", () => { + const sel = selectImpacted({ changed: ["electron/main.js"], map: MAP }); + assert.deepEqual(sel, []); +}); + +test("changed bin/ file alone → empty (not unit fail-safe)", () => { + const sel = selectImpacted({ changed: ["bin/omniroute.js"], map: MAP }); + assert.deepEqual(sel, []); +}); diff --git a/tests/unit/validate-release-green.test.ts b/tests/unit/validate-release-green.test.ts index db3f4f5f24..bf62fd8096 100644 --- a/tests/unit/validate-release-green.test.ts +++ b/tests/unit/validate-release-green.test.ts @@ -275,7 +275,8 @@ test("extractCiGates: the REAL ci.yml yields the base-reds that leaked in v3.8.4 // never ran them — --full-ci now reproduces every one. for (const g of [ "check:route-validation:t06", - "check:docs-symbols", + // openapi-routes + docs-symbols collapsed into one FS walk (#6716). + "check:api-docs-refs", "check:bundle-size", "check:test-masking", "check:file-size",