mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-20 22:22:57 +03:00
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:
committed by
GitHub
parent
599c10b048
commit
65f9ce7edf
13
.github/workflows/ci.yml
vendored
13
.github/workflows/ci.yml
vendored
@@ -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
31
.github/workflows/codeql.yml
vendored
Normal 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
62
.github/workflows/dast-smoke.yml
vendored
Normal 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
|
||||
6
.github/workflows/nightly-mutation.yml
vendored
6
.github/workflows/nightly-mutation.yml
vendored
@@ -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
|
||||
|
||||
27
.github/workflows/quality.yml
vendored
27
.github/workflows/quality.yml
vendored
@@ -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
29
.github/workflows/semgrep.yml
vendored
Normal 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
3
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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).",
|
||||
|
||||
23
docs/ops/BRANCH_PROTECTION_MAIN.md
Normal file
23
docs/ops/BRANCH_PROTECTION_MAIN.md
Normal 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.
|
||||
243
docs/ops/QUALITY_GATE_PLAYBOOK.md
Normal file
243
docs/ops/QUALITY_GATE_PLAYBOOK.md
Normal 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 ~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.<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 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
|
||||
@@ -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();
|
||||
|
||||
72
scripts/quality/build-test-impact-map.mjs
Normal file
72
scripts/quality/build-test-impact-map.mjs
Normal 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`
|
||||
);
|
||||
56
scripts/quality/select-impacted-tests.mjs
Normal file
56
scripts/quality/select-impacted-tests.mjs
Normal 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");
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
141
tests/unit/check-openapi-breaking-ratchet.test.ts
Normal file
141
tests/unit/check-openapi-breaking-ratchet.test.ts
Normal 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/);
|
||||
});
|
||||
39
tests/unit/select-impacted-tests.test.ts
Normal file
39
tests/unit/select-impacted-tests.test.ts
Normal 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__"]);
|
||||
});
|
||||
Reference in New Issue
Block a user