feat(ci): Quality Gate v2 — Onda 0 + Onda 1 (gate flips, TIA, SAST, DAST-smoke, mutation infra) (#4016)

* docs(ops): add quality-gate assessment + replication playbook (Fase 9 foundation)

* feat(ci): flip oasdiff breaking-change gate to blocking (ratchet)

* docs(ops): deliver main branch-protection ruleset for owner to apply

* fix(ci): run typecheck:core in PR->release fast-gates (close fast-gates hole, part 1)

* perf(mutation): enable Stryker incremental mode + cache (scales the 60/80 rollout)

* feat(ci): commit CodeQL advanced config (security-extended), replacing default-setup

* feat(ci): version semgrep SAST workflow (owasp/secrets), advisory

* feat(quality): TIA test-impact map builder (import-graph; map built at runtime, gitignored)

* feat(quality): TIA impacted-test selector with run-all fail-safe

* fix(ci): run TIA-impacted unit tests in PR->release fast-gates (build map at runtime, fail-safe full)

* feat(ci): DAST-smoke per-PR (schemathesis subset + promptfoo injection-guard, blocking)

* fix(ci): unbreak Fase 9 PR CI (MDX frontmatter, CodeQL conflict, dast-smoke advisory)

- Add MDX frontmatter to docs/ops/{BRANCH_PROTECTION_MAIN,QUALITY_GATE_PLAYBOOK}.md.
  fumadocs rejects frontmatter-less docs -> 'npm run build' failed -> broke dast-smoke's
  build step (the release fast-gates never runs build, so this only surfaced on the PR).
- codeql.yml: workflow_dispatch-only until the owner switches repo CodeQL Default->Advanced
  (advanced configs cannot be processed while default setup is enabled; documented inline).
- dast-smoke.yml: job-level continue-on-error (advisory) so this brand-new gate matures
  before it blocks (repo convention: advisory -> blocking).

* ci(quality): make TIA unit-test step advisory until release test-debt is cleared

release/v3.8.27 carries ~17 pre-existing failing unit tests (budget #3537, apiKey
#3552, several Zod schemas, Puter/Qwen executors, mimocode entry, etc.) unrelated to
this PR — the new 'run tests on PR->release' gate surfaced them. Per the repo's
advisory->blocking convention, this step enters advisory (it still runs + reports)
so pre-existing debt doesn't block the gate program. typecheck:core stays blocking.
Flip to blocking (remove continue-on-error) once the release suite is green.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-16 17:27:36 -03:00
committed by GitHub
parent 599c10b048
commit 65f9ce7edf
16 changed files with 872 additions and 19 deletions

View File

@@ -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)

31
.github/workflows/codeql.yml vendored Normal file
View File

@@ -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"

62
.github/workflows/dast-smoke.yml vendored Normal file
View File

@@ -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

View File

@@ -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

View File

@@ -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[@]}"

29
.github/workflows/semgrep.yml vendored Normal file
View File

@@ -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

3
.gitignore vendored
View File

@@ -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

View File

@@ -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).",

View File

@@ -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.

View File

@@ -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 ~510% 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 12. | **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. | **~78/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.<ext>`** — roda a ferramenta, extrai o número, escreve `metrics.json`.
3. **`check-ratchet.<ext>`** — 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 56)
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

View File

@@ -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 <BASE_REF>: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();

View File

@@ -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`
);

View File

@@ -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");
}

View File

@@ -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": {

View File

@@ -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/);
});

View File

@@ -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__"]);
});