diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a3a9b84c6f..b06bb391e4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -248,14 +248,15 @@ jobs: run: npm run check:workflows -- --ratchet # OpenAPI breaking-change detection (oasdiff). Diffs the PR's public API # contract (docs/reference/openapi.yaml) against the base branch's spec. - # ADVISORY: reports `openapiBreaking=N` and self-skips when oasdiff is absent - # or the base spec can't be resolved. BASE_REF is read by the script from the - # env (never interpolated into a shell body) — workflow-injection-safe. - - name: OpenAPI breaking-change (oasdiff; advisory) - continue-on-error: true + # BLOCKING ratchet (Fase 9 Onda 0): reads metrics.openapiBreaking.value and + # exits 1 ONLY on a measured regression (count > baseline). It SKIPs (exit 0) + # when oasdiff is absent or the base spec can't be resolved — a missing + # measurement never blocks. BASE_REF is read by the script from the env + # (never interpolated into a shell body) — workflow-injection-safe. + - name: OpenAPI breaking-change (oasdiff, ratchet, blocking) env: BASE_REF: ${{ github.base_ref }} - run: npm run check:openapi-breaking + run: npm run check:openapi-breaking -- --ratchet docs-sync-strict: name: Docs Sync (Strict) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000000..4a3e345bd2 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,31 @@ +name: CodeQL +# OWNER ACTION REQUIRED before enabling auto-triggers: advanced CodeQL conflicts with +# GitHub "default setup" — the analyze step fails with "CodeQL analyses from advanced +# configurations cannot be processed when the default setup is enabled". Switch repo +# Settings → Code security → CodeQL from Default to Advanced, THEN restore the +# push/pull_request/schedule triggers below. Until then this only runs on manual dispatch +# so it never produces a red check on PRs. (The codeqlAlerts ratchet keeps working via the +# default setup's alerts in the meantime.) +on: + workflow_dispatch: +permissions: + contents: read +jobs: + analyze: + name: Analyze (javascript-typescript) + runs-on: ubuntu-latest + permissions: + security-events: write + actions: read + contents: read + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: github/codeql-action/init@v3 + with: + languages: javascript-typescript + queries: security-extended + - uses: github/codeql-action/analyze@v3 + with: + category: "/language:javascript-typescript" diff --git a/.github/workflows/dast-smoke.yml b/.github/workflows/dast-smoke.yml new file mode 100644 index 0000000000..be138599ef --- /dev/null +++ b/.github/workflows/dast-smoke.yml @@ -0,0 +1,62 @@ +name: DAST smoke (PR) +on: + pull_request: + branches: ["main", "release/**"] +permissions: + contents: read +jobs: + dast-smoke: + runs-on: ubuntu-latest + # ADVISORY while this new gate matures (repo convention: advisory -> blocking). + # Flip to blocking (remove continue-on-error) once it's proven stable across a few PRs. + continue-on-error: true + timeout-minutes: 12 + env: + JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation + API_KEY_SECRET: ci-api-key-secret-with-sufficient-length-aaaa + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-node@v4 + with: + node-version: "24" + cache: npm + - run: npm ci + - name: Build CLI bundle + run: npm run build:cli + - name: Start OmniRoute + env: + PORT: "20128" + INJECTION_GUARD_MODE: block + run: | + node dist/server.js > server.log 2>&1 & + echo $! > server.pid + for _ in $(seq 1 30); do + if curl -sf http://localhost:20128/api/monitoring/health >/dev/null; then echo up; break; fi + sleep 2 + done + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install schemathesis + - name: Schemathesis smoke (high-risk endpoints, blocking) + run: | + schemathesis run docs/reference/openapi.yaml --url http://localhost:20128 \ + --include-path-regex '^/v1/(chat/completions|models)$|^/api/(auth|keys)' \ + --max-examples 8 --workers 4 --checks all --max-response-time 30 \ + --request-timeout 20 --suppress-health-check all --no-color + - name: promptfoo injection-guard (blocking) + env: + OMNIROUTE_URL: http://localhost:20128 + OMNIROUTE_API_KEY: not-needed-blocked-before-upstream + run: npx --yes promptfoo@latest eval -c promptfooconfig.yaml --no-cache + - name: Stop server + if: always() + run: kill "$(cat server.pid)" || true + - uses: actions/upload-artifact@v4 + if: always() + with: + name: dast-smoke-logs + path: server.log + retention-days: 7 diff --git a/.github/workflows/nightly-mutation.yml b/.github/workflows/nightly-mutation.yml index ed7b4b0c86..efe30c0955 100644 --- a/.github/workflows/nightly-mutation.yml +++ b/.github/workflows/nightly-mutation.yml @@ -25,6 +25,12 @@ jobs: node-version: "24" cache: npm - run: npm ci + - name: Restore Stryker incremental cache + uses: actions/cache@v4 + with: + path: reports/mutation/stryker-incremental.json + key: stryker-incremental-${{ github.run_id }} + restore-keys: stryker-incremental- - name: Run Stryker (advisory) id: stryker continue-on-error: true diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 0b3af394c7..4aa7f5016b 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -29,6 +29,7 @@ jobs: steps: - uses: actions/checkout@v6 with: + fetch-depth: 0 persist-credentials: false - uses: actions/setup-node@v6 with: @@ -49,3 +50,29 @@ jobs: - run: npm run check:route-guard-membership - run: npm run check:test-discovery - run: npm run check:any-budget:t11 + - 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. + # + # ADVISORY for now (repo convention: advisory -> blocking). release/v3.8.27 carries + # ~17 pre-existing red unit tests (budget #3537, apiKey #3552, several Zod schemas, + # executors, etc.) unrelated to this PR; running them is what surfaced the debt. + # Keep this advisory until that debt is cleared, THEN remove continue-on-error to + # block test regressions on PR->release. typecheck:core above stays blocking. + - name: Impacted unit tests (TIA, fail-safe full; advisory until release test-debt cleared) + continue-on-error: true + env: + GITHUB_BASE_REF: ${{ github.base_ref }} + run: | + git fetch --no-tags origin "$GITHUB_BASE_REF" || true + node scripts/quality/build-test-impact-map.mjs + SEL="$(node scripts/quality/select-impacted-tests.mjs)" + if [ -z "$SEL" ]; then echo "No source/test changes — skipping unit tests"; exit 0; fi + if echo "$SEL" | grep -q "__RUN_ALL__"; then + echo "Fail-safe: running FULL unit suite"; npm run test:unit; exit $? + fi + echo "Running impacted tests:"; echo "$SEL" + mapfile -t FILES <<< "$SEL" + node --import tsx --import ./open-sse/utils/setupPolyfill.ts --test --test-force-exit --test-concurrency=8 "${FILES[@]}" diff --git a/.github/workflows/semgrep.yml b/.github/workflows/semgrep.yml new file mode 100644 index 0000000000..9f0a9f5676 --- /dev/null +++ b/.github/workflows/semgrep.yml @@ -0,0 +1,29 @@ +name: semgrep +on: + pull_request: + branches: ["main", "release/**"] + push: + branches: ["main"] +permissions: + contents: read +jobs: + semgrep: + runs-on: ubuntu-latest + container: + image: semgrep/semgrep + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Run semgrep (advisory) + continue-on-error: true + run: | + semgrep scan --config p/owasp-top-ten --config p/secrets \ + --sarif --output semgrep.sarif --metrics off || true + python -c "import json; d=json.load(open('semgrep.sarif')); print('semgrepFindings=%d' % len(d['runs'][0]['results']))" || echo "semgrepFindings=SKIP" + - uses: actions/upload-artifact@v4 + if: always() + with: + name: semgrep-sarif + path: semgrep.sarif + retention-days: 14 diff --git a/.gitignore b/.gitignore index 82431a3290..4810f236f2 100644 --- a/.gitignore +++ b/.gitignore @@ -162,6 +162,9 @@ typescript # Superpowers plans/specs (internal tooling, not project code) docs/superpowers/ +# TIA test-impact map — generated at runtime in CI (build-test-impact-map.mjs), never committed (~21MB) +config/quality/test-impact-map.json + # GitNexus local index .gitnexus .worktrees diff --git a/config/quality/quality-baseline.json b/config/quality/quality-baseline.json index ac164955a4..cbb2762b57 100644 --- a/config/quality/quality-baseline.json +++ b/config/quality/quality-baseline.json @@ -129,7 +129,14 @@ "value": 5601, "direction": "down", "dedicatedGate": true - } + }, + "openapiBreaking": { + "value": 0, + "direction": "down", + "dedicatedGate": true, + "_note": "oasdiff breaking-change gate (Fase 9 Onda 0). Blocks any breaking change vs base spec." + }, + "semgrepFindings": { "value": 0, "direction": "down", "dedicatedGate": true, "_note": "semgrep owasp/secrets findings. ADVISORY until first CI value is frozen, then flip blocking (Fase 9)." } }, "_coverage_note": "Pisos anti-flake ~2pt abaixo do real do CI mergeado MEDIDO COM os 135 testes religados (run 27247237268: statements 78.4 / lines 78.4 / functions 83.84 / branches 75.73). O religamento da 6A.1 HONESTIFICOU a regua: os ~82.5 anteriores eram inflados porque modulos nunca importados ficavam fora do denominador do c8. Apertar via --require-tighten na Fase 6A (2026-06-16).", "_eslint_rebaseline_2026_06_09": "Baseline 3482 foi congelado na branch das Fases 0-6 (base ~v3.8.17) ANTES da v3.8.18 sair; a tag v3.8.18 publicada ja media 3501 (delta nasceu no fim daquele ciclo, antes do job quality-gate existir no ci.yml). O ciclo v3.8.19 esta NEUTRO (tag 3501 == HEAD 3501). Re-baseline para o estado real da main publicada; reduzir os ~19 (any em codigo do ciclo v3.8.18) e ligar --require-tighten ficam para a Fase 6A (2026-06-16).", diff --git a/docs/ops/BRANCH_PROTECTION_MAIN.md b/docs/ops/BRANCH_PROTECTION_MAIN.md new file mode 100644 index 0000000000..5ed0fb750d --- /dev/null +++ b/docs/ops/BRANCH_PROTECTION_MAIN.md @@ -0,0 +1,23 @@ +--- +title: "Branch Protection — main" +--- + +# Branch protection — `main` (OpenSSF Scorecard: Branch-Protection) + +Owner action. Apply via Settings → Branches → Add rule, or: + +```bash +gh api -X PUT repos/diegosouzapw/OmniRoute/branches/main/protection \ + --input - <<'JSON' +{ "required_status_checks": { "strict": true, "contexts": ["Quality Ratchet", "Quality Gates (Extended)", "Fast Quality Gates"] }, + "enforce_admins": false, + "required_pull_request_reviews": { "required_approving_review_count": 0, "dismiss_stale_reviews": true }, + "restrictions": null, + "required_linear_history": false, + "allow_force_pushes": false, + "allow_deletions": false } +JSON +``` + +Lifts Scorecard Branch-Protection from 0. `enforce_admins:false` keeps the existing +forward-merge flow workable; tighten to `true` once stable. diff --git a/docs/ops/QUALITY_GATE_PLAYBOOK.md b/docs/ops/QUALITY_GATE_PLAYBOOK.md new file mode 100644 index 0000000000..bd6b877c57 --- /dev/null +++ b/docs/ops/QUALITY_GATE_PLAYBOOK.md @@ -0,0 +1,243 @@ +--- +title: "Quality Gate Playbook" +--- + +# Quality-Gate System — Avaliação Crítica, Catálogo e Playbook de Replicação + +> **O que é este documento.** Uma avaliação crítica do sistema de quality-gates do OmniRoute, +> comparado às melhores práticas da indústria, **mais** um catálogo completo de todos os pontos +> de qualidade e um **plano de replicação tool-agnóstico** para aplicar o mesmo sistema em +> qualquer projeto. Gerado em 2026-06-16 a partir do estado real do repositório (não da memória). +> +> Régua de comparação: OWASP DSOMM · OpenSSF Scorecard · SLSA · SonarQube "Clean as You Code" · +> Quality-Ratchet pattern · DORA 2024 · OWASP LLM Top 10 (2025) · mutation-testing best practices. + +--- + +## Parte 1 — Veredito e Classificação de Maturidade + +**Nota geral: A− / "Avançado". Top ~5–10% de projetos.** O sistema implementa, de forma +independente, vários padrões que a indústria nomeia explicitamente — o que é o melhor sinal de +alinhamento (não copiamos uma checklist; convergimos para as práticas certas). + +| Framework de referência | Onde estamos | Nota | +| --- | --- | --- | +| **OWASP DSOMM** (5 níveis, 5 dimensões) | Nível 3 sólido, alcançando 4 em *Test Intensity* e *Static Depth*. A maioria das orgs fica em 1–2. | **L3→L4** | +| **OpenSSF Scorecard** (18 checks) | Atendemos CI-Tests, Code-Review, Dependency-Update-Tool, Fuzzing, SAST, Signed-Releases (provenance), Token-Permissions, Vulnerabilities, Dangerous-Workflow. **Gaps:** Branch-Protection na `main` OFF; algumas actions não-pinadas. | **~7–8/10** | +| **SLSA** (4 níveis) | `npm publish --provenance` + `id-token: write` + build GitHub-hosted = **L2**, encostando em L3. Falta builder endurecido/hermético p/ L3+. | **L2→L3** | +| **SonarQube "Clean as You Code"** | Filosofia idêntica: o ratchet gateia *não-regressão* (código novo não piora a métrica). **Divergência:** Sonar recomenda **poucas** condições; temos ~46 gates (risco de fadiga). | **Alinhado, com ressalva** | +| **Quality-Ratchet pattern** | Implementação de referência: ratchet + `dedicatedGate` + `tightenSlack` + `--require-tighten` + skip-gracioso. Mais sofisticado que a maioria dos exemplos públicos. | **Exemplar** | +| **DORA 2024** | Fortíssimos no eixo *estabilidade*. Risco: gates pesados podem custar *lead time* — mitigado pelo split fast-gates, mas com buraco de cobertura (ver Parte 2). | **Forte (estabilidade)** | +| **OWASP LLM Top 10 (2025)** | Cobrimos o risco #1 (prompt-injection) com guard em runtime + promptfoo (eval) + garak (red-team). Ferramentas-padrão da indústria. | **Coberto** | +| **Mutation testing** | Stryker nightly, thresholds 70/50, 8 módulos críticos. Consenso da indústria (60% existente / 80% novo, nightly) — **batemos**. **Gap:** score ainda não é catraca. | **Quase lá** | + +--- + +## Parte 2 — Avaliação Crítica (forças + fraquezas honestas) + +### Forças (o que está acima da média) + +1. **Motor de ratchet multi-métrica.** O coração do sistema. 24 métricas em `quality-baseline.json` + + 4 baselines dedicados, cada uma com direção (`up`/`down`), tolerância (`eps`), folga + (`tightenSlack`) e flag `dedicatedGate`. Coisas consertadas **ficam** consertadas — é o + antídoto da entropia de codebase. +2. **Defesa-em-profundidade de supply-chain.** SAST (CodeQL/Sonar) + segredos (gitleaks com + `useDefault`) + SCA (osv/npm-audit/Trivy/Dependabot) + licenças + lockfile + SBOM + proveniência + SLSA + Scorecard + hardening de workflow (zizmor). Poucas codebases têm essa pilha completa. +3. **Antídotos contra a Lei de Goodhart.** Cobertura como alvo é um anti-padrão clássico + ("quando a medida vira alvo, deixa de ser boa medida"). Temos os contra-pesos: **mutation + testing** (mede se o teste pega o bug, não só se executa a linha), **`check-test-masking`** + (bloqueia enfraquecer asserts pra passar), **pisos de cobertura por-módulo** (força testar o + código de ALTO risco, não só o fácil) e **`check-pr-evidence`** (Hard Rule #18). +4. **Gates anti-alucinação / consistência.** Categoria rara e valiosa: `check-known-symbols`, + `check-fetch-targets`, `check-openapi-routes`, `check-docs-symbols` garantem que docs, specs e + dispatch por-string apontam para símbolos vivos. Pega "rot" que lint/test não pegam. +5. **Ciclo de vida advisory→bloqueante.** Gate novo entra advisory (não trava merges enquanto + amadurece), depois vira bloqueante no fim do ciclo. Reduz fricção sem perder o teto. +6. **Skip-gracioso quando a infra falta.** Scanners (`--ratchet`) saem `exit 0` se o binário/rede + falha — infra ausente nunca trava um PR legítimo. Engenharia madura. +7. **Cultura codificada.** Hard Rules + `trust-but-verify` + stale-allowlist + evidence-gate + transformam disciplina em verificação automática. + +### Fraquezas honestas (gaps reais) + +1. **🔴 O split fast-gates é um buraco estrutural.** `quality.yml` (PR→`release/**`) roda **só os + gates de filesystem** — sem typecheck, sem testes, sem build, sem cobertura. Uma regressão de + typecheck/teste passa num PR de release e só explode no forward-merge pra `main`. A motivação + (velocidade) é válida, mas o gate deveria estar onde o merge acontece (shift-left). **Maior + correção estrutural pendente.** +2. **🟠 Risco de sprawl/fadiga de gates.** ~46 gates + 25 jobs é MUITO. O próprio Sonar alerta: + muitas condições causam "fadiga de gate" e debate sobre prioridade, com risco de um gate + ignorado. DORA alerta que gates pesados custam lead-time. Mitigamos com tiers advisory e + ratchet-não-absoluto, mas falta um **review periódico de ROI por gate** (alguns micro-gates de + doc-sync são consolidáveis). +3. **🟠 Mutation score ainda não é catraca.** O antídoto mais forte contra coverage-gaming está + **advisory**. É o item de maior valor pendente (e já 90% construído). +4. **🟡 Advisory que deveriam bloquear (com escopo certo).** `osv` (vulnCount) e `oasdiff` são + advisory apesar de baseline congelado. osv-advisory tem razão (CVE nova em dep velha bloquearia + PR não-relacionado) — mas há meio-termo (bloquear só CRITICAL+fixable, como fizemos no Trivy). + oasdiff advisory significa que uma mudança quebra-contrato pode passar. +5. **🟡 Segurança runtime é nightly-only.** schemathesis/garak/promptfoo/chaos/k6 rodam à noite. + Decisão correta (lentos, precisam de servidor vivo), mas um PR pode introduzir regressão de + injection-guard só pega na noite seguinte. +6. **🟡 Branch-protection na `main` OFF.** O `BRANCH_LOCK_TOKEN` trava branches de *release*, mas a + `main` em si não é protegida. Ding no Scorecard/DSOMM. Ação do owner. +7. **🟡 CodeQL default-setup; semgrep não codificado.** default-setup funciona (0 alertas), mas um + `codeql.yml` commitado dá mais controle; o semgrep roda via plataforma cloud externa, não está + versionado no repo. + +--- + +## Parte 3 — Catálogo Completo dos Pontos de Qualidade (portável) + +As 12 categorias abaixo são o "sistema de qualidade" em forma reutilizável. Cada uma lista o +**objetivo** (o que proteger), as **ferramentas que usamos** e o **equivalente tool-agnóstico** +para replicar em qualquer stack. + +### 1. Estilo & formatação (determinístico, rápido) +- **OmniRoute:** Prettier + ESLint via lint-staged (pre-commit), 2-espaços/aspas-duplas/100col. +- **Genérico:** um formatter auto-fixável + um linter, rodando em pre-commit nos arquivos staged. + +### 2. Tipos +- **OmniRoute:** `typecheck:core` (bloqueante) + `typecheck:noimplicit:core` (advisory) + `type-coverage` ratchet 92.17% + any-budget por-arquivo. +- **Genérico:** typecheck estrito no CI + métrica de cobertura-de-tipo ratcheteada + orçamento de `any`/escape-hatches por-arquivo. + +### 3. Testes (intensidade) +- **OmniRoute:** 2 runners não-sobrepostos (Node native + vitest), 8 shards, cobertura global 60/60/60/60 + ratchet ~76% + **8 pisos por-módulo crítico** + testes de propriedade nightly + **mutation testing** nightly. +- **Genérico:** runner(s) de teste + piso de cobertura **absoluto** (anti-zero) + **ratchet** de cobertura (anti-regressão) + **pisos por-módulo de alto risco** (anti-Goodhart) + property-based para lógica pura + **mutation testing** nightly como medida real de qualidade-de-teste. + +### 4. Política de testes (anti-gaming) +- **OmniRoute:** `pr-test-policy` (código de prod exige teste), `check-test-masking` (bloqueia enfraquecer asserts), `pr-evidence` (claim de sucesso exige bloco de evidência), `test-discovery` (todo teste coletado por um runner). +- **Genérico:** gate "código novo ⇒ teste novo" + detector de assert-removido/tautologia + exigência de evidência (TDD ou teste-vivo) + garantia de que nenhum teste fica órfão fora dos globs. + +### 5. Complexidade & saúde de código (ratchets) +- **OmniRoute:** ESLint-warnings (3769↓), duplicação jscpd (5.72%↓), complexidade ciclomática+max-lines (1800↓), complexidade cognitiva sonarjs (753↓), dead-code/unused-exports knip (339↓), file-size por-arquivo (frozen, só-encolhe), circular-deps (Tarjan próprio, bloqueante). +- **Genérico:** ratchetear toda métrica de saúde (warnings, duplicação, complexidade ciclomática **e** cognitiva, código-morto, tamanho-de-arquivo, ciclos de import). Direção sempre "não-piorar". + +### 6. Segurança estática (SAST + segredos) +- **OmniRoute:** CodeQL (ratchet de alertas = 0), gitleaks (`[extend] useDefault=true` — crítico!), SonarQube, regras de segurança próprias (public-creds, error-helper, route-guard-membership, route-validation). +- **Genérico:** SAST (CodeQL/Sonar/semgrep) com ratchet-de-alertas + scanner de segredos com **ruleset default herdado** (config custom que substitui o default = cego) + gates próprios para as Hard Rules de segurança do projeto. + +### 7. Supply-chain (dependências) +- **OmniRoute:** osv-scanner + npm-audit + Trivy + Dependabot (SCA), license-checker (SPDX allowlist), lockfile-lint (HTTPS+sha512+registry), `check-deps` anti-slopsquatting (allowlist + idade ≥72h). +- **Genérico:** SCA multi-fonte + allowlist de licenças + verificação de integridade de lockfile + allowlist de dependências com checagem de idade/typosquatting + bot de atualização agrupado. + +### 8. Supply-chain (build & release) +- **OmniRoute:** SBOM (CycloneDX + syft), proveniência SLSA (`--provenance`), OpenSSF Scorecard (weekly), hardening de workflow (zizmor: artipacked→`persist-credentials:false`, cache-poisoning, token-permissions). +- **Genérico:** gerar SBOM no publish + proveniência assinada (SLSA L2+) + Scorecard agendado + endurecer todos os workflows (mínimo-privilégio de token, sem credencial persistida em checkout não-pusher, actions pinadas por SHA). + +### 9. Contratos & API +- **OmniRoute:** oasdiff (breaking-change OpenAPI), schemathesis (fuzz de contrato nightly), openapi-coverage (% rotas documentadas, ratchet 38.3%), openapi-security-tiers (spec vs route-guard). +- **Genérico:** diff de breaking-change do contrato (oasdiff/buf) + fuzz property-based contra o spec (schemathesis) + cobertura-de-documentação ratcheteada + consistência spec↔código. + +### 10. Docs & i18n (anti-rot) +- **OmniRoute:** docs-sync (versões espelhadas), docs-counts-sync (números nos docs vs código), env-doc-sync, doc-links, fabricated-docs, cli-i18n, i18n-ui-coverage (`--threshold=65` + ratchet 80.1%). +- **Genérico:** sincronizar versões/contagens/env-vars entre docs e código (gate, não confiança) + validar links internos + cobertura de i18n ratcheteada. + +### 11. Anti-alucinação / consistência (a categoria rara) +- **OmniRoute:** known-symbols (dispatch por-string ⇒ símbolo vivo), provider-consistency, fetch-targets (fetch cliente ⇒ rota real), docs-symbols, db-rules (Hard Rules #2/#5), migration-numbering. +- **Genérico:** para toda "fonte de verdade duplicada" (registry, dispatch por-string, referências cross-camada), um gate que prova que os dois lados batem. Pega o rot que typecheck/test não pegam. + +### 12. Resiliência & domínio (específico do produto) +- **OmniRoute:** chaos (fault-injection), heap-growth (leak), k6 (soak), promptfoo+garak (LLM red-team OWASP LLM Top 10), as 3 leis de resiliência (circuit-breaker/cooldown/lockout). +- **Genérico:** identificar os modos-de-falha do **seu** domínio e ter um gate (ainda que nightly) para cada um. Para apps de IA: red-team de injeção. Para sistemas distribuídos: chaos + leak + soak. + +--- + +## Parte 4 — Plano de Replicação em Qualquer Projeto + +Construa em **fases**, cada uma entregando valor sozinha. Não tente as 12 categorias de uma vez — +isso causa exatamente a fadiga de gate que a Parte 2 alerta. Cada gate novo entra **advisory** e +vira **bloqueante** quando estável. + +### A peça central reutilizável: a "anatomia de um gate de ratchet" + +Todo o sistema gira em torno deste padrão de 3 arquivos. Copie-o primeiro: + +1. **`baseline.json`** — o valor congelado da métrica + `direction` (`up`/`down`) + `eps` (anti-flake) + `tightenSlack` + `dedicatedGate`. +2. **`collect-metrics.`** — roda a ferramenta, extrai o número, escreve `metrics.json`. +3. **`check-ratchet.`** — compara `metrics.json` vs `baseline.json`; `exit 1` **só** se regrediu além de `eps`; `exit 0` (skip-gracioso) se a ferramenta/infra faltou; com `--require-tighten`, `exit 1` se **melhorou** sem atualizar o baseline (trava o ganho). + +Com isso pronto, **toda** métrica nova (cobertura, complexidade, warnings, alertas SAST, tamanho de bundle, mutation score…) é só uma linha no baseline. + +### Fase 0 — Fundação (semana 1) +CI existe; formatter + linter + typecheck + 1 runner de teste + piso de cobertura **absoluto** +(ex.: 60%). Pre-commit roda os checks rápidos auto-fixáveis. *Saída: nenhum PR entra quebrando o básico.* + +### Fase 1 — O motor de ratchet (semana 2) — **a fundação de tudo** +Implemente os 3 arquivos acima. Congele baselines de: warnings, cobertura, complexidade, duplicação, +código-morto, tamanho-de-arquivo. *Saída: a codebase só pode melhorar dali pra frente.* + +### Fase 2 — Profundidade estática (semana 3) +SAST (CodeQL/Sonar/semgrep) com ratchet-de-alertas; scanner de segredos (**herde o ruleset default**); +SCA (osv/Dependabot) + allowlist de licenças + lockfile-lint. *Saída: vulnerabilidade conhecida e +segredo vazado não passam.* + +### Fase 3 — Supply-chain de build (semana 4) +SBOM no publish + proveniência assinada (SLSA L2) + Scorecard agendado + hardening de workflow +(zizmor: token mínimo, sem credencial persistida, actions pinadas). *Saída: release rastreável e +à prova de adulteração.* + +### Fase 4 — Intensidade de teste (semana 5–6) +2º runner se útil; **pisos de cobertura por-módulo crítico** (anti-Goodhart); property-based para +lógica pura; **mutation testing nightly** → quando der o 1º score, vire catraca `mutationScore`. +*Saída: cobertura deixa de ser vanity-metric; testes provadamente pegam bugs.* + +### Fase 5 — Contrato & dinâmico (semana 7) +Se há API pública: oasdiff (breaking-change, **bloqueante**) + schemathesis (fuzz nightly). DAST/ +red-team nightly conforme o domínio. *Saída: contrato não quebra em silêncio.* + +### Fase 6 — Anti-alucinação & domínio (semana 8) +Um gate de consistência para cada "verdade duplicada" do projeto. Gates de modo-de-falha do seu +domínio (para IA: red-team de injeção). *Saída: rot estrutural e falhas de domínio têm rede.* + +### Fase 7 — Governança (contínuo) +- Ciclo advisory→bloqueante para cada gate novo. +- `stale-allowlist`: toda supressão tem justificativa + issue; supressão obsoleta é pega. +- `evidence-gate`: claim de sucesso em PR exige prova (teste ou teste-vivo). +- **Review trimestral de ROI por gate** (mate/funda os que não pagam o custo — combate a fadiga). +- Mature os Hard Rules do projeto em gates executáveis. + +### Princípios transversais (não-negociáveis) +- **Ratchet, não absoluto.** Gateie *não-regressão*, não um número fixo (exceto pisos anti-zero). +- **Piso absoluto + ratchet juntos.** O piso impede o colapso; o ratchet impede a erosão lenta. +- **Anti-Goodhart por design.** Toda métrica-alvo precisa de um contra-peso (cobertura ⇒ mutation + anti-masking; pisos por-módulo p/ forçar o código difícil). +- **Skip-gracioso.** Infra ausente nunca bloqueia; só regressão real bloqueia. +- **`dedicatedGate` para métricas caras.** Métrica que precisa de binário externo tem seu próprio script (com skip), fora do ratchet central síncrono. +- **Gate onde o merge acontece.** Não deixe buraco entre o gate-rápido e o merge real (a lição do split fast-gates). +- **Poucos gates bloqueantes, bem-escolhidos.** Sonar/DORA: muitas condições = fadiga. Prefira advisory + ratchet a um muro de gates bloqueantes. + +--- + +## Parte 5 — Melhorias recomendadas (priorizadas, compatíveis) + +**P0 — maior ROI, já quase prontas** +1. **Catraca de mutation score** (após 1º nightly Stryker dar valores). Antídoto-chave contra coverage-Goodhart; ~90% pronto. +2. **Fechar o buraco fast-gates** — adicionar typecheck + testes-impactados ao `quality.yml` (PR→release). +3. **Branch-protection na `main`** (setting do owner) — sobe Scorecard, fecha o gap DSOMM. + +**P1 — valiosas** +4. **osv/oasdiff → bloqueante com escopo certo** — osv só CRITICAL+fixable (two-step como o Trivy); oasdiff bloqueia breaking-change. +5. **`require-tighten` → bloqueante** (fim de ciclo) — trava ganhos de métrica. +6. **Review de ROI / timing por-gate** no `ci-summary` — achar e podar gates lentos/de-baixo-valor. + +**P2 — diminishing returns** +7. **SLSA L3** — builder hermético/reprodutível (gerador SLSA do GitHub) se quiser subir de L2. +8. **CodeQL config commitado + semgrep versionado** — mais controle/reprodutibilidade. +9. **DAST smoke por-PR** — subconjunto rápido de schemathesis/promptfoo nos endpoints de maior risco (não só nightly). +10. **Dashboard de flakiness + métricas DORA** — garantir que os gates não erodem a velocidade. + +--- + +## Fontes (boas práticas da indústria) + +- OWASP DevSecOps Maturity Model (DSOMM) — https://dsomm.owasp.org/about +- OpenSSF Scorecard / SLSA — https://openssf.org · https://slsa.dev +- SonarQube "Clean as You Code" — https://docs.sonarsource.com/sonarqube-server/latest/user-guide/clean-as-you-code +- Quality Ratchets (LeadDev) — https://leaddev.com/software-quality/introducing-quality-ratchets-tool-managing-complex-systems +- Continuous Code Improvement Using Ratcheting (Greiner) — https://robertgreiner.com/continuous-code-improvement-using-ratcheting/ +- DORA 2024 State of DevOps — https://cloud.google.com/blog/products/devops-sre/announcing-the-2024-dora-report +- Mutation testing best practices (Stryker) — https://stryker-mutator.io +- Coverage como anti-padrão (Goodhart) — https://www.industriallogic.com/blog/code-coverage-complications/ +- OWASP Top 10 for LLM Applications (2025) — https://owasp.org/www-project-top-10-for-large-language-model-applications/ +- Contract testing (oasdiff/schemathesis) — https://www.oasdiff.com · https://schemathesis.readthedocs.io diff --git a/scripts/check/check-openapi-breaking.mjs b/scripts/check/check-openapi-breaking.mjs index cc761537b2..445e4ffafc 100644 --- a/scripts/check/check-openapi-breaking.mjs +++ b/scripts/check/check-openapi-breaking.mjs @@ -19,19 +19,26 @@ // (arquivo não existia no base, ou // clone shallow sem o ref base) // -// Esta versão é ADVISORY (sai 0 SEMPRE, mesmo com N>0). Promove a bloqueante -// depois (mesma trajetória de todo gate novo neste repo: report → ratchet → block). +// Por default é ADVISORY (sai 0 SEMPRE, mesmo com N>0). Passe --ratchet para +// tornar BLOQUEANTE: lê metrics.openapiBreaking.value de +// config/quality/quality-baseline.json, compara a contagem MEDIDA e SAI 1 SE — E +// SOMENTE SE — a medida for MAIOR que o baseline (regressão real, direction:down). +// Qualquer SKIP gracioso (oasdiff ausente do PATH, spec base não resolvível em +// clone shallow, JSON inválido) SAI 0 MESMO com --ratchet — uma falha de MEDIÇÃO +// nunca bloqueia, só uma regressão MEDIDA bloqueia (mesma trajetória de todo gate +// neste repo: report → ratchet → block). // // Base ref: -// • CI passa BASE_REF=${{ github.base_ref }} (ex.: "release/v3.8.26"). -// • Local: default origin/release/v3.8.26. +// • CI passa BASE_REF=${{ github.base_ref }} (ex.: "release/v3.8.27"). +// • Local: default origin/release/v3.8.27. // A spec base é extraída com `git show :docs/reference/openapi.yaml`. // // Uso: // node scripts/check/check-openapi-breaking.mjs -// BASE_REF=origin/release/v3.8.26 node scripts/check/check-openapi-breaking.mjs +// BASE_REF=origin/release/v3.8.27 node scripts/check/check-openapi-breaking.mjs // node scripts/check/check-openapi-breaking.mjs --json # imprime JSON bruto do oasdiff // node scripts/check/check-openapi-breaking.mjs --quiet # suprime logs de diagnóstico +// node scripts/check/check-openapi-breaking.mjs --ratchet # falha (exit 1) numa regressão import fs from "node:fs"; import os from "node:os"; @@ -42,10 +49,12 @@ import { pathToFileURL } from "node:url"; const ROOT = process.cwd(); const QUIET = process.argv.includes("--quiet"); const PRINT_JSON = process.argv.includes("--json"); +const RATCHET = process.argv.includes("--ratchet"); const SPEC_REL = "docs/reference/openapi.yaml"; const SPEC_PATH = path.join(ROOT, "docs", "reference", "openapi.yaml"); -const DEFAULT_BASE_REF = "origin/release/v3.8.26"; +const DEFAULT_BASE_REF = "origin/release/v3.8.27"; +const BASELINE_PATH = path.join(ROOT, "config/quality/quality-baseline.json"); // --------------------------------------------------------------------------- // Pure parsing function (exported for tests) @@ -110,6 +119,52 @@ export function parseOasdiffBreaking(oasdiffJson) { return { count, byId, byPath, items }; } +// --------------------------------------------------------------------------- +// Ratchet (direction:down) — exported for tests +// --------------------------------------------------------------------------- + +/** + * Avalia a contagem MEDIDA de breaking changes contra o baseline. + * Direction: down (a contagem só pode CAIR — mais breaking changes = regressão). + * + * Uma medição ausente (current null/undefined) OU um baseline ausente + * (baseline null/undefined) → { regressed:false, skipped:true }: sem uma das + * duas pontas não há ratchet possível, então o caller trata como SKIP gracioso + * (exit 0 mesmo com --ratchet). Uma falha de MEDIÇÃO nunca bloqueia. + * + * @param {object} args + * @param {number|null} args.current - Breaking changes medidos agora (null = sem medição). + * @param {number|null} args.baseline - Contagem congelada em quality-baseline.json (null = sem baseline). + * @returns {{ regressed: boolean, skipped: boolean }} + */ +export function evaluateOpenapiRatchet({ current, baseline }) { + if (current === null || current === undefined || baseline === null || baseline === undefined) { + return { regressed: false, skipped: true }; + } + return { regressed: current > baseline, skipped: false }; +} + +/** + * Lê metrics.openapiBreaking.value do quality-baseline.json. + * Retorna null se o arquivo ou a métrica estiverem ausentes/inválidos (sem + * baseline não há ratchet possível — o caller trata como SKIP gracioso, exit 0). + * + * @param {string} baselinePath + * @returns {number|null} + */ +export function readBaselineOpenapiValue(baselinePath = BASELINE_PATH) { + if (!fs.existsSync(baselinePath)) return null; + let baselineJson; + try { + baselineJson = JSON.parse(fs.readFileSync(baselinePath, "utf8")); + } catch { + return null; + } + const metric = baselineJson?.metrics?.openapiBreaking; + if (!metric || typeof metric.value !== "number") return null; + return metric.value; +} + // --------------------------------------------------------------------------- // Binary detection // --------------------------------------------------------------------------- @@ -331,10 +386,18 @@ function main() { .map(([p, n]) => `${p}(${n})`) .join(", "); process.stderr.write(`[openapi-breaking] affected paths: ${topPaths}\n`); - process.stderr.write( - "[openapi-breaking] ADVISORY — promove a BLOQUEANTE depois. Se a quebra é\n" + - "[openapi-breaking] intencional (major bump), documente no PR; senão, ajuste a spec.\n" - ); + if (RATCHET) { + process.stderr.write( + "[openapi-breaking] Se a quebra é intencional (major bump), documente no PR e\n" + + "[openapi-breaking] re-baseline metrics.openapiBreaking em config/quality/quality-baseline.json\n" + + "[openapi-breaking] com justificativa + issue de tracking; senão, ajuste a spec.\n" + ); + } else { + process.stderr.write( + "[openapi-breaking] ADVISORY — passe --ratchet para BLOQUEAR uma regressão. Se a quebra é\n" + + "[openapi-breaking] intencional (major bump), documente no PR; senão, ajuste a spec.\n" + ); + } } else { process.stderr.write( `[openapi-breaking] OK — nenhuma breaking change na spec vs '${baseRef}'.\n` @@ -342,8 +405,8 @@ function main() { } } - // ADVISORY — sai 0 SEMPRE nesta versão, mesmo com count > 0. - process.exitCode = 0; + // Medição bem-sucedida → aplica o ratchet (bloqueante só com --ratchet). + applyRatchet(count); } finally { // Limpa o arquivo temp da spec base. try { @@ -354,4 +417,52 @@ function main() { } } +/** + * Aplica o ratchet (direction:down) sobre a contagem medida vs o baseline. + * Sem --ratchet: advisory (exit 0). Com --ratchet: exit 1 numa regressão real + * (medida > baseline). Baseline ausente → SKIP gracioso (exit 0). + * + * @param {number} count - Contagem MEDIDA de breaking changes (medição bem-sucedida). + */ +function applyRatchet(count) { + if (!RATCHET) { + process.exitCode = 0; + return; + } + + const baselineValue = readBaselineOpenapiValue(BASELINE_PATH); + const { regressed, skipped } = evaluateOpenapiRatchet({ + current: count, + baseline: baselineValue, + }); + + if (skipped) { + if (!QUIET) { + process.stderr.write( + "[openapi-breaking] baseline ausente (metrics.openapiBreaking) — SKIP gracioso, sai 0.\n" + ); + } + process.exitCode = 0; + return; + } + + if (regressed) { + process.stderr.write( + `[openapi-breaking] REGRESSÃO — ${count} breaking change(s) > baseline ${baselineValue}\n` + + " → Ajuste a spec para não quebrar clientes existentes. Se a quebra é intencional\n" + + " (major bump), re-baseline metrics.openapiBreaking em\n" + + " config/quality/quality-baseline.json com justificativa + issue de tracking.\n" + ); + process.exitCode = 1; + return; + } + + if (!QUIET) { + process.stderr.write( + `[openapi-breaking] OK — sem regressão (${count} breaking change(s), baseline ${baselineValue}).\n` + ); + } + process.exitCode = 0; +} + if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main(); diff --git a/scripts/quality/build-test-impact-map.mjs b/scripts/quality/build-test-impact-map.mjs new file mode 100644 index 0000000000..f95cfdad7c --- /dev/null +++ b/scripts/quality/build-test-impact-map.mjs @@ -0,0 +1,72 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { globSync } from "tinyglobby"; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const SRC_ROOTS = ["src", "open-sse"]; +const IMPORT_RE = + /(?:import|export)[^'"]*from\s*['"]([^'"]+)['"]|require\(\s*['"]([^'"]+)['"]\s*\)|import\(\s*['"]([^'"]+)['"]\s*\)/g; +const EXTS = [".ts", ".tsx", ".mts", ".js", ".mjs"]; + +function resolveImport(spec, fromFile) { + let base; + if (spec.startsWith("@/")) base = path.join(ROOT, "src", spec.slice(2)); + else if (spec.startsWith("@omniroute/open-sse")) + base = path.join(ROOT, "open-sse", spec.replace(/^@omniroute\/open-sse\/?/, "")); + else if (spec.startsWith(".")) base = path.resolve(path.dirname(fromFile), spec); + else return null; + for (const e of EXTS) { + if (fs.existsSync(base + e)) return base + e; + } + for (const e of EXTS) { + const idx = path.join(base, "index" + e); + if (fs.existsSync(idx)) return idx; + } + return fs.existsSync(base) && fs.statSync(base).isFile() ? base : null; +} + +function sourceDepsOf(entry) { + const seen = new Set(); + const stack = [entry]; + const sources = new Set(); + while (stack.length) { + const f = stack.pop(); + if (seen.has(f)) continue; + seen.add(f); + let code; + try { + code = fs.readFileSync(f, "utf8"); + } catch { + continue; + } + for (const m of code.matchAll(IMPORT_RE)) { + const spec = m[1] || m[2] || m[3]; + if (!spec) continue; + const r = resolveImport(spec, f); + if (!r) continue; + const rel = path.relative(ROOT, r); + if (SRC_ROOTS.some((s) => rel.startsWith(s + path.sep))) sources.add(rel); + stack.push(r); + } + } + return sources; +} + +const testFiles = globSync( + ["tests/**/*.test.{ts,tsx,mts}", "src/**/*.test.{ts,tsx}", "open-sse/**/*.test.{ts,tsx}"], + { cwd: ROOT, absolute: true } +); +const map = {}; +for (const tf of testFiles) { + const relTest = path.relative(ROOT, tf); + for (const src of sourceDepsOf(tf)) { + (map[src] ||= []).push(relTest); + } +} +for (const k of Object.keys(map)) map[k].sort(); +const out = path.join(ROOT, "config/quality/test-impact-map.json"); +fs.writeFileSync(out, JSON.stringify({ generatedFrom: "import-graph", sources: map }, null, 2) + "\n"); +console.log( + `test-impact-map: ${Object.keys(map).length} source files mapped from ${testFiles.length} test files` +); diff --git a/scripts/quality/select-impacted-tests.mjs b/scripts/quality/select-impacted-tests.mjs new file mode 100644 index 0000000000..a5c0239af0 --- /dev/null +++ b/scripts/quality/select-impacted-tests.mjs @@ -0,0 +1,56 @@ +import fs from "node:fs"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const HUB_RE = /(setupPolyfill|tsconfig|package\.json|package-lock\.json|\.env|vitest\.config|stryker\.conf)/; +const TEST_RE = /\.(test|spec)\.(ts|tsx|mts)$/; + +export function selectImpacted({ changed, map }) { + const out = new Set(); + for (const f of changed) { + if (HUB_RE.test(f)) return ["__RUN_ALL__"]; + if (TEST_RE.test(f)) { + out.add(f); + continue; + } + const isSource = + f.startsWith("src/") || + f.startsWith("open-sse/") || + f.startsWith("electron/") || + f.startsWith("bin/"); + if (!isSource) continue; + const hits = map.sources[f]; + if (!hits) return ["__RUN_ALL__"]; + hits.forEach((t) => out.add(t)); + } + return [...out].sort(); +} + +function changedFiles() { + const baseRef = process.env.GITHUB_BASE_REF; + const baseTarget = process.env.GITHUB_BASE_SHA || (baseRef ? `origin/${baseRef}` : "HEAD~1"); + const stdout = execFileSync( + "git", + ["diff", "--name-only", "--diff-filter=ACMR", `${baseTarget}...HEAD`], + { cwd: ROOT, encoding: "utf8" } + ); + return stdout + .split("\n") + .map((s) => s.trim()) + .filter(Boolean); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + const mapPath = path.join(ROOT, "config/quality/test-impact-map.json"); + let map; + try { + map = JSON.parse(fs.readFileSync(mapPath, "utf8")); + } catch { + console.log("__RUN_ALL__"); + process.exit(0); + } + const sel = selectImpacted({ changed: changedFiles(), map }); + process.stdout.write(sel.join("\n") + "\n"); +} diff --git a/stryker.conf.json b/stryker.conf.json index e76ea6a978..ef0624be91 100644 --- a/stryker.conf.json +++ b/stryker.conf.json @@ -36,6 +36,8 @@ "Direction: up (score can only improve; ratchet blocks drops — wired in a later INT phase)." ], "packageManager": "npm", + "incremental": true, + "incrementalFile": "reports/mutation/stryker-incremental.json", "testRunner": "tap", "plugins": ["@stryker-mutator/tap-runner"], "tap": { diff --git a/tests/unit/check-openapi-breaking-ratchet.test.ts b/tests/unit/check-openapi-breaking-ratchet.test.ts new file mode 100644 index 0000000000..cefd96cace --- /dev/null +++ b/tests/unit/check-openapi-breaking-ratchet.test.ts @@ -0,0 +1,141 @@ +// tests/unit/check-openapi-breaking-ratchet.test.ts +// TDD unit tests for the --ratchet mode added to scripts/check/check-openapi-breaking.mjs +// (Fase 9 Onda 0 — flip the oasdiff breaking-change gate from advisory to blocking). +// +// Strategy: test the exported pure evaluator without spawning oasdiff or touching +// git. The evaluator is the load-bearing decision: regression iff a real breaking +// change appears (measured > baseline); a null measurement (graceful skip) never +// blocks. End-to-end SKIP behavior (binary absent / base unresolved) is covered by +// the script's own advisory tests and a process-level skip assertion below. +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; +import { + evaluateOpenapiRatchet, + readBaselineOpenapiValue, + // @ts-expect-error — .mjs helper has no type declarations; runtime shape is known. +} from "../../scripts/check/check-openapi-breaking.mjs"; + +type RatchetVerdict = { regressed: boolean; skipped: boolean }; +const evaluate = evaluateOpenapiRatchet as (args: { + current: number | null; + baseline: number | null; +}) => RatchetVerdict; +const readBaseline = readBaselineOpenapiValue as (p?: string) => number | null; + +const SCRIPT_PATH = fileURLToPath( + new URL("../../scripts/check/check-openapi-breaking.mjs", import.meta.url) +); + +// --------------------------------------------------------------------------- +// evaluateOpenapiRatchet — the three contract cases from the plan +// --------------------------------------------------------------------------- + +test("evaluateOpenapiRatchet: current=0 baseline=0 → not regressed", () => { + const r = evaluate({ current: 0, baseline: 0 }); + assert.equal(r.regressed, false); + assert.equal(r.skipped, false); +}); + +test("evaluateOpenapiRatchet: current=1 baseline=0 → regressed (a single breaking change blocks)", () => { + const r = evaluate({ current: 1, baseline: 0 }); + assert.equal(r.regressed, true); + assert.equal(r.skipped, false); +}); + +test("evaluateOpenapiRatchet: current=null baseline=0 → graceful skip (no measurement never blocks)", () => { + const r = evaluate({ current: null, baseline: 0 }); + assert.equal(r.regressed, false); + assert.equal(r.skipped, true); +}); + +// --------------------------------------------------------------------------- +// evaluateOpenapiRatchet — additional edge cases for the ratchet semantics +// --------------------------------------------------------------------------- + +test("evaluateOpenapiRatchet: null baseline → graceful skip (no baseline, no ratchet)", () => { + const r = evaluate({ current: 3, baseline: null }); + assert.equal(r.regressed, false); + assert.equal(r.skipped, true); +}); + +test("evaluateOpenapiRatchet: undefined current → graceful skip", () => { + const r = evaluate({ current: undefined as unknown as null, baseline: 0 }); + assert.equal(r.regressed, false); + assert.equal(r.skipped, true); +}); + +test("evaluateOpenapiRatchet: measured == baseline → not regressed", () => { + const r = evaluate({ current: 2, baseline: 2 }); + assert.equal(r.regressed, false); + assert.equal(r.skipped, false); +}); + +test("evaluateOpenapiRatchet: measured < baseline → not regressed (improvement)", () => { + const r = evaluate({ current: 1, baseline: 5 }); + assert.equal(r.regressed, false); + assert.equal(r.skipped, false); +}); + +// --------------------------------------------------------------------------- +// readBaselineOpenapiValue — tolerant read of quality-baseline.json +// --------------------------------------------------------------------------- + +function withTmpBaseline(content: string | null, fn: (p: string) => void) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openapi-baseline-")); + const p = path.join(dir, "quality-baseline.json"); + if (content !== null) fs.writeFileSync(p, content); + try { + fn(p); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +test("readBaselineOpenapiValue: reads metrics.openapiBreaking.value", () => { + withTmpBaseline(JSON.stringify({ metrics: { openapiBreaking: { value: 0 } } }), (p) => { + assert.equal(readBaseline(p), 0); + }); +}); + +test("readBaselineOpenapiValue: missing file returns null (graceful skip)", () => { + assert.equal(readBaseline("/tmp/does-not-exist-99999/quality-baseline.json"), null); +}); + +test("readBaselineOpenapiValue: missing metric returns null", () => { + withTmpBaseline(JSON.stringify({ metrics: {} }), (p) => { + assert.equal(readBaseline(p), null); + }); +}); + +test("readBaselineOpenapiValue: non-numeric value returns null", () => { + withTmpBaseline(JSON.stringify({ metrics: { openapiBreaking: { value: "0" } } }), (p) => { + assert.equal(readBaseline(p), null); + }); +}); + +test("readBaselineOpenapiValue: invalid JSON returns null (does not throw)", () => { + withTmpBaseline("{ not valid json", (p) => { + assert.equal(readBaseline(p), null); + }); +}); + +// --------------------------------------------------------------------------- +// --ratchet end-to-end: binary-absent SKIP exits 0 (a missing measurement never +// blocks). Runs the script with an empty PATH so oasdiff is unresolvable. +// --------------------------------------------------------------------------- + +test("--ratchet with oasdiff absent (empty PATH) SKIPs and exits 0", () => { + const res = spawnSync(process.execPath, [SCRIPT_PATH, "--ratchet", "--quiet"], { + encoding: "utf8", + // Empty PATH → `which`/`oasdiff` unresolvable → findOasdiff() returns null. + env: { ...process.env, PATH: "/nonexistent-bin-dir" }, + timeout: 30_000, + }); + assert.equal(res.status, 0, "binary-absent SKIP must exit 0 even with --ratchet"); + assert.match(res.stdout, /openapiBreaking=SKIP reason=binary-absent/); +}); diff --git a/tests/unit/select-impacted-tests.test.ts b/tests/unit/select-impacted-tests.test.ts new file mode 100644 index 0000000000..9d702efd5f --- /dev/null +++ b/tests/unit/select-impacted-tests.test.ts @@ -0,0 +1,39 @@ +/** + * tests/unit/select-impacted-tests.test.ts + * + * TIA (Test Impact Analysis) selector — given a PR's changed files plus the + * import-graph impact map, pick the impacted unit tests with a run-all + * fail-safe. The `__RUN_ALL__` sentinel and `selectImpacted({changed, map})` + * signature are load-bearing — CI wiring depends on them. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { selectImpacted } from "../../scripts/quality/select-impacted-tests.mjs"; + +const MAP = { + sources: { + "open-sse/services/combo.ts": ["tests/unit/combo-routing-engine.test.ts"], + "src/sse/services/auth.ts": ["tests/unit/sse-auth.test.ts"], + }, +}; + +test("mapped source → its impacted test(s)", () => { + const sel = selectImpacted({ changed: ["open-sse/services/combo.ts"], map: MAP }); + assert.deepEqual(sel, ["tests/unit/combo-routing-engine.test.ts"]); +}); + +test("changed test file → itself (always run a changed test)", () => { + const sel = selectImpacted({ changed: ["tests/unit/sse-auth.test.ts"], map: MAP }); + assert.deepEqual(sel, ["tests/unit/sse-auth.test.ts"]); +}); + +test("hub file (setupPolyfill) → run all (fail-safe)", () => { + const sel = selectImpacted({ changed: ["open-sse/utils/setupPolyfill.ts"], map: MAP }); + assert.deepEqual(sel, ["__RUN_ALL__"]); +}); + +test("unmapped source file → run all (fail-safe)", () => { + const sel = selectImpacted({ changed: ["open-sse/brand-new-file.ts"], map: MAP }); + assert.deepEqual(sel, ["__RUN_ALL__"]); +});