diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eb51874720..6a97f3e457 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,6 +24,12 @@ jobs: lint: name: Lint runs-on: ubuntu-latest + env: + # tsx gates below (known-symbols, route-guard-membership) import modules that + # open SQLite on load; provide DB env so a fresh CI DB initializes cleanly. + JWT_SECRET: ci-lint-secret-with-sufficient-length-for-validation + API_KEY_SECRET: ci-lint-api-key-secret-long + DISABLE_SQLITE_AUTO_BACKUP: "true" steps: - uses: actions/checkout@v6 - uses: actions/setup-node@v6 @@ -37,6 +43,16 @@ jobs: - run: npm run check:cycles - run: npm run check:route-validation:t06 - run: npm run check:any-budget:t11 + - run: npm run check:provider-consistency + - run: npm run check:fetch-targets + - run: npm run check:deps + - run: npm run check:file-size + - run: npm run check:error-helper + - run: npm run check:migration-numbering + - run: npm run check:public-creds + - run: npm run check:db-rules + - run: npm run check:known-symbols + - run: npm run check:route-guard-membership - run: npm run check:docs-sync - run: npm run typecheck:core # typecheck:noimplicit:core is a forward-looking gate (noImplicitAny). @@ -45,6 +61,46 @@ jobs: - run: npm run typecheck:noimplicit:core continue-on-error: true + quality-gate: + name: Quality Ratchet + runs-on: ubuntu-latest + needs: test-coverage + if: ${{ always() && needs.test-coverage.result == 'success' }} + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: ${{ env.CI_NODE_VERSION }} + cache: npm + - run: npm ci + # Coverage mergeada (coverage-summary.json) p/ o ratchet de cobertura. + - uses: actions/download-artifact@v8 + with: + name: coverage-report + path: coverage/ + - run: npm run quality:collect + # Catraca: falha se qualquer métrica regredir vs quality-baseline.json (commitado). + # Hoje: contagem de warnings do ESLint. Fase 4 estende com cobertura (lida do + # coverage mergeado). Tamanho de arquivo e duplicação têm gates dedicados. + - name: Ratchet check + run: node scripts/quality/check-quality-ratchet.mjs --summary .artifacts/quality-ratchet.md + # Catraca de duplicação (jscpd@4 sobre src+open-sse). Roda neste job (paralelo) + # para não pesar no caminho crítico do lint. + - name: Duplication ratchet + run: npm run check:duplication + - name: Complexity ratchet + run: npm run check:complexity + - name: Append summary + if: always() + run: cat .artifacts/quality-ratchet.md >> "$GITHUB_STEP_SUMMARY" + - name: Upload ratchet report + if: always() + uses: actions/upload-artifact@v7 + with: + name: quality-ratchet + path: .artifacts/quality-ratchet.md + if-no-files-found: warn + docs-sync-strict: name: Docs Sync (Strict) runs-on: ubuntu-latest @@ -56,6 +112,19 @@ jobs: cache: npm - run: npm ci - run: npm run check:docs-all + # Previously-orphaned contract gates (existed as files, never wired anywhere). + # All exit 0 today: cli-i18n is a hard gate, openapi-coverage is a ratchet + # (floor ~36), openapi-security-tiers is advisory (Hard Rules #15/#17). + - name: CLI i18n consistency + run: npm run check:cli-i18n + - name: OpenAPI route coverage (ratchet) + run: npm run check:openapi-coverage + - name: OpenAPI security-tier consistency (advisory) + run: npm run check:openapi-security-tiers + - name: OpenAPI spec paths resolve to real routes (anti-hallucination) + run: npm run check:openapi-routes + - name: Doc /api refs resolve to real routes (anti-hallucination) + run: npm run check:docs-symbols - name: i18n translation drift (warn) run: node scripts/i18n/check-translation-drift.mjs --warn @@ -125,6 +194,9 @@ jobs: run: git fetch --no-tags origin "${GITHUB_BASE_REF}" --depth=1 - name: Validate source changes include tests run: node scripts/check/check-pr-test-policy.mjs --summary-file .artifacts/pr-test-policy.md + # Anti test-masking: flag net assert removal / new assert.ok(true) in changed tests. + - name: Detect test-masking (weakened assertions) + run: npm run check:test-masking - name: Publish PR test policy summary if: always() run: | @@ -360,10 +432,12 @@ jobs: find . -maxdepth 3 -type f | sort exit 1 fi - # Gate aligned to the project's local coverage bar (npm run test:coverage - # uses 40/40/40/40). The previous 75/70 gate never ran on main (the - # coverage shards always failed → this job was skipped), so it was never - # actually enforced and is inconsistent with the repo's real standard. + # Gate aligned to the project's local coverage bar: `npm run test:coverage` + # gates at 60/60/60/60, so CI must match it (the previous CI floor of 40 + # silently undershot the local bar — a real drift). Real merged coverage is + # ~79/79/82/75, so 60 is a conservative floor with headroom; the Fase-4 + # coverage ratchet (quality-baseline.json) layers "must not drop vs baseline" + # on top of this floor. npx c8 report \ --temp-directory coverage-shards \ --reports-dir coverage \ @@ -374,7 +448,7 @@ jobs: --exclude=tests/** \ --exclude=**/*.test.* \ --check-coverage \ - --statements 40 --lines 40 --functions 40 --branches 40 + --statements 60 --lines 60 --functions 60 --branches 60 - name: Build coverage summary if: always() run: | diff --git a/.gitignore b/.gitignore index a05fc7fd67..5598c1052a 100644 --- a/.gitignore +++ b/.gitignore @@ -203,8 +203,5 @@ pr_reviews*.json # internal setup prompts with personal credentials — never commit CODEX-SETUP-PROMPT.md --home-diegosouzapw-dev-automações-bots-yt-downloader-20260504 .txt -PLANO-QUALITY-GATES.md +# Quality ratchet — métricas efêmeras (baseline é commitado, métricas não) quality-metrics.json -RELATORIO-QUALITY-GATES.md --home-diegosouzapw-dev-automações-bots-yt-downloader-20260410 .txt diff --git a/.husky/pre-commit b/.husky/pre-commit index 69609f0799..c302cfb654 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,31 +1,12 @@ -# #!/usr/bin/env sh -# if ! command -v npx >/dev/null 2>&1; then -# echo "⚠️ npx not found in PATH — skipping pre-commit hooks" -# echo " Run 'npm run lint && npm run check:any-budget:t11' manually before pushing." -# exit 0 -# fi +#!/usr/bin/env sh +if ! command -v npx >/dev/null 2>&1; then + echo "⚠️ npx not found in PATH — skipping pre-commit hooks" + echo " Run 'npm run lint && npm run check:any-budget:t11' manually before pushing." + exit 0 +fi -# npx lint-staged -# node scripts/check/check-docs-sync.mjs -# npm run check:any-budget:t11 - -# # Strict env-doc sync (FASE 2) -# node scripts/check/check-env-doc-sync.mjs - -# # CLI i18n consistency check — all t() keys must exist in en.json (FASE 8.3) -# node scripts/check/check-cli-i18n.mjs - -# # i18n docs drift advisory (FASE 5) — warn-only on pre-commit; CI enforces strict. -# node scripts/i18n/check-translation-drift.mjs --warn || \ -# echo "⚠️ i18n drift detected. Run 'npm run i18n:run' to update locale mirrors." - -# # i18n UI coverage advisory (FASE 6) — pre-commit warns; CI enforces strict. -# node scripts/i18n/check-ui-keys-coverage.mjs --threshold=80 || \ -# echo "⚠️ UI i18n coverage below 80% for at least one locale." - -# # OpenAPI coverage check — fails if coverage < 99% (FASE 08 content audit) -# node scripts/check/check-openapi-coverage.mjs - -# # OpenAPI security tier consistency check — fails if x-loopback-only / x-always-protected -# # annotations diverge from routeGuard.ts compile-time constants (FASE 08 content audit) -# node scripts/check/check-openapi-security-tiers.mjs +# Cheap, deterministic local gates (re-enabled). Slower checks (i18n drift, +# openapi coverage/security-tiers, env-doc sync) run in CI to keep commits fast. +npx lint-staged +node scripts/check/check-docs-sync.mjs +npm run check:any-budget:t11 diff --git a/PLANO-QUALITY-GATES-FASE7.md b/PLANO-QUALITY-GATES-FASE7.md new file mode 100644 index 0000000000..4029007553 --- /dev/null +++ b/PLANO-QUALITY-GATES-FASE7.md @@ -0,0 +1,159 @@ +# Fase 7 — Quality Gates: Segurança, Dead-Code, Mutação & Ferramental Community + +> **Para workers agênticos:** SUB-SKILL OBRIGATÓRIA: `superpowers:subagent-driven-development` (recomendado) ou `superpowers:executing-plans`, tarefa-a-tarefa. Cada tarefa aqui é um subsistema independente → **expandir em sub-plano bite-sized próprio** no momento da execução. Hard Rule #18 (TDD/VPS) em tudo. + +> # ⏳ PORTÃO DE ATIVAÇÃO — NÃO INICIAR ANTES DE **2026-06-16** +> **Este plano está GUARDADO, não ativo.** Decisão do owner (2026-06-09): finalizar 100% as Fases 0–6 (PR #3471), **usar em produção por 1 semana** para validar na prática, e só então evoluir. **Data cravada de início da Fase 7: 2026-06-16.** Não ativar antes — o objetivo da semana é coletar sinal real (falsos-positivos dos gates, custo de CI, atrito) antes de adicionar mais. +> **Pré-condições para ativar:** (1) PR #3471 (Fases 0–6) mergeada e rodada ≥1 semana; (2) re-home do PR para `release/v3.8.18` resolvido; (3) as issues #3483–#3501 com decisões aplicadas ou conscientemente adiadas. + +**Goal:** Maximizar a cobertura de quality gates do OmniRoute adicionando catracas de **segurança** (Sonar/osv/CodeQL → zero na timeline), **dead-code**, **complexidade cognitiva**, **type-coverage**, **mutação**, **bundle-size**, **a11y** e completando o anti-slopsquatting — usando **somente ferramentas Community/OSS** (projeto é open-source, zero SaaS pago, dados na box). + +**Architecture:** Reusa o motor existente — toda métrica numérica entra como `{value, direction}` em `quality-baseline.json` (catraca só-regressão) ou vira um `scripts/check/check-*.mjs` dedicado (padrão `check-t11-any-budget.mjs`). Gates pesados vão no job paralelo `quality-gate`; gates rápidos no `lint`; mutação/visual em job nightly separado. Tudo só-regressão (sem flag-day). + +**Tech Stack (tudo OSS/Community):** SonarQube **Community Build** (self-hosted) · osv-scanner (Google) · CodeQL (GitHub, grátis p/ público) · knip · eslint-plugin-sonarjs · type-coverage · lockfile-lint · dpdm · Stryker (`@stryker-mutator/*`) · size-limit · `@axe-core/playwright` · semcheck · agent-lsp (MCP) · Qlty CLI (OSS, opcional). ESLint 9 flat · c8 · Node native test runner · GitHub Actions. + +--- + +## Princípio (igual às Fases 0–6) + +Toda catraca é **só-regressão**: congela o baseline atual, bloqueia QUALQUER piora, decai a zero/melhor com o tempo via `--update`. Nenhum gate exige limpeza imediata (flag-day). Cada ferramenta nova que vira dependência **deve ser adicionada a `dependency-allowlist.json`** (o gate `check-deps` da Fase 2 vai exigir — é o ponto de revisão humana). + +## Mapa de arquivos (criar/modificar) + +| Arquivo | Responsabilidade | +|---|---| +| `quality-baseline.json` (modificar) | + `vulnCount`, `codeqlAlerts`, `sonarIssues`, `cognitiveComplexity`, `typeCoveragePct`, `deadExports` | +| `scripts/quality/collect-metrics.mjs` (modificar) | + coletores: osv-scanner, CodeQL count, Sonar API, knip, type-coverage, sonarjs | +| `scripts/check/check-vuln-ratchet.mjs` (criar) | osv-scanner → vulnCount (catraca) | +| `scripts/check/check-dead-code.mjs` (criar) | knip → exports/files/deps mortos (catraca) | +| `scripts/check/check-cognitive-complexity.mjs` + `eslint.sonarjs.config.mjs` (criar) | sonarjs/cognitive-complexity em config isolado (não polui o count principal) | +| `scripts/check/check-type-coverage.mjs` (criar) | type-coverage % (catraca up) | +| `scripts/check/check-lockfile.mjs` (criar) | lockfile-lint (host/https/integrity) | +| `scripts/check/check-pr-evidence.mjs` (criar) | exige output de comando no corpo do PR (Rule #18 mecânica) | +| `scripts/check/check-bundle-size.mjs` + `.size-limit.json` (criar) | size-limit → orçamento de bundle | +| `tests/e2e/a11y.spec.ts` (criar) | `@axe-core/playwright` nas páginas-chave | +| `stryker.conf.json` (criar) | mutação nos ~8 módulos críticos (nightly) | +| `sonar-project.properties` (modificar) | remover `coverage`/`cpd` exclusions; ativar new-code gate | +| `.github/workflows/ci.yml` (modificar) | wirar novos gates (lint / quality-gate / nightly) + `qualitygate.wait` no Sonar | +| `semcheck.yaml` (criar) | semcheck: docs↔código (camada fuzzy LLM, opcional) | +| `.mcp.json` / config de agentes (modificar) | registrar agent-lsp (LSP-in-the-loop) | +| `dependency-allowlist.json` (modificar) | + osv-scanner, knip, sonarjs, type-coverage, lockfile-lint, stryker, size-limit, axe-core, dpdm | + +--- + +## Tarefas (cada uma = 1 sub-plano bite-sized na execução) + +### Task 1 — Ativar SonarQube Community + "Clean as You Code" (gate de segurança nativo) +- **Tool:** SonarQube Community Build (self-hosted, grátis). +- **Files:** `sonar-project.properties`, `.github/workflows/ci.yml` (job `sonarqube`). +- **Approach:** setar secrets `SONAR_TOKEN`/`SONAR_HOST_URL`; **remover** `sonar.coverage.exclusions=**/*` e `sonar.cpd.exclusions=**/*` (hoje neutralizam o Sonar); ativar o quality gate **new-code / "Clean as You Code"** (código novo não pode adicionar issue/bug/vuln/hotspot; legado grandfathered) + adicionar `-Dsonar.qualitygate.wait=true` para **bloquear** o PR (hoje o job é inerte: secrets-gated, sem wait). +- **Acceptance:** PR que introduz um code-smell/bug/vuln em código novo falha o gate; legado não bloqueia. Documentar suppressions legítimas (já há h1–h6 no properties). + +### Task 2 — Catraca de vulnerabilidades (osv-scanner) +- **Tool:** osv-scanner (Google/OSV, OSS) — `--format json`, on-box. +- **Files:** `scripts/check/check-vuln-ratchet.mjs`, `quality-baseline.json` (+`vulnCount`), `collect-metrics.mjs`, `ci.yml` (job `quality-gate`), `dependency-allowlist.json`. +- **Approach:** rodar `osv-scanner --format json` sobre os lockfiles → contar vulns → métrica `vulnCount {direction: down}`. Catraca: não pode subir, decai a zero. Mantém o `npm audit` escalonado da Fase 0 como bloqueio de crítico imediato; osv é o ratchet de timeline. +- **Acceptance:** nova dep com vuln conhecida sobe o count → falha; remediar/remover baixa → `--update`. + +### Task 3 — Catraca de alertas CodeQL +- **Tool:** GitHub CodeQL (já roda; grátis p/ repo público). +- **Files:** `scripts/check/check-codeql-ratchet.mjs`, `quality-baseline.json` (+`codeqlAlerts`), `ci.yml`. +- **Approach:** puxar a contagem de alertas abertos via `gh api /repos/{owner}/{repo}/code-scanning/alerts?state=open` → métrica `codeqlAlerts {down}`. (Respeitar Hard Rule #14 — dismiss só com justificativa; alertas dismissed não contam.) +- **Acceptance:** novo alerta CodeQL sobe o count → sinaliza; resolver baixa. + +### Task 4 — Dead-code / unused-exports / unused-deps (knip) +- **Tool:** knip (OSS, v6+) — `--reporter json`. +- **Files:** `scripts/check/check-dead-code.mjs`, `quality-baseline.json` (+`deadExports`/`unusedDeps`), `knip.json` (config), `collect-metrics.mjs`, `ci.yml`, `dependency-allowlist.json`. +- **Approach:** `knip --reporter json` sobre os workspaces `src/`+`open-sse/` → contar unused files/exports/deps → catraca `down`. Config knip ciente do monorepo + Next 16. +- **Acceptance:** novo export/dep morto sobe → falha; remoção baixa. + +### Task 5 — Complexidade cognitiva (eslint-plugin-sonarjs, config isolado) +- **Tool:** eslint-plugin-sonarjs (OSS) — `sonarjs/cognitive-complexity`. +- **Files:** `eslint.sonarjs.config.mjs` (config standalone, NÃO o principal — não polui o `eslintWarnings=3482`), `scripts/check/check-cognitive-complexity.mjs`, `quality-baseline.json` (+`cognitiveComplexity`), `ci.yml` (job `quality-gate`), `dependency-allowlist.json`. +- **Approach:** mesmo molde do `check-complexity` (Fase 6) mas com `sonarjs/cognitive-complexity` num config isolado; contar violações → catraca `down`. (Complementa a complexidade ciclomática core já existente.) +- **Acceptance:** função acima do limite cognitivo sobe o count → falha. + +### Task 6 — Type-coverage ratchet +- **Tool:** type-coverage (OSS). +- **Files:** `scripts/check/check-type-coverage.mjs`, `quality-baseline.json` (+`typeCoveragePct {up}`), `dependency-allowlist.json`. +- **Approach:** `type-coverage --detail --json` → % de símbolos tipados → catraca `up`. Complementa o `check:any-budget` (count de `any` por arquivo) com a visão %-global. +- **Acceptance:** queda do % tipado → falha. + +### Task 7 — Lockfile policy (lockfile-lint) +- **Tool:** lockfile-lint (OSS, v5). +- **Files:** `scripts/check/check-lockfile.mjs`, `ci.yml` (lint), `dependency-allowlist.json`. +- **Approach:** `lockfile-lint --path package-lock.json --type npm --validate-https --validate-integrity --allowed-hosts npm` → gate pass/fail (não é ratchet; é política anti-poisoning). Complementa o `check-deps` (Fase 2). +- **Acceptance:** lockfile com host não-https/sem integrity → falha. + +### Task 8 — Completar anti-slopsquatting (registry-existence + age-cooldown) +- **Tool:** npm registry API (`npm view time.created`). +- **Files:** `scripts/check/check-deps.mjs` (estender), test. +- **Approach:** além do allowlist-diff atual, para uma dep NOVA: verificar que existe no registry E que foi publicada há ≥72h (age-cooldown contra "registra o nome alucinado em horas"). Base: CSA 2026 (19,7% de nomes alucinados; 43% reaparecem). +- **Acceptance:** dep nova inexistente no registry ou publicada há <72h → falha (a menos que allowlistada com justificativa). + +### Task 9 — Pisos de cobertura por módulo crítico (peça adiada da Fase 4) +- **Files:** `scripts/quality/collect-metrics.mjs` (estender), `quality-baseline.json`. +- **Approach:** emitir `coverage..lines` (lido do `coverage-summary.json` por-arquivo) para ~8 módulos de alto risco: `open-sse/handlers/chatCore.ts`, `open-sse/services/combo.ts`, `open-sse/services/accountFallback.ts`, `src/sse/services/auth.ts`, `src/server/authz/routeGuard.ts`, `open-sse/utils/error.ts`, `open-sse/utils/publicCreds.ts`, `src/shared/utils/circuitBreaker.ts`. Cada um vira métrica `up`. **Calibrar a partir do coverage mergeado real do 1º run verde na main.** +- **Acceptance:** queda de cobertura num módulo crítico → falha, mesmo que o global não caia. + +### Task 10 — Evidence-in-PR-body (peça adiada da Fase 5) +- **Files:** `scripts/check/check-pr-evidence.mjs`, `ci.yml` (job `pr-test-policy`). +- **Approach:** se o corpo do PR afirma "tests pass"/"added endpoint X"/"fixed Y" sem um bloco de **output de comando** anexado (typecheck/test/grep), falha (torna a Rule #18 mecânica — "evidence before assertions"). Heurístico, no contexto de PR. +- **Acceptance:** PR alegando sucesso sem output anexado → falha. + +### Task 11 — Mutation testing nos módulos críticos (Stryker, nightly) +- **Tool:** Stryker (`@stryker-mutator/core` + runner; OSS). +- **Files:** `stryker.conf.json`, `ci.yml` (job NIGHTLY separado — não no PR), `dependency-allowlist.json`. +- **Approach:** escopar Stryker aos ~8 módulos críticos da Task 9 (não repo-wide — é caro + c8 já OOM-prone). Mutantes sobreviventes = **testes tautológicos** (passam sem provar nada) → complementa o `check-test-masking` da Fase 4. Rodar nightly/weekly, não por-PR. +- **Acceptance:** mutation score por módulo crítico vira métrica (catraca `up`, nightly). + +### Task 12 — Bundle-size / perf budget (size-limit) +- **Tool:** size-limit (OSS). +- **Files:** `.size-limit.json`, `scripts/check/check-bundle-size.mjs`, `ci.yml`, `dependency-allowlist.json`. +- **Approach:** definir orçamento por bundle Next 16; size-limit emite tamanhos → catraca `down` (bundle não pode inchar). +- **Acceptance:** PR que estoura o orçamento de bundle → falha. + +### Task 13 — a11y gate (axe-core + Playwright) +- **Tool:** `@axe-core/playwright` (OSS; Playwright já existe). +- **Files:** `tests/e2e/a11y.spec.ts`, `ci.yml` (job `test-e2e`), `dependency-allowlist.json`. +- **Approach:** rodar axe nas páginas-chave do dashboard; congelar violações atuais (catraca `down`). Atende o item a11y/visual do plano t15. +- **Acceptance:** nova violação a11y → falha; correção baixa. + +### Task 14 — semcheck (camada fuzzy docs↔código, opcional/LLM) +- **Tool:** semcheck (OSS, MIT) — `fail-on-issues`. +- **Files:** `semcheck.yaml`, `ci.yml` (advisory). +- **Approach:** regras ligando `docs/**` ao módulo de código que documentam; pega docs que descrevem o que o código NÃO faz (camada fuzzy sobre o determinístico `check-docs-symbols` da Fase 6). É LLM → rodar advisory/non-blocking ou em label, por custo. +- **Acceptance:** doc que descreve comportamento inexistente → flag (advisory). + +### Task 15 — agent-lsp (LSP-in-the-loop para os agentes) +- **Tool:** agent-lsp (MCP server, OSS). +- **Files:** config MCP dos agentes (`.mcp.json`/equivalente). +- **Approach:** expor `tsserver`/agent-lsp aos agentes para `blast_radius`/diagnostics/`preview_edit` ANTES de escrever — vira "símbolo inventado" de catch-de-review para impossibilidade-no-edit. Pareia com `typecheck:core` como gate pré-PR (compile-before-claim). +- **Acceptance:** agentes resolvem símbolo/import via LSP; menos alucinação de símbolo na origem. + +### Task 16 — dpdm circular-deps JSON cross-check (opcional) +- **Tool:** dpdm (OSS, v4) — `--circular --output`. +- **Approach:** cross-check JSON de ciclos complementando o `check-cycles.mjs` existente (AST-TS mais preciso). Catraca de contagem de ciclos. Baixa prioridade (já temos check-cycles). + +### Task 17 — Avaliar Qlty CLI como consolidador (opcional, spike) +- **Tool:** Qlty CLI (OSS, grátis). +- **Approach:** spike: avaliar se Qlty (Baseline analysis + 70 analyzers) substitui N scripts caseiros sem perder o controle/determinismo. Decisão build-vs-buy. Não obrigatório. + +--- + +## Wiring & CI (resumo) +- **lint job:** check-lockfile, check-cognitive-complexity (rápido?), check-type-coverage. +- **quality-gate job (paralelo):** check-vuln-ratchet, check-dead-code, check-codeql-ratchet, check-cognitive-complexity (se lento), check-bundle-size. +- **pr-test-policy job:** check-pr-evidence. +- **sonarqube job:** Clean-as-You-Code + `qualitygate.wait`. +- **NIGHTLY job (novo):** Stryker (mutação), semcheck (advisory), a11y full. +- Todas as métricas numéricas → `quality-baseline.json` (motor da Fase 1). Toda dep nova → `dependency-allowlist.json`. + +## Self-Review +- **Cobertura do spec:** 7 gates sugeridos = Task 1-3 (segurança), 4 (knip), 9 (coverage por módulo), 10 (evidence), 11 (mutação), 12 (bundle), 13 (a11y). "Todas as ferramentas discutidas" = Tasks 1-8, 11-17 (Sonar/osv/CodeQL/knip/sonarjs/type-coverage/lockfile/dpdm/stryker/size-limit/axe/semcheck/agent-lsp/Qlty). ✓ +- **Community/OSS only:** confirmado — Sonar Community Build, todos os demais OSS, zero SaaS pago. ✓ +- **Sem flag-day:** toda catraca é só-regressão, calibrada do estado atual. ✓ +- **Consistência:** todas as métricas usam o formato `{value, direction}` do motor da Fase 1; deps novas passam pelo `check-deps`+`dependency-allowlist.json`. ✓ + +## Handoff (na ativação, 2026-06-16+) +Quando o portão abrir: começar pela **Task 1-3 (catraca de segurança)** e **Task 4 (knip)** — maior retorno. Cada Task vira um sub-plano `writing-plans` bite-sized próprio. Recomendado: Subagent-Driven, 1 subagente por Task, com auditoria (trust-but-verify) e o ratchet `eslintWarnings`/`check-deps` validando que cada adição não regride o que já temos. diff --git a/PLANO-QUALITY-GATES.md b/PLANO-QUALITY-GATES.md new file mode 100644 index 0000000000..c2727665db --- /dev/null +++ b/PLANO-QUALITY-GATES.md @@ -0,0 +1,688 @@ +# Plano de Implementação — Quality Gates & Catraca Anti-Alucinação + +> **Para workers agênticos:** SUB-SKILL OBRIGATÓRIA: use `superpowers:subagent-driven-development` (recomendado) ou `superpowers:executing-plans` para executar este plano tarefa-a-tarefa. Os passos usam checkbox (`- [ ]`) para rastreio. **Cada fix de bug obedece à Hard Rule #18** (teste falha→passa OU validação ao vivo no VPS). Não burle Husky (`--no-verify`) sem aprovação. Veja o diagnóstico completo em [`RELATORIO-QUALITY-GATES.md`](./RELATORIO-QUALITY-GATES.md). + +**Goal:** Generalizar a catraca de qualidade do OmniRoute (hoje só para `any`) para todas as métricas relevantes — cobertura, duplicação, tamanho de arquivo, complexidade — e adicionar gates determinísticos anti-alucinação, tudo no padrão `check-*.mjs` já existente, sem SaaS novo. + +**Architecture:** Camadas incrementais. (0) reativa/reconcilia o que já existe; (1) constrói o **motor de catraca** (`quality-baseline.json` commitado + coletor + comparador genérico que clona o `check-t11-any-budget.mjs`); (2) adiciona gates determinísticos que matam os ímãs de alucinação (provider-consistency, fetch-targets, openapi-routes); (3) catraca de duplicação+tamanho; (4) catraca de cobertura + anti test-masking; (5) skill `/babysit` + evidência. Toda catraca é **só-regressão** (baseline congelado) — nunca um piso absoluto que exija limpeza flag-day. + +**Tech Stack:** Node ≥20 ESM (`.mjs`/`.ts` via `tsx`), ESLint 9 flat config, c8, jscpd v5, eslint-plugin-sonarjs v4, GitHub Actions, Node native test runner (`node --import tsx --test`), `gh` CLI + GraphQL. + +**Escopo / sub-planos (scope-check):** Fases 0, 1 e 2 estão totalmente bite-sized aqui. Fases 3, 4 e 5 são subsistemas independentes — cada uma deve ser **expandida no próprio sub-plano** (via `writing-plans`) no momento da execução, a partir das specs/critérios de aceitação definidos aqui. Cada fase entrega software funcional e testável por si só. + +--- + +## Mapa de arquivos (o que será criado/modificado) + +| Arquivo | Responsabilidade | +|---------|------------------| +| `quality-baseline.json` (criar, **commitar**) | Baseline congelado: por métrica `{value, direction}` (`down`=menor-é-melhor, `up`=maior-é-melhor) | +| `scripts/quality/collect-metrics.mjs` (criar) | Roda os coletores → emite `quality-metrics.json` | +| `scripts/quality/check-quality-ratchet.mjs` (criar) | Comparador genérico: falha em qualquer regressão; com `--update` ratcheta o baseline | +| `scripts/check/check-fetch-targets.mjs` (criar) | Todo `fetch("/api/...")` do dashboard resolve para um `route.ts` real | +| `scripts/check/check-provider-consistency.ts` (criar) | ids de provider batem entre `providers.ts` ↔ `providerRegistry.ts` ↔ `validation.ts` | +| `scripts/check/check-openapi-routes.mjs` (criar) | Toda `path` do `openapi.yaml` ↔ `route.ts` real (bidirecional) | +| `scripts/check/check-deps.mjs` (criar) | Anti-slopsquatting: allowlist + existência no registry + age-cooldown | +| `.github/workflows/ci.yml` (modificar) | Novo job `quality-gate`; reconciliar gate de cobertura; escalonar audit; plugar scripts órfãos | +| `.husky/pre-commit` (modificar) | Reativar a parte barata | +| `package.json` (modificar) | Novos scripts `check:*` / `quality:*` | +| `eslint.config.mjs` (modificar, Fase 3) | `max-lines`, `max-lines-per-function`, `complexity`, `sonarjs/cognitive-complexity` (warn) | +| `.claude/skills/babysit/SKILL.md` (criar, Fase 5) | Skill `/babysit` | +| `tests/unit/quality-ratchet.test.ts` etc. (criar) | Testes TDD de cada gate | + +--- + +# FASE 0 — Reativar & Reconciliar (quick wins, sem tooling novo) + +### Task 0.1: Reconciliar o gate de cobertura do CI (40 → baseline real) + +**Contexto:** `ci.yml:377` gata em `40/40/40/40`; local gata `60`; comentário renderiza `60`; baseline real ≈ 79–82%. O 40 torna o gate quase banguela. + +**Files:** +- Modify: `.github/workflows/ci.yml:376-377` + +- [ ] **Step 1: Confirmar o baseline real de cobertura** + +Run: +```bash +npm run test:coverage 2>&1 | tail -20 +node -e "const c=require('./coverage/coverage-summary.json').total; console.log(c.statements.pct,c.lines.pct,c.functions.pct,c.branches.pct)" +``` +Expected: 4 números (ex.: `79.8 79.8 82.2 75.2`). Anote-os. + +- [ ] **Step 2: Subir o gate do CI para `baseline_real - 2` (headroom anti-flake)** + +Em `.github/workflows/ci.yml`, troque a linha `--statements 40 --lines 40 --functions 40 --branches 40` pelos valores `(real-2)` de cada métrica (ex.: `--statements 77 --lines 77 --functions 80 --branches 73`). Mantenha como **piso**; a catraca da Fase 4 cuidará do "não-cair". + +- [ ] **Step 3: Alinhar o script local e o display do comentário** para os mesmos números (procure `60` em `package.json` `test:coverage` e em `scripts/check/test-report-summary.mjs`). + +- [ ] **Step 4: Verificar que o CI não quebra** — abrir um PR de teste (ou rodar `act`/push numa branch) e confirmar que o job `test-coverage` fica verde com o novo piso. + +- [ ] **Step 5: Commit** +```bash +git add .github/workflows/ci.yml package.json scripts/check/test-report-summary.mjs +git commit -m "fix(ci): reconcile coverage gate to real baseline (40->~78) across CI/local/report" +``` + +### Task 0.2: Escalonar `npm audit` (critical=bloqueia / high=avisa) + +**Files:** Modify: `package.json:112` + +- [ ] **Step 1: Trocar o script `audit:deps`** de `npm audit --audit-level=moderate && npm run audit:electron` para: +```json +"audit:deps": "npm audit --audit-level=critical && (npm audit --audit-level=high || echo '::warning::high-severity advisories present (non-blocking)') && npm run audit:electron", +``` + +- [ ] **Step 2: Rodar e observar o comportamento** +```bash +npm run audit:deps; echo "exit=$?" +``` +Expected: `exit=0` se não houver critical; mensagem de warning se houver high. + +- [ ] **Step 3: Commit** +```bash +git add package.json && git commit -m "chore(ci): tier npm audit (critical blocks, high warns)" +``` + +### Task 0.3: Plugar os 3 scripts órfãos no CI + +**Contexto:** `check:cli-i18n`, `check:openapi-coverage`, `check:openapi-security-tiers` existem, dão `exit 1`, mas não rodam em lugar nenhum (Hard Rules #15/#17). + +**Files:** Modify: `.github/workflows/ci.yml` (job `docs-sync-strict` ou `lint`) + +- [ ] **Step 1: Rodar os 3 localmente para confirmar verde no estado atual** +```bash +npm run check:cli-i18n && npm run check:openapi-coverage && npm run check:openapi-security-tiers; echo "exit=$?" +``` +Expected: `exit=0` (se algum falhar, corrija a deriva antes de plugar). + +- [ ] **Step 2: Adicionar os 3 como steps** no job `docs-sync-strict` do `ci.yml`, após `check:docs-all`. + +- [ ] **Step 3: Commit** +```bash +git add .github/workflows/ci.yml && git commit -m "ci: wire orphaned gates (cli-i18n, openapi-coverage, openapi-security-tiers)" +``` + +### Task 0.4: Reativar a parte barata do pre-commit do Husky + +**Files:** Modify: `.husky/pre-commit` + +- [ ] **Step 1: Descomentar SÓ as 3 linhas baratas e determinísticas:** +```sh +npx lint-staged +node scripts/check/check-docs-sync.mjs +npm run check:any-budget:t11 +``` +(Deixe i18n/openapi comentados por enquanto — eles são mais lentos; rodam no CI.) + +- [ ] **Step 2: Testar o hook** com um commit trivial e medir o tempo: +```bash +time git commit --allow-empty -m "chore: test pre-commit hook" +git reset --soft HEAD~1 +``` +Expected: hook roda lint-staged + 2 checks em poucos segundos. + +- [ ] **Step 3: Commit** +```bash +git add .husky/pre-commit && git commit -m "chore(husky): re-enable cheap pre-commit gates (lint-staged, docs-sync, any-budget)" +``` + +--- + +# FASE 1 — Motor de Catraca (o coração) ⭐ + +> Generaliza o `check-t11-any-budget.mjs` (catraca de `any` por arquivo) para um motor de catraca genérico, multi-métrica, que lê um baseline commitado e falha em qualquer regressão. Começa com 2 métricas (warnings de ESLint + cobertura) e é estendido nas fases seguintes. + +### Task 1.1: Comparador genérico de catraca (TDD) + +**Files:** +- Create: `scripts/quality/check-quality-ratchet.mjs` +- Test: `tests/unit/quality-ratchet.test.ts` + +- [ ] **Step 1: Escrever o teste que falha** +```ts +// tests/unit/quality-ratchet.test.ts +import { test } from "node:test"; +import assert from "node:assert"; +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const SCRIPT = path.resolve("scripts/quality/check-quality-ratchet.mjs"); + +function run(baseline, metrics, extraArgs = []) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ratchet-")); + const bPath = path.join(dir, "baseline.json"); + const mPath = path.join(dir, "metrics.json"); + fs.writeFileSync(bPath, JSON.stringify(baseline)); + fs.writeFileSync(mPath, JSON.stringify(metrics)); + try { + const out = execFileSync("node", [SCRIPT, "--baseline", bPath, "--metrics", mPath, ...extraArgs], { encoding: "utf8" }); + return { code: 0, out, dir, bPath }; + } catch (e) { + return { code: e.status, out: (e.stdout || "") + (e.stderr || ""), dir, bPath }; + } +} + +test("passes when metrics equal baseline", () => { + const b = { metrics: { eslintWarnings: { value: 100, direction: "down" }, "coverage.lines": { value: 80, direction: "up" } } }; + assert.equal(run(b, { eslintWarnings: 100, "coverage.lines": 80 }).code, 0); +}); + +test("fails when a 'down' metric regresses (more warnings)", () => { + const b = { metrics: { eslintWarnings: { value: 100, direction: "down" } } }; + const r = run(b, { eslintWarnings: 101 }); + assert.equal(r.code, 1); + assert.match(r.out, /eslintWarnings/); +}); + +test("fails when an 'up' metric regresses (coverage drops)", () => { + const b = { metrics: { "coverage.lines": { value: 80, direction: "up" } } }; + assert.equal(run(b, { "coverage.lines": 79 }).code, 1); +}); + +test("passes on improvement; --update ratchets the baseline", () => { + const b = { metrics: { eslintWarnings: { value: 100, direction: "down" } } }; + const r = run(b, { eslintWarnings: 90 }, ["--update"]); + assert.equal(r.code, 0); + const updated = JSON.parse(fs.readFileSync(r.bPath, "utf8")); + assert.equal(updated.metrics.eslintWarnings.value, 90); +}); + +test("fails (code 2) when a baseline metric is missing from collected metrics", () => { + const b = { metrics: { eslintWarnings: { value: 100, direction: "down" } } }; + assert.equal(run(b, {}).code, 1); +}); +``` + +- [ ] **Step 2: Rodar o teste e ver falhar** +```bash +node --import tsx --test tests/unit/quality-ratchet.test.ts +``` +Expected: FAIL (script não existe ainda). + +- [ ] **Step 3: Implementar o comparador** +```js +#!/usr/bin/env node +// scripts/quality/check-quality-ratchet.mjs +// Catraca genérica multi-métrica. Clona o espírito de check-t11-any-budget.mjs: +// um baseline congelado por métrica; falha em qualquer regressão; só anda num sentido. +import fs from "node:fs"; +import path from "node:path"; + +const cwd = process.cwd(); +function getArg(name, fallback) { + const i = process.argv.indexOf(name); + return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : fallback; +} +const BASELINE = path.resolve(getArg("--baseline", path.join(cwd, "quality-baseline.json"))); +const METRICS = path.resolve(getArg("--metrics", path.join(cwd, "quality-metrics.json"))); +const SUMMARY = getArg("--summary", null); +const UPDATE = process.argv.includes("--update"); +const EPS = 0.01; + +function load(p) { + if (!fs.existsSync(p)) { console.error(`[quality-ratchet] arquivo ausente: ${p}`); process.exit(2); } + return JSON.parse(fs.readFileSync(p, "utf8")); +} + +const baseline = load(BASELINE); +const metrics = load(METRICS); +const failures = []; +const improvements = []; +const rows = []; + +for (const [key, spec] of Object.entries(baseline.metrics)) { + const current = metrics[key]; + const base = spec.value; + const dir = spec.direction; // "down" = menor-é-melhor | "up" = maior-é-melhor + if (current === undefined) { failures.push(`métrica "${key}" ausente em ${path.basename(METRICS)}`); rows.push([key, base, "—", "MISSING"]); continue; } + let status = "ok"; + if (dir === "down") { + if (current > base + EPS) { failures.push(`${key}: ${current} > baseline ${base} (não pode aumentar)`); status = "REGRESSÃO"; } + else if (current < base - EPS) { improvements.push([key, current]); status = "↑ melhorou"; } + } else { + if (current < base - EPS) { failures.push(`${key}: ${current} < baseline ${base} (não pode cair)`); status = "REGRESSÃO"; } + else if (current > base + EPS) { improvements.push([key, current]); status = "↑ melhorou"; } + } + rows.push([key, base, current, status]); +} + +if (SUMMARY) { + const md = ["# Quality Ratchet", "", "| Métrica | Baseline | Atual | Status |", "|---|---|---|---|", + ...rows.map(([k, b, c, s]) => `| ${k} | ${b} | ${c} | ${s} |`), "", + failures.length ? `**${failures.length} regressão(ões) — gate BLOQUEADO.**` : "**Sem regressões — gate OK.**"].join("\n"); + fs.mkdirSync(path.dirname(SUMMARY), { recursive: true }); + fs.writeFileSync(SUMMARY, md + "\n"); +} + +if (UPDATE && failures.length === 0 && improvements.length) { + for (const [key, val] of improvements) baseline.metrics[key].value = val; + fs.writeFileSync(BASELINE, JSON.stringify(baseline, null, 2) + "\n"); + console.log(`[quality-ratchet] baseline ratcheado: ${improvements.length} métrica(s) melhoraram`); +} + +if (failures.length) { console.error("[quality-ratchet] FALHOU:\n" + failures.map((f) => " ✗ " + f).join("\n")); process.exit(1); } +console.log(`[quality-ratchet] OK (${rows.length} métricas, ${improvements.length} melhoraram)`); +``` + +- [ ] **Step 4: Rodar o teste e ver passar** +```bash +node --import tsx --test tests/unit/quality-ratchet.test.ts +``` +Expected: PASS (5/5). + +- [ ] **Step 5: Commit** +```bash +git add scripts/quality/check-quality-ratchet.mjs tests/unit/quality-ratchet.test.ts +git commit -m "feat(quality): generic ratchet comparator (multi-metric, regression-only)" +``` + +### Task 1.2: Coletor de métricas (ESLint warnings + cobertura) + +**Files:** Create: `scripts/quality/collect-metrics.mjs` + +- [ ] **Step 1: Implementar o coletor** +```js +#!/usr/bin/env node +// scripts/quality/collect-metrics.mjs — emite quality-metrics.json +import fs from "node:fs"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; + +const cwd = process.cwd(); +const out = {}; + +// 1) ESLint: contagem de warnings (errors devem ser 0; o lint já gata isso) +function eslintCounts() { + let stdout; + try { + stdout = execFileSync("npx", ["eslint", ".", "--format", "json"], { encoding: "utf8", maxBuffer: 256 * 1024 * 1024 }); + } catch (e) { stdout = e.stdout?.toString() || "[]"; } // eslint sai !=0 quando há errors + const results = JSON.parse(stdout); + out.eslintWarnings = results.reduce((n, r) => n + (r.warningCount || 0), 0); + out.eslintErrors = results.reduce((n, r) => n + (r.errorCount || 0), 0); +} + +// 2) Cobertura: lê coverage/coverage-summary.json se existir +function coverage() { + const p = path.join(cwd, "coverage", "coverage-summary.json"); + if (!fs.existsSync(p)) return; + const t = JSON.parse(fs.readFileSync(p, "utf8")).total; + out["coverage.statements"] = t.statements.pct; + out["coverage.lines"] = t.lines.pct; + out["coverage.functions"] = t.functions.pct; + out["coverage.branches"] = t.branches.pct; +} + +eslintCounts(); +coverage(); +fs.writeFileSync(path.join(cwd, "quality-metrics.json"), JSON.stringify(out, null, 2) + "\n"); +console.log("[collect-metrics]", JSON.stringify(out)); +``` + +- [ ] **Step 2: Rodar e inspecionar a saída** +```bash +node scripts/quality/collect-metrics.mjs && cat quality-metrics.json +``` +Expected: JSON com `eslintWarnings`, `eslintErrors` e (se houver coverage) os 4 `coverage.*`. **Anote `eslintWarnings`.** + +- [ ] **Step 3: Commit** +```bash +git add scripts/quality/collect-metrics.mjs && echo "quality-metrics.json" >> .gitignore +git add .gitignore && git commit -m "feat(quality): metrics collector (eslint warnings + coverage)" +``` + +### Task 1.3: Congelar o baseline inicial + +**Files:** Create: `quality-baseline.json` (**commitado**) + +- [ ] **Step 1: Gerar o baseline a partir das métricas reais** (use os números anotados): +```json +{ + "_comment": "Catraca: 'down' nao pode aumentar, 'up' nao pode cair. Atualize via 'npm run quality:ratchet -- --update' (so em melhora).", + "metrics": { + "eslintWarnings": { "value": , "direction": "down" }, + "coverage.statements": { "value": , "direction": "up" }, + "coverage.lines": { "value": , "direction": "up" }, + "coverage.functions": { "value": , "direction": "up" }, + "coverage.branches": { "value": , "direction": "up" } + } +} +``` + +- [ ] **Step 2: Validar a catraca contra si mesma** +```bash +node scripts/quality/collect-metrics.mjs +node scripts/quality/check-quality-ratchet.mjs; echo "exit=$?" +``` +Expected: `[quality-ratchet] OK` e `exit=0`. + +- [ ] **Step 3: Provar que pega regressão** (teste manual): edite `quality-metrics.json` somando 1 a `eslintWarnings`, rode o comparador, confirme `exit=1`, depois descarte a edição. + +- [ ] **Step 4: Adicionar scripts npm** +```json +"quality:collect": "node scripts/quality/collect-metrics.mjs", +"quality:ratchet": "node scripts/quality/check-quality-ratchet.mjs", +"quality:gate": "npm run quality:collect && npm run quality:ratchet" +``` + +- [ ] **Step 5: Commit** +```bash +git add quality-baseline.json package.json +git commit -m "feat(quality): freeze initial quality baseline (eslint warnings + coverage)" +``` + +### Task 1.4: Wire no CI (job + artefato + comentário no PR) + +**Files:** Modify: `.github/workflows/ci.yml` + +- [ ] **Step 1: Adicionar job `quality-gate`** (depois de `test-coverage`, para reusar `coverage/coverage-summary.json`): +```yaml + quality-gate: + name: Quality Ratchet + runs-on: ubuntu-latest + needs: test-coverage + if: ${{ always() && needs.test-coverage.result == 'success' }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: { node-version: '24', cache: 'npm' } + - run: npm ci + - uses: actions/download-artifact@v4 + with: { name: coverage-report, path: coverage/ } + - run: npm run quality:collect + - run: node scripts/quality/check-quality-ratchet.mjs --summary .artifacts/quality-ratchet.md + - if: always() + run: cat .artifacts/quality-ratchet.md >> "$GITHUB_STEP_SUMMARY" + - if: always() + uses: actions/upload-artifact@v4 + with: { name: quality-ratchet, path: .artifacts/quality-ratchet.md } +``` + +- [ ] **Step 2: Adicionar comentário no PR** clonando o job `coverage-pr-comment` (marcador ``, lê `.artifacts/quality-ratchet.md`). Reuse o mesmo `github-script` de upsert de comentário. + +- [ ] **Step 3: Validar num PR de teste** — confirmar que o job aparece, o step summary mostra a tabela, e o comentário é postado. + +- [ ] **Step 4: Commit** +```bash +git add .github/workflows/ci.yml +git commit -m "ci(quality): add quality-ratchet job with PR comment + artifact" +``` + +--- + +# FASE 2 — Gates Determinísticos Anti-Alucinação + +> Cada gate ataca um ímã de alucinação específico (ver §2.4 do relatório). Todos no padrão `check-*.mjs`. **`check-fetch-targets` vem com código completo** (parsing determinístico de arquivos, sem risco de inventar nomes de export). **`check-provider-consistency` e `check-openapi-routes` começam com um Step 0 de verificação dos nomes reais** — propositalmente, porque fabricar a forma do import/spec seria o exato anti-padrão que este plano combate. + +### Task 2.1: `check-fetch-targets.mjs` — toda rota chamada pelo dashboard existe (TDD) + +**Ataca:** ímã nº2 (300 paths `fetch("/api/...")` sem ligação com as rotas). + +**Files:** +- Create: `scripts/check/check-fetch-targets.mjs` +- Test: `tests/unit/check-fetch-targets.test.ts` + +- [ ] **Step 1: Escrever o teste que falha** +```ts +// tests/unit/check-fetch-targets.test.ts +import { test } from "node:test"; +import assert from "node:assert"; +import { resolveApiPathToRoute } from "../../scripts/check/check-fetch-targets.mjs"; + +test("matches a static route file", () => { + const files = new Set(["src/app/api/usage/route.ts"]); + assert.equal(resolveApiPathToRoute("/api/usage", files), true); +}); + +test("matches a dynamic [param] segment", () => { + const files = new Set(["src/app/api/providers/[id]/models/route.ts"]); + assert.equal(resolveApiPathToRoute("/api/providers/abc-123/models", files), true); +}); + +test("rejects a hallucinated route", () => { + const files = new Set(["src/app/api/usage/route.ts"]); + assert.equal(resolveApiPathToRoute("/api/providers/refresh", files), false); +}); +``` + +- [ ] **Step 2: Rodar e ver falhar** +```bash +node --import tsx --test tests/unit/check-fetch-targets.test.ts +``` +Expected: FAIL (módulo não existe). + +- [ ] **Step 3: Implementar o gate** +```js +#!/usr/bin/env node +// scripts/check/check-fetch-targets.mjs +// Todo fetch("/api/...") em src/app/(dashboard) deve resolver para um route.ts real. +import fs from "node:fs"; +import path from "node:path"; + +const cwd = process.cwd(); +const DASH = path.join(cwd, "src/app/(dashboard)"); +const API = path.join(cwd, "src/app/api"); + +// allowlist de paths dinâmicos/externos que o checker não consegue resolver estaticamente +const IGNORE = [/^\/api\/v1\//, /\$\{/, /` \+/]; // /v1 é a superfície OpenAI-compat; templates + +function walk(dir, acc = []) { + if (!fs.existsSync(dir)) return acc; + for (const e of fs.readdirSync(dir, { withFileTypes: true })) { + const p = path.join(dir, e.name); + if (e.isDirectory()) walk(p, acc); + else if (/\.(ts|tsx)$/.test(e.name)) acc.push(p); + } + return acc; +} + +function collectRouteFiles() { + return new Set(walk(API).filter((p) => /route\.tsx?$/.test(p)).map((p) => path.relative(cwd, p).replace(/\\/g, "/"))); +} + +export function resolveApiPathToRoute(apiPath, routeFiles) { + // apiPath ex.: /api/providers/abc/models → src/app/api/providers/[id]/models/route.ts + const segs = apiPath.replace(/^\//, "").replace(/[?#].*$/, "").split("/"); // ["api","providers","abc","models"] + for (const rf of routeFiles) { + const rsegs = rf.replace(/^src\/app\//, "").replace(/\/route\.tsx?$/, "").split("/"); // ["api","providers","[id]","models"] + if (rsegs.length !== segs.length) continue; + const ok = rsegs.every((rs, i) => rs === segs[i] || /^\[.*\]$/.test(rs)); + if (ok) return true; + } + return false; +} + +function extractFetchPaths(file) { + const src = fs.readFileSync(file, "utf8"); + const re = /(?:fetch|fetchJson|apiFetch)\(\s*["'`](\/api\/[^"'`?]+)/g; + const out = []; + let m; + while ((m = re.exec(src))) out.push(m[1]); + return out; +} + +function main() { + const routeFiles = collectRouteFiles(); + const misses = []; + for (const f of walk(DASH)) { + for (const apiPath of extractFetchPaths(f)) { + if (IGNORE.some((rx) => rx.test(apiPath))) continue; + if (!resolveApiPathToRoute(apiPath, routeFiles)) misses.push(`${path.relative(cwd, f)} → ${apiPath}`); + } + } + if (misses.length) { + console.error(`[check-fetch-targets] ${misses.length} fetch(es) para rota inexistente:\n` + misses.map((m) => " ✗ " + m).join("\n")); + process.exit(1); + } + console.log(`[check-fetch-targets] OK (${routeFiles.size} rotas conhecidas)`); +} + +if (import.meta.url === `file://${process.argv[1]}`) main(); +``` + +- [ ] **Step 4: Rodar o teste unitário e ver passar** +```bash +node --import tsx --test tests/unit/check-fetch-targets.test.ts +``` +Expected: PASS (3/3). + +- [ ] **Step 5: Rodar o gate no repo real** (modo descoberta — pode encontrar misses legítimos) +```bash +node scripts/check/check-fetch-targets.mjs; echo "exit=$?" +``` +Se encontrar misses reais: triagem — ou são rotas faltantes (bug), ou paths dinâmicos a adicionar ao `IGNORE`. **Não** mascare; documente cada exceção no `IGNORE` com comentário. + +- [ ] **Step 6: Adicionar script + commit** +```json +"check:fetch-targets": "node scripts/check/check-fetch-targets.mjs" +``` +```bash +git add scripts/check/check-fetch-targets.mjs tests/unit/check-fetch-targets.test.ts package.json +git commit -m "feat(check): gate that every dashboard fetch() targets a real API route" +``` + +### Task 2.2: `check-provider-consistency.ts` — ids batem entre os 3 arquivos + +**Ataca:** ímã nº1 (split de provider; 229 vs 155 vs N já divergem). + +**Files:** Create: `scripts/check/check-provider-consistency.ts` (rodado via `tsx`); Test: `tests/unit/check-provider-consistency.test.ts` + +- [ ] **Step 0 (VERIFICAÇÃO — obrigatório antes de codar):** descobrir os nomes reais de export, para **não inventar**: +```bash +grep -nE "export (const|function) " src/shared/constants/providers.ts | grep -iE "provider|section" | head +grep -nE "getOrCreateAiProviders|_PROVIDER_SECTIONS|AI_PROVIDERS" src/shared/constants/providers.ts | head +grep -nE "baseUrl|^\s*['\"][a-z0-9-]+['\"]\s*:" open-sse/config/providerRegistry.ts | head +grep -nE "case ['\"]|=== ['\"]|SPECIALTY_VALIDATORS" src/lib/providers/validation.ts | head +``` +Anote: como enumerar os ids canônicos em runtime, a forma das chaves do `providerRegistry.ts`, e onde `validation.ts` lista os providers cobertos. + +- [ ] **Step 1: Escrever o teste** com fixtures sintéticas (3 conjuntos de ids), afirmando que o diff detecta um id presente em um e ausente em outro. (Implemente a lógica como função pura `diffProviderSets(canonical, registry, validators)` que retorna `{missingInRegistry, missingInValidators, orphanInRegistry}`.) + +- [ ] **Step 2: Rodar e ver falhar.** + +- [ ] **Step 3: Implementar** importando os ids reais (descobertos no Step 0) e cruzando-os. Reusar o padrão de `scripts/check/check-docs-counts-sync.mjs` (que **já conta** executors/oauth/strategies vs docs — é o template direto). Para ids que legitimamente vivem só num lugar, manter um `KNOWN_EXCEPTIONS` allowlist com comentário por entrada. + +- [ ] **Step 4: Rodar no repo real**, triar as divergências reais (as contagens já divergem — algumas serão bugs de registro pela metade, outras exceções legítimas). + +- [ ] **Step 5: Commit** (`feat(check): provider-id consistency gate across providers.ts/registry/validation`). + +**Critério de aceitação:** o gate falha quando um id existe em `providers.ts` mas não no `providerRegistry.ts` (ou vice-versa), e quando um model no registry referencia um provider id desconhecido. + +### Task 2.3: `check-openapi-routes.mjs` — spec ↔ rotas (bidirecional) + +**Ataca:** docs-hallucination (endpoint inventado no `openapi.yaml`). + +**Files:** Create: `scripts/check/check-openapi-routes.mjs`; Test correspondente. + +- [ ] **Step 0 (VERIFICAÇÃO):** confirmar o caminho e a forma do spec: +```bash +ls docs/reference/openapi.yaml && grep -nE "^\s{2,4}/[a-z]" docs/reference/openapi.yaml | head +``` +Confirmar como `check-openapi-coverage.mjs` já parseia o YAML (reusar o parser dele). + +- [ ] **Step 1–5 (TDD):** teste com spec sintética → implementar: toda `path` do spec resolve para um `route.ts` (reusando `resolveApiPathToRoute` da Task 2.1) e toda rota não-interna aparece no spec (com allowlist para rotas LOCAL_ONLY/internas). Commit. + +### Task 2.4: `check-deps.mjs` — anti-slopsquatting + +**Ataca:** pacotes alucinados (CSA: 19,7% das amostras IA). + +**Files:** Create: `scripts/check/check-deps.mjs`; Test. + +- [ ] **Step 0 (VERIFICAÇÃO):** decidir política — allowlist = todas as deps atuais do `package.json`/lockfile como baseline; novas deps exigem (a) existir no registry, (b) idade ≥ 72h, (c) entrada explícita. +- [ ] **Step 1–5 (TDD):** teste com `package.json` sintético adicionando uma dep nova → gate falha se a dep não está no baseline E (não existe no registry OU foi publicada há <72h). Usar `npm view time.created` para idade. Reusar o padrão diff-base...HEAD de `check-pr-test-policy.mjs`. Commit. + +### Task 2.5 (opcional): lints Rule #11/#12 via `no-restricted-syntax` + +- [ ] Estender o `eslint.config.mjs` (bloco `no-restricted-syntax` já usado p/ a regra de busca turca) para sinalizar `NextResponse.json({ error: "" })` cru (exigir `buildErrorBody()`) e literais de `clientIdDefault`/`clientSecretDefault` no `providerRegistry.ts` (exigir `resolvePublicCred()`). Ratchet via contagem (adoção 11% → sobe). TDD com fixtures de ESLint. + +**Plugar tudo da Fase 2 no CI:** adicionar `check:fetch-targets`, `check:provider-consistency`, `check:openapi-routes`, `check:deps` ao job `lint` (ou `docs-sync-strict`) do `ci.yml`, e ao pre-commit barato quando rápidos o suficiente. + +--- + +# FASE 3 — Catraca de Duplicação + Tamanho (mata-slop) — *expandir em sub-plano* + +**Justificativa:** GitClear mostra duplicação 4–8× na era IA; é a assinatura nº1 de slop. Nenhum gate hoje (Sonar CPD excluído). + +**Specs (criar sub-plano `writing-plans` a partir daqui):** + +1. **Adicionar `jscpd` v5** como devDependency. Rodar `jscpd --reporters json src open-sse` → `quality-metrics.json` ganha `duplication.pct`. **[verificar no install]** o schema JSON do v5 antes de parsear (ver ressalva no relatório §4.2). +2. **Estender `collect-metrics.mjs`** com um coletor de duplicação (lê o JSON do jscpd) e um coletor de **tamanho de arquivo** (conta LOC de cada arquivo em `src`+`open-sse`, emite `fileSize.` para os arquivos já acima de um teto, ex. >700 LOC — congelando os 64 atuais). +3. **Adicionar regras ESLint** em `eslint.config.mjs` como `warn`: `max-lines` (700), `max-lines-per-function` (80), `complexity` (15), `sonarjs/cognitive-complexity` (15). Coletor adiciona `eslintWarnings` por categoria (a contagem já está na catraca da Fase 1; opcionalmente segregar `complexityWarnings`). +4. **Estender `quality-baseline.json`** com `duplication.pct` (`down`) e os `fileSize.*` dos arquivos grandes (`down` — só podem encolher; arquivos novos têm teto absoluto). +5. **Teto absoluto para arquivos novos:** o gate falha se um arquivo **não** presente no baseline nasce acima do teto (ex. 700 LOC) — impede o próximo god-component. + +**Critérios de aceitação:** PR que aumenta a duplicação % falha; PR que cresce qualquer um dos 64 arquivos grandes falha; PR que cria arquivo novo >700 LOC falha; refator que encolhe um arquivo grande ratcheta o baseline para baixo (via `--update`). + +--- + +# FASE 4 — Catraca de Cobertura + Anti Test-Masking — *expandir em sub-plano* + +**Specs:** + +1. **`check-coverage-ratchet`** já é coberto pelo motor da Fase 1 (as 4 métricas `coverage.*` com `direction: "up"`). Confirmar que o job `quality-gate` roda **após** o merge dos 8 shards de cobertura no CI (não localmente, onde a cobertura é parcial). Adicionar **epsilon** maior (ex. 0.5) para `coverage.branches` por causa do não-determinismo do v8. +2. **Pisos por módulo crítico:** estender `collect-metrics.mjs` para emitir `coverage..lines` para uma lista curta de módulos de alto risco (lendo o `coverage-summary.json` por arquivo): `open-sse/handlers/chatCore.ts`, `open-sse/services/combo.ts`, `open-sse/services/accountFallback.ts`, `src/sse/services/auth.ts`, `src/server/authz/routeGuard.ts`, `open-sse/utils/error.ts`, `open-sse/utils/publicCreds.ts`, `src/shared/utils/circuitBreaker.ts`. Cada um vira métrica `up` no baseline (implementa o "risco, não % bruto" do vídeo). +3. **`check-test-masking.mjs`** (anti enfraquecimento de asserts): clonar `check-pr-test-policy.mjs` (diff `base...HEAD`); para cada `*.test.ts`/`*.spec.ts` alterado, contar `assert*(`/`expect(` em base vs HEAD; **sinalizar remoção líquida** de asserts para revisão humana. Banir novos `assert.ok(true)`. Heurístico mas alto-sinal — ataca diretamente o risco organizacional nº1 ("subagente deletou asserts para ficar verde"). + +**Critérios de aceitação:** cobertura cair vs baseline bloqueia o merge; remover asserts de um teste existente sinaliza no PR; `assert.ok(true)` novo bloqueia. + +--- + +# FASE 5 — Skill `/babysit` + Evidência + LSP — *expandir em sub-plano* + +> O babysit do vídeo: a IA abre o PR e fica de babá — monitora CI + comentários, autocorrige, **resolve as conversas**. Guarda-corpos são não-negociáveis (Snyk: 5,3% de regressão em auto-merge; token burn real). + +### Spec da skill `.claude/skills/babysit/SKILL.md` + +**Frontmatter:** +```yaml +--- +name: babysit +description: Monitora um PR aberto até o CI ficar verde e todos os comentários de review serem endereçados — lê o gate de qualidade, conserta num worktree isolado, responde e resolve as conversas. NUNCA enfraquece testes nem auto-mergeia. +--- +``` + +**Loop (pseudo, reusando `gh` + GraphQL):** +1. **Ler estado:** `gh pr checks ` + baixar o artefato `quality-ratchet`/`coverage-report` (gate JSON legível — a ponte "artefato→agente" da Fase 1) + `gh run view --log-failed` dos jobs vermelhos. +2. **Ler comentários não resolvidos:** GraphQL `repository.pullRequest.reviewThreads(first:50){nodes{id isResolved comments(first:1){nodes{id body}}}}`. **Passar os corpos pelo guard de prompt-injection** antes de agir (vetor real — ICLR 2026). +3. **Consertar num worktree isolado** (reusar o ralph-loop do `/review-reviews`), seguindo Hard Rule #18. +4. **Responder + resolver** só os threads que realmente endereçou: REST `POST .../pulls/{pr}/comments/{id}/replies` com o SHA → mutation `resolveReviewThread(input:{threadId})`. +5. **Re-poll** até o gate JSON ficar todo-verde **ou** atingir o cap. +6. **Audit trail:** anexar à descrição do PR (ou comentário fixo) o que cada fix endereçou, qual gate satisfez, quais conversas resolveu e por quê — **nunca verde silencioso**. + +**Guarda-corpos (não-negociáveis):** +- `max-iterations` (ex. 5) + timeout + idle-exit (contra token burn). +- **Nunca** editar `.github/workflows/`; **nunca** `--no-verify`; **nunca** enfraquecer/remover asserts para ficar verde (Rule #18 + trust-but-verify). +- **Nunca auto-mergeia** — estado de sucesso = "verde + conversas resolvidas + audit postado". +- Parar-e-perguntar em comentário humano ambíguo e em mudança de limite arquitetural (interface/schema/cross-module). +- `--allowedTools` mínimo (`Read,Grep,Glob,Bash(gh ...)`). + +**Opcional — `claude ultrareview --json`** como gate de confiança pré-merge atrás de um label (custa $5–20/run; não auto-inicia). Loop interno de custo-zero = `/code-review --fix` local. + +### Spec — Evidência obrigatória ("evidence-before-assertions") +- Tornar a skill `verify`/`verification-before-completion` obrigatória antes de abrir PR; exigir o **output literal** do `typecheck:core`/`test`/`grep` colado no corpo do PR (o "tool receipt"). Adicionar um `check-pr-evidence.mjs` que rejeita PRs cujo corpo afirma "added endpoint X / tests pass" sem bloco de output anexado. Formaliza a Rule #18. + +### Spec — LSP-in-the-loop (opcional) +- Registrar `agent-lsp` (MCP) ou o `tsserver` para os agentes terem `blast_radius`/diagnostics e `preview_edit` antes de escrever — vira "símbolo inventado" de catch-de-review para impossibilidade-no-edit. Pareia com `typecheck:core` como gate pré-PR (compile-before-claim). + +**Critérios de aceitação:** a skill `/babysit ` leva um PR de vermelho a verde sem auto-merge, resolve só as conversas que endereçou, respeita o cap de iterações, e deixa rastro auditável; nenhum assert é enfraquecido. + +--- + +## Self-Review (checklist do autor) + +- **Cobertura do spec:** Fases 0–2 mapeiam 1:1 com as recomendações do relatório §5; Fases 3–5 cobrem duplicação/tamanho, cobertura/masking e babysit/evidência. ✓ +- **Sem placeholders nos passos bite-sized:** Fases 0/1/2.1 têm comandos e código reais. As Fases 2.2–2.4 usam um Step-0 de verificação **deliberado** (não placeholder) para não fabricar nomes de export — coerente com o objetivo anti-alucinação. ✓ +- **Consistência de tipos:** `resolveApiPathToRoute` (Task 2.1) é reusada na Task 2.3; o comparador usa o mesmo formato `{value, direction}` em todas as fases; `quality-metrics.json` é a interface única coletor↔comparador. ✓ +- **Ordem de dependência:** Fase 1 (motor) precede 3/4 (que só adicionam métricas ao baseline); Fase 0 destrava o resto. ✓ + +## Handoff de Execução + +**Plano salvo em `PLANO-QUALITY-GATES.md`.** Duas opções: + +1. **Subagent-Driven (recomendado)** — um subagente fresco por task, review entre tasks. SUB-SKILL: `superpowers:subagent-driven-development`. +2. **Inline** — executar nesta sessão com checkpoints. SUB-SKILL: `superpowers:executing-plans`. + +**Recomendação:** começar pela **Fase 0** (quick wins, baixo risco) e **Fase 1** (motor de catraca) numa branch `feat/quality-ratchet`, validar num PR de teste, e só então abrir as fases anti-alucinação. Fases 3–5 viram sub-planos próprios. diff --git a/RELATORIO-QUALITY-GATES.md b/RELATORIO-QUALITY-GATES.md new file mode 100644 index 0000000000..6ffb3b86e3 --- /dev/null +++ b/RELATORIO-QUALITY-GATES.md @@ -0,0 +1,219 @@ +# Relatório — Quality Gates, Catraca & Anti-Alucinação no OmniRoute + +> **Data:** 2026-06-09 +> **Origem:** Auditoria do projeto (5 subagentes Opus em paralelo mapeando todas as pastas exceto `node_modules`/`_references`/`dist`) + análise da transcrição do vídeo *"Qualidade de código"* (Stupid Button Club, 2026-05-04) + pesquisa web 2026 (4 frentes, últimos ~3 meses). +> **Companheiro:** Veja [`PLANO-QUALITY-GATES.md`](./PLANO-QUALITY-GATES.md) para o plano de implementação bite-sized (TDD). + +--- + +## 0. TL;DR + +1. **O OmniRoute já é muito mais maduro** que o projeto "Strawberry" do vídeo: tem CI com 20 jobs, gate de cobertura, ESLint 9 flat, SonarQube, 14 scripts `check-*.mjs` e **uma catraca real já funcionando** (`check-t11-any-budget.mjs` — orçamento de `any` por arquivo que só pode encolher). O vídeo descreve onde queremos chegar; nós já estamos a meio caminho. +2. **Mas faltam exatamente as catracas que o vídeo prega.** Não há baseline congelado de métricas, nem gate de **duplicação**, nem de **tamanho de arquivo**, e o gate de cobertura é um **piso fixo** — não uma catraca "nunca piorar". +3. **Há derivas (drifts) reais que pegamos na auditoria:** o gate de cobertura **no CI é `40/40/40/40`** (ci.yml:377), não os `60/60/60/60` que o CLAUDE.md anuncia (esse é só o script local). O Husky está **100% comentado** (zero gate local). O SonarQube tem `coverage` e `cpd` **excluídos** (`sonar-project.properties:9-10`) — as duas métricas mais úteis contra "slop" estão desligadas. E 3 scripts `check-*` existem mas **não rodam em lugar nenhum**. +4. **Os maiores ímãs de alucinação são estruturais:** o split de provider em 3 arquivos gigantes em 2 workspaces (`providers.ts` ↔ `providerRegistry.ts` ↔ `validation.ts`, com contagens que já divergem: 229 ids vs 155 blocos vs N validadores), os 300 paths `fetch("/api/...")` hardcoded sem ligação de compilação com as rotas, e o arquivo de **12.760 linhas** (`providers/[id]/page.tsx`) que nenhum agente consegue segurar em contexto. +5. **2026 confirma a tese do vídeo com dados:** GitClear (211M linhas) mostra duplicação crescendo 4–8× na era da IA; o paper SlopCodeBench prova que *instrução de prompt sozinha não impede a degradação* — só gates determinísticos seguram. O ecossistema 2026 tem ferramentas maduras para cada métrica (jscpd v5, knip v6, eslint-plugin-sonarjs v4, osv-scanner, Qlty, Sonar "Clean as You Code"/"AI Code Assurance"). +6. **A jogada é em camadas:** (a) reativar/reconciliar o que já existe; (b) construir o **motor de catraca** (baseline.json + coletor + comparador, clonando o `any-budget`); (c) adicionar **gates determinísticos anti-alucinação** (provider-consistency, fetch-target, openapi-routes); (d) catraca de **duplicação + tamanho**; (e) catraca de **cobertura** + detecção de test-masking; (f) skill **`/babysit`** com guarda-corpos. Detalhe no plano. + +--- + +## 1. O que o vídeo ensina (insights destilados) + +O vídeo é uma fala sem roteiro sobre *qualidade de código no mundo em que a IA escreve ~100% do código*. Pontos centrais: + +| # | Insight | Citação/essência | +|---|---------|------------------| +| 1 | **O humano virou o gargalo.** | "Eu acabei virando o gargalo da IA. Fazer o babysit das coisas do request básicas é o gargalo. Não consigo entregar 4 tarefas ao mesmo tempo se preciso ler 10.000 linhas/dia." | +| 2 | **Quality Gate = portão que a IA tem que passar.** | Todo PR passa por um portão; a IA fica em loop se autocorrigindo até ficar verde, em vez de o humano revisar e pedir refação. | +| 3 | **Baseline + Catraca (ratchet).** | "Tu congela o baseline e o repositório só pode melhorar a partir dali ou empatar." A catraca anda num sentido só. | +| 4 | **Regra de ouro.** | "Cada PR pode adicionar código, mas não pode aumentar nenhuma das métricas — nem por uma violação, nem por uma linha, nem por 0,1 ponto percentual." | +| 5 | **Métricas do baseline.** | Violações de ESLint (483 em 120 arquivos), duplicação de código (2,2% via JSCPD), cobertura (%), arquivos acima do limite de tamanho (19 arquivos; o maior com 4.600 linhas). | +| 6 | **Pipeline de CI.** | `npm ci` → `npm audit` (critical=bloqueia / high=avisa) → `npm run lint` → `test:coverage` → **script node de quality gate** que compara métricas atuais vs `baseline.json` e falha em qualquer regressão → comentário no PR + **upload de artefatos** que o agente lê para se autocorrigir. | +| 7 | **Artefatos legíveis pelo agente.** | "Não adianta cuspir isso no PR. O agente precisa ter acesso ao que está dando errado." | +| 8 | **Babysit skill.** | "Recomendo criar uma skill de babysit": a IA monitora o CI + comentários dos revisores, endereça os comentários e **resolve as conversas** para dar rastreabilidade no GitHub. | +| 9 | **Comentários perto do código (legibilidade p/ agente).** | Mudou de ideia: antes era contra comentários ("o código é a documentação"); agora, no mundo de agentes, comentário explicando *o quê* e *por quê* perto do código vale mais que um MD gigante, porque o harness faz `grep` no arquivo e lê o comentário junto. | +| 10 | **É "só" colar ferramentas.** | "Não é nada excepcional. Eu só estou colando um monte de ferramentas e chamando de quality gate." Pode-se usar SonarQube ou GitHub Code Quality no lugar do script caseiro. | +| 11 | **Por que a IA não faz certo de primeira.** | Modelos top já sabem (foram treinados com os livros), mas são "preguiçosos" porque output imperfeito = mais tokens vendidos. A catraca força o nível. | + +**Tradução para o nosso contexto:** o vídeo descreve um sistema que **já temos em embrião** (o `any-budget` é exatamente a catraca da regra de ouro, só que para uma métrica). O salto é (1) generalizar a catraca para todas as métricas, (2) reconciliar os drifts, e (3) fechar os buracos anti-alucinação que são específicos do nosso tamanho. + +--- + +## 2. Onde o OmniRoute está hoje (panorama auditado) + +### 2.1 O que já temos (e o vídeo nem sonha) + +- **CI robusto** (`.github/workflows/ci.yml`, ~25 KB, 20 jobs): lint + audit + cycles + route-validation + any-budget + docs-sync + typecheck (core e noimplicit) + build + package-artifact + electron-smoke + unit (8 shards) + Node 24/26 compat + coverage (8 shards + merge) + SonarQube + e2e (9 shards) + integration + security. +- **Catraca real já existente:** `scripts/check/check-t11-any-budget.mjs` — array `{file, maxAny}` (a maioria `0`), strip de comentários, anotações de falso-positivo, `exit 1` em regressão. **É o template exato da catraca do vídeo.** +- **14 scripts `check-*.mjs`** (cycles, route-validation, any-budget, docs-sync, docs-counts, env-doc-sync, deprecated-versions, doc-links, cli-i18n, openapi-coverage, openapi-security-tiers, pr-test-policy, node-runtime, test-report-summary) — vários já são *gates de consistência fonte-vs-derivado*, o mesmo padrão que precisamos para anti-alucinação. +- **PR test policy:** `check-pr-test-policy.mjs` já força "mudou código de produção ⇒ mudou teste" (diff base...HEAD). +- **Cobertura sumarizada + comentada no PR:** `test-report-summary.mjs` + `coverage/coverage-summary.json` + job `coverage-pr-comment` (comentário com marcador ``). **Isto é exatamente o "artefato legível pelo agente" do vídeo** — já construído. +- **Disciplina TDD institucionalizada** (Hard Rule #18: todo fix precisa de teste falha→passa ou validação ao vivo no VPS). +- **SonarQube** configurado (job no CI + `sonar-project.properties`). +- **Skills agênticas de review** já existem: `/review-prs`, `/review-reviews` (bateria de 8 reviewers + ralph-loop), `/code-review`, `/generate-release` (a única com babysit real de CI, mas de workflows de *release*, não do `ci.yml` do PR). + +### 2.2 O que está DESLIGADO ou inerte ⚠️ (achados da auditoria) + +| Item | Estado | Evidência | Impacto | +|------|--------|-----------|---------| +| **Husky** | 100% comentado (pre-commit **e** pre-push) | `.husky/pre-commit`, `.husky/pre-push` (todas as linhas com `#`) | Zero enforcement local — lint-staged, docs-sync, any-budget, env-doc-sync, openapi checks e o `test:unit` de pre-push dependem 100% do CI. | +| **Gate de cobertura no CI** | **40/40/40/40** (não 60) | `ci.yml:377` `--statements 40 --lines 40 --functions 40 --branches 40` | O comentário do PR renderiza contra 60, o script local gata 60, o RELEASE_CHECKLIST diz 75/70, e o baseline real é ~79–82%. **O único número que bloqueia merge é 40** → gate de cobertura quase banguela. | +| **Sonar coverage** | Excluído | `sonar-project.properties:9` `sonar.coverage.exclusions=**/*` | Sonar ignora cobertura de todo arquivo. | +| **Sonar CPD (duplicação)** | Excluído | `sonar-project.properties:10` `sonar.cpd.exclusions=**/*` | Sonar não detecta copy-paste — a assinatura nº1 de slop de IA. | +| **SonarQube job** | Inerte | `ci.yml` roda scan só se `PR && SONAR_TOKEN != '' && SONAR_HOST_URL != ''`; sem `qualitygate.wait` | Em runs sem secret, escreve "skipped"; mesmo quando roda, nunca falha o build. | +| **`npm audit`** | Plano (`moderate`), não escalonado | `package.json:112` `--audit-level=moderate` | O vídeo prega critical=bloqueia / high=avisa. O nosso é um nível único. | +| **3 scripts órfãos** | Sem CI nem husky | `check:cli-i18n`, `check:openapi-coverage`, `check:openapi-security-tiers` | Existem, dão `exit 1`, mas não rodam em lugar nenhum (Hard Rules #15/#17 guardadas só por um deles). | +| **`typecheck:noimplicit:core`** | `continue-on-error: true` | `ci.yml:45-46` | Warn-only "forward-looking". | + +### 2.3 Hotspots de tamanho (sem gate hoje) + +`64 arquivos > 1000 LOC`, `194 > 500 LOC` (src + open-sse, sem testes). Top: + +| LOC | Arquivo | Risco para edição por IA | +|-----|---------|--------------------------| +| **12.760** | `src/app/(dashboard)/dashboard/providers/[id]/page.tsx` | God-component: **192 `useState`**, 21 `useEffect`, 87 `fetch()` inline, 34 tipos inline. Nenhuma IA segura em contexto; qualquer edição arrisca apagar estado não relacionado. | +| 5.977 | `open-sse/handlers/chatCore.ts` | God-handler: 58 funções, invariantes demais, blast radius alto. | +| 4.590 | `open-sse/config/providerRegistry.ts` | Array gigante providers+models+OAuth; mistura `resolvePublicCred()` e literais crus. | +| 4.456 | `open-sse/services/combo.ts` | 14 estratégias num `if/else if` sem enum/exhaustiveness — estratégia desconhecida vira no-op silencioso. | +| 4.349 | `src/app/(dashboard)/dashboard/combos/page.tsx` | 51 `useState`, mesmo padrão god-component. | +| 4.205 | `src/lib/providers/validation.ts` | Mega-função com closures e `SPECIALTY_VALIDATORS` definidos *dentro* da função. | +| 3.776 | `open-sse/handlers/imageGeneration.ts` | Branching multi-provider num handler. | +| 3.076 | `src/shared/constants/providers.ts` | 229 ids em 27 consts agrupados via `Proxy` — sem lista plana. | +| 2.869 | `open-sse/executors/chatgpt-web.ts` | Sessão web reversa; classe só começa na linha 2443. | +| 2.278 | `src/app/api/providers/[id]/models/route.ts` | God-route: importa ~30 módulos provider-específicos e ramifica por provider num GET. | + +### 2.4 Ímãs de alucinação (ranqueados, da auditoria) + +1. **Split de provider em 3 arquivos / 2 workspaces (nº1).** Não há lista plana de providers — 229 ids escondidos atrás de 27 consts + merge via `Proxy` (`AI_PROVIDERS`). Uma IA não consegue enumerar "quais providers existem" barato → **inventa ids plausíveis** (variantes `*-web`/`*-cli` inexistentes) ou registra no grupo errado. As três contagens (`providers.ts` 229 ids ↔ `providerRegistry.ts` 155 blocos ↔ `validation.ts` N validadores) **já divergem**, então não há cross-check autoritativo. *(Esse é o tema recorrente das nossas memórias de alucinação — ex.: ids inventados, modelos inexistentes.)* +2. **300 paths `fetch("/api/...")` hardcoded** no dashboard (659 call sites), sem client tipado. Refatore uma rota e os call sites apodrecem silenciosamente; uma IA editando a UI inventa rota (`/api/providers/[id]/refresh`) ou assume `res.error.message` numa rota que devolve `{error:"..."}`. Sem ligação de símbolo entre os 659 call sites e os 488 `route.ts`. +3. **Dual chat stack (armadilha documentada).** Seleção/fallback de conta vive em `src/sse/` (não `open-sse/`). Uma IA pedida para "consertar fallback de conta" edita `open-sse/handlers/chatCore.ts` (errado) em vez de `src/sse/services/auth.ts`. Nada no código cruza os dois stacks. *(Já erramos um diagnóstico público por isso.)* +4. **Estratégias de combo inventadas.** 14 nomes reais soterrados num `if/else` de strings (sem enum exportado) → IA inventa nomes plausíveis-mas-falsos (`"latency-optimized"`, `"failover"`) que passam no typecheck como string e viram no-op. +5. **Métodos de executor inventados.** O padrão real é "sobrescreve `execute()` inteiro" (48/50 executors), sem hooks documentados → IA inventa `buildRequest`/`parseChunk`/`mapError` que não existem em `BaseExecutor`. +6. **Helpers de erro inventados** + queda para `err.message` cru (viola Rule #12) quando o helper inventado "falha"; 5 web executors hoje **não importam helper nenhum**. +7. **AGENTS.md de DB defasado:** documenta 21 migrations / 22 módulos quando o real é **94 / 75** — uma IA lendo isso acredita em conjuntos de tabelas/módulos que não existem mais. +8. **Route-guard omitido:** rotas novas spawn-capazes (`/api/services/`, `/api/mcp/`) devem entrar em `LOCAL_ONLY_API_PREFIXES`; a convenção está só no CLAUDE.md, não num teste que a IA veja (parcialmente coberta por 1 script órfão). + +--- + +## 3. Gap analysis — modelo do vídeo vs OmniRoute + +| Métrica/peça do vídeo | OmniRoute hoje | Gap | +|-----------------------|----------------|-----| +| `npm ci` determinístico | ✅ em todos os 14 jobs | — | +| `npm audit` critical=bloqueia / high=avisa | ⚠️ `--audit-level=moderate` (nível único) | **Escalonar** em dois invokes | +| `lint` | ✅ bloqueante | — | +| `test` + cobertura | ✅ mas piso **40** no CI (drift) | **Reconciliar** p/ baseline real + catraca | +| **Contagem de ESLint congelada** | ❌ (lint é 0-erros, mas warnings livres) | **Construir** (ratchet de violações) | +| **Duplicação % (JSCPD)** | ❌ (Sonar CPD excluído, sem jscpd) | **Construir** (jscpd + catraca) | +| **Limite de tamanho de arquivo** | ❌ (sem `max-lines`, sem script) | **Construir** (ESLint max-lines + catraca, freeze dos 64) | +| **`baseline.json` congelado** | ❌ (nenhum baseline de métricas no repo) | **Construir** (o coração da catraca) | +| **Script comparador (regra de ouro)** | 🟡 existe **para `any`** (`any-budget`) | **Generalizar** p/ todas as métricas | +| **Sumário markdown + artefatos p/ o agente** | ✅ (coverage summary + PR comment + artifact) | **Reusar** wholesale | +| **Babysit skill (monitora CI + resolve conversas)** | 🟡 `review-prs`/`review-reviews`/`generate-release` parciais; nenhuma resolve threads do PR nem loopa no `ci.yml` | **Construir** `/babysit` | +| **Comentários perto do código p/ legibilidade de agente** | 🟡 `routeGuard.ts` é exemplar; resto irregular | **Padrão cultural** (Karpathy/guidelines) | + +--- + +## 4. O que o mundo faz em 2026 (pesquisa, últimos ~3 meses) + +> Todas as fontes abaixo vêm com URL + data nas seções de origem (ver §7). Onde a pesquisa **não conseguiu confirmar** algo de fonte primária, está marcado **[não-verificado]** — honestidade de engenharia. + +### 4.1 Catraca / baseline-freeze (a "catraca" do vídeo) + +- **betterer** — o tool canônico de ratchet (snapshot de métrica → `.betterer.results`; CI falha se piora, auto-atualiza se melhora). **[caveat]** Baixa velocidade: último commit no `master` em **ago/2025**, releases vazias no GitHub. Viável, mas **não** apostar como peça load-bearing de longo prazo. +- **eslint-formatter-ratchet** — formatter que congela contagem de violações de ESLint. **Ativamente mantido** (commit 2026-03-17). Mais estreito (só ESLint) mas é a trajetória oposta ao betterer. +- **SonarQube "Clean as You Code" (new-code conditions)** — o padrão baseline-freeze mais maduro: o quality gate aplica condições **só ao código novo** (branch de referência), grandfathering do legado. Atual. +- **SonarQube "AI Code Assurance" (2026.1.0)** — gate específico para código gerado por IA (tag o projeto → workflow de assurance + "Sonar way for AI Code" mais estrito). **[não-verificado]** se *bloqueia* o PR (docs canônicas deram 404; descrito como "enforced quality gate" mas mecânica de bloqueio não confirmada em fonte única). +- **Qlty CLI (qlty.sh)** — o produto 2026 mais aderente: CLI Rust **OSS e grátis** (v0.630.0, 2026-05-08) que agrega 70+ analisadores; tem **Baseline analysis** (= a catraca), **Quality Gates** com veredito go/no-go e coverage gates. **[caveat]** `qlty metrics` (a tabela LOC/complexidade) **não tem flag JSON** — só `qlty check --sarif`/`qlty smells --sarif`; o ratchet de tamanho/complexidade por arquivo ainda precisa do JSON do ESLint. +- **Code Climate Quality → Qlty** — a marca clássica de ratchet virou empresa separada (Qlty, nov/2024). Write-ups antigos de "Code Climate" = Qlty hoje. + +### 4.2 Ferramentas de métrica por tipo (todas com JSON p/ alimentar a catraca) + +| Métrica | Tool 2026 | Comando JSON | Status | +|---------|-----------|--------------|--------| +| Duplicação | **jscpd v5** (reescrita Rust) | `jscpd --reporters json` (ou `sarif`) | Muito ativo (v5.0.4, 2026-06-08). **[caveat]** schema JSON v4→v5 não confirmado — verificar no install. | +| Tamanho/fn-length/ciclomática | **ESLint core** (`max-lines`, `max-lines-per-function`, `complexity`) | `eslint --format json` | Built-in ESLint 9 | +| Complexidade cognitiva | **eslint-plugin-sonarjs** (`sonarjs/cognitive-complexity`) | `eslint --format json` | Mantido (v4.0.3, 2026-04-16; agora no monorepo SonarJS — o repo standalone foi arquivado, mas o pacote está vivo). **[caveat]** README das rules deu 404; presença de S3776 em v4 é alta-confiança mas confirmar no install. | +| Dead code / unused exports / unused deps | **knip** (vence ts-prune **arquivado** + depcheck **arquivado**) | `knip --reporter json` | Muito ativo (v6.16.1, 2026-06-06) | +| Ciclos | **check-cycles.mjs** (já temos) + **dpdm** opcional | `dpdm --circular --output deps.json` | dpdm ativo (v4.2.0, 2026-05-09); madge estagnado | +| Vulnerabilidades | **osv-scanner** (Google/OSV) | `osv-scanner --format json` | Muito ativo (push 2026-06-08) | +| Política de lockfile (gate, não métrica) | **lockfile-lint** | `lockfile-lint --validate-https --validate-integrity` | Mantido (v5.0.0, 2026-01-25) | + +> **Realidade do ratchet:** não existe tool único 2026 que emita *todas* as métricas como um JSON limpo. O padrão robusto é **N tools que emitem JSON + um reducer Node** que monta `metrics-summary.json` + o comparador que falha só em regressão (exatamente o que o `any-budget` já faz para uma métrica). + +### 4.3 Anti-alucinação (2026) + +- **LSP-in-the-loop / `agent-lsp` (MCP)** — servidor MCP que dá ao agente fatos verificáveis do language server (definições, referências, tipos, diagnostics, `blast_radius`) e `preview_edit` antes de escrever. Funciona com Claude Code. **Fit alto:** vira "símbolo inventado" de catch-de-review para *impossibilidade-no-edit*. (v0.13.0, 2026-06-04 — pequeno mas ativo.) +- **Slopsquatting / pacotes alucinados** — **CSA Research Note (2026-04-19):** **19,7%** de 2,23M amostras de código IA continham nomes de pacote alucinados; 205k nomes fabricados únicos; **43%** reaparecem em re-runs (registráveis por atacantes). Defesas: **allowlist** de deps para agentes, **registry existence check** antes de instalar, **age-cooldown** (24–72h), lockfile-exact, scripts de install desabilitados. **Fit alto:** novo `check-deps.mjs`. +- **Semcheck** (v1.2.1, fev/2026) — CLI que usa LLM para verificar que a implementação bate com o spec/doc, via `semcheck.yaml` ligando doc↔código; roda em pre-commit e Actions com `fail-on-issues`. Feito para pegar **"docs que descrevem features não implementadas"**. **Fit muito alto** para nossos incidentes recorrentes de docs alucinadas — porém é fuzzy (LLM); pareie com checks determinísticos. +- **OpenAPI drift determinístico** — check que toda `path` do `openapi.yaml` resolve para um `route.ts` real (e vice-versa). Rápido, sem LLM, pega "endpoint inventado". **Fit alto** para `docs/reference/openapi.yaml`. +- **Skill `verify` / `verification-before-completion`** (já no nosso ambiente) — "evidence before assertions": o veredito PASS/FAIL repousa **só** no que o app rodando demonstrou; rejeita "rodei os testes" como prova. **Fit altíssimo:** é a formalização da nossa Hard Rule #18 — exigir o *output literal* do comando colado no PR ("tool receipt"). +- **Adversarial review (críticos de sessão fresca)** — agentes Skeptic/Architect/Minimalist leem o diff *contra o spec* ("o autor está comprometido — vai racionalizar"); símbolos/APIs inventados viram violação de spec. Mapeia no nosso `/review-reviews`. +- **SlopCodeBench (arXiv 2603.24755, ~mai/2026)** — sem mitigação, erosão estrutural aumentou em **77%** das trajetórias; o código de agente acumula verbosidade ~7× e erosão ~5× mais rápido que repos humanos. **Mitigações só-de-prompt ("anti-slop", "plan-first") melhoram o início mas NÃO param a degradação iterativa.** → **justificativa empírica** de que precisamos de gates determinísticos, não instrução. +- **GPT-5.5 System Card (2026-04-23)** — figuras oficiais são modestas (23% mais provável de acerto factual; 3% menos erros num set propenso). O headline de **"queda de 60% em alucinação / 88,7% SWE-bench" é imprensa secundária [não-verificado]**, não a seção de factualidade do system card. Upgrade de modelo ajuda na margem, não substitui gate. + +### 4.4 Babysit loops (2026) + +- **Claude Code "auto-fix in the cloud"** (Anthropic, lançado 2026-03-27): "observa seus PRs na nuvem, resolvendo falhas de CI e comentários de review automaticamente; empurra fixes quando claro, pergunta quando ambíguo." Não auto-mergeia. +- **Devin Autofix** (2026-02-10): auto-conserta comentários de review + lint/CI; endereça comentários de bots, mas deixa julgamento humano nas conversas humanas. +- **CodeRabbit Autofix** (early access abr/2026): coleta o bloco **"Prompt for AI Agents"** de cada comentário, aplica fix, roda build-verification; **nada mergeia automaticamente**. +- **Greptile `greploop` + skill `check-pr`** (MIT) — "dispara review → conserta comentários → re-review até 5/5 de confiança e zero comentários". **Template quase-exato** para a nossa `/babysit`. +- **Claude `claude ultrareview --json`** (subcomando não-interativo, research preview): bloqueia até terminar, `exit 0/1`, payload de bugs verificados parseável. **Não auto-inicia** e custa $5–20/run → usar atrás de label, não em todo push. (O `/code-review ultra` local com `--fix` é o loop interno de custo-zero.) +- **Resolver threads de review:** não há comando `gh` nativo (cli/cli#12419). Padrão de 2 passos GraphQL: `reviewThreads(first:50){nodes{id isResolved...}}` → `mutation { resolveReviewThread(input:{threadId}) }`, respondendo antes com o SHA do commit via REST. +- **Guarda-corpos (críticos):** + - **Snyk Agent Fix field test (2026): ~5,3% de regressão** ("1 em 19 fixes auto-mergeados introduz problema novo") → **forte argumento contra auto-merge**. + - **Token burn:** loops ingênuos realimentam a conversa crescente → prompt incha → alucina do próprio histórico. Uber capou gasto em **$1.500/mês/dev/tool** (abr/2026). Mitigação: **max-iterations**, time limit, idle-exit. + - **Test-masking:** o babysit **NÃO** pode enfraquecer/remover asserts para ficar verde (= nossa Rule #18 + memória "trust but verify"). Revisão humana fica nos limites arquiteturais (interface/schema/cross-service). + - **Audit trail:** deixar rastro humano-legível (qual fix endereçou o quê, qual gate satisfez, quais conversas resolveu) — nunca um verde silencioso. Reforça o guard de prompt-injection sobre os *corpos de comentário* que o agente ingere (21% dos reviews do ICLR 2026 eram IA; injeção embutida em código é vetor real). + +--- + +## 5. Recomendações (ranqueadas) → ver o PLANO + +> Build-vs-buy: **construir in-repo** os gates determinísticos (zero SaaS, dados não saem da box, reusa o harness `check-*.mjs`). **Avaliar Qlty CLI** depois, se quisermos consolidar N scripts num tool. **Não** depender de CodeRabbit/Greptile/Diamond como *o* gate de "não-piorar-métrica" — são opiniões de LLM, não contadores determinísticos. + +**Fase 0 — Reativar & reconciliar (quick wins, sem tooling novo):** +1. Reativar pre-commit barato do Husky (lint-staged + docs-sync + any-budget). +2. Reconciliar o gate de cobertura: subir o CI de 40 → baseline real (com headroom) e alinhar os 4 lugares que divergem. +3. Escalonar `npm audit` (critical=bloqueia / high=avisa). +4. Plugar os 3 scripts órfãos (`cli-i18n`, `openapi-coverage`, `openapi-security-tiers`) no CI. + +**Fase 1 — Motor de catraca (o coração):** +5. `quality-baseline.json` commitado + `collect-metrics.mjs` (coletor) + `check-quality-ratchet.mjs` (comparador, clone do `any-budget`) + job de CI + artefato + comentário no PR (clone do `coverage-pr-comment`). + +**Fase 2 — Gates determinísticos anti-alucinação:** +6. `check-provider-consistency.mjs` (o ímã nº1), `check-fetch-targets.mjs`, `check-openapi-routes.mjs`, allow-list de estratégias/translators/executors, lint Rule #11/#12, `check-deps.mjs` (slopsquatting). + +**Fase 3 — Catraca de duplicação + tamanho (mata-slop):** +7. jscpd + ESLint `max-lines`/`max-lines-per-function`/`complexity` + `sonarjs/cognitive-complexity`, congelando os 64 arquivos grandes (catraca só-pode-encolher). + +**Fase 4 — Catraca de cobertura + anti test-masking:** +8. `check-coverage-ratchet.mjs` (cobertura não cai vs baseline) + pisos por módulo crítico + `check-test-masking.mjs` (delta de contagem de asserts em testes alterados). + +**Fase 5 — Skill `/babysit` + evidência + LSP:** +9. Skill `/babysit` (gh pr checks + reviewThreads + worktree de fix + resolveReviewThread + loop-até-verde, com guarda-corpos), "evidence-before-assertions" obrigatório no corpo do PR, e (opcional) `agent-lsp` MCP. + +--- + +## 6. Riscos & ressalvas (honestidade de engenharia) + +- **Flag-day risk:** ligar qualquer gate num projeto que nunca o teve deixa tudo vermelho. **Toda** catraca aqui é *só-regressão* (baseline congelado), nunca um piso absoluto que exige limpeza imediata — exatamente o ponto do vídeo. +- **Custo de IA:** o babysit pode queimar tokens. Guarda-corpos (max-iterations, sem auto-merge, sem editar `.github/workflows/`) são não-negociáveis. +- **Ressalvas de pesquisa não-verificadas:** betterer baixa-velocidade; Sonar "AI Code Assurance" bloqueio-de-PR não confirmado; "GPT-5.5 −60% alucinação" é imprensa, não system card; schema JSON do jscpd v5 e S3776 do sonarjs v4 a confirmar no install; `qlty metrics` sem JSON. Nenhuma decisão do plano depende criticamente de um item não-verificado. +- **Trust-but-verify:** estes números internos (CI=40, husky off, sonar exclusions, 12.760 LOC, any-budget como catraca) foram **conferidos manualmente** contra os arquivos, não só relatados pelos subagentes. + +--- + +## 7. Fontes (consolidadas) + +**Catraca / ratchet:** betterer `github.com/phenomnomnominal/betterer` (último master ago/2025); eslint-formatter-ratchet `github.com/Jmsa/eslint-formatter-ratchet` (commit 2026-03-17); SonarQube Clean as You Code `docs.sonarsource.com/.../clean-as-you-code/about-new-code/`; Sonar AI Code Assurance `sonarsource.com/solutions/ai/ai-code-assurance/` + community thread 2026.1.0; Qlty `github.com/qltysh/qlty` (v0.630.0, 2026-05-08), `docs.qlty.sh`; Code Climate→Qlty `codeclimate.com/legacy/...` (2024-11-11). + +**Métricas:** jscpd `github.com/kucherenko/jscpd` (v5.0.4, 2026-06-08); ESLint v10 `eslint.org/blog/2026/02/eslint-v10.0.0-released/` (2026-02-06); eslint-plugin-sonarjs `npm` (v4.0.3, 2026-04-16) + `github.com/SonarSource/SonarJS`; knip `knip.dev` (v6.16.1, 2026-06-06); dpdm `github.com/acrazing/dpdm` (v4.2.0, 2026-05-09); osv-scanner `google.github.io/osv-scanner` (push 2026-06-08); lockfile-lint `github.com/lirantal/lockfile-lint` (v5.0.0, 2026-01-25); GitClear `gitclear.com/ai_assistant_code_quality_2025_research`. + +**Anti-alucinação:** agent-lsp `github.com/blackwell-systems/agent-lsp` (v0.13.0, 2026-06-04); CSA Slopsquatting `labs.cloudsecurityalliance.org/research/...slopsquatting...20260419...` (2026-04-19); Nesbitt package defenses `nesbitt.io/2026/04/09/...` (2026-04-09); Semcheck `github.com/rejot-dev/semcheck` (v1.2.1, fev/2026); verify skill `github.com/Piebald-AI/claude-code-system-prompts/.../skill-verify-skill.md`; Claude best practices `code.claude.com/docs/en/best-practices`; SlopCodeBench `arxiv.org/pdf/2603.24755`; GPT-5.5 system card `deploymentsafety.openai.com/gpt-5-5` (2026-04-23); adversarial review `asdlc.io/patterns/adversarial-code-review/`; OpenAPI drift `speakeasy.com/blog/openapi-spec-drift-detection`. + +**Babysit:** Claude auto-fix cloud `producthunt.com/products/claude-code-auto-fix-in-the-cloud` (2026-03-27); Devin Autofix `cognition.ai/blog/closing-the-agent-loop-...` (2026-02-10); CodeRabbit Autofix `coderabbit.ai/blog/fix-all-issues-with-ai-agents` (2026-02-19); Greptile skills `github.com/greptileai/skills`; Claude GitHub Actions/Code Review/ultrareview `code.claude.com/docs/en/{github-actions,code-review,ultrareview}`; Nx self-healing `nx.dev/blog/autonomous-ai-workflows-with-nx` (2026-02-03); Snyk Agent Fix 5,3% `safeguard.sh/resources/blog/snyk-agent-fix-autofix-field-test-2026`; resolveReviewThread `nakamasato.medium.com/...` + `github.com/cli/cli/issues/12419`; ICLR 2026 AI review `blog.pebblous.ai/report/iclr-2026-ai-peer-review-crisis`. + +--- + +*Relatório gerado a partir de auditoria paralela do código + transcrição do vídeo + pesquisa web 2026. Próximo passo: aprovar o [`PLANO-QUALITY-GATES.md`](./PLANO-QUALITY-GATES.md) e escolher por onde começar (recomendação: Fase 0 → Fase 1).* diff --git a/complexity-baseline.json b/complexity-baseline.json new file mode 100644 index 0000000000..79c2605fbe --- /dev/null +++ b/complexity-baseline.json @@ -0,0 +1,4 @@ +{ + "_comment": "Catraca de complexidade (check-complexity.mjs, ESLint core rules complexity>=15 e max-lines-per-function>80 sobre src+open-sse via eslint.complexity.config.mjs). Conta total de violacoes; so pode cair. --update ratcheta.", + "count": 1739 +} diff --git a/dependency-allowlist.json b/dependency-allowlist.json new file mode 100644 index 0000000000..bfc1b1fdf6 --- /dev/null +++ b/dependency-allowlist.json @@ -0,0 +1,110 @@ +{ + "_comment": "Allowlist anti-slopsquatting (check-deps.mjs). Toda dep nova exige adicao EXPLICITA aqui apos verificar que e legitima.", + "allowed": [ + "@aws-sdk/client-bedrock-runtime", + "@dnd-kit/core", + "@dnd-kit/sortable", + "@dnd-kit/utilities", + "@huggingface/transformers", + "@lobehub/icons", + "@modelcontextprotocol/sdk", + "@monaco-editor/react", + "@ngrok/ngrok", + "@playwright/test", + "@swc/helpers", + "@tailwindcss/postcss", + "@testing-library/jest-dom", + "@testing-library/react", + "@types/bcryptjs", + "@types/better-sqlite3", + "@types/bun", + "@types/keytar", + "@types/mdx", + "@types/node", + "@types/react", + "@types/react-dom", + "@types/ws", + "@vitejs/plugin-react", + "@xyflow/react", + "axios", + "bcryptjs", + "better-sqlite3", + "bottleneck", + "c8", + "commander", + "concurrently", + "cross-env", + "csv-stringify", + "electron", + "electron-builder", + "electron-updater", + "eslint", + "eslint-config-next", + "express", + "fetch-socks", + "fflate", + "fumadocs-core", + "fumadocs-mdx", + "fumadocs-ui", + "glob", + "gray-matter", + "http-proxy-middleware", + "https-proxy-agent", + "husky", + "ink", + "ink-spinner", + "ink-text-input", + "ioredis", + "jose", + "js-yaml", + "jsdom", + "jsonc-parser", + "keytar", + "lint-staged", + "lowdb", + "lucide-react", + "marked", + "marked-terminal", + "mermaid", + "monaco-editor", + "next", + "next-intl", + "next-themes", + "node-loader", + "node-machine-id", + "open", + "ora", + "parse5", + "pino", + "pino-abstract-transport", + "pino-pretty", + "playwright", + "prettier", + "react", + "react-dom", + "react-is", + "react-markdown", + "react-reconciler", + "recharts", + "selfsigned", + "sql.js", + "sqlite-vec", + "tailwindcss", + "tls-client-node", + "tsx", + "typescript", + "typescript-eslint", + "undici", + "update-notifier", + "uuid", + "vitest", + "wait-on", + "wreq-js", + "ws", + "wtfnode", + "xxhash-wasm", + "yazl", + "zod", + "zustand" + ] +} diff --git a/duplication-baseline.json b/duplication-baseline.json new file mode 100644 index 0000000000..ccbfa72e13 --- /dev/null +++ b/duplication-baseline.json @@ -0,0 +1,4 @@ +{ + "_comment": "Catraca de duplicacao (check-duplication.mjs, jscpd@4 sobre src+open-sse, min-tokens 50). So pode cair. --update ratcheta.", + "percentage": 5.72 +} diff --git a/eslint.complexity.config.mjs b/eslint.complexity.config.mjs new file mode 100644 index 0000000000..99766138a0 --- /dev/null +++ b/eslint.complexity.config.mjs @@ -0,0 +1,64 @@ +// eslint.complexity.config.mjs +// STANDALONE flat config for the complexity ratchet (scripts/check/check-complexity.mjs). +// Intentionally does NOT extend the project's main eslint.config.mjs — it enables ONLY two +// ESLint CORE rules so its violation count is isolated from the main lint's ratcheted +// warning budget (3482). No plugins, no extra dependency: just the TypeScript parser +// (typescript-eslint) so .ts/.tsx files can be parsed. +// +// complexity — cyclomatic complexity ceiling per function +// max-lines-per-function — function-length ceiling (skips blank lines + comments) +// +// Run via: +// npx eslint --no-config-lookup --config eslint.complexity.config.mjs --format json src open-sse +import tseslint from "typescript-eslint"; + +/** @type {import("eslint").Linter.Config[]} */ +const complexityConfig = [ + { + files: ["src/**/*.{ts,tsx}", "open-sse/**/*.{ts,tsx}"], + languageOptions: { + parser: tseslint.parser, + parserOptions: { + ecmaVersion: 2022, + sourceType: "module", + ecmaFeatures: { jsx: true }, + }, + }, + // Ignore ALL inline directive comments. Source files carry + // `// eslint-disable-next-line react-hooks/...` / `@next/next/...` directives that + // reference rules from plugins this standalone config deliberately does NOT load. + // Without this, ESLint emits an error-severity "Definition for rule ... was not + // found" for each such directive — polluting (and destabilizing) the violation + // count. noInlineConfig keeps the count to exactly our two rules. + linterOptions: { + noInlineConfig: true, + reportUnusedDisableDirectives: "off", + }, + // ONLY these two core rules. Keep this list minimal so the reported violation + // count is exactly "functions over the complexity / length thresholds". + rules: { + complexity: ["error", 15], + "max-lines-per-function": [ + "error", + { max: 80, skipBlankLines: true, skipComments: true }, + ], + }, + }, + // Ignore everything that is not first-party src/open-sse production code so the count + // is not polluted by tests, type declarations, or build output. + { + ignores: [ + "**/*.test.ts", + "**/*.test.tsx", + "**/__tests__/**", + "**/*.d.ts", + "node_modules/**", + ".next/**", + ".build/**", + "dist/**", + "coverage/**", + ], + }, +]; + +export default complexityConfig; diff --git a/file-size-baseline.json b/file-size-baseline.json new file mode 100644 index 0000000000..7059cd822d --- /dev/null +++ b/file-size-baseline.json @@ -0,0 +1,97 @@ +{ + "_comment": "Catraca de tamanho (check-file-size.mjs). frozen so pode encolher; arquivos novos <= cap. --update ratcheta.", + "cap": 800, + "frozen": { + "open-sse/config/providerRegistry.ts": 4677, + "open-sse/executors/antigravity.ts": 1533, + "open-sse/executors/base.ts": 1175, + "open-sse/executors/chatgpt-web.ts": 2870, + "open-sse/executors/claude-web.ts": 1057, + "open-sse/executors/codex.ts": 1439, + "open-sse/executors/cursor.ts": 1391, + "open-sse/executors/deepseek-web.ts": 1116, + "open-sse/executors/duckduckgo-web.ts": 917, + "open-sse/executors/grok-web.ts": 1871, + "open-sse/executors/muse-spark-web.ts": 1284, + "open-sse/executors/perplexity-web.ts": 867, + "open-sse/handlers/audioSpeech.ts": 952, + "open-sse/handlers/chatCore.ts": 5978, + "open-sse/handlers/imageGeneration.ts": 3777, + "open-sse/handlers/responseSanitizer.ts": 1080, + "open-sse/handlers/search.ts": 1441, + "open-sse/handlers/videoGeneration.ts": 1026, + "open-sse/mcp-server/schemas/tools.ts": 1437, + "open-sse/mcp-server/server.ts": 1457, + "open-sse/mcp-server/tools/advancedTools.ts": 1118, + "open-sse/services/accountFallback.ts": 1631, + "open-sse/services/batchProcessor.ts": 828, + "open-sse/services/browserBackedChat.ts": 850, + "open-sse/services/claudeCodeCompatible.ts": 1202, + "open-sse/services/combo.ts": 4457, + "open-sse/services/rateLimitManager.ts": 1017, + "open-sse/services/tokenRefresh.ts": 1896, + "open-sse/services/usage.ts": 3042, + "open-sse/translator/request/openai-to-gemini.ts": 822, + "open-sse/translator/response/openai-responses.ts": 873, + "open-sse/utils/cursorAgentProtobuf.ts": 1499, + "open-sse/utils/stream.ts": 2593, + "src/app/(dashboard)/dashboard/HomePageClient.tsx": 1417, + "src/app/(dashboard)/dashboard/analytics/ComboHealthTab.tsx": 1020, + "src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx": 2680, + "src/app/(dashboard)/dashboard/cache/media/MediaPageClient.tsx": 1105, + "src/app/(dashboard)/dashboard/cache/page.tsx": 841, + "src/app/(dashboard)/dashboard/cli-code/components/CodexToolCard.tsx": 894, + "src/app/(dashboard)/dashboard/cloud-agents/page.tsx": 913, + "src/app/(dashboard)/dashboard/combos/page.tsx": 4350, + "src/app/(dashboard)/dashboard/costs/CostOverviewTab.tsx": 1481, + "src/app/(dashboard)/dashboard/costs/quota-share/components/PoolWizard.tsx": 1007, + "src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx": 2570, + "src/app/(dashboard)/dashboard/health/page.tsx": 1091, + "src/app/(dashboard)/dashboard/playground/components/tabs/ApiTab.tsx": 847, + "src/app/(dashboard)/dashboard/providers/[id]/page.tsx": 12883, + "src/app/(dashboard)/dashboard/providers/components/onboarding/ProviderOnboardingWizard.tsx": 906, + "src/app/(dashboard)/dashboard/providers/page.tsx": 1925, + "src/app/(dashboard)/dashboard/runtime/RuntimePageClient.tsx": 1127, + "src/app/(dashboard)/dashboard/settings/components/AppearanceTab.tsx": 819, + "src/app/(dashboard)/dashboard/settings/components/CompressionSettingsTab.tsx": 932, + "src/app/(dashboard)/dashboard/settings/components/MemorySkillsTab.tsx": 880, + "src/app/(dashboard)/dashboard/settings/components/PricingTab.tsx": 1012, + "src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx": 1072, + "src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx": 851, + "src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx": 1580, + "src/app/(dashboard)/dashboard/settings/components/SystemStorageTab.tsx": 1924, + "src/app/(dashboard)/dashboard/usage/components/BudgetTab.tsx": 1016, + "src/app/(dashboard)/dashboard/usage/components/EvalsTab.tsx": 2148, + "src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx": 1015, + "src/app/api/oauth/[provider]/[action]/route.ts": 897, + "src/app/api/providers/[id]/models/route.ts": 2287, + "src/app/api/providers/[id]/test/route.ts": 842, + "src/app/api/usage/analytics/route.ts": 1355, + "src/app/api/v1/models/catalog.ts": 1403, + "src/lib/cloudflaredTunnel.ts": 934, + "src/lib/db/apiKeys.ts": 1490, + "src/lib/db/core.ts": 1813, + "src/lib/db/migrationRunner.ts": 1100, + "src/lib/db/models.ts": 1132, + "src/lib/db/providers.ts": 993, + "src/lib/db/proxies.ts": 1031, + "src/lib/db/settings.ts": 1101, + "src/lib/evals/evalRunner.ts": 961, + "src/lib/memory/retrieval.ts": 1171, + "src/lib/modelsDevSync.ts": 934, + "src/lib/providers/validation.ts": 4201, + "src/lib/tailscaleTunnel.ts": 1189, + "src/lib/usage/callLogs.ts": 971, + "src/shared/components/OAuthModal.tsx": 956, + "src/shared/components/RequestLoggerV2.tsx": 951, + "src/shared/components/analytics/charts.tsx": 1558, + "src/shared/constants/cliTools.ts": 875, + "src/shared/constants/pricing.ts": 1447, + "src/shared/constants/providers.ts": 3121, + "src/shared/constants/sidebarVisibility.ts": 990, + "src/shared/services/cliRuntime.ts": 1073, + "src/shared/validation/schemas.ts": 2490, + "src/sse/handlers/chat.ts": 1381, + "src/sse/services/auth.ts": 2198 + } +} diff --git a/package.json b/package.json index 1a853e0317..34263eed61 100644 --- a/package.json +++ b/package.json @@ -110,7 +110,28 @@ "i18n:check-ui-coverage": "node scripts/i18n/check-ui-keys-coverage.mjs", "check:node-runtime": "node --import tsx scripts/check/check-supported-node-runtime.ts", "check:pack-artifact": "node --import tsx scripts/build/validate-pack-artifact.ts", - "audit:deps": "npm audit --audit-level=moderate && npm run audit:electron", + "check:cli-i18n": "node scripts/check/check-cli-i18n.mjs", + "check:openapi-coverage": "node scripts/check/check-openapi-coverage.mjs", + "check:openapi-security-tiers": "node scripts/check/check-openapi-security-tiers.mjs", + "check:provider-consistency": "node --import tsx scripts/check/check-provider-consistency.ts", + "check:fetch-targets": "node scripts/check/check-fetch-targets.mjs", + "check:openapi-routes": "node scripts/check/check-openapi-routes.mjs", + "check:deps": "node scripts/check/check-deps.mjs", + "check:file-size": "node scripts/check/check-file-size.mjs", + "check:duplication": "node scripts/check/check-duplication.mjs", + "check:test-masking": "node scripts/check/check-test-masking.mjs", + "check:error-helper": "node scripts/check/check-error-helper.mjs", + "check:migration-numbering": "node scripts/check/check-migration-numbering.mjs", + "check:public-creds": "node scripts/check/check-public-creds.mjs", + "check:db-rules": "node scripts/check/check-db-rules.mjs", + "check:docs-symbols": "node scripts/check/check-docs-symbols.mjs", + "check:known-symbols": "node --import tsx scripts/check/check-known-symbols.ts", + "check:route-guard-membership": "node --import tsx scripts/check/check-route-guard-membership.ts", + "check:complexity": "node scripts/check/check-complexity.mjs", + "quality:collect": "node scripts/quality/collect-metrics.mjs", + "quality:ratchet": "node scripts/quality/check-quality-ratchet.mjs", + "quality:gate": "npm run quality:collect && npm run quality:ratchet -- --allow-missing", + "audit:deps": "npm audit --audit-level=critical && (npm audit --audit-level=high || echo '::warning::high-severity advisories present (non-blocking)') && npm run audit:electron", "audit:electron": "npm --prefix electron audit --audit-level=moderate", "typecheck:core": "tsc --pretty false -p tsconfig.typecheck-core.json", "typecheck:noimplicit:core": "tsc --pretty false -p tsconfig.typecheck-noimplicit-core.json", diff --git a/quality-baseline.json b/quality-baseline.json new file mode 100644 index 0000000000..96ef62ad83 --- /dev/null +++ b/quality-baseline.json @@ -0,0 +1,11 @@ +{ + "_comment": "Catraca de qualidade. 'down' = nao pode aumentar; 'up' = nao pode cair. Atualize via 'npm run quality:ratchet -- --update' (somente quando melhora). Cada valor e um numero REAL medido, nunca um chute. Cobertura entra na Fase 4 a partir de um run de cobertura mergeada no CI.", + "metrics": { + "eslintWarnings": { "value": 3482, "direction": "down" }, + "coverage.statements": { "value": 80, "direction": "up" }, + "coverage.lines": { "value": 80, "direction": "up" }, + "coverage.functions": { "value": 82, "direction": "up" }, + "coverage.branches": { "value": 73, "direction": "up" } + }, + "_coverage_note": "Pisos conservadores (real ~82,58/82,58/84,23/75,22 em COVERAGE_PLAN.md 2026-05-13, ~2pt de margem p/ evitar false-fail antes de calibrar no CI). Apos o 1o run verde de coverage mergeada na main, aperte com 'npm run quality:ratchet -- --update'." +} diff --git a/scripts/check/check-complexity.mjs b/scripts/check/check-complexity.mjs new file mode 100644 index 0000000000..bc2f8c42b4 --- /dev/null +++ b/scripts/check/check-complexity.mjs @@ -0,0 +1,87 @@ +#!/usr/bin/env node +// scripts/check/check-complexity.mjs +// Catraca de complexidade de código. Roda o ESLint sobre src+open-sse usando um config +// flat STANDALONE (eslint.complexity.config.mjs) que liga APENAS duas regras CORE do +// ESLint — `complexity` (ciclomática) e `max-lines-per-function` (tamanho de função) — +// e compara a contagem total de violações contra um baseline congelado +// (complexity-baseline.json). Falha se a contagem SUBIR. Completa a dimensão +// "complexity" do snapshot de qualidade, ao lado de duplicação/tamanho-de-arquivo. +// +// O config dedicado evita poluir a contagem de warnings do lint principal (ratcheada +// em exatamente 3482): este gate roda isolado, com seu próprio par de regras. --update +// ratcheta (a contagem só pode CAIR). +import fs from "node:fs"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; +import { pathToFileURL } from "node:url"; + +const ROOT = process.cwd(); +const BASELINE_PATH = path.resolve( + process.argv.includes("--baseline") + ? process.argv[process.argv.indexOf("--baseline") + 1] + : path.join(ROOT, "complexity-baseline.json") +); +const UPDATE = process.argv.includes("--update"); +const CONFIG_PATH = path.join(ROOT, "eslint.complexity.config.mjs"); +const ESLINT_ARGS = [ + "eslint", + "--no-config-lookup", + "--config", + CONFIG_PATH, + "--format", + "json", + "src", + "open-sse", +]; + +/** Avalia a contagem atual de violações contra o baseline. */ +export function evaluateComplexity(current, baseline) { + return { + regressed: current > baseline, + improved: current < baseline, + }; +} + +function measureComplexityCount() { + let stdout; + try { + stdout = execFileSync("npx", ["--yes", ...ESLINT_ARGS], { + encoding: "utf8", + maxBuffer: 64 * 1024 * 1024, + }); + } catch (err) { + // ESLint sai com código !=0 quando há erros (e nossas regras são "error"); o relatório + // JSON ainda vai no stdout. Só relançamos se não houver stdout parseável. + stdout = err.stdout ? String(err.stdout) : ""; + if (!stdout.trim()) throw err; + } + const report = JSON.parse(stdout); + return report.reduce((sum, file) => sum + file.errorCount, 0); +} + +function main() { + if (!fs.existsSync(BASELINE_PATH)) { + console.error(`[complexity] FAIL — ${path.basename(BASELINE_PATH)} ausente.`); + process.exit(2); + } + const baseline = JSON.parse(fs.readFileSync(BASELINE_PATH, "utf8")); + const current = measureComplexityCount(); + const { regressed, improved } = evaluateComplexity(current, baseline.count); + + if (UPDATE && improved) { + console.log(`[complexity] baseline ratcheado: ${current} (era ${baseline.count})`); + baseline.count = current; + fs.writeFileSync(BASELINE_PATH, JSON.stringify(baseline, null, 2) + "\n"); + } + if (regressed) { + console.error( + `[complexity] REGRESSÃO — ${current} violações > baseline ${baseline.count}\n` + + ` → quebre a função em helpers menores (reduza ramos/tamanho) ou rode\n` + + ` 'node scripts/check/check-complexity.mjs --update' se a contagem caiu legitimamente.` + ); + process.exit(1); + } + console.log(`[complexity] OK — ${current} violações (baseline ${baseline.count})`); +} + +if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main(); diff --git a/scripts/check/check-db-rules.mjs b/scripts/check/check-db-rules.mjs new file mode 100644 index 0000000000..8c76b2f737 --- /dev/null +++ b/scripts/check/check-db-rules.mjs @@ -0,0 +1,252 @@ +#!/usr/bin/env node +// scripts/check/check-db-rules.mjs +// Gate de convenções de banco (CLAUDE.md Hard Rules #2 e #5). Três verificações: +// (a) Todo módulo de domínio em src/lib/db/*.ts deve ser re-exportado por +// src/lib/localDb.ts (camada de compat). Um módulo db NOVO que não é +// re-exportado (e não está congelado) falha — força a decisão consciente +// de expor ou justificar (Hard Rule #2). +// (b) src/lib/localDb.ts é APENAS camada de re-export: nada de lógica +// (function/class/arrow de negócio). Mata o anti-padrão de "só uma +// funçãozinha aqui" que vira regra de negócio fora dos módulos db/. +// (c) Nenhum SQL cru em src/app/api/**/route.ts ou open-sse/handlers/*.ts. +// SQL deve viver em src/lib/db/ (Hard Rule #5). Ofensores pré-existentes +// são congelados; QUALQUER novo SQL cru em rota/handler falha. +import fs from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +const cwd = process.cwd(); +const DB_DIR = path.join(cwd, "src/lib/db"); +const LOCAL_DB = path.join(cwd, "src/lib/localDb.ts"); +const API_DIR = path.join(cwd, "src/app/api"); +const HANDLERS_DIR = path.join(cwd, "open-sse/handlers"); + +// (a) Módulos db/ que NÃO são re-exportados por localDb.ts hoje. Congelados +// para a catraca ficar verde e bloquear QUALQUER módulo novo não re-exportado. +// CADA UM é dívida: ou é consumido por import direto de "@/lib/db/X" (legítimo, +// não precisa de re-export) ou deveria ser re-exportado. NÃO adicione novos aqui +// sem justificativa — esse é o ponto do gate (Hard Rule #2). +const KNOWN_UNEXPORTED = new Set([ + "_rowTypes", // só tipos de linha (sem runtime API), consumido localmente pelos CRUDs F2 + "cleanup", // rotina de manutenção, chamada por jobs/rotas via import direto + "cliToolState", // estado de CLI tools, import direto pelos consumidores + "comboForecast", // previsão de combo, import direto + "commandCodeAuth", // auth de command-code, import direto + "compression", // núcleo de compressão, import direto + "compressionScheduler", // scheduler, import direto + "detailedLogs", // logs detalhados, import direto + "discovery", // discovery de modelos, import direto + "domainState", // estado de domínio/circuit breaker, import direto + "encryption", // util de cripto at-rest, import direto + "healthCheck", // health check de DB, import direto + "jsonMigration", // migração JSON→SQLite (one-shot), import direto + "migrationRunner", // runner de migrations, import direto + "notion", // integração Notion, import direto + "obsidian", // integração Obsidian, import direto + "pluginMetrics", // métricas de plugin, import direto + "prompts", // prompts salvos, import direto + "providerStats", // stats de provider, import direto + "recovery", // recuperação de DB, import direto + "secrets", // secrets store, import direto + "serviceModels", // modelos de serviços embutidos, import direto + "stateReset", // reset de estado de resiliência, import direto + "stats", // agregações de stats, import direto + "tierConfig", // config de tier, import direto +]); + +// (c) Ofensores de SQL cru PRÉ-EXISTENTES em rotas/handlers. Congelados para a +// catraca ficar verde e bloquear QUALQUER nova rota/handler com SQL inline. +// CADA UM é dívida da Hard Rule #5: mover para um módulo src/lib/db/. NÃO +// adicione novos aqui sem justificativa — crie/estenda um módulo db/ em vez disso. +// (Chaves = caminho relativo POSIX a partir da raiz do repo.) +const KNOWN_RAW_SQL = new Set([ + "src/app/api/analytics/auto-routing/route.ts", // SELECT … FROM usage_logs + "src/app/api/cache/entries/route.ts", // semantic_cache COUNT/DELETE inline + "src/app/api/db-backups/exportAll/route.ts", // SELECT key_value/combos/connections/keys + "src/app/api/db-backups/import/route.ts", // SELECT sqlite_master + COUNTs + "src/app/api/gamification/federation/leaderboard/route.ts", // SELECT community_servers + "src/app/api/gamification/federation/score/route.ts", // SELECT community_servers + "src/app/api/logs/export/route.ts", // SELECT de proxy_logs + "src/app/api/oauth/cursor/auto-import/route.ts", // SELECT no itemTable do Cursor (DB externo) + "src/app/api/oauth/kiro/auto-import/route.ts", // SELECT no SQLite do Kiro (DB externo) + "src/app/api/provider-metrics/route.ts", // SELECT … FROM call_logs (agregação) + "src/app/api/search/stats/route.ts", // SELECT … FROM call_logs + "src/app/api/settings/export-json/route.ts", // SELECT * de usage_history/domain_* + "src/app/api/skills/[id]/route.ts", // UPDATE skills SET dinâmico + "src/app/api/usage/analytics/route.ts", // SELECT … FROM usage_history/daily_usage_summary + "src/app/api/v1/search/analytics/route.ts", // SELECT … FROM call_logs (request_type=search) +]); + +// Módulos sempre excluídos da checagem (a): não são domínio re-exportável. +const DB_MODULE_EXCLUDE = new Set(["core", "localDb", "index"]); + +function walk(dir, acc = []) { + if (!fs.existsSync(dir)) return acc; + for (const e of fs.readdirSync(dir, { withFileTypes: true })) { + const p = path.join(dir, e.name); + if (e.isDirectory()) walk(p, acc); + else acc.push(p); + } + return acc; +} + +// Lista os módulos de domínio em src/lib/db (top-level *.ts), excluindo +// core/localDb/index, *.d.ts e qualquer subdiretório (migrations/, adapters/, __tests__/). +export function collectDbModules(dbDir = DB_DIR) { + if (!fs.existsSync(dbDir)) return []; + return fs + .readdirSync(dbDir, { withFileTypes: true }) + .filter((e) => e.isFile() && /\.ts$/.test(e.name) && !/\.d\.ts$/.test(e.name)) + .map((e) => e.name.replace(/\.ts$/, "")) + .filter((name) => !DB_MODULE_EXCLUDE.has(name)) + .sort(); +} + +// Extrai os nomes de módulo re-exportados de localDb.ts a partir de +// `... from "./db/X"` (cobre export {…}, export * e export type {…}). +export function extractReexportedModules(localDbSource) { + const re = /from\s+["']\.\/db\/([A-Za-z0-9_]+)["']/g; + const out = new Set(); + let m; + while ((m = re.exec(localDbSource))) out.add(m[1]); + return out; +} + +// (a) Módulos db/ que não são re-exportados e não estão congelados. +export function findMissingReexports(dbModules, reexported, allowlist = KNOWN_UNEXPORTED) { + return dbModules.filter((mod) => !reexported.has(mod) && !allowlist.has(mod)); +} + +// (b) localDb.ts deve conter SOMENTE import/export + comentários (sem lógica). +// Remove comentários e strings, depois procura declarações de runtime. +export function hasLogic(localDbSource) { + const stripped = localDbSource + // comentários de bloco + .replace(/\/\*[\s\S]*?\*\//g, "") + // comentários de linha + .replace(/\/\/[^\n]*/g, "") + // template strings + .replace(/`(?:\\[\s\S]|[^\\`])*`/g, '""') + // strings simples/duplas (paths de import etc.) + .replace(/"(?:\\.|[^"\\])*"/g, '""') + .replace(/'(?:\\.|[^'\\])*'/g, '""'); + + // function/class declaradas, ou atribuição a função (const X = (…) =>, const X = function). + const logicPatterns = [ + /(^|[^.\w])function\s+[A-Za-z_$]/, // function decl (não method .foo()) + /(^|[^.\w])class\s+[A-Za-z_$]/, // class decl + /(?:const|let|var)\s+[A-Za-z_$][\w$]*\s*=\s*(?:async\s*)?\(/, // const X = (…) ... (arrow/call) + /(?:const|let|var)\s+[A-Za-z_$][\w$]*\s*=\s*(?:async\s+)?function\b/, // const X = function + ]; + return logicPatterns.some((rx) => rx.test(stripped)); +} + +// SQL cru é sempre uma STRING passada a db.prepare()/exec(): casamos os padrões +// SÓ dentro de literais de string (não em código JS — `import … from`, `.set(`, +// `new Set(`, `delete x` etc. são falsos positivos se varrermos o código todo). +const SQL_PATTERNS = [ + /\bSELECT\b[\s\S]*?\bFROM\b/i, // SELECT … FROM (multi-linha) + /\bINSERT\s+INTO\b/i, + /\bUPDATE\b[\s\S]*?\bSET\b/i, // UPDATE … SET (multi-linha) + /\bDELETE\s+FROM\b/i, + /\bCREATE\s+TABLE\b/i, +]; + +// Remove comentários (linha // … e blocos /* */) — SQL em comentário não conta. +function stripComments(source) { + return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/[^\n]*/g, ""); +} + +// Extrai o conteúdo de todos os literais de string (template, aspas duplas, aspas +// simples) de um trecho de código já sem comentários. Retorna a concatenação dos +// corpos — é nesse corpo que SQL cru vive. +export function extractStringLiterals(code) { + const re = /`(?:\\[\s\S]|[^\\`])*`|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'/g; + const out = []; + let m; + while ((m = re.exec(code))) { + // tira as aspas/crases delimitadoras + out.push(m[0].slice(1, -1)); + } + return out.join("\n\n"); // separador que nenhum padrão SQL atravessa +} + +// (c) Arquivos com SQL cru dentro de literais de string (linhas não-comentário), +// fora do allowlist. +export function findRawSql(files, allowlist = KNOWN_RAW_SQL) { + const offenders = []; + for (const file of files) { + const rel = path.relative(cwd, file).replace(/\\/g, "/"); + if (allowlist.has(rel)) continue; + let src; + try { + src = fs.readFileSync(file, "utf8"); + } catch { + continue; + } + const literals = extractStringLiterals(stripComments(src)); + if (SQL_PATTERNS.some((rx) => rx.test(literals))) { + offenders.push(rel); + } + } + return offenders; +} + +// Coleta os arquivos sujeitos à checagem (c): rotas de API + handlers de stream. +export function collectSqlScanFiles(apiDir = API_DIR, handlersDir = HANDLERS_DIR) { + const routes = walk(apiDir).filter((p) => /(^|\/)route\.tsx?$/.test(p.replace(/\\/g, "/"))); + const handlers = fs.existsSync(handlersDir) + ? fs + .readdirSync(handlersDir, { withFileTypes: true }) + .filter((e) => e.isFile() && /\.tsx?$/.test(e.name)) + .map((e) => path.join(handlersDir, e.name)) + : []; + return [...routes, ...handlers]; +} + +function main() { + const failures = []; + + // (a) re-export completeness + const dbModules = collectDbModules(); + const reexported = extractReexportedModules(fs.readFileSync(LOCAL_DB, "utf8")); + const missing = findMissingReexports(dbModules, reexported); + if (missing.length) { + failures.push( + `[#2 re-export] ${missing.length} módulo(s) db/ não re-exportado(s) por src/lib/localDb.ts:\n` + + missing.map((m) => ` ✗ src/lib/db/${m}.ts`).join("\n") + + `\n → re-exporte de src/lib/localDb.ts (apenas a lista de re-export, nada de lógica)` + + ` ou adicione a KNOWN_UNEXPORTED com justificativa (import direto de "@/lib/db/${missing[0]}").` + ); + } + + // (b) localDb sem lógica + if (hasLogic(fs.readFileSync(LOCAL_DB, "utf8"))) { + failures.push( + `[#2 sem-lógica] src/lib/localDb.ts contém lógica (function/class/arrow). É camada de` + + ` re-export apenas — mova a lógica para um módulo src/lib/db/.` + ); + } + + // (c) SQL cru fora de db/ + const rawSql = findRawSql(collectSqlScanFiles()); + if (rawSql.length) { + failures.push( + `[#5 sql-cru] ${rawSql.length} arquivo(s) com SQL cru fora de src/lib/db/:\n` + + rawSql.map((f) => ` ✗ ${f}`).join("\n") + + `\n → mova o SQL para um módulo src/lib/db/ (nunca SQL cru em rota/handler)` + + ` ou congele em KNOWN_RAW_SQL com justificativa.` + ); + } + + if (failures.length) { + console.error(`[check-db-rules] FALHOU:\n\n` + failures.join("\n\n")); + process.exit(1); + } + console.log( + `[check-db-rules] OK (${dbModules.length} módulos db/, ${reexported.size} re-exportados, ` + + `${KNOWN_UNEXPORTED.size} congelados; ${KNOWN_RAW_SQL.size} ofensores de SQL congelados)` + ); +} + +if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main(); diff --git a/scripts/check/check-deps.mjs b/scripts/check/check-deps.mjs new file mode 100644 index 0000000000..f39954d51a --- /dev/null +++ b/scripts/check/check-deps.mjs @@ -0,0 +1,66 @@ +#!/usr/bin/env node +// scripts/check/check-deps.mjs +// Gate anti-slopsquatting: toda dependência em package.json (raiz + electron) deve +// estar numa allowlist commitada (dependency-allowlist.json). Uma dep nova exige +// adição EXPLÍCITA à allowlist — assim um agente não consegue introduzir um pacote +// alucinado/typosquatted silenciosamente (CSA 2026: 19,7% do código IA cita pacotes +// inexistentes; 43% dos nomes alucinados reaparecem, registráveis por atacantes). +// A revisão humana ao adicionar à allowlist é o ponto de controle. +import fs from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +const ROOT = process.cwd(); +const ALLOWLIST_PATH = path.join(ROOT, "dependency-allowlist.json"); +const MANIFESTS = ["package.json", path.join("electron", "package.json")]; + +/** Nomes de deps no manifesto que não estão na allowlist (de-dup, ordem preservada). */ +export function findUnapprovedDeps(depNames, allowlist) { + const seen = new Set(); + const out = []; + for (const name of depNames) { + if (seen.has(name)) continue; + seen.add(name); + if (!allowlist.has(name)) out.push(name); + } + return out; +} + +function depNamesFromManifest(file) { + const full = path.join(ROOT, file); + if (!fs.existsSync(full)) return []; + const pkg = JSON.parse(fs.readFileSync(full, "utf8")); + return [ + ...Object.keys(pkg.dependencies || {}), + ...Object.keys(pkg.devDependencies || {}), + ...Object.keys(pkg.optionalDependencies || {}), + ]; +} + +function collectDepNames() { + return MANIFESTS.flatMap(depNamesFromManifest); +} + +function main() { + if (!fs.existsSync(ALLOWLIST_PATH)) { + console.error( + `[check-deps] FAIL — ${path.basename(ALLOWLIST_PATH)} ausente. Gere com:\n` + + ` node -e "require('./scripts/check/check-deps.mjs')" (ou veja o passo de bootstrap no PLANO)` + ); + process.exit(1); + } + const allowlist = new Set(JSON.parse(fs.readFileSync(ALLOWLIST_PATH, "utf8")).allowed || []); + const unapproved = findUnapprovedDeps(collectDepNames(), allowlist); + if (unapproved.length) { + console.error( + `[check-deps] ${unapproved.length} dependência(s) FORA da allowlist:\n` + + unapproved.map((d) => " ✗ " + d).join("\n") + + `\n → confirme que o pacote é legítimo (existe no registry, publisher conhecido, não é typosquat)\n` + + ` e adicione o nome a dependency-allowlist.json ("allowed"). Esse é o ponto de revisão humana.` + ); + process.exit(1); + } + console.log(`[check-deps] OK — ${allowlist.size} dependências na allowlist, nenhuma nova`); +} + +if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main(); diff --git a/scripts/check/check-docs-symbols.mjs b/scripts/check/check-docs-symbols.mjs new file mode 100644 index 0000000000..34d71791f7 --- /dev/null +++ b/scripts/check/check-docs-symbols.mjs @@ -0,0 +1,237 @@ +#!/usr/bin/env node +// scripts/check/check-docs-symbols.mjs +// Gate anti-alucinação (docs → código): toda referência a uma rota `/api/...` dentro de +// docs/**/*.md deve resolver para um `route.ts` real em src/app/api/. Pega endpoint +// INVENTADO/obsoleto que a IA escreve em docs/PRs descrevendo uma rota que não existe — +// o padrão recorrente das PRs de docs (ex.: oyi77) que fabricam endpoints/APIs. +// +// Complementa os outros gates anti-alucinação: +// - check-fetch-targets.mjs : fetch("/api/...") na UI → route.ts (código → código) +// - check-openapi-routes.mjs : path da openapi.yaml → route.ts (spec → código) +// - este gate : /api/... na prosa/markdown → route.ts (docs → código) +// +// LOW-NOISE por design: escopo APENAS a paths de rota `/api/...` (sinal mais alto). +// Tudo que é ruído conhecido (superfície proxy OpenAI-compat, refs a arquivos-fonte, +// APIs upstream de terceiros, placeholders) vai para IGNORE com justificativa, NÃO para +// a allowlist. A allowlist congela só drift REAL pré-existente de docs. +import fs from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +const ROOT = process.cwd(); +const DOCS = path.join(ROOT, "docs"); +const API = path.join(ROOT, "src/app/api"); + +// Padrões que NÃO são rotas internas do OmniRoute (ruído estrutural, não drift). +// Adicione aqui (com justificativa) em vez da allowlist quando uma categoria gera +// falsos positivos — a allowlist é só para endpoints stale REAIS. +const IGNORE = [ + /^\/api\/v1\//, // superfície OpenAI-compat (proxy), não rota interna + /^\/api\/v1beta\//, // superfície Gemini-compat (proxy) + /^\/api\/v0\//, // APIs upstream de terceiros citadas em docs de pesquisa + /^\/api\/v2\//, // idem (deployments etc.) + /^\/api\/(organizations|map-image|graphql|gql)\b/, // APIs de provedores externos documentadas + /your-/i, // placeholder de exemplo + /example/i, // placeholder de exemplo + /\.{3}/, // placeholder "..." + /\{\}/, // placeholder de param vazio + /_(POST|GET|PUT|DELETE|PATCH)$/, // refs estilo trace de rede (gql_POST) +]; + +// Refs a ARQUIVOS-FONTE, não a URLs (ex.: src/app/api/.../route.ts citado em prosa). +// O gate só valida URLs de rota, não caminhos de arquivo. +function isFileRef(p) { + return /\.(ts|tsx|js|mjs|jsx)$/.test(p) || /\/route$/.test(p); +} + +// Refs a `/api/...` que NÃO resolvem para rota real, congeladas para triagem +// (catraca: bloqueia QUALQUER nova ref inventada em docs). Estas são achados REAIS de +// drift/alucinação em docs pré-existentes — cada uma precisa de: criar a rota, corrigir +// o path na doc, ou remover a menção. NÃO adicione novas aqui sem justificativa — esse +// é o ponto do gate. Issues de tracking devem ser abertas para cada cluster. +export const KNOWN_STALE_DOC_REFS = new Set([ + // docs/reference/API_REFERENCE.md — tabela de endpoints com várias rotas obsoletas: + "/api/acp/agents/[id]", // só existe /api/acp/agents (sem [id]) + "/api/acp/agents/refresh", // sem rota /refresh + "/api/admin/circuit-breaker", // admin só tem /concurrency + "/api/admin/circuit-breaker/reset", // idem + "/api/admin/rate-limits", // idem + "/api/cache/clear", // cache usa DELETE em /api/cache, não /clear + "/api/cache/reasoning/clear", // /api/cache/reasoning existe; /clear não + "/api/guardrails", // sem dir de API guardrails (feature server-side, sem rota REST) + "/api/guardrails/[id]/disable", + "/api/guardrails/[id]/enable", + "/api/guardrails/logs", + "/api/guardrails/test", + "/api/plugins/[id]/disable", // rota real usa [name] + activate/deactivate + "/api/plugins/[id]/enable", // idem + "/api/shadow", // sem dir de API shadow (shadow routing não tem rota REST) + "/api/shadow/[id]", + "/api/shadow/[id]/results", + "/api/shadow/metrics", + "/api/skills/[id]/disable", // skills tem [id] e /executions (base), não estas sub-ações + "/api/skills/[id]/enable", + "/api/skills/[id]/execute", + "/api/skills/[id]/executions", + "/api/system-info", // sem rota /system-info + // docs/research/DISCOVERY_TOOL_DESIGN.md — design doc de feature NÃO implementada: + "/api/discovery/results", + "/api/discovery/results/:id", + "/api/discovery/scan", + "/api/discovery/verify/:id", + // docs/frameworks/AGENTBRIDGE.md — state POR-AGENTE; rota real é o /state GLOBAL + // (mesmo drift congelado em check-openapi-routes.mjs::KNOWN_STALE_SPEC): + "/api/tools/agent-bridge/agents/{id}/state", + // docs/reference/ENVIRONMENT.md — endpoint UPSTREAM do provedor Blackbox Web, + // citado na descrição de env var (não é rota do OmniRoute): + "/api/chat", + // docs/ops/TUNNELS_GUIDE.md — a doc afirma EXPLICITAMENTE que este endpoint NÃO + // existe ("There is no central /api/settings/tunnels endpoint"); menção pedagógica: + "/api/settings/tunnels", +]); + +function walk(dir, filter, acc = []) { + if (!fs.existsSync(dir)) return acc; + for (const e of fs.readdirSync(dir, { withFileTypes: true })) { + const p = path.join(dir, e.name); + if (e.isDirectory()) walk(p, filter, acc); + else if (filter(e.name)) acc.push(p); + } + return acc; +} + +export function collectRouteFiles() { + return new Set( + walk(API, (n) => /^route\.tsx?$/.test(n)).map((p) => + path.relative(ROOT, p).replace(/\\/g, "/") + ) + ); +} + +/** Normaliza um segmento dinâmico ({param} / [param] / [...param] / :param) para wildcard. */ +function normSeg(seg) { + if (/^\[\[?\.{3}.+\]\]?$/.test(seg)) return ""; // catch-all [...x] / [[...x]] + if (/^\{[^}]+\}$/.test(seg) || /^\[[^\]]+\]$/.test(seg) || /^:[^/]+$/.test(seg)) return " "; + return seg; +} + +// /api/providers/{id}/models → src/app/api/providers/[id]/models/route.ts +// Casa por contagem de segmentos OU por prefixo (uma doc pode citar só o prefixo de +// uma rota mais profunda, ex.: /api/auth descrevendo a família /api/auth/login). Qualquer +// segmento dinâmico ([..]/{..}/:..) casa com um segmento dinâmico real. +export function resolveApiDocPathToRoute(apiPath, routeFiles) { + const segs = apiPath + .replace(/^\//, "") + .replace(/[?#].*$/, "") + .split("/") + .map(normSeg); + for (const rf of routeFiles) { + const rsegs = rf + .replace(/^src\/app\//, "") + .replace(/\/route\.tsx?$/, "") + .split("/"); + const rnorm = rsegs.map((rs) => { + if (/^\[\[?\.{3}.+\]\]?$/.test(rs)) return ""; // catch-all + if (/^\[[^\]]+\]$/.test(rs)) return " "; // [param] + return rs; + }); + const catchAll = rnorm.includes(""); + const effLen = catchAll ? rnorm.indexOf("") : rnorm.length; + if (!catchAll && segs.length > rnorm.length) continue; // doc mais profunda que a rota + if (catchAll && segs.length < effLen) continue; + const cmpLen = Math.min(segs.length, effLen || rnorm.length); + let match = true; + for (let i = 0; i < cmpLen; i++) { + const rs = rnorm[i]; + if (rs === "") break; // catch-all absorve o resto + if (!(rs === segs[i] || rs === " " || segs[i] === " ")) { + match = false; + break; + } + } + if (match) return true; + } + return false; +} + +/** Limpa o path capturado: remove pontuação/ênfase de prosa, fecha brackets pendentes. */ +function cleanCapturedPath(raw) { + let p = raw.replace(/[.,:;_)>]+$/, ""); + const ob = (p.match(/\[/g) || []).length; + const cb = (p.match(/\]/g) || []).length; + const oc = (p.match(/\{/g) || []).length; + const cc = (p.match(/\}/g) || []).length; + if (ob !== cb || oc !== cc) { + // segmento final truncado pelo regex (bracket aberto sem fechar na prosa) → descarta + p = p.replace(/\/[^/]*[[{][^/]*$/, ""); + } + return p.replace(/\/$/, ""); // remove barra final (forma de prefixo) +} + +// /api/... só conta como URL quando NÃO é a cauda de um caminho de arquivo-fonte +// (src/lib/api/, @/app/api/, app/api/). O grupo 2 é o path. +const API_PATH_RE = /(^|[^A-Za-z0-9_/])(\/api\/[A-Za-z0-9_\-/{}\[\].:]+)/g; + +/** Extrai os paths /api/... distintos de um arquivo markdown (forma URL, não arquivo). */ +export function extractDocApiPaths(src) { + const out = new Set(); + let m; + API_PATH_RE.lastIndex = 0; + while ((m = API_PATH_RE.exec(src))) { + const p = cleanCapturedPath(m[2]); + if (p && p !== "/api") out.add(p); + } + return [...out]; +} + +/** + * Núcleo puro/testável. + * @param {{file: string, paths: string[]}[]} docPathsByFile + * @param {Set} routeFiles conjunto de "src/app/api/.../route.ts" + * @param {Set} allowlist paths stale congelados + * @returns {string[]} misses no formato "file → /api/path" + */ +export function findStaleDocApiRefs(docPathsByFile, routeFiles, allowlist) { + const misses = []; + for (const { file, paths } of docPathsByFile) { + for (const p of paths) { + if (IGNORE.some((rx) => rx.test(p))) continue; + if (isFileRef(p)) continue; + if (allowlist.has(p)) continue; + if (!resolveApiDocPathToRoute(p, routeFiles)) { + misses.push(`${file} → ${p}`); + } + } + } + return misses; +} + +function main() { + const routeFiles = collectRouteFiles(); + // docs/i18n/** são espelhos auto-gerados das docs canônicas — validar só o canônico + // evita 40× de ruído duplicado (e os mirrors herdam qualquer fix do canônico). + const docFiles = walk(DOCS, (n) => /\.md$/.test(n)).filter( + (f) => !path.relative(ROOT, f).replace(/\\/g, "/").startsWith("docs/i18n/") + ); + const docPathsByFile = docFiles.map((f) => ({ + file: path.relative(ROOT, f).replace(/\\/g, "/"), + paths: extractDocApiPaths(fs.readFileSync(f, "utf8")), + })); + const misses = findStaleDocApiRefs(docPathsByFile, routeFiles, KNOWN_STALE_DOC_REFS); + if (misses.length) { + console.error( + `[check-docs-symbols] ${misses.length} ref(s) /api em docs sem rota real:\n` + + misses.map((m) => " ✗ " + m).join("\n") + + `\n → crie o route.ts, corrija o path na doc, ou (se for upstream/placeholder)` + + ` adicione um padrão a IGNORE com justificativa. NÃO adicione à allowlist sem` + + ` confirmar que é drift pré-existente real.` + ); + process.exit(1); + } + console.log( + `[check-docs-symbols] OK — ${docFiles.length} docs canônicas, ` + + `${routeFiles.size} rotas conhecidas, ${KNOWN_STALE_DOC_REFS.size} stale congeladas` + ); +} + +if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main(); diff --git a/scripts/check/check-duplication.mjs b/scripts/check/check-duplication.mjs new file mode 100644 index 0000000000..7843f16e88 --- /dev/null +++ b/scripts/check/check-duplication.mjs @@ -0,0 +1,63 @@ +#!/usr/bin/env node +// scripts/check/check-duplication.mjs +// Catraca de duplicação de código. Roda jscpd@4 (PINADO — o v5 é um rewrite Rust com +// CLI/JSON incompatíveis) sobre src+open-sse e compara a % atual contra um baseline +// congelado (duplication-baseline.json). Falha se a duplicação SUBIR. Ataca a assinatura +// nº1 de slop de IA (GitClear 2026: duplicação 4-8x na era IA) — no nosso caso, o +// copy-paste dos executors (48/50 sobrescrevem execute() inteiro). --update ratcheta. +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; +import { pathToFileURL } from "node:url"; + +const ROOT = process.cwd(); +const BASELINE_PATH = path.resolve( + process.argv.includes("--baseline") + ? process.argv[process.argv.indexOf("--baseline") + 1] + : path.join(ROOT, "duplication-baseline.json") +); +const UPDATE = process.argv.includes("--update"); +const EPS = 0.05; // tolerância de ruído de float (jscpd é determinístico; isto é margem) +const JSCPD_ARGS = ["jscpd@4", "src", "open-sse", "--reporters", "json", "--silent", "--min-tokens", "50", "--ignore", "**/*.test.ts,**/*.test.tsx,**/__tests__/**"]; + +/** Avalia a % atual contra o baseline. */ +export function evaluateDuplication(current, baseline, eps = EPS) { + return { + regressed: current > baseline + eps, + improved: current < baseline - eps, + }; +} + +function measureDuplicationPct() { + const out = fs.mkdtempSync(path.join(os.tmpdir(), "jscpd-")); + execFileSync("npx", ["--yes", ...JSCPD_ARGS, "--output", out], { stdio: "ignore" }); + const report = JSON.parse(fs.readFileSync(path.join(out, "jscpd-report.json"), "utf8")); + return report.statistics.total.percentage; +} + +function main() { + if (!fs.existsSync(BASELINE_PATH)) { + console.error(`[duplication] FAIL — ${path.basename(BASELINE_PATH)} ausente.`); + process.exit(2); + } + const baseline = JSON.parse(fs.readFileSync(BASELINE_PATH, "utf8")); + const current = measureDuplicationPct(); + const { regressed, improved } = evaluateDuplication(current, baseline.percentage, EPS); + + if (UPDATE && improved) { + baseline.percentage = current; + fs.writeFileSync(BASELINE_PATH, JSON.stringify(baseline, null, 2) + "\n"); + console.log(`[duplication] baseline ratcheado: ${current}% (era ${baseline.percentage}%)`); + } + if (regressed) { + console.error( + `[duplication] REGRESSÃO — ${current}% > baseline ${baseline.percentage}% (+${EPS} tolerância)\n` + + ` → extraia o trecho duplicado (helper compartilhado) ou ajuste duplication-baseline.json com justificativa.` + ); + process.exit(1); + } + console.log(`[duplication] OK — ${current}% (baseline ${baseline.percentage}%)`); +} + +if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main(); diff --git a/scripts/check/check-error-helper.mjs b/scripts/check/check-error-helper.mjs new file mode 100644 index 0000000000..56a0ac8699 --- /dev/null +++ b/scripts/check/check-error-helper.mjs @@ -0,0 +1,288 @@ +#!/usr/bin/env node +// scripts/check/check-error-helper.mjs +// Gate Hard Rule #12 (error sanitization): error responses/results built in +// open-sse/executors/ and open-sse/handlers/ MUST route through the helpers in +// open-sse/utils/error.ts (buildErrorBody / errorResponse / sanitizeErrorMessage / +// sanitizeUpstreamDetails / makeExecutorErrorResult / formatProviderError / …) so +// raw err.stack / err.message / upstream body.error.message never reach a client. +// +// The risk: a file that builds its own `new Response(JSON.stringify({ error: { +// message: err.message } }))` (or a result object with `error: `) and does +// NOT import the sanitizer leaks stack traces / absolute paths / upstream internals. +// CodeQL's js/stack-trace-exposure does not understand the custom sanitizer, so this +// static gate is the canonical enforcement. See docs/security/ERROR_SANITIZATION.md. +// +// Conservative by design: a file is flagged ONLY when it both (a) appears to forward +// a RAW error value into a response/result body AND (b) imports nothing from a +// utils/error path. Files that import the helper are trusted (the `body.error.message` +// they reference is the sanitized output of buildErrorBody, not raw upstream). +import fs from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +const cwd = process.cwd(); +const SCAN_DIRS = [ + path.join(cwd, "open-sse/executors"), + path.join(cwd, "open-sse/handlers"), +]; + +// Pre-existing violators frozen so the gate is green NOW and blocks only NEW leaks. +// Each entry is a real Rule #12 gap (raw err.message forwarded into a response body +// with no utils/error import) and should become a tracked cleanup issue: route the +// message through sanitizeErrorMessage()/buildErrorBody()/makeExecutorErrorResult(). +// Do NOT add new entries without a justification — that defeats the gate. +export const KNOWN_MISSING_ERROR_HELPER = new Set([ + // adapta-web: local makeErrorResponse() + `Adapta auth failed: ${msg}` where + // msg = err.message — raw auth/upstream error string in the JSON error body, + // no open-sse/utils/error import. Fix: sanitizeErrorMessage(msg) before forwarding. + "open-sse/executors/adapta-web.ts", + // deepseek-web: local errorResponse() shadow that puts `message` raw into the body, + // fed `DeepSeek error: ${msg}` where msg = err.message — bypasses the canonical + // sanitizer. Fix: route through buildErrorBody()/sanitizeErrorMessage(). + "open-sse/executors/deepseek-web.ts", + // perplexity-web: `new Response({ error: { message: `Perplexity connection failed: + // ${err.message}` }})` (multi-line envelope) for TLS/connection failures — raw + // err.message in the client error body, no sanitizer import. + "open-sse/executors/perplexity-web.ts", + // qoder: `response: new Response({ error: { message: `Qoder fetch error: + // ${error.message}` }})` — raw error.message in the returned response body, + // no sanitizer import. + "open-sse/executors/qoder.ts", + // veoaifree-web: local errResp(msg) on nonce-fetch failure where msg = err.message — + // raw error string in the response body, no sanitizer import. + "open-sse/executors/veoaifree-web.ts", + // embeddings handler: `return { success: false, status: 502, error: `Embedding + // provider error: ${err.message}` }` — raw err.message in the result error field, + // no sanitizer import. (The saveCallLog `error: err.message` rows are internal and + // correctly NOT what is frozen here.) + "open-sse/handlers/embeddings.ts", + // search handler: `return { …, error: `Search provider …: ${err.message}` }` — raw + // err.message in the result error field, no sanitizer import. + "open-sse/handlers/search.ts", +]); + +// Import specifiers that count as "uses the error helper" (path ends in utils/error). +const ERROR_HELPER_IMPORT = + /\bfrom\s*["'](?:\.{1,2}\/)*(?:open-sse\/)?utils\/error(?:\.[tj]s)?["']|@omniroute\/open-sse\/utils\/error/; + +// A caught-error identifier whose .message/.stack is RAW (not sanitized): the leading +// token must be exactly `err` / `error` / `e` (optionally `(err as Error)` cast), and +// NOT preceded by a member access — so `event.error.message` (an upstream-event read) +// does not match, only our own caught `err.message` / `error.stack` / `(err as …).msg`. +// The `(? = ` — a tainted local holding a +// raw, unsanitized error string. Captures the variable name for downstream tracking. +const TAINT_DECL = new RegExp( + String.raw`\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*[^;\n]*` + RAW_ERR +); + +/** + * Does this source forward a RAW error value into a CLIENT-FACING response/result body? + * + * Line-anchored + sink-aware so it does not false-positive on logging, DB audit rows + * (saveCallLog), thrown Errors, rejected promises, or parsed upstream-event reads. + * + * A line is a violation when, after skipping internal-sink lines, it either: + * - assigns/interpolates a raw caught-error into a `message:`/`error:` field, or + * - interpolates a raw caught-error AND is itself a Response/result-builder line, or + * - forwards upstream `body.error.message` into a field without sanitizing, or + * - passes a TAINTED local (a var assigned from a raw error, never sanitized) into a + * response-builder call (errResp / makeErrorResponse / errorResponse / new Response). + */ +function forwardsRawError(source) { + const lines = source.split("\n").map((l) => l.replace(/\/\/.*$/, "")); + + // Pass 1: collect tainted local variables (raw error, no sanitize on the line). + const tainted = new Set(); + for (const line of lines) { + if (INTERNAL_SINK.test(line)) continue; + const m = line.match(TAINT_DECL); + if (m && !/sanitize/i.test(line)) tainted.add(m[1]); + } + const taintedUse = + tainted.size > 0 + ? new RegExp(String.raw`\b(?:${[...tainted].join("|")})\b`) + : null; + + // Pass 2: scan for leak lines. + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (!line.trim()) continue; + if (INTERNAL_SINK.test(line)) continue; // log / audit / throw / reject + if (TAINT_DECL.test(line)) continue; // the assignment itself is not the leak + + const directLeak = + RAW_ERR_FIELD.test(line) || + RAW_ERR_FIELD_INTERP.test(line) || + (RAW_ERR_INTERP.test(line) && RESPONSE_LINE.test(line)) || + // Multi-line OpenAI error envelope: a raw-error interpolation that sits inside + // an enclosing `error: {` / `message:` field of a `new Response(` body. + (RAW_ERR_INTERP.test(line) && enclosedByErrorResponseBody(lines, i)) || + (RAW_BODY_ERR.test(line) && !/sanitize/i.test(line)); + + const taintedLeak = + taintedUse !== null && RESPONSE_BUILDER_CALL.test(line) && taintedUse.test(line); + + // The raw error reaches a client body unless it lives inside an internal-sink + // call's argument object (saveCallLog / log / console / reqLogger). + if ((directLeak || taintedLeak) && !enclosedByInternalSinkCall(lines, i)) return true; + } + return false; +} + +/** + * Walk back from `idx`, tracking net brace/paren depth, to find the line that opens + * the call enclosing `idx`. Returns true if that opener is an internal-sink call. + * Bounded lookback (sink-call argument objects are small) keeps this cheap. + */ +function enclosedByInternalSinkCall(lines, idx) { + let depth = 0; + for (let j = idx; j >= 0 && idx - j < 80; j--) { + const l = lines[j].replace(/\/\/.*$/, ""); + for (let k = l.length - 1; k >= 0; k--) { + const ch = l[k]; + if (ch === ")" || ch === "}") depth++; + else if (ch === "(" || ch === "{") { + if (depth === 0) { + // Unbalanced opener at this position — the enclosing construct starts here. + return INTERNAL_SINK_CALL.test(l.slice(0, k + 1)); + } + depth--; + } + } + } + return false; +} + +// Field opener that is part of an OpenAI-style error envelope (`error: {` / `message:`). +const ERROR_FIELD_OPENER = /\b(?:error|message)\s*:\s*[`{]?\s*$/; + +/** + * Walk back from `idx` to the nearest enclosing `{`/`(` opener; if it opens an error + * envelope field (`error: {` / `message:`) AND a `new Response(` / `response:` builder + * appears just above it, the raw error reaches a client error body. Conservative: only + * the canonical error-envelope shape qualifies (not `content:` / data fields). + */ +function enclosedByErrorResponseBody(lines, idx) { + let depth = 0; + for (let j = idx; j >= 0 && idx - j < 80; j--) { + const l = lines[j].replace(/\/\/.*$/, ""); + for (let k = l.length - 1; k >= 0; k--) { + const ch = l[k]; + if (ch === ")" || ch === "}") depth++; + else if (ch === "(" || ch === "{") { + if (depth === 0) { + if (!ERROR_FIELD_OPENER.test(l.slice(0, k + 1))) return false; + // Confirm a Response builder sits in the few lines above the envelope. + const window = lines.slice(Math.max(0, j - 8), j + 1).join("\n"); + return /new\s+Response\s*\(|\bresponse\s*:/.test(window); + } + depth--; + } + } + } + return false; +} + +export function findErrorHelperViolations(files, allowlist) { + const violations = []; + for (const { path: rel, source } of files) { + if (allowlist.has(rel)) continue; + if (ERROR_HELPER_IMPORT.test(source)) continue; // trusts the helper + if (forwardsRawError(source)) violations.push(rel); + } + return violations; +} + +function collectFiles() { + const files = []; + for (const dir of SCAN_DIRS) { + for (const p of walk(dir)) { + files.push({ + path: path.relative(cwd, p).replace(/\\/g, "/"), + source: fs.readFileSync(p, "utf8"), + }); + } + } + return files; +} + +function main() { + const files = collectFiles(); + const violations = findErrorHelperViolations(files, KNOWN_MISSING_ERROR_HELPER); + + // Surface allowlist drift: entries that no longer match a real file (cleaned up or + // renamed) so the allowlist does not rot. This is a warning, not a failure. + const present = new Set(files.map((f) => f.path)); + const stale = [...KNOWN_MISSING_ERROR_HELPER].filter((p) => !present.has(p)); + if (stale.length) { + console.warn( + `[check-error-helper] WARN: ${stale.length} allowlist entr${ + stale.length === 1 ? "y" : "ies" + } no longer match a file (remove from KNOWN_MISSING_ERROR_HELPER):\n` + + stale.map((p) => " - " + p).join("\n") + ); + } + + if (violations.length) { + console.error( + `[check-error-helper] ${violations.length} file(s) build an error response/result with a ` + + `raw err.message/err.stack/body.error.message but do NOT import open-sse/utils/error:\n` + + violations.map((v) => " ✗ " + v).join("\n") + + `\n → route the message through buildErrorBody()/sanitizeErrorMessage()/` + + `makeExecutorErrorResult() (see docs/security/ERROR_SANITIZATION.md), or — if it is a ` + + `false positive — add it to KNOWN_MISSING_ERROR_HELPER with a justification.` + ); + process.exit(1); + } + console.log( + `[check-error-helper] OK (${files.length} files scanned, ${KNOWN_MISSING_ERROR_HELPER.size} known-missing frozen)` + ); +} + +if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main(); diff --git a/scripts/check/check-fetch-targets.mjs b/scripts/check/check-fetch-targets.mjs new file mode 100644 index 0000000000..85f5a08d06 --- /dev/null +++ b/scripts/check/check-fetch-targets.mjs @@ -0,0 +1,105 @@ +#!/usr/bin/env node +// scripts/check/check-fetch-targets.mjs +// Gate anti-alucinação: todo fetch("/api/...") em src/app/(dashboard) deve resolver +// para um route.ts real em src/app/api/. Mata rotas inventadas (a IA editando a UI +// "chuta" um endpoint que não existe). 300 paths hardcoded sem ligação de compilação +// com as 488 rotas — este gate cria essa ligação no CI. +import fs from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +const cwd = process.cwd(); +const DASH = path.join(cwd, "src/app/(dashboard)"); +const API = path.join(cwd, "src/app/api"); + +// Paths que o checker não resolve estaticamente (allowlist com justificativa): +// - /api/v1/* é a superfície OpenAI-compat (proxy), não rotas internas do dashboard. +// - paths construídos por template/concatenação não são literais estáticos. +const IGNORE = [ + /^\/api\/v1\//, // superfície OpenAI-compat +]; + +// Mismatches dashboard→rota PRÉ-EXISTENTES (UI chama rota que não existe → 404 ou +// código morto). Congelados para a catraca ficar verde e bloquear QUALQUER nova rota +// inventada. CADA UM precisa de triagem: criar a rota, corrigir o path, ou remover a +// chamada morta. NÃO adicione novos aqui sem justificativa — esse é o ponto do gate. +const KNOWN_MISSING = new Set([ + "/api/gamification/level", // profile/page.tsx — rota inexistente (gamification tem transfer/leaderboard/… mas não level) + "/api/gamification/badges", // profile/page.tsx — idem + "/api/gamification/badges/earned", // profile/page.tsx — idem + "/api/settings/obsidian/webdav", // ObsidianSourceCard.tsx — só existe /api/settings/obsidian + "/api/tools/traffic-inspector/custom-hosts", // CustomHostsManager.tsx — provável typo de /hosts + "/api/health", // FeatureFlagsGrid.tsx — health real é /api/monitoring/health + "/api/tools/agent-bridge/upstream-ca/test", // UpstreamCaField.tsx — rota inexistente +]); + +function walk(dir, acc = []) { + if (!fs.existsSync(dir)) return acc; + for (const e of fs.readdirSync(dir, { withFileTypes: true })) { + const p = path.join(dir, e.name); + if (e.isDirectory()) walk(p, acc); + else if (/\.(ts|tsx)$/.test(e.name)) acc.push(p); + } + return acc; +} + +function collectRouteFiles() { + return new Set( + walk(API) + .filter((p) => /route\.tsx?$/.test(p)) + .map((p) => path.relative(cwd, p).replace(/\\/g, "/")) + ); +} + +// /api/providers/abc/models → src/app/api/providers/[id]/models/route.ts +export function resolveApiPathToRoute(apiPath, routeFiles) { + const segs = apiPath + .replace(/^\//, "") + .replace(/[?#].*$/, "") + .split("/"); + for (const rf of routeFiles) { + const rsegs = rf + .replace(/^src\/app\//, "") + .replace(/\/route\.tsx?$/, "") + .split("/"); + if (rsegs.length !== segs.length) continue; + const ok = rsegs.every((rs, i) => rs === segs[i] || /^\[.*\]$/.test(rs)); + if (ok) return true; + } + return false; +} + +function extractFetchPaths(file) { + const src = fs.readFileSync(file, "utf8"); + // Só literais ESTÁTICOS começando em /api/ (não template literals com ${...}). + const re = /(?:fetch|fetchJson|apiFetch)\(\s*["'`](\/api\/[A-Za-z0-9_\-/[\]]+)["'`]/g; + const out = []; + let m; + while ((m = re.exec(src))) out.push(m[1]); + return out; +} + +function main() { + const routeFiles = collectRouteFiles(); + const misses = []; + for (const f of walk(DASH)) { + for (const apiPath of extractFetchPaths(f)) { + if (IGNORE.some((rx) => rx.test(apiPath))) continue; + if (KNOWN_MISSING.has(apiPath)) continue; + if (!resolveApiPathToRoute(apiPath, routeFiles)) { + misses.push(`${path.relative(cwd, f)} → ${apiPath}`); + } + } + } + if (misses.length) { + console.error( + `[check-fetch-targets] ${misses.length} fetch(es) para rota inexistente:\n` + + misses.map((m) => " ✗ " + m).join("\n") + + `\n → crie o route.ts faltante, corrija o path, ou adicione um padrão a IGNORE com justificativa.` + ); + process.exit(1); + } + console.log(`[check-fetch-targets] OK (${routeFiles.size} rotas conhecidas)`); +} + +if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main(); diff --git a/scripts/check/check-file-size.mjs b/scripts/check/check-file-size.mjs new file mode 100644 index 0000000000..220c4001b1 --- /dev/null +++ b/scripts/check/check-file-size.mjs @@ -0,0 +1,95 @@ +#!/usr/bin/env node +// scripts/check/check-file-size.mjs +// Catraca de tamanho de arquivo (mata o god-component). Modelado no +// check-t11-any-budget.mjs: um baseline congelado por arquivo (file-size-baseline.json). +// - arquivo congelado: só pode ENCOLHER (nunca crescer); +// - arquivo NOVO (fora do baseline): não pode passar do CAP. +// Assim o próximo arquivo de 12.760 linhas é impossível, e os 91 atuais só melhoram. +// --update ratcheta o baseline para baixo (encolhimentos + remove quem caiu < cap). +import fs from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +const ROOT = process.cwd(); +function getArg(name, fallback) { + const i = process.argv.indexOf(name); + return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : fallback; +} +const BASELINE_PATH = path.resolve(getArg("--baseline", path.join(ROOT, "file-size-baseline.json"))); +const UPDATE = process.argv.includes("--update"); +const SCAN_DIRS = ["src", "open-sse"]; + +/** + * Avalia LOC atuais contra o baseline congelado. + * @returns {{violations: string[], improvements: [string, number][]}} + */ +export function evaluateFileSizes(currentLocByFile, frozen, cap) { + const violations = []; + const improvements = []; + for (const [file, loc] of Object.entries(currentLocByFile)) { + if (file in frozen) { + if (loc > frozen[file]) violations.push(`${file}: ${loc} > congelado ${frozen[file]} (não pode crescer)`); + else if (loc < frozen[file]) improvements.push([file, loc]); + } else if (loc > cap) { + violations.push(`${file}: ${loc} > cap ${cap} (arquivo novo acima do limite)`); + } + } + return { violations, improvements }; +} + +function countLines(file) { + return fs.readFileSync(file, "utf8").split("\n").length; +} + +function walk(dir, acc = []) { + if (!fs.existsSync(dir)) return acc; + for (const e of fs.readdirSync(dir, { withFileTypes: true })) { + const p = path.join(dir, e.name); + if (e.isDirectory()) walk(p, acc); + else if (/\.(ts|tsx)$/.test(e.name) && !/\.test\.tsx?$/.test(e.name) && !/\.d\.ts$/.test(e.name)) acc.push(p); + } + return acc; +} + +function collectLoc() { + const out = {}; + for (const d of SCAN_DIRS) + for (const f of walk(path.join(ROOT, d))) out[path.relative(ROOT, f).replace(/\\/g, "/")] = countLines(f); + return out; +} + +function main() { + if (!fs.existsSync(BASELINE_PATH)) { + console.error(`[file-size] FAIL — ${path.basename(BASELINE_PATH)} ausente.`); + process.exit(2); + } + const baseline = JSON.parse(fs.readFileSync(BASELINE_PATH, "utf8")); + const cap = baseline.cap; + const frozen = baseline.frozen || {}; + const current = collectLoc(); + const { violations, improvements } = evaluateFileSizes(current, frozen, cap); + + if (UPDATE && violations.length === 0 && improvements.length) { + for (const [file, loc] of improvements) { + if (loc <= cap) delete frozen[file]; // caiu para dentro do cap → sai do baseline + else frozen[file] = loc; // continua grande mas encolheu → trava no novo valor + } + baseline.frozen = Object.fromEntries(Object.entries(frozen).sort()); + fs.writeFileSync(BASELINE_PATH, JSON.stringify(baseline, null, 2) + "\n"); + console.log(`[file-size] baseline ratcheado: ${improvements.length} arquivo(s) encolheram`); + } + + if (violations.length) { + console.error( + `[file-size] ${violations.length} violação(ões):\n` + + violations.map((v) => " ✗ " + v).join("\n") + + `\n → modularize/extraia (DRY) para encolher, ou (último caso) ajuste file-size-baseline.json com justificativa.` + ); + process.exit(1); + } + console.log( + `[file-size] OK — ${Object.keys(frozen).length} arquivos congelados, cap ${cap} para novos (${Object.keys(current).length} arquivos verificados)` + ); +} + +if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main(); diff --git a/scripts/check/check-known-symbols.ts b/scripts/check/check-known-symbols.ts new file mode 100644 index 0000000000..9596f8b745 --- /dev/null +++ b/scripts/check/check-known-symbols.ts @@ -0,0 +1,292 @@ +#!/usr/bin/env node +// scripts/check/check-known-symbols.ts +// Gate anti-alucinação: known-symbol allow-lists. Mata o padrão "símbolo inventado +// que silenciosamente vira no-op" em três superfícies de despacho por-string/por-chave: +// +// (1) EXECUTOR CONFORMANCE — toda entrada registrada no mapa de executores +// (open-sse/executors/index.ts) DEVE resolver, via getExecutor(), para uma +// instância de BaseExecutor que expõe execute() + getProvider(). Um alias que +// não resolve para um executor válido é um símbolo morto (roteia para fallback +// silencioso em vez de falhar). +// +// (2) COMBO STRATEGIES — a cadeia de despacho `strategy === "..."` em +// open-sse/services/combo.ts DEVE tratar exatamente o conjunto canônico de +// ROUTING_STRATEGY_VALUES (src/shared/constants/routingStrategies.ts), exceto +// as estratégias-default implícitas (priority não tem branch; cai no +// ordenamento padrão). Adicionar um valor canônico sem fiá-lo no despacho, ou +// fiar uma string de estratégia que não é canônica (inventada), falha aqui. +// +// (3) TRANSLATOR PAIRS — os pares from:to registrados em runtime no registry de +// tradutores (após bootstrap) são congelados em KNOWN_TRANSLATOR_PAIRS. Catraca: +// se um par registrado some, falha (regressão de cobertura de formato). Pares +// novos não falham — apenas são reportados — para não bloquear adições legítimas. +// +// Catraca: cada divergência pré-existente fica numa allowlist documentada e sai 0 hoje. +// Padrão herdado de scripts/check/check-provider-consistency.ts (gate .ts via +// `node --import tsx` que IMPORTA módulos reais + funções puras + main() guardado). + +import { readFileSync } from "node:fs"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { dirname, resolve as resolvePath } from "node:path"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = resolvePath(HERE, "..", ".."); + +// ─────────────────────────────────────────────────────────────────────────── +// (2) COMBO STRATEGIES — fonte canônica + defaults implícitos +// ─────────────────────────────────────────────────────────────────────────── + +/** + * Estratégias canônicas que NÃO têm um branch `strategy === "..."` na cadeia de + * despacho porque são o comportamento padrão (sem reordenamento explícito). Cada + * uma documentada. Remover daqui se um branch dedicado for adicionado. + */ +export const IMPLICIT_DEFAULT_STRATEGIES: Record = { + priority: + 'Default sem branch: combo.ts não tem `strategy === "priority"`; cai no ordenamento padrão de resolveComboTargets (ordem de prioridade declarada). É o fallback de normalizeRoutingStrategy.', +}; + +/** Extrai todas as strings literais de `strategy === "..."` da fonte do combo. */ +export function extractHandledStrategies(comboSource: string): Set { + const handled = new Set(); + const re = /strategy\s*===\s*"([a-z0-9-]+)"/g; + let match: RegExpExecArray | null; + while ((match = re.exec(comboSource)) !== null) { + handled.add(match[1]); + } + return handled; +} + +export type StrategyMismatch = { + canonicalNotHandled: string[]; + handledNotCanonical: string[]; +}; + +/** + * Compara o conjunto canônico (ROUTING_STRATEGY_VALUES) com o conjunto efetivamente + * tratado (branches do despacho ∪ defaults implícitos). + * - canonicalNotHandled: estratégia canônica adicionada sem fiação no despacho. + * - handledNotCanonical: branch de despacho para uma string não-canônica (inventada). + */ +export function diffComboStrategies( + canonical: readonly string[], + handled: Set, + implicitDefaults: Record +): StrategyMismatch { + const canonicalSet = new Set(canonical); + const effectivelyHandled = new Set(handled); + for (const id of Object.keys(implicitDefaults)) effectivelyHandled.add(id); + + const canonicalNotHandled = [...canonicalSet].filter((s) => !effectivelyHandled.has(s)); + // Strings tratadas que não são canônicas NEM defaults implícitos = inventadas. + const handledNotCanonical = [...handled].filter( + (s) => !canonicalSet.has(s) && !(s in implicitDefaults) + ); + return { canonicalNotHandled, handledNotCanonical }; +} + +// ─────────────────────────────────────────────────────────────────────────── +// (1) EXECUTOR CONFORMANCE — parse do mapa + validação de conformidade +// ─────────────────────────────────────────────────────────────────────────── + +/** + * Extrai as chaves (aliases) do objeto literal `const executors = { ... }` da fonte + * de open-sse/executors/index.ts. O mapa não é exportado, então enumeramos pela fonte + * (determinístico — é um literal simples). Cada chave é validada em runtime via + * getExecutor() na função main(). + */ +export function extractExecutorAliases(indexSource: string): string[] { + const start = indexSource.indexOf("const executors = {"); + if (start < 0) throw new Error("could not find `const executors = {` in executors/index.ts"); + const end = indexSource.indexOf("\n};", start); + if (end < 0) throw new Error("could not find end of executors map (`\\n};`)"); + const block = indexSource.slice(start, end); + const keyRe = /^\s*(?:"([^"]+)"|([A-Za-z0-9_$-]+))\s*:/gm; + const keys: string[] = []; + let match: RegExpExecArray | null; + while ((match = keyRe.exec(block)) !== null) { + keys.push(match[1] ?? match[2]); + } + return keys; +} + +/** Superfície pública mínima que todo executor registrado deve expor. */ +export type ExecutorLike = { + execute?: unknown; + getProvider?: unknown; +}; + +/** + * Dada a lista de aliases e um resolvedor (getExecutor), retorna os aliases que NÃO + * resolvem para um BaseExecutor válido (não é instância, ou falta execute/getProvider). + * isInstance é injetado para manter a função pura/testável com inputs sintéticos. + */ +export function findNonConformingExecutors( + aliases: string[], + resolve: (alias: string) => ExecutorLike | null | undefined, + isInstance: (value: unknown) => boolean +): string[] { + return aliases.filter((alias) => { + const ex = resolve(alias); + if (!ex || !isInstance(ex)) return true; + return typeof ex.execute !== "function" || typeof ex.getProvider !== "function"; + }); +} + +// ─────────────────────────────────────────────────────────────────────────── +// (3) TRANSLATOR PAIRS — snapshot congelado (catraca: pares não somem) +// ─────────────────────────────────────────────────────────────────────────── + +/** + * Pares from:to congelados, registrados no registry de tradutores após bootstrap. + * Snapshot real medido em 2026-06-09 (18 pares). Catraca: se um par some, falha. + * Adicionar um par NÃO falha aqui (apenas reportado) — só remoções são regressões. + * Para regravar após adicionar/remover legitimamente um adapter, atualize esta lista. + */ +export const KNOWN_TRANSLATOR_PAIRS: readonly string[] = [ + "antigravity:claude", + "antigravity:openai", + "claude:gemini", + "claude:openai", + "cursor:openai", + "gemini-cli:claude", + "gemini-cli:openai", + "gemini:claude", + "gemini:openai", + "kiro:openai", + "openai-responses:openai", + "openai:antigravity", + "openai:claude", + "openai:cursor", + "openai:gemini", + "openai:gemini-cli", + "openai:kiro", + "openai:openai-responses", +]; + +/** + * Pares frozen que sumiram do registry vivo (regressão). frozen = snapshot; + * live = pares observados em runtime. Retorna os que estão no frozen mas não no live. + */ +export function findMissingTranslatorPairs( + frozen: readonly string[], + live: Set +): string[] { + return frozen.filter((pair) => !live.has(pair)); +} + +/** Pares vivos que ainda não estão no snapshot frozen (informativo, não falha). */ +export function findNewTranslatorPairs(frozen: readonly string[], live: Set): string[] { + const frozenSet = new Set(frozen); + return [...live].filter((pair) => !frozenSet.has(pair)).sort(); +} + +// ─────────────────────────────────────────────────────────────────────────── +// main() — importa módulos reais, lê fontes, roda as três sub-checagens +// ─────────────────────────────────────────────────────────────────────────── + +async function main(): Promise { + const failures: string[] = []; + + // ── (1) Executor conformance ────────────────────────────────────────────── + const executorsMod = await import("@omniroute/open-sse/executors/index.ts"); + const getExecutor = executorsMod.getExecutor as (alias: string) => ExecutorLike; + const BaseExecutor = executorsMod.BaseExecutor as new (...args: never[]) => unknown; + const indexSource = readFileSync( + resolvePath(REPO_ROOT, "open-sse/executors/index.ts"), + "utf8" + ); + const aliases = extractExecutorAliases(indexSource); + if (aliases.length === 0) { + failures.push("[executor] parse do mapa `executors` não encontrou nenhum alias (regex quebrada?)"); + } + const isExecutorInstance = (value: unknown) => value instanceof BaseExecutor; + const badExecutors = findNonConformingExecutors(aliases, getExecutor, isExecutorInstance); + if (badExecutors.length) { + failures.push( + `[executor] ${badExecutors.length} alias(es) registrado(s) não resolvem para um BaseExecutor válido (instância + execute() + getProvider()):\n` + + badExecutors.map((a) => ` ✗ ${a}`).join("\n") + + `\n → verifique a entrada em open-sse/executors/index.ts (classe importada/exportada e estende BaseExecutor).` + ); + } + + // ── (2) Combo strategies ────────────────────────────────────────────────── + const strategiesMod = await import("@/shared/constants/routingStrategies.ts"); + const canonical = strategiesMod.ROUTING_STRATEGY_VALUES as readonly string[]; + const comboSource = readFileSync(resolvePath(REPO_ROOT, "open-sse/services/combo.ts"), "utf8"); + const handled = extractHandledStrategies(comboSource); + const { canonicalNotHandled, handledNotCanonical } = diffComboStrategies( + canonical, + handled, + IMPLICIT_DEFAULT_STRATEGIES + ); + if (canonicalNotHandled.length) { + failures.push( + `[combo] ${canonicalNotHandled.length} estratégia(s) canônica(s) sem branch de despacho em combo.ts:\n` + + canonicalNotHandled.map((s) => ` ✗ ${s}`).join("\n") + + `\n → fie no despacho (\`strategy === "${canonicalNotHandled[0]}"\`) ou documente em IMPLICIT_DEFAULT_STRATEGIES.` + ); + } + if (handledNotCanonical.length) { + failures.push( + `[combo] ${handledNotCanonical.length} string(s) de estratégia tratada(s) no despacho mas ausente(s) de ROUTING_STRATEGY_VALUES (inventada/órfã):\n` + + handledNotCanonical.map((s) => ` ✗ ${s}`).join("\n") + + `\n → registre em src/shared/constants/routingStrategies.ts ou remova o branch morto.` + ); + } + + // ── (3) Translator pairs ────────────────────────────────────────────────── + await import("@omniroute/open-sse/translator/bootstrap.ts").then((m) => + (m.bootstrapTranslatorRegistry as () => void)() + ); + const formatsMod = await import("@omniroute/open-sse/translator/formats.ts"); + const registryMod = await import("@omniroute/open-sse/translator/registry.ts"); + const FORMATS = formatsMod.FORMATS as Record; + const getRequestTranslator = registryMod.getRequestTranslator as ( + from: string, + to: string + ) => unknown; + const getResponseTranslator = registryMod.getResponseTranslator as ( + from: string, + to: string + ) => unknown; + const formatIds = Object.values(FORMATS); + const livePairs = new Set(); + for (const from of formatIds) { + for (const to of formatIds) { + if (from === to) continue; + if (getRequestTranslator(from, to) || getResponseTranslator(from, to)) { + livePairs.add(`${from}:${to}`); + } + } + } + const missingPairs = findMissingTranslatorPairs(KNOWN_TRANSLATOR_PAIRS, livePairs); + if (missingPairs.length) { + failures.push( + `[translator] ${missingPairs.length} par(es) from:to congelado(s) sumiram do registry vivo (regressão):\n` + + missingPairs.map((p) => ` ✗ ${p}`).join("\n") + + `\n → restaure o adapter em open-sse/translator/ ou, se a remoção foi intencional, atualize KNOWN_TRANSLATOR_PAIRS.` + ); + } + const newPairs = findNewTranslatorPairs(KNOWN_TRANSLATOR_PAIRS, livePairs); + + // ── Resultado ───────────────────────────────────────────────────────────── + if (failures.length) { + console.error(`[known-symbols] ${failures.length} sub-checagem(ns) falharam:\n\n${failures.join("\n\n")}`); + process.exit(1); + } + + const newPairsNote = newPairs.length + ? ` (${newPairs.length} par(es) novo(s) não-congelado(s): ${newPairs.join(", ")} — atualize KNOWN_TRANSLATOR_PAIRS se intencional)` + : ""; + console.log( + `[known-symbols] OK — ${aliases.length} executores conformes; ${canonical.length} estratégias canônicas (${handled.size} via despacho + ${Object.keys(IMPLICIT_DEFAULT_STRATEGIES).length} default(s) implícito(s)); ${livePairs.size} pares de tradutor vivos vs ${KNOWN_TRANSLATOR_PAIRS.length} congelados${newPairsNote}` + ); +} + +if (import.meta.url === pathToFileURL(process.argv[1] || "").href) { + main().catch((err) => { + console.error(`[known-symbols] erro fatal: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); + }); +} diff --git a/scripts/check/check-migration-numbering.mjs b/scripts/check/check-migration-numbering.mjs new file mode 100644 index 0000000000..3e157c9a70 --- /dev/null +++ b/scripts/check/check-migration-numbering.mjs @@ -0,0 +1,145 @@ +#!/usr/bin/env node +// scripts/check/check-migration-numbering.mjs +// Gate de numeração de migrations: protege src/lib/db/migrations/ contra regressões +// de numeração. WHY: um incidente destrutivo aconteceu DUAS VEZES — um `git rm` de +// uma migration duplicada durante um merge apagou uma migration REAL da release +// (094/095 em #3365/#3371). Este gate cria uma ligação de CI entre o disco e as +// anomalias já reconhecidas em migrationRunner.ts, falhando em QUALQUER: +// - nome de arquivo sem prefixo numérico zero-padded (NNN_*.sql); +// - prefixo de versão DUPLICADO no disco (exceto duplicatas já reconhecidas); +// - NOVO gap inexplicado na sequência (gaps conhecidos 026/055 são congelados). +// As anomalias conhecidas são derivadas das listas de migrationRunner.ts +// (LEGACY_VERSION_SLOT_MIGRATIONS / SUPERSEDED_DUPLICATE_MIGRATIONS) + a auditoria +// de gaps de sequência. NÃO adicione novos itens sem justificativa — esse é o ponto. +import fs from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +const cwd = process.cwd(); +const MIGRATIONS_DIR = path.join(cwd, "src/lib/db/migrations"); + +// Convenção de nome: NNN_descricao.sql (prefixo numérico zero-padded de >= 3 dígitos). +// Mesmo regex usado pelo runner de produção (migrationRunner.ts ~linha 282). +const MIGRATION_NAME_RE = /^(\d{3,})_(.+)\.sql$/; + +// --------------------------------------------------------------------------- +// ALLOWLIST 1 — duplicatas de versão CONHECIDAS. +// Fonte: src/lib/db/migrationRunner.ts → SUPERSEDED_DUPLICATE_MIGRATIONS (~L188). +// O runner já aceita estes slots de versão reutilizados (a migration "renomeada" +// foi promovida para um número novo, e o slot antigo é tolerado). No disco atual +// NÃO há arquivos físicos colidindo, mas congelamos os números reconhecidos para +// que, se um arquivo legado reaparecer com esse prefixo, o gate não exploda. +// --------------------------------------------------------------------------- +export const KNOWN_DUPLICATE_VERSIONS = new Set([ + "041", // session_account_affinity → promovida para 050 (SUPERSEDED_DUPLICATE_MIGRATIONS) +]); + +// --------------------------------------------------------------------------- +// ALLOWLIST 2 — gaps de sequência CONHECIDOS. +// Fonte: auditoria do disco (src/lib/db/migrations/) — a sequência pula 026 e 055. +// Estes números nunca tiveram arquivo físico (slots legados que viraram outros +// números via RENAMED_MIGRATION_COMPATIBILITY em migrationRunner.ts). Congelados +// para que o gate bloqueie apenas NOVOS buracos inexplicados na sequência. +// --------------------------------------------------------------------------- +export const KNOWN_GAPS = new Set(["026", "055"]); + +function pad3(n) { + return String(n).padStart(3, "0"); +} + +/** + * Função pura — detecta anomalias de numeração de migrations. + * + * @param {string[]} filenames nomes de arquivo (basename) em src/lib/db/migrations/ + * @param {Set} knownDuplicates versões com duplicata reconhecida (ex.: "041") + * @param {Set} knownGaps gaps de sequência reconhecidos (ex.: "026") + * @returns {{ duplicates: Array<{version:string,names:string[]}>, gaps: string[], badNames: string[] }} + */ +export function findMigrationAnomalies(filenames, knownDuplicates, knownGaps) { + const dups = knownDuplicates || new Set(); + const gapsAllow = knownGaps || new Set(); + + const badNames = []; + const byVersion = new Map(); + + for (const filename of filenames) { + if (!filename.endsWith(".sql")) continue; + const match = filename.match(MIGRATION_NAME_RE); + if (!match) { + badNames.push(filename); + continue; + } + const version = match[1]; + if (!byVersion.has(version)) byVersion.set(version, []); + byVersion.get(version).push(filename); + } + + // Duplicatas: dois arquivos físicos com o mesmo prefixo, exceto os reconhecidos. + const duplicates = []; + for (const [version, names] of byVersion.entries()) { + if (names.length <= 1) continue; + if (dups.has(version)) continue; + duplicates.push({ version, names: [...names].sort() }); + } + duplicates.sort((a, b) => a.version.localeCompare(b.version)); + + // Gaps: buracos na sequência min..max que não estão na allowlist. + const versions = [...byVersion.keys()].map((v) => parseInt(v, 10)).sort((a, b) => a - b); + const gaps = []; + if (versions.length > 0) { + const min = versions[0]; + const max = versions[versions.length - 1]; + const present = new Set(versions); + for (let n = min + 1; n < max; n++) { + if (present.has(n)) continue; + const padded = pad3(n); + if (gapsAllow.has(padded)) continue; + gaps.push(padded); + } + } + + return { duplicates, gaps, badNames }; +} + +function listMigrationFilenames() { + if (!fs.existsSync(MIGRATIONS_DIR)) return []; + return fs.readdirSync(MIGRATIONS_DIR).filter((f) => f.endsWith(".sql")); +} + +function main() { + const filenames = listMigrationFilenames(); + const { duplicates, gaps, badNames } = findMigrationAnomalies( + filenames, + KNOWN_DUPLICATE_VERSIONS, + KNOWN_GAPS + ); + + const problems = []; + for (const b of badNames) { + problems.push(` ✗ nome inválido (esperado NNN_descricao.sql): ${b}`); + } + for (const d of duplicates) { + problems.push(` ✗ prefixo de versão duplicado ${d.version}: [${d.names.join(", ")}]`); + } + for (const g of gaps) { + problems.push(` ✗ gap inexplicado na sequência: faltando ${g}`); + } + + if (problems.length > 0) { + console.error( + `[check-migration-numbering] ${problems.length} anomalia(s) de numeração:\n` + + problems.join("\n") + + `\n → renomeie o arquivo colidente, preencha o gap, ou — se for legítimo — ` + + `adicione o número às allowlists KNOWN_DUPLICATE_VERSIONS / KNOWN_GAPS com ` + + `justificativa rastreável a src/lib/db/migrationRunner.ts.` + ); + process.exit(1); + } + + console.log( + `[check-migration-numbering] OK (${filenames.length} migrations, ` + + `${KNOWN_GAPS.size} gap(s) conhecido(s), ${KNOWN_DUPLICATE_VERSIONS.size} duplicata(s) conhecida(s))` + ); +} + +if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main(); diff --git a/scripts/check/check-openapi-routes.mjs b/scripts/check/check-openapi-routes.mjs new file mode 100644 index 0000000000..398c00785e --- /dev/null +++ b/scripts/check/check-openapi-routes.mjs @@ -0,0 +1,77 @@ +#!/usr/bin/env node +// scripts/check/check-openapi-routes.mjs +// Gate anti-alucinação (docs): toda `path` documentada em docs/reference/openapi.yaml +// deve resolver para um route.ts real em src/app/api/. Pega endpoint INVENTADO/obsoleto +// na spec (a IA escreve docs descrevendo rota que não existe). Complementa +// check-openapi-coverage.mjs (que mede a direção inversa: % de rotas documentadas). +import fs from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import yaml from "js-yaml"; + +const ROOT = process.cwd(); +const API_ROOT = path.join(ROOT, "src", "app", "api"); +const OPENAPI_PATH = path.join(ROOT, "docs", "reference", "openapi.yaml"); + +// Entradas da spec sem rota real, congeladas para triagem (catraca: bloqueia NOVAS). +const KNOWN_STALE_SPEC = new Set([ + // openapi.yaml documenta um state por-agente, mas a rota real é o state GLOBAL + // (/api/tools/agent-bridge/state); por-agente só há /{id}, /{id}/detect, /mappings, /dns. + // Triagem: corrigir a spec para /state ou criar a rota. (pré-existente) + "/api/tools/agent-bridge/agents/{agentId}/state", +]); + +/** Normaliza qualquer {param} para {} para casar independente do nome do parâmetro. */ +export function normalizeParams(p) { + return p.replace(/\{[^}]+\}/g, "{}"); +} + +/** Paths da spec que não casam com nenhuma rota implementada (param-insensitive). */ +export function findSpecPathsWithoutRoute(specPaths, implPaths) { + const impl = new Set(implPaths.map(normalizeParams)); + return specPaths.filter((p) => !impl.has(normalizeParams(p))); +} + +function collectRoutePaths(dir) { + const paths = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + paths.push(...collectRoutePaths(full)); + } else if (entry.isFile() && entry.name === "route.ts") { + const apiPath = path + .dirname(full) + .replace(API_ROOT, "") + .replace(/\/\[\.\.\.([^\]]+)\]/g, "/{$1}") + .replace(/\[([^\]]+)\]/g, "{$1}"); + paths.push(`/api${apiPath}`); + } + } + return paths; +} + +function main() { + if (!fs.existsSync(OPENAPI_PATH)) { + console.error(`[openapi-routes] FAIL — openapi.yaml não encontrado: ${OPENAPI_PATH}`); + process.exit(1); + } + const raw = yaml.load(fs.readFileSync(OPENAPI_PATH, "utf-8")); + const specPaths = Object.keys(raw.paths || {}).filter((p) => p.startsWith("/api")); + const implPaths = collectRoutePaths(API_ROOT); + const orphans = findSpecPathsWithoutRoute(specPaths, implPaths).filter( + (p) => !KNOWN_STALE_SPEC.has(p) + ); + if (orphans.length) { + console.error( + `[openapi-routes] ${orphans.length} path(s) documentado(s) sem rota real:\n` + + orphans.map((p) => " ✗ " + p).join("\n") + + `\n → crie a rota, corrija/remova a entrada na spec, ou adicione a KNOWN_STALE_SPEC com justificativa.` + ); + process.exit(1); + } + console.log( + `[openapi-routes] OK — ${specPaths.length} paths na spec, todos com rota real (${implPaths.length} rotas)` + ); +} + +if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main(); diff --git a/scripts/check/check-provider-consistency.ts b/scripts/check/check-provider-consistency.ts new file mode 100644 index 0000000000..ab08643815 --- /dev/null +++ b/scripts/check/check-provider-consistency.ts @@ -0,0 +1,45 @@ +#!/usr/bin/env node +// scripts/check/check-provider-consistency.ts +// Gate anti-alucinação nº1: toda entrada em REGISTRY (open-sse/config/providerRegistry.ts) +// deve corresponder a um provider canônico em src/shared/constants/providers.ts. +// Pega entradas de registry inventadas/meia-registradas (provider com baseUrl+models +// mas ausente da lista canônica → não selecionável pela máquina normal de providers). +// Catraca: exceções pré-existentes ficam em KNOWN_REGISTRY_ONLY; só NOVOS órfãos falham. +import { pathToFileURL } from "node:url"; +import { AI_PROVIDERS, getProviderById } from "@/shared/constants/providers.ts"; +import { REGISTRY } from "@omniroute/open-sse/config/providerRegistry.ts"; + +// Entradas registry-only conhecidas (meia-registro pré-existente). Cada uma com +// justificativa. Remover daqui ao registrar o provider em providers.ts. +export const KNOWN_REGISTRY_ONLY: Record = { + krutrim: + "Registry-only (baseUrl + krutrim-2-7b-instruct presentes) mas ausente de providers.ts — meia-registro pré-existente; triar em follow-up (registrar em APIKEY_PROVIDERS ou remover a entrada).", +}; + +/** Ids do REGISTRY que não são providers canônicos e não estão na allowlist. */ +export function findOrphanRegistryIds( + registryIds: string[], + isKnownProvider: (id: string) => boolean, + allowlist: Record +): string[] { + return registryIds.filter((id) => !isKnownProvider(id) && !(id in allowlist)); +} + +function main(): void { + const canonical = new Set(Object.keys(AI_PROVIDERS)); + const isKnown = (id: string) => canonical.has(id) || Boolean(getProviderById(id)); + const orphans = findOrphanRegistryIds(Object.keys(REGISTRY), isKnown, KNOWN_REGISTRY_ONLY); + if (orphans.length) { + console.error( + `[provider-consistency] ${orphans.length} entrada(s) no REGISTRY sem provider canônico em providers.ts:\n` + + orphans.map((id) => ` ✗ ${id}`).join("\n") + + `\n → registre o provider em src/shared/constants/providers.ts ou adicione a KNOWN_REGISTRY_ONLY (scripts/check/check-provider-consistency.ts) com justificativa.` + ); + process.exit(1); + } + console.log( + `[provider-consistency] OK — ${Object.keys(REGISTRY).length} entradas REGISTRY, ${canonical.size} providers canônicos, ${Object.keys(KNOWN_REGISTRY_ONLY).length} exceção(ões) conhecida(s)` + ); +} + +if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main(); diff --git a/scripts/check/check-public-creds.mjs b/scripts/check/check-public-creds.mjs new file mode 100644 index 0000000000..f5407d19a4 --- /dev/null +++ b/scripts/check/check-public-creds.mjs @@ -0,0 +1,161 @@ +#!/usr/bin/env node +// scripts/check/check-public-creds.mjs +// Gate de segurança — CLAUDE.md Hard Rule #11. +// +// Credenciais públicas de upstream (OAuth client_id/client_secret de CLIs públicas +// + Firebase web keys) DEVEM ser embutidas via `resolvePublicCred()` / +// `resolvePublicCredMulti()` (de open-sse/utils/publicCreds.ts), NUNCA como string +// literal no código. Ver docs/security/PUBLIC_CREDS.md. +// +// Literais embutidos (a) disparam scanners de secret/CodeQL a cada release, gerando +// ruído, e (b) acoplam o valor ao texto-fonte em vez de ao decodificador central — +// se o upstream rotacionar o client_id público, há N cópias para atualizar e o +// override por `process.env` deixa de ser a única fonte de verdade. +// +// Este gate varre os arquivos que carregam configuração de credencial e bloqueia +// QUALQUER atribuição NOVA de uma chave de credencial a uma string literal não-vazia. +// Os literais pré-existentes (auditados abaixo) ficam congelados em +// KNOWN_LITERAL_CREDS para a catraca sair 0 hoje e bloquear regressões. +import fs from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +const cwd = process.cwd(); + +// Arquivos que carregam configuração de credencial de upstream. O escopo é restrito +// de propósito: estes são os únicos pontos onde client_id/secret públicos vivem. +// Adicionar um novo arquivo de config de credencial? Inclua-o aqui. +const SCANNED_FILES = [ + "open-sse/config/providerRegistry.ts", + "src/lib/oauth/constants/oauth.ts", +]; + +// Chaves de objeto cujo valor é uma credencial. Atribuir qualquer uma destas a uma +// string literal não-vazia viola a Hard Rule #11. +// - clientIdDefault / clientSecretDefault: forma do providerRegistry (entry.oauth) +// - clientId / clientSecret: forma dos *_CONFIG em oauth.ts +// - apiKey / apiKeyDefault: chaves de API embutidas (mesmo princípio) +const CRED_KEY_RE = + /(?:^|[\s{,([])(clientIdDefault|clientSecretDefault|clientId|clientSecret|apiKeyDefault|apiKey)\s*:/; + +// Chaves de ambiente (clientIdEnv, clientSecretEnv, …) terminam em "Env" e carregam +// o NOME da variável de ambiente, não a credencial — nunca devem ser flagadas. +const ENV_KEY_RE = /(clientId|clientSecret|apiKey)Env\s*:/; + +// Literais pré-existentes auditados (DISCOVERY 2026-06-09). Cada um é uma credencial +// pública de upstream embutida ANTES deste gate existir. Ficam congelados aqui para +// a catraca sair 0 agora e bloquear QUALQUER literal NOVO. CADA UM é dívida de +// segurança Rule #11 a ser migrada para resolvePublicCred() — NÃO adicione novos +// sem justificativa; esse é o ponto do gate. +// +// A allowlist casa por VALOR do literal (o mesmo client_id público aparece nos dois +// arquivos, então congelar por valor cobre ambas as cópias). Para congelar um valor +// só num arquivo:linha específico, use a chave "arquivo:linha:valor". +// +// Tracking: estes 5 valores (9 call-sites) devem virar uma issue de segurança e +// migrar para resolvePublicCred() — Gemini/Antigravity já seguem o padrão correto. +export const KNOWN_LITERAL_CREDS = new Set([ + // Claude — CLAUDE_OAUTH_CLIENT_ID (public, PKCE auth-code flow) + // providerRegistry.ts:659 + oauth.ts:37 + "9d1c250a-e61b-44d9-88ed-5944d1962f5e", + // Codex (OpenAI) — CODEX_OAUTH_CLIENT_ID (public, PKCE) + // providerRegistry.ts:831 + oauth.ts:54 + "app_EMoamEEZ73f0CkXaXp7hrann", + // Qwen — QWEN_OAUTH_CLIENT_ID (public, device-code + PKCE) + // providerRegistry.ts:925 + oauth.ts:101 + "f0304373b74a44d2b584a3fb70ca9e56", + // Kimi Coding — KIMI_CODING_OAUTH_CLIENT_ID (public, device-code) + // providerRegistry.ts:1961 + oauth.ts:136 + "17e5f671-d194-4dfb-9706-5516cb48c098", + // GitHub Copilot — GITHUB_OAUTH_CLIENT_ID (public, device-code) + // oauth.ts:238 + "Iv1.b507a08c87ecfe98", +]); + +/** + * Encontra atribuições de uma chave de credencial a uma string literal não-vazia. + * + * Pura: recebe o texto-fonte e a allowlist, devolve a lista de violações. Não toca + * em I/O. Cada violação é "L: = \"\"". + * + * Regras de detecção (linha a linha): + * 1. A linha precisa atribuir uma das CRED_KEY (clientIdDefault, clientId, …) + * e não ser uma chave *Env (que carrega só o nome da env-var). + * 2. Se o RHS chama resolvePublicCred()/resolvePublicCredMulti(), está CORRETO + * (o literal ali é a CHAVE do default embutido, não a credencial) → ignora. + * 3. Caso contrário, qualquer string literal NÃO-VAZIA no RHS é uma violação + * — cobre tanto `key: "literal"` quanto `key: process.env.X || "literal"`. + * 4. Literais vazios ("" / '') são fallback legítimo de process.env → ignorados. + * 5. Literais presentes na allowlist (por valor OU por chave "arquivo:linha:valor") + * ficam congelados → ignorados. + * + * @param {string} source conteúdo do arquivo + * @param {Set} allowlist valores de literal (ou chaves arquivo:linha:valor) congelados + * @param {string} [relFile] caminho relativo do arquivo (para chaves arquivo:linha:valor) + * @returns {string[]} violações legíveis + */ +export function findLiteralCreds(source, allowlist, relFile = "") { + const violations = []; + const lines = String(source).split("\n"); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const keyMatch = CRED_KEY_RE.exec(line); + if (!keyMatch) continue; + if (ENV_KEY_RE.test(line)) continue; + const key = keyMatch[1]; + + // RHS = tudo após o primeiro ":" da chave de credencial. + const colonIdx = line.indexOf(":", keyMatch.index); + const rhs = colonIdx >= 0 ? line.slice(colonIdx + 1) : line; + + // Forma correta: embutido via decodificador central. Não inspeciona literais. + if (/resolvePublicCred(?:Multi)?\s*\(/.test(rhs)) continue; + + // Extrai todo literal de string do RHS (aspas simples, duplas ou crase). + const litRe = /(["'`])((?:\\.|(?!\1).)*)\1/g; + let lit; + while ((lit = litRe.exec(rhs))) { + const value = lit[2]; + if (!value) continue; // "" / '' — fallback de env, legítimo + const lineNo = i + 1; + const fileLineKey = relFile ? `${relFile}:${lineNo}:${value}` : ""; + if (allowlist.has(value)) continue; + if (fileLineKey && allowlist.has(fileLineKey)) continue; + violations.push(`L${lineNo}: ${key} = ${JSON.stringify(value)}`); + } + } + return violations; +} + +function main() { + const allMisses = []; + for (const rel of SCANNED_FILES) { + const abs = path.join(cwd, rel); + if (!fs.existsSync(abs)) { + console.error(`[check-public-creds] arquivo de escopo não encontrado: ${rel}`); + process.exit(1); + } + const src = fs.readFileSync(abs, "utf8"); + for (const v of findLiteralCreds(src, KNOWN_LITERAL_CREDS, rel)) { + allMisses.push(`${rel} ${v}`); + } + } + if (allMisses.length) { + console.error( + `[check-public-creds] ${allMisses.length} credencial(is) pública(s) como string literal ` + + `(viola CLAUDE.md Hard Rule #11):\n` + + allMisses.map((m) => " ✗ " + m).join("\n") + + `\n → embuta via resolvePublicCred()/resolvePublicCredMulti() ` + + `(open-sse/utils/publicCreds.ts). Ver docs/security/PUBLIC_CREDS.md.\n` + + ` → se for um literal pré-existente já auditado, congele em KNOWN_LITERAL_CREDS ` + + `com justificativa (e abra tracking de migração).` + ); + process.exit(1); + } + console.log( + `[check-public-creds] OK (${SCANNED_FILES.length} arquivo(s), ` + + `${KNOWN_LITERAL_CREDS.size} literal(is) congelado(s))` + ); +} + +if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main(); diff --git a/scripts/check/check-route-guard-membership.ts b/scripts/check/check-route-guard-membership.ts new file mode 100644 index 0000000000..9b09414d59 --- /dev/null +++ b/scripts/check/check-route-guard-membership.ts @@ -0,0 +1,113 @@ +#!/usr/bin/env node +// scripts/check/check-route-guard-membership.ts +// Quality gate: route-guard membership (CLAUDE.md Hard Rules #15 + #17). +// +// WHY: routes that spawn child processes (`npm install`, `node`, MITM/Playwright, +// worker_threads) MUST be classified loopback-only by `isLocalOnlyPath()` in +// src/server/authz/routeGuard.ts. Loopback enforcement runs unconditionally +// BEFORE any auth check — so a leaked JWT over a tunnel cannot reach a spawn. +// A single spawn-capable `route.ts` that `isLocalOnlyPath()` does NOT match is an +// RCE-via-tunnel hole (the GHSA-fhh6-4qxv-rpqj surface the LOCAL_ONLY tier closes). +// +// This gate enumerates every `route.ts` under the spawn-capable prefixes and +// asserts each resolved URL path is classified local-only by the REAL predicate. +// +// Ratchet: any pre-existing unclassified route is frozen in KNOWN_UNCLASSIFIED +// with a justification so the gate exits 0 today; only NEW spawn-capable routes +// that slip past the guard fail. KNOWN_UNCLASSIFIED is empty today (clean +// baseline) — keep it that way; an entry here is a documented security debt. +import { readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { isLocalOnlyPath } from "@/server/authz/routeGuard.ts"; + +// Spawn-capable route roots (relative to repo root). Mirrors the spawn-capable +// prefixes documented in routeGuard.ts (SPAWN_CAPABLE_PREFIXES) and CLAUDE.md +// Hard Rules #15/#17 for the dirs that physically exist under src/app/api/. +export const SPAWN_CAPABLE_ROUTE_ROOTS: ReadonlyArray = [ + "src/app/api/services", + "src/app/api/mcp", + "src/app/api/cli-tools/runtime", +]; + +// Frozen pre-existing exceptions: spawn-capable routes NOT yet classified +// local-only. Each entry is a documented security debt — the route is reachable +// past the loopback gate. Empty today (every spawn-capable route is classified). +// Adding an entry here REQUIRES a justification + a follow-up to classify it in +// LOCAL_ONLY_API_PREFIXES / LOCAL_ONLY_API_PATTERNS (src/server/authz/routeGuard.ts). +export const KNOWN_UNCLASSIFIED: Record = {}; + +/** + * Map a Next.js App Router `route.ts` file path to the URL path the route + * serves, in the exact shape `isLocalOnlyPath()` expects (a plain `/api/...` + * path). Dynamic `[param]` segments become a concrete `_param_` placeholder — + * `isLocalOnlyPath` matches prefixes via `startsWith`, so any non-empty segment + * satisfies the classification (e.g. `/api/services/_name_/logs` still starts + * with `/api/services/`). + */ +export function routeFileToApiPath(routeFile: string): string { + return routeFile + .replace(/^src\/app/, "") + .replace(/\/route\.ts$/, "") + .replace(/\[([^\]]+)\]/g, "_$1_"); +} + +/** + * Pure matching core: given resolved URL paths, a classifier predicate, and an + * allowlist, return the paths that are NEITHER classified local-only NOR + * allowlisted (input order preserved). These are the RCE-via-tunnel holes. + */ +export function findUnclassifiedSpawnRoutes( + apiPaths: string[], + isLocalOnly: (path: string) => boolean, + allowlist: Record +): string[] { + return apiPaths.filter((p) => !isLocalOnly(p) && !(p in allowlist)); +} + +/** Recursively collect every `route.ts` under `dir` (returns [] if dir absent). */ +function collectRouteFiles(dir: string): string[] { + let entries: string[]; + try { + entries = readdirSync(dir); + } catch { + return []; // dir does not exist — nothing to enumerate + } + const out: string[] = []; + for (const entry of entries) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) { + out.push(...collectRouteFiles(full)); + } else if (entry === "route.ts") { + out.push(full); + } + } + return out; +} + +function main(): void { + const apiPaths = SPAWN_CAPABLE_ROUTE_ROOTS.flatMap(collectRouteFiles) + .map(routeFileToApiPath) + .sort(); + + const unclassified = findUnclassifiedSpawnRoutes(apiPaths, isLocalOnlyPath, KNOWN_UNCLASSIFIED); + + if (unclassified.length) { + console.error( + `[route-guard-membership] CRITICAL — ${unclassified.length} spawn-capable route(s) NOT classified local-only by isLocalOnlyPath() (RCE-via-tunnel risk, Hard Rules #15/#17):\n` + + unclassified.map((p) => ` ✗ ${p}`).join("\n") + + `\n → add a matching prefix to LOCAL_ONLY_API_PREFIXES or a pattern to LOCAL_ONLY_API_PATTERNS in src/server/authz/routeGuard.ts (loopback enforcement must run before auth), or — only with written justification — freeze it in KNOWN_UNCLASSIFIED (scripts/check/check-route-guard-membership.ts).` + ); + process.exit(1); + } + + console.log( + `[route-guard-membership] OK — ${apiPaths.length} spawn-capable route(s) across ${SPAWN_CAPABLE_ROUTE_ROOTS.length} root(s) all classified local-only, ${Object.keys(KNOWN_UNCLASSIFIED).length} frozen exception(s)` + ); + // Explicit exit: importing routeGuard.ts pulls in runtime settings, which opens + // the SQLite DB and starts a background health-check timer that would otherwise + // keep the process alive. The gate's work is done — exit cleanly. + process.exit(0); +} + +if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main(); diff --git a/scripts/check/check-test-masking.mjs b/scripts/check/check-test-masking.mjs new file mode 100644 index 0000000000..c1db1e1139 --- /dev/null +++ b/scripts/check/check-test-masking.mjs @@ -0,0 +1,89 @@ +#!/usr/bin/env node +// scripts/check/check-test-masking.mjs +// Gate anti test-masking (a preocupação nº1 do CLAUDE.md: "subagente não pode +// enfraquecer/remover asserts pra ficar verde"). Para cada arquivo de teste MODIFICADO +// num PR, compara a contagem de asserts base vs HEAD: sinaliza REMOÇÃO LÍQUIDA de asserts +// e NOVAS tautologias `assert.ok(true)`. Heurístico mas alto-sinal. Espelha o plumbing +// de check-pr-test-policy.mjs (diff base...HEAD); no-op fora de contexto de PR. +import fs from "node:fs"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; +import { pathToFileURL } from "node:url"; + +const TEST_RE = /\.(test|spec)\.(ts|tsx)$/; + +/** Conta chamadas de assert.*( / assert( / expect( . */ +export function countAssertions(src) { + const a = (src.match(/\bassert\b\s*[.(]/g) || []).length; + const e = (src.match(/\bexpect\s*\(/g) || []).length; + return a + e; +} + +/** Conta tautologias assert.ok(true). */ +export function countTautologies(src) { + return (src.match(/\bassert\s*\.\s*ok\s*\(\s*true\s*\)/g) || []).length; +} + +/** Avalia por-arquivo: flag em remoção líquida de asserts ou nova tautologia. */ +export function evaluateMasking(perFile) { + const flags = []; + for (const f of perFile) { + if (f.headAsserts < f.baseAsserts) + flags.push(`${f.file}: asserts ${f.baseAsserts} → ${f.headAsserts} (REMOÇÃO de ${f.baseAsserts - f.headAsserts} — enfraquecimento?)`); + if (f.headTaut > f.baseTaut) + flags.push(`${f.file}: nova(s) ${f.headTaut - f.baseTaut} tautologia(s) assert.ok(true)`); + } + return flags; +} + +function git(args) { + try { + return execFileSync("git", args, { encoding: "utf8" }); + } catch { + return ""; + } +} + +function resolveBase() { + if (process.env.GITHUB_BASE_SHA) return process.env.GITHUB_BASE_SHA; + if (process.env.GITHUB_BASE_REF) return `origin/${process.env.GITHUB_BASE_REF}`; + return null; +} + +function main() { + const base = resolveBase(); + if (!base) { + console.log("[test-masking] sem base ref (não é PR) — pulando."); + return; + } + const changed = git(["diff", "--name-only", "--diff-filter=M", `${base}...HEAD`]) + .split("\n") + .map((s) => s.trim()) + .filter((f) => TEST_RE.test(f) && fs.existsSync(f)); + + const perFile = []; + for (const file of changed) { + const baseSrc = git(["show", `${base}:${file}`]); + const headSrc = fs.readFileSync(file, "utf8"); + perFile.push({ + file, + baseAsserts: countAssertions(baseSrc), + headAsserts: countAssertions(headSrc), + baseTaut: countTautologies(baseSrc), + headTaut: countTautologies(headSrc), + }); + } + + const flags = evaluateMasking(perFile); + if (flags.length) { + console.error( + `[test-masking] ${flags.length} sinal(is) de enfraquecimento de teste:\n` + + flags.map((f) => " ✗ " + f).join("\n") + + `\n → se a redução é legítima (refator/consolidação), explique no PR; senão, restaure os asserts.` + ); + process.exit(1); + } + console.log(`[test-masking] OK — ${changed.length} arquivo(s) de teste modificado(s), sem enfraquecimento`); +} + +if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main(); diff --git a/scripts/quality/check-quality-ratchet.mjs b/scripts/quality/check-quality-ratchet.mjs new file mode 100644 index 0000000000..791c194817 --- /dev/null +++ b/scripts/quality/check-quality-ratchet.mjs @@ -0,0 +1,97 @@ +#!/usr/bin/env node +// scripts/quality/check-quality-ratchet.mjs +// Catraca genérica multi-métrica. Clona o espírito de check-t11-any-budget.mjs: +// um baseline congelado por métrica; falha em qualquer regressão; só anda num sentido. +import fs from "node:fs"; +import path from "node:path"; + +const cwd = process.cwd(); +function getArg(name, fallback) { + const i = process.argv.indexOf(name); + return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : fallback; +} +const BASELINE = path.resolve(getArg("--baseline", path.join(cwd, "quality-baseline.json"))); +const METRICS = path.resolve(getArg("--metrics", path.join(cwd, "quality-metrics.json"))); +const SUMMARY = getArg("--summary", null); +const UPDATE = process.argv.includes("--update"); +// --allow-missing: pula métricas do baseline ausentes do metrics (em vez de falhar). +// Uso local: cobertura só existe no CI; localmente quality:gate roda com este flag. +// No CI o job quality-gate roda SEM o flag (estrito — baixa o coverage mergeado antes). +const ALLOW_MISSING = process.argv.includes("--allow-missing"); +const EPS = 0.01; + +function load(p) { + if (!fs.existsSync(p)) { + console.error(`[quality-ratchet] arquivo ausente: ${p}`); + process.exit(2); + } + return JSON.parse(fs.readFileSync(p, "utf8")); +} + +const baseline = load(BASELINE); +const metrics = load(METRICS); +const failures = []; +const improvements = []; +const rows = []; + +for (const [key, spec] of Object.entries(baseline.metrics)) { + const current = metrics[key]; + const base = spec.value; + const dir = spec.direction; // "down" = menor-é-melhor | "up" = maior-é-melhor + if (current === undefined) { + if (ALLOW_MISSING) { + rows.push([key, base, "—", "SKIP (ausente)"]); + } else { + failures.push(`métrica "${key}" ausente em ${path.basename(METRICS)}`); + rows.push([key, base, "—", "MISSING"]); + } + continue; + } + let status = "ok"; + if (dir === "down") { + if (current > base + EPS) { + failures.push(`${key}: ${current} > baseline ${base} (não pode aumentar)`); + status = "REGRESSÃO"; + } else if (current < base - EPS) { + improvements.push([key, current]); + status = "↑ melhorou"; + } + } else { + if (current < base - EPS) { + failures.push(`${key}: ${current} < baseline ${base} (não pode cair)`); + status = "REGRESSÃO"; + } else if (current > base + EPS) { + improvements.push([key, current]); + status = "↑ melhorou"; + } + } + rows.push([key, base, current, status]); +} + +if (SUMMARY) { + const md = [ + "# Quality Ratchet", + "", + "| Métrica | Baseline | Atual | Status |", + "|---|---|---|---|", + ...rows.map(([k, b, c, s]) => `| ${k} | ${b} | ${c} | ${s} |`), + "", + failures.length + ? `**${failures.length} regressão(ões) — gate BLOQUEADO.**` + : "**Sem regressões — gate OK.**", + ].join("\n"); + fs.mkdirSync(path.dirname(SUMMARY), { recursive: true }); + fs.writeFileSync(SUMMARY, md + "\n"); +} + +if (UPDATE && failures.length === 0 && improvements.length) { + for (const [key, val] of improvements) baseline.metrics[key].value = val; + fs.writeFileSync(BASELINE, JSON.stringify(baseline, null, 2) + "\n"); + console.log(`[quality-ratchet] baseline ratcheado: ${improvements.length} métrica(s) melhoraram`); +} + +if (failures.length) { + console.error("[quality-ratchet] FALHOU:\n" + failures.map((f) => " ✗ " + f).join("\n")); + process.exit(1); +} +console.log(`[quality-ratchet] OK (${rows.length} métricas, ${improvements.length} melhoraram)`); diff --git a/scripts/quality/collect-metrics.mjs b/scripts/quality/collect-metrics.mjs new file mode 100644 index 0000000000..471094e99b --- /dev/null +++ b/scripts/quality/collect-metrics.mjs @@ -0,0 +1,43 @@ +#!/usr/bin/env node +// scripts/quality/collect-metrics.mjs — emite quality-metrics.json +// Coletores incrementais: Fase 1 traz ESLint warnings + cobertura. +// Fases 3/4 estendem com duplicação (jscpd), tamanho de arquivo e cobertura por módulo. +import fs from "node:fs"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; + +const cwd = process.cwd(); +const out = {}; + +// 1) ESLint: contagem de warnings (errors devem ser 0; o lint já gata isso) +function eslintCounts() { + let stdout; + try { + stdout = execFileSync("npx", ["eslint", ".", "--format", "json"], { + encoding: "utf8", + maxBuffer: 256 * 1024 * 1024, + }); + } catch (e) { + // eslint sai com código != 0 quando há errors; o JSON ainda vem no stdout + stdout = e.stdout?.toString() || "[]"; + } + const results = JSON.parse(stdout); + out.eslintWarnings = results.reduce((n, r) => n + (r.warningCount || 0), 0); + out.eslintErrors = results.reduce((n, r) => n + (r.errorCount || 0), 0); +} + +// 2) Cobertura: lê coverage/coverage-summary.json se existir (gerado por c8) +function coverage() { + const p = path.join(cwd, "coverage", "coverage-summary.json"); + if (!fs.existsSync(p)) return; + const t = JSON.parse(fs.readFileSync(p, "utf8")).total; + out["coverage.statements"] = t.statements.pct; + out["coverage.lines"] = t.lines.pct; + out["coverage.functions"] = t.functions.pct; + out["coverage.branches"] = t.branches.pct; +} + +eslintCounts(); +coverage(); +fs.writeFileSync(path.join(cwd, "quality-metrics.json"), JSON.stringify(out, null, 2) + "\n"); +console.log("[collect-metrics]", JSON.stringify(out)); diff --git a/tests/unit/check-complexity.test.ts b/tests/unit/check-complexity.test.ts new file mode 100644 index 0000000000..fbd35a8a26 --- /dev/null +++ b/tests/unit/check-complexity.test.ts @@ -0,0 +1,46 @@ +import { test } from "node:test"; +import assert from "node:assert"; +import { evaluateComplexity } from "../../scripts/check/check-complexity.mjs"; + +// The .mjs module has no .d.ts; type the pure comparator locally so the test file +// stays free of explicit `any` (ratchet 3482 — zero new warnings allowed). +type ComplexityVerdict = { regressed: boolean; improved: boolean }; +const evaluate = evaluateComplexity as (current: number, baseline: number) => ComplexityVerdict; + +const BASELINE = 1739; + +test("equal to baseline passes", () => { + const r = evaluate(BASELINE, BASELINE); + assert.equal(r.regressed, false); + assert.equal(r.improved, false); +}); + +test("one more violation is a regression", () => { + const r = evaluate(BASELINE + 1, BASELINE); + assert.equal(r.regressed, true); + assert.equal(r.improved, false); +}); + +test("a large increase is a regression", () => { + const r = evaluate(BASELINE + 200, BASELINE); + assert.equal(r.regressed, true); +}); + +test("one fewer violation is an improvement (ratchet down)", () => { + const r = evaluate(BASELINE - 1, BASELINE); + assert.equal(r.regressed, false); + assert.equal(r.improved, true); +}); + +test("zero violations is an improvement and never regresses", () => { + const r = evaluate(0, BASELINE); + assert.equal(r.regressed, false); + assert.equal(r.improved, true); +}); + +test("exact-integer comparison — no epsilon tolerance", () => { + // Unlike the duplication gate (float %), complexity is an integer count: any increase + // at all must fail, with no slack. + assert.equal(evaluate(11, 10).regressed, true); + assert.equal(evaluate(10, 10).regressed, false); +}); diff --git a/tests/unit/check-db-rules.test.ts b/tests/unit/check-db-rules.test.ts new file mode 100644 index 0000000000..c714a9dd80 --- /dev/null +++ b/tests/unit/check-db-rules.test.ts @@ -0,0 +1,198 @@ +import { test } from "node:test"; +import assert from "node:assert"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + collectDbModules, + extractReexportedModules, + findMissingReexports, + hasLogic, + extractStringLiterals, + findRawSql, + collectSqlScanFiles, +} from "../../scripts/check/check-db-rules.mjs"; + +const REPO_ROOT = path.resolve(fileURLToPath(import.meta.url), "../../.."); +const LOCAL_DB = path.join(REPO_ROOT, "src/lib/localDb.ts"); + +// ---------- (a) re-export completeness ---------- + +test("findMissingReexports: flags a NEW db module that is not re-exported", () => { + const dbModules = ["providers", "brandNewModule"]; + const reexported = new Set(["providers"]) as Set; + const allowlist = new Set(); + const missing = findMissingReexports(dbModules, reexported, allowlist) as string[]; + assert.deepEqual(missing, ["brandNewModule"]); +}); + +test("findMissingReexports: a re-exported module passes", () => { + const dbModules = ["providers"]; + const reexported = new Set(["providers"]) as Set; + const missing = findMissingReexports(dbModules, reexported, new Set()) as string[]; + assert.deepEqual(missing, []); +}); + +test("findMissingReexports: an allowlisted (frozen) module passes even if not re-exported", () => { + const dbModules = ["notion"]; + const reexported = new Set(); + const allowlist = new Set(["notion"]) as Set; + const missing = findMissingReexports(dbModules, reexported, allowlist) as string[]; + assert.deepEqual(missing, []); +}); + +test("extractReexportedModules: parses ./db/X from export forms", () => { + const src = [ + 'export { getCombos } from "./db/combos";', + 'export * from "./db/featureFlags";', + 'export type { Webhook } from "./db/webhooks";', + 'export { sumUsageTokensThisMonth } from "./db/usageSummary";', + // not a db module — must be ignored + 'export { initPricingSync } from "./pricingSync";', + ].join("\n"); + const mods = extractReexportedModules(src) as Set; + assert.equal(mods.has("combos"), true); + assert.equal(mods.has("featureFlags"), true); + assert.equal(mods.has("webhooks"), true); + assert.equal(mods.has("usageSummary"), true); + assert.equal(mods.has("pricingSync"), false); +}); + +test("collectDbModules: returns real modules and excludes core/localDb/index", () => { + const mods = collectDbModules() as string[]; + assert.ok(mods.includes("providers"), "expected providers module"); + assert.ok(mods.includes("combos"), "expected combos module"); + assert.equal(mods.includes("core"), false, "core must be excluded"); + assert.equal(mods.includes("localDb"), false, "localDb must be excluded"); + assert.equal(mods.includes("index"), false, "index must be excluded"); +}); + +// FREEZE GUARD: the live repo state must be green under the shipped allowlist. +test("live repo: no NEW unexported db modules beyond the frozen allowlist", async () => { + // Re-import the gate's frozen allowlist indirectly by running its default behavior: + // findMissingReexports with the gate default allowlist must be empty for the repo. + const dbModules = collectDbModules() as string[]; + const reexported = extractReexportedModules(fs.readFileSync(LOCAL_DB, "utf8")) as Set; + // Default allowlist (KNOWN_UNEXPORTED) is applied inside findMissingReexports. + const missing = findMissingReexports(dbModules, reexported) as string[]; + assert.deepEqual( + missing, + [], + `Unexported db module(s) not in KNOWN_UNEXPORTED: ${missing.join(", ")}` + ); +}); + +// ---------- (b) localDb has no logic ---------- + +test("hasLogic: false for a pure re-export layer", () => { + const src = [ + "// re-export layer", + 'export { a, b } from "./db/foo";', + 'export * from "./db/bar";', + 'export type { T } from "./db/baz";', + ].join("\n"); + assert.equal(hasLogic(src) as boolean, false); +}); + +test("hasLogic: true for a function declaration", () => { + const src = 'export { a } from "./db/foo";\nfunction doThing() { return 1; }'; + assert.equal(hasLogic(src) as boolean, true); +}); + +test("hasLogic: true for an arrow-function const", () => { + const src = 'export { a } from "./db/foo";\nconst helper = (x) => x + 1;'; + assert.equal(hasLogic(src) as boolean, true); +}); + +test("hasLogic: true for a class declaration", () => { + const src = 'export { a } from "./db/foo";\nclass Thing {}'; + assert.equal(hasLogic(src) as boolean, true); +}); + +test("hasLogic: SQL/logic-looking text inside comments or strings does not trip", () => { + const src = [ + "/* function notReal() {} */", + "// const fake = () => 1;", + 'export const SOURCE = "./db/foo";', // string only, no function on rhs + 'export { a } from "./db/foo";', + ].join("\n"); + // export const X = "string" is a value (not logic): the rhs is a string literal, + // so the arrow/call pattern must NOT match. + assert.equal(hasLogic(src) as boolean, false); +}); + +test("live repo: src/lib/localDb.ts contains no logic", () => { + const src = fs.readFileSync(LOCAL_DB, "utf8"); + assert.equal(hasLogic(src) as boolean, false); +}); + +// ---------- (c) no raw SQL outside db/ ---------- + +test("extractStringLiterals: returns only string bodies, ignoring code", () => { + const code = 'import { x } from "y";\nconst q = `SELECT * FROM t`;\nobj.set(1);'; + const literals = extractStringLiterals(code) as string; + assert.ok(literals.includes("SELECT * FROM t"), "captures the template body"); + assert.ok(literals.includes("y"), "captures the import path string"); + assert.equal(literals.includes("set"), false, "JS .set() call is not a string body"); +}); + +test("findRawSql: flags a NEW route with raw SQL in a string literal", () => { + const tmp = path.join(REPO_ROOT, ".tmp-check-db-rules-raw-sql.route.ts"); + fs.writeFileSync( + tmp, + 'const rows = db.prepare(`SELECT id FROM users WHERE x = ?`).all();\n', + "utf8" + ); + try { + const offenders = findRawSql([tmp], new Set()) as string[]; + assert.equal(offenders.length, 1, "raw SELECT...FROM should be flagged"); + } finally { + fs.rmSync(tmp, { force: true }); + } +}); + +test("findRawSql: does NOT flag SQL that only appears in a comment", () => { + const tmp = path.join(REPO_ROOT, ".tmp-check-db-rules-comment.route.ts"); + fs.writeFileSync(tmp, "// SELECT id FROM users -- documentation only\nexport const x = 1;\n", "utf8"); + try { + const offenders = findRawSql([tmp], new Set()) as string[]; + assert.deepEqual(offenders, []); + } finally { + fs.rmSync(tmp, { force: true }); + } +}); + +test("findRawSql: does NOT flag JS .set()/import-from/new Set() false positives", () => { + const tmp = path.join(REPO_ROOT, ".tmp-check-db-rules-falsepos.route.ts"); + fs.writeFileSync( + tmp, + [ + 'import { NextResponse } from "next/server";', + "const seen = new Set();", + "headers.set(key, value);", + "delete obj.field;", + ].join("\n"), + "utf8" + ); + try { + const offenders = findRawSql([tmp], new Set()) as string[]; + assert.deepEqual(offenders, []); + } finally { + fs.rmSync(tmp, { force: true }); + } +}); + +test("findRawSql: an allowlisted (frozen) offender passes", () => { + const rel = "src/app/api/skills/[id]/route.ts"; + const abs = path.join(REPO_ROOT, rel); + const allowlist = new Set([rel]) as Set; + const offenders = findRawSql([abs], allowlist) as string[]; + assert.deepEqual(offenders, []); +}); + +test("live repo: no NEW raw-SQL offenders beyond the frozen allowlist", () => { + // findRawSql uses the gate default allowlist (KNOWN_RAW_SQL) when none is passed. + const files = collectSqlScanFiles() as string[]; + const offenders = findRawSql(files) as string[]; + assert.deepEqual(offenders, [], `New raw-SQL offender(s): ${offenders.join(", ")}`); +}); diff --git a/tests/unit/check-deps.test.ts b/tests/unit/check-deps.test.ts new file mode 100644 index 0000000000..fcc24d7b9e --- /dev/null +++ b/tests/unit/check-deps.test.ts @@ -0,0 +1,21 @@ +import { test } from "node:test"; +import assert from "node:assert"; +import { findUnapprovedDeps } from "../../scripts/check/check-deps.mjs"; + +test("no unapproved deps when all are allowlisted", () => { + assert.deepEqual(findUnapprovedDeps(["react", "next"], new Set(["react", "next", "zod"])), []); +}); + +test("flags a dependency not on the allowlist (potential slopsquat)", () => { + assert.deepEqual( + findUnapprovedDeps(["react", "reactt-router"], new Set(["react"])), + ["reactt-router"] + ); +}); + +test("flags multiple new deps, preserves order, de-dupes", () => { + assert.deepEqual( + findUnapprovedDeps(["a", "b", "a", "c"], new Set(["a"])), + ["b", "c"] + ); +}); diff --git a/tests/unit/check-docs-symbols.test.ts b/tests/unit/check-docs-symbols.test.ts new file mode 100644 index 0000000000..c24524843a --- /dev/null +++ b/tests/unit/check-docs-symbols.test.ts @@ -0,0 +1,169 @@ +import { test } from "node:test"; +import assert from "node:assert"; +import { execFileSync } from "node:child_process"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + resolveApiDocPathToRoute, + extractDocApiPaths, + findStaleDocApiRefs, + collectRouteFiles, + KNOWN_STALE_DOC_REFS, +} from "../../scripts/check/check-docs-symbols.mjs"; + +// Tipos explícitos (não `any`) para as exports do .mjs — mantém o test em 0 warnings de +// no-explicit-any (catraca 3482) usando `as `. +const resolve = resolveApiDocPathToRoute as (apiPath: string, routeFiles: Set) => boolean; +const extract = extractDocApiPaths as (src: string) => string[]; +const findStale = findStaleDocApiRefs as ( + docPathsByFile: { file: string; paths: string[] }[], + routeFiles: Set, + allowlist: Set +) => string[]; +const collect = collectRouteFiles as () => Set; +const allowlist = KNOWN_STALE_DOC_REFS as Set; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const GATE = path.resolve(here, "../../scripts/check/check-docs-symbols.mjs"); +const REPO = path.resolve(here, "../.."); + +// --- resolveApiDocPathToRoute -------------------------------------------------------- + +test("resolves a static doc path to a real route", () => { + const files = new Set(["src/app/api/usage/route.ts"]); + assert.equal(resolve("/api/usage", files), true); +}); + +test("resolves a {param} doc segment against a real [param] dynamic segment", () => { + const files = new Set(["src/app/api/providers/[id]/models/route.ts"]); + assert.equal(resolve("/api/providers/{providerId}/models", files), true); +}); + +test("resolves a :param (Express-style) doc segment too", () => { + const files = new Set(["src/app/api/shadow/[id]/route.ts"]); + assert.equal(resolve("/api/shadow/:id", files), true); +}); + +test("resolves a doc path that is a prefix of a deeper route (family reference)", () => { + const files = new Set(["src/app/api/auth/login/route.ts"]); + assert.equal(resolve("/api/auth", files), true); +}); + +test("resolves into a catch-all route segment", () => { + const files = new Set(["src/app/api/mcp/[...transport]/route.ts"]); + assert.equal(resolve("/api/mcp/sse/extra", files), true); +}); + +test("flags a hallucinated route with no matching file", () => { + const files = new Set(["src/app/api/usage/route.ts"]); + assert.equal(resolve("/api/shadow/metrics", files), false); +}); + +test("does NOT match when the doc path is deeper than the real route", () => { + const files = new Set(["src/app/api/acp/agents/route.ts"]); + assert.equal(resolve("/api/acp/agents/refresh", files), false); +}); + +test("static segment mismatch is not absorbed by an unrelated dynamic route", () => { + const files = new Set(["src/app/api/plugins/[name]/activate/route.ts"]); + // /api/plugins/{id}/enable: dynamic seg ok, but "enable" != "activate" + assert.equal(resolve("/api/plugins/{id}/enable", files), false); +}); + +// --- extractDocApiPaths -------------------------------------------------------------- + +test("extracts an /api path from a code fence", () => { + const md = "```\nGET /api/cache/stats\n```"; + assert.deepEqual(extract(md), ["/api/cache/stats"]); +}); + +test("extracts an /api path from inline code and strips trailing prose punctuation", () => { + const md = "Call `/api/usage`, then check the result."; + assert.deepEqual(extract(md), ["/api/usage"]); +}); + +test("keeps balanced [param] / {param} segments intact", () => { + const md = "DELETE | `/api/shadow/[id]` | and `/api/tools/agent-bridge/agents/{id}/state`"; + assert.deepEqual(extract(md), [ + "/api/shadow/[id]", + "/api/tools/agent-bridge/agents/{id}/state", + ]); +}); + +test("does NOT capture a source-file path tail (src/lib/api/..., @/app/api/...)", () => { + const md = "see `src/lib/api/requireManagementAuth.ts` and `@/app/api/oauth/route.ts`"; + assert.deepEqual(extract(md), []); +}); + +test("ignores file references ending in .ts even when URL-shaped", () => { + // file-ref filter lives in findStaleDocApiRefs; extract still returns it, but the + // capture must not include the leading file-path context. + const md = "endpoint is /api/cache/route.ts in the tree"; + assert.deepEqual(extract(md), ["/api/cache/route.ts"]); +}); + +test("drops a trailing markdown-emphasis underscore (table italics)", () => { + const md = "| _500 no POST /api/cli-tools/config_ | _Zod faltando_ |"; + assert.deepEqual(extract(md), ["/api/cli-tools/config"]); +}); + +test("discards a segment with an unbalanced bracket (regex truncation in prose)", () => { + // greedy regex captured "/api/mcp/{status" — drop the dangling segment, keep prefix. + const md = "the `/api/mcp/{status` field (prose ran on)"; + assert.deepEqual(extract(md), ["/api/mcp"]); +}); + +// --- findStaleDocApiRefs ------------------------------------------------------------- + +const routes = new Set([ + "src/app/api/usage/route.ts", + "src/app/api/providers/[id]/models/route.ts", +]); + +test("passes a doc path that resolves to a real route", () => { + const docs = [{ file: "docs/x.md", paths: ["/api/usage"] }]; + assert.deepEqual(findStale(docs, routes, new Set()), []); +}); + +test("flags a hallucinated doc path as 'file → path'", () => { + const docs = [{ file: "docs/x.md", paths: ["/api/ghost"] }]; + assert.deepEqual(findStale(docs, routes, new Set()), ["docs/x.md → /api/ghost"]); +}); + +test("allowlisted stale path is frozen (not flagged)", () => { + const docs = [{ file: "docs/x.md", paths: ["/api/ghost"] }]; + assert.deepEqual(findStale(docs, routes, new Set(["/api/ghost"])), []); +}); + +test("IGNORE swallows the OpenAI-compat /api/v1 proxy surface", () => { + const docs = [{ file: "docs/x.md", paths: ["/api/v1/chat/completions"] }]; + assert.deepEqual(findStale(docs, routes, new Set()), []); +}); + +test("IGNORE swallows obvious example/placeholder paths", () => { + const docs = [{ file: "docs/x.md", paths: ["/api/your-route/here", "/api/example/foo"] }]; + assert.deepEqual(findStale(docs, routes, new Set()), []); +}); + +test("file-ref paths (.ts / /route) are skipped, not flagged", () => { + const docs = [{ file: "docs/x.md", paths: ["/api/cache/route.ts", "/api/oauth/route"] }]; + assert.deepEqual(findStale(docs, routes, new Set()), []); +}); + +// --- live gate smoke ----------------------------------------------------------------- + +test("collectRouteFiles finds the real route tree (non-empty, all route.ts)", () => { + const files = collect(); + assert.ok(files.size > 100, "expected the full route tree"); + for (const f of files) assert.match(f, /route\.tsx?$/); +}); + +test("KNOWN_STALE_DOC_REFS is a frozen, documented allowlist (non-empty)", () => { + assert.ok(allowlist.size > 0); + for (const p of allowlist) assert.match(p, /^\/api\//); +}); + +test("the gate itself exits 0 on the current repo (baseline frozen)", () => { + const out = execFileSync("node", [GATE], { cwd: REPO, encoding: "utf8" }); + assert.match(out, /\[check-docs-symbols\] OK/); +}); diff --git a/tests/unit/check-duplication.test.ts b/tests/unit/check-duplication.test.ts new file mode 100644 index 0000000000..be4939e1e8 --- /dev/null +++ b/tests/unit/check-duplication.test.ts @@ -0,0 +1,24 @@ +import { test } from "node:test"; +import assert from "node:assert"; +import { evaluateDuplication } from "../../scripts/check/check-duplication.mjs"; + +const EPS = 0.05; + +test("equal to baseline passes", () => { + assert.equal(evaluateDuplication(5.72, 5.72, EPS).regressed, false); +}); + +test("within epsilon passes (float noise)", () => { + assert.equal(evaluateDuplication(5.74, 5.72, EPS).regressed, false); +}); + +test("meaningful increase is a regression", () => { + const r = evaluateDuplication(5.9, 5.72, EPS); + assert.equal(r.regressed, true); +}); + +test("a decrease is an improvement (ratchet down)", () => { + const r = evaluateDuplication(5.0, 5.72, EPS); + assert.equal(r.regressed, false); + assert.equal(r.improved, true); +}); diff --git a/tests/unit/check-error-helper.test.ts b/tests/unit/check-error-helper.test.ts new file mode 100644 index 0000000000..ad98b9a6d0 --- /dev/null +++ b/tests/unit/check-error-helper.test.ts @@ -0,0 +1,179 @@ +// Tests for the Rule #12 error-sanitization gate (scripts/check/check-error-helper.mjs). +// Exercises the pure findErrorHelperViolations() against synthetic file shapes so the +// conservative heuristic (flag direct + indirect raw-error leaks, never internal sinks +// or helper-importing files) is locked down as a regression guard. +import test from "node:test"; +import assert from "node:assert/strict"; +// @ts-expect-error — .mjs gate module has no type declarations; runtime shape is known. +import { findErrorHelperViolations, KNOWN_MISSING_ERROR_HELPER } from "../../scripts/check/check-error-helper.mjs"; + +type FileEntry = { path: string; source: string }; +type FindFn = (files: FileEntry[], allowlist: Set) => string[]; +const find = findErrorHelperViolations as FindFn; +const allowlist = KNOWN_MISSING_ERROR_HELPER as Set; + +const EMPTY = new Set(); + +function run(source: string, path = "open-sse/executors/x.ts"): string[] { + return find([{ path, source } as FileEntry], EMPTY); +} + +test("flags raw err.message assigned directly to an error: field", () => { + const src = `export function exec() { + try { doThing(); } catch (err) { + return { success: false, status: 502, error: err.message }; + } + }`; + assert.deepEqual(run(src), ["open-sse/executors/x.ts"]); +}); + +test("flags raw err.message interpolated into a message: field", () => { + const src = `function build(err: Error) { + return new Response(JSON.stringify({ error: { message: \`boom: \${err.message}\` } })); + }`; + assert.deepEqual(run(src), ["open-sse/executors/x.ts"]); +}); + +test("flags err.stack placed into a message: field", () => { + const src = `function build(err: Error) { + return { error: { message: err.stack } }; + }`; + assert.deepEqual(run(src), ["open-sse/executors/x.ts"]); +}); + +test("flags multi-line OpenAI error envelope inside new Response()", () => { + const src = `function build(err: unknown) { + return new Response( + JSON.stringify({ + error: { + message: isTls + ? \`tls failed: \${(err as Error).message}\` + : \`conn failed: \${err instanceof Error ? err.message : String(err)}\`, + type: "upstream_error", + }, + }), + { status: 502 } + ); + }`; + assert.deepEqual(run(src), ["open-sse/executors/x.ts"]); +}); + +test("flags a tainted local variable passed into a response-builder call", () => { + const src = `function makeErrorResponse(s: number, m: string) { return new Response(m); } + function exec(err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + return { response: makeErrorResponse(401, \`auth failed: \${msg}\`) }; + }`; + assert.deepEqual(run(src), ["open-sse/executors/x.ts"]); +}); + +test("flags errResp(msg) where msg is tainted", () => { + const src = `function errResp(message: string) { return new Response(JSON.stringify({ error: { message } })); } + function exec(err: unknown) { + const msg = err instanceof Error ? err.message : "Failed to get nonce"; + return { response: errResp(msg) }; + }`; + assert.deepEqual(run(src), ["open-sse/executors/x.ts"]); +}); + +test("flags forwarded upstream body.error.message without sanitize", () => { + const src = `function build(body: { error: { message: string } }) { + return { success: false, error: body.error.message }; + }`; + assert.deepEqual(run(src), ["open-sse/executors/x.ts"]); +}); + +// --- Negative cases: the gate must NOT flag these (conservative, no false positives) --- + +test("does NOT flag a file that imports utils/error (relative)", () => { + const src = `import { sanitizeErrorMessage } from "../utils/error.ts"; + function build(err: Error) { return { error: { message: err.message } }; }`; + assert.deepEqual(run(src), []); +}); + +test("does NOT flag a file that imports utils/error (workspace alias)", () => { + const src = `import { buildErrorBody } from "@omniroute/open-sse/utils/error"; + function build(err: Error) { return new Response(JSON.stringify({ error: { message: \`x \${err.message}\` } })); }`; + assert.deepEqual(run(src), []); +}); + +test("does NOT flag raw err.message inside a saveCallLog audit row", () => { + const src = `function exec(err: Error) { + saveCallLog({ + method: "POST", + status: 502, + error: err.message, + requestBody: rb, + }).catch(() => {}); + return ok; + }`; + assert.deepEqual(run(src), []); +}); + +test("does NOT flag raw err.message inside a log call", () => { + const src = `function exec(err: Error) { + log?.error?.("X", \`refresh error: \${err.message}\`); + return ok; + }`; + assert.deepEqual(run(src), []); +}); + +test("does NOT flag err.message inside a thrown Error", () => { + const src = `function exec(err: Error) { + throw new Error(\`SPA send failed: \${err instanceof Error ? err.message : String(err)}\`); + }`; + assert.deepEqual(run(src), []); +}); + +test("does NOT flag err.message inside reject()", () => { + const src = `new Promise((_, reject) => { + onErr((err: Error) => reject(new Error(\`failed: \${err.message}\`))); + });`; + assert.deepEqual(run(src), []); +}); + +test("does NOT flag upstream-event read event.error.message", () => { + const src = `function parse(event: { error: { message: string } }) { + const content = typeof event.error === "string" ? event.error : event.error.message; + return { choices: [{ message: { content } }] }; + }`; + assert.deepEqual(run(src), []); +}); + +test("does NOT flag a sanitized body.error.message line", () => { + const src = `function build(body: { error: { message: string } }) { + return { error: sanitizeErrorMessage(body.error.message) }; + }`; + assert.deepEqual(run(src), []); +}); + +// --- Allowlist behavior --- + +test("an allowlisted path is suppressed even when it would otherwise flag", () => { + const src = `function build(err: Error) { return { error: { message: err.message } }; }`; + const path = "open-sse/executors/legacy.ts"; + assert.deepEqual(find([{ path, source: src } as FileEntry], EMPTY), [path]); + assert.deepEqual(find([{ path, source: src } as FileEntry], new Set([path])), []); +}); + +test("the shipped allowlist freezes exactly the known current violators", () => { + const frozen = [...allowlist].sort(); + assert.deepEqual(frozen, [ + "open-sse/executors/adapta-web.ts", + "open-sse/executors/deepseek-web.ts", + "open-sse/executors/perplexity-web.ts", + "open-sse/executors/qoder.ts", + "open-sse/executors/veoaifree-web.ts", + "open-sse/handlers/embeddings.ts", + "open-sse/handlers/search.ts", + ]); +}); + +test("returns multiple violating paths and preserves input order", () => { + const files: FileEntry[] = [ + { path: "open-sse/executors/a.ts", source: `return { error: { message: err.message } };` }, + { path: "open-sse/executors/b.ts", source: `import { x } from "../utils/error.ts"; return { error: err.message };` }, + { path: "open-sse/executors/c.ts", source: `return { error: e.stack };` }, + ]; + assert.deepEqual(find(files, EMPTY), ["open-sse/executors/a.ts", "open-sse/executors/c.ts"]); +}); diff --git a/tests/unit/check-fetch-targets.test.ts b/tests/unit/check-fetch-targets.test.ts new file mode 100644 index 0000000000..4a29576f8b --- /dev/null +++ b/tests/unit/check-fetch-targets.test.ts @@ -0,0 +1,28 @@ +import { test } from "node:test"; +import assert from "node:assert"; +import { resolveApiPathToRoute } from "../../scripts/check/check-fetch-targets.mjs"; + +test("matches a static route file", () => { + const files = new Set(["src/app/api/usage/route.ts"]); + assert.equal(resolveApiPathToRoute("/api/usage", files), true); +}); + +test("matches a dynamic [param] segment", () => { + const files = new Set(["src/app/api/providers/[id]/models/route.ts"]); + assert.equal(resolveApiPathToRoute("/api/providers/abc-123/models", files), true); +}); + +test("rejects a hallucinated route", () => { + const files = new Set(["src/app/api/usage/route.ts"]); + assert.equal(resolveApiPathToRoute("/api/providers/refresh", files), false); +}); + +test("does not match when segment counts differ", () => { + const files = new Set(["src/app/api/providers/[id]/route.ts"]); + assert.equal(resolveApiPathToRoute("/api/providers/abc/models", files), false); +}); + +test("strips query string before resolving", () => { + const files = new Set(["src/app/api/usage/route.ts"]); + assert.equal(resolveApiPathToRoute("/api/usage?range=7d", files), true); +}); diff --git a/tests/unit/check-file-size.test.ts b/tests/unit/check-file-size.test.ts new file mode 100644 index 0000000000..fb80ccccd8 --- /dev/null +++ b/tests/unit/check-file-size.test.ts @@ -0,0 +1,34 @@ +import { test } from "node:test"; +import assert from "node:assert"; +import { evaluateFileSizes } from "../../scripts/check/check-file-size.mjs"; + +const cap = 800; + +test("frozen file at exactly its baseline passes", () => { + const r = evaluateFileSizes({ "a.ts": 1000 }, { "a.ts": 1000 }, cap); + assert.deepEqual(r.violations, []); + assert.deepEqual(r.improvements, []); +}); + +test("frozen file that grew is a violation", () => { + const r = evaluateFileSizes({ "a.ts": 1001 }, { "a.ts": 1000 }, cap); + assert.equal(r.violations.length, 1); + assert.match(r.violations[0], /a\.ts/); +}); + +test("frozen file that shrank is an improvement, not a violation", () => { + const r = evaluateFileSizes({ "a.ts": 950 }, { "a.ts": 1000 }, cap); + assert.deepEqual(r.violations, []); + assert.deepEqual(r.improvements, [["a.ts", 950]]); +}); + +test("new file over the cap is a violation", () => { + const r = evaluateFileSizes({ "new.ts": 801 }, {}, cap); + assert.equal(r.violations.length, 1); + assert.match(r.violations[0], /new\.ts/); +}); + +test("new file at or under the cap passes", () => { + const r = evaluateFileSizes({ "new.ts": 800 }, {}, cap); + assert.deepEqual(r.violations, []); +}); diff --git a/tests/unit/check-known-symbols.test.ts b/tests/unit/check-known-symbols.test.ts new file mode 100644 index 0000000000..0021d33e5a --- /dev/null +++ b/tests/unit/check-known-symbols.test.ts @@ -0,0 +1,179 @@ +import { test } from "node:test"; +import assert from "node:assert"; +import { + extractHandledStrategies, + diffComboStrategies, + extractExecutorAliases, + findNonConformingExecutors, + findMissingTranslatorPairs, + findNewTranslatorPairs, + IMPLICIT_DEFAULT_STRATEGIES, + KNOWN_TRANSLATOR_PAIRS, + type ExecutorLike, +} from "../../scripts/check/check-known-symbols.ts"; + +// ─────────────────────────────────────────────────────────────────────────── +// (2) COMBO STRATEGIES — extractHandledStrategies + diffComboStrategies +// ─────────────────────────────────────────────────────────────────────────── + +test("extractHandledStrategies pulls every `strategy === \"...\"` literal, deduped", () => { + const src = [ + 'if (strategy === "round-robin") {', + '} else if (strategy === "p2c") {', + 'const x = strategy === "weighted" ? a : b;', + '} else if (strategy === "p2c") {', // dup → deduped by the Set + ].join("\n"); + const handled = extractHandledStrategies(src); + assert.deepEqual([...handled].sort(), ["p2c", "round-robin", "weighted"]); +}); + +test("extractHandledStrategies ignores non-matching comparisons", () => { + const src = 'if (mode === "fast") {}\nif (strategy == "loose") {}\nif (strategy === "auto") {}'; + // `mode ===` and the loose `==` must not match; only the strict strategy compare. + assert.deepEqual([...extractHandledStrategies(src)], ["auto"]); +}); + +test("diffComboStrategies: no mismatch when dispatch + implicit defaults cover canonical exactly", () => { + const canonical = ["priority", "weighted", "auto"]; + const handled = new Set(["weighted", "auto"]); + const implicit = { priority: "default no-branch" }; + const result = diffComboStrategies(canonical, handled, implicit); + assert.deepEqual(result.canonicalNotHandled, []); + assert.deepEqual(result.handledNotCanonical, []); +}); + +test("diffComboStrategies flags a canonical strategy added without a dispatch branch", () => { + const canonical = ["priority", "weighted", "newfangled"]; + const handled = new Set(["weighted"]); + const implicit = { priority: "default no-branch" }; + const result = diffComboStrategies(canonical, handled, implicit); + assert.deepEqual(result.canonicalNotHandled, ["newfangled"]); + assert.deepEqual(result.handledNotCanonical, []); +}); + +test("diffComboStrategies flags an invented dispatch string not in the canonical set", () => { + const canonical = ["priority", "weighted"]; + const handled = new Set(["weighted", "ghost-strategy"]); + const implicit = { priority: "default no-branch" }; + const result = diffComboStrategies(canonical, handled, implicit); + assert.deepEqual(result.handledNotCanonical, ["ghost-strategy"]); + assert.deepEqual(result.canonicalNotHandled, []); +}); + +test("diffComboStrategies: an implicit-default string handled in dispatch is not flagged as invented", () => { + // If priority later gets an explicit branch, it appears in both handled AND implicit — + // it must NOT be reported as an invented (handledNotCanonical) string. + const canonical = ["priority", "weighted"]; + const handled = new Set(["priority", "weighted"]); + const implicit = { priority: "default no-branch" }; + const result = diffComboStrategies(canonical, handled, implicit); + assert.deepEqual(result.canonicalNotHandled, []); + assert.deepEqual(result.handledNotCanonical, []); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// (1) EXECUTOR CONFORMANCE — extractExecutorAliases + findNonConformingExecutors +// ─────────────────────────────────────────────────────────────────────────── + +test("extractExecutorAliases parses quoted and bare keys from the executors literal", () => { + const src = [ + 'import { Foo } from "./foo.ts";', + "const executors = {", + " antigravity: new Foo(),", + ' "gemini-cli": new Foo(),', + " agy: new Foo(), // Alias", + ' "amazon-q": new Foo("amazon-q"),', + "};", + "export function getExecutor() {}", + ].join("\n"); + assert.deepEqual(extractExecutorAliases(src), [ + "antigravity", + "gemini-cli", + "agy", + "amazon-q", + ]); +}); + +test("extractExecutorAliases throws when the executors map cannot be located", () => { + assert.throws(() => extractExecutorAliases("const other = { a: 1 };"), /could not find/); +}); + +test("findNonConformingExecutors returns [] when every alias resolves to a valid executor", () => { + const good = { execute: () => {}, getProvider: () => "x" } as ExecutorLike; + const resolve = (_alias: string) => good; + const isInstance = (_value: unknown) => true; + assert.deepEqual(findNonConformingExecutors(["a", "b"], resolve, isInstance), []); +}); + +test("findNonConformingExecutors flags an alias that does not resolve at all", () => { + const good = { execute: () => {}, getProvider: () => "x" } as ExecutorLike; + const resolve = (alias: string) => (alias === "ghost" ? null : good); + const isInstance = (_value: unknown) => true; + assert.deepEqual(findNonConformingExecutors(["a", "ghost", "b"], resolve, isInstance), ["ghost"]); +}); + +test("findNonConformingExecutors flags an alias resolving to a non-BaseExecutor instance", () => { + const stray = { execute: () => {}, getProvider: () => "x" } as ExecutorLike; + const resolve = (_alias: string) => stray; + // Simulate `instanceof BaseExecutor` returning false for the stray object. + const isInstance = (_value: unknown) => false; + assert.deepEqual(findNonConformingExecutors(["stray"], resolve, isInstance), ["stray"]); +}); + +test("findNonConformingExecutors flags an executor missing execute() or getProvider()", () => { + const noExecute = { getProvider: () => "x" } as ExecutorLike; + const noProvider = { execute: () => {} } as ExecutorLike; + const valid = { execute: () => {}, getProvider: () => "x" } as ExecutorLike; + const map: Record = { ne: noExecute, np: noProvider, ok: valid }; + const resolve = (alias: string) => map[alias]; + const isInstance = (_value: unknown) => true; + assert.deepEqual(findNonConformingExecutors(["ne", "np", "ok"], resolve, isInstance), [ + "ne", + "np", + ]); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// (3) TRANSLATOR PAIRS — findMissingTranslatorPairs + findNewTranslatorPairs +// ─────────────────────────────────────────────────────────────────────────── + +test("findMissingTranslatorPairs returns [] when every frozen pair is still live", () => { + const frozen = ["openai:claude", "claude:openai"]; + const live = new Set(["openai:claude", "claude:openai", "gemini:openai"]); + assert.deepEqual(findMissingTranslatorPairs(frozen, live), []); +}); + +test("findMissingTranslatorPairs flags a frozen pair that disappeared from the live registry", () => { + const frozen = ["openai:claude", "claude:openai"]; + const live = new Set(["openai:claude"]); + assert.deepEqual(findMissingTranslatorPairs(frozen, live), ["claude:openai"]); +}); + +test("findNewTranslatorPairs reports live pairs absent from the frozen snapshot, sorted", () => { + const frozen = ["openai:claude"]; + const live = new Set(["openai:claude", "z:y", "a:b"]); + assert.deepEqual(findNewTranslatorPairs(frozen, live), ["a:b", "z:y"]); +}); + +test("findNewTranslatorPairs returns [] when live is a subset of frozen", () => { + const frozen = ["openai:claude", "claude:openai"]; + const live = new Set(["openai:claude"]); + assert.deepEqual(findNewTranslatorPairs(frozen, live), []); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// Allowlist / snapshot sanity (documented frozen sets stay well-formed) +// ─────────────────────────────────────────────────────────────────────────── + +test("IMPLICIT_DEFAULT_STRATEGIES documents `priority` with a justification", () => { + assert.ok(Object.prototype.hasOwnProperty.call(IMPLICIT_DEFAULT_STRATEGIES, "priority")); + assert.ok(IMPLICIT_DEFAULT_STRATEGIES.priority.length > 20); +}); + +test("KNOWN_TRANSLATOR_PAIRS is a non-empty, well-formed, deduped from:to snapshot", () => { + assert.ok(KNOWN_TRANSLATOR_PAIRS.length > 0); + assert.equal(new Set(KNOWN_TRANSLATOR_PAIRS).size, KNOWN_TRANSLATOR_PAIRS.length); + for (const pair of KNOWN_TRANSLATOR_PAIRS) { + assert.match(pair, /^[a-z0-9-]+:[a-z0-9-]+$/, `malformed translator pair: ${pair}`); + } +}); diff --git a/tests/unit/check-migration-numbering.test.ts b/tests/unit/check-migration-numbering.test.ts new file mode 100644 index 0000000000..b93d874029 --- /dev/null +++ b/tests/unit/check-migration-numbering.test.ts @@ -0,0 +1,105 @@ +import { test } from "node:test"; +import assert from "node:assert"; +import fs from "node:fs"; +import path from "node:path"; +import { + findMigrationAnomalies, + KNOWN_DUPLICATE_VERSIONS, + KNOWN_GAPS, +} from "../../scripts/check/check-migration-numbering.mjs"; + +type Anomalies = { + duplicates: Array<{ version: string; names: string[] }>; + gaps: string[]; + badNames: string[]; +}; + +const EMPTY = new Set(); + +test("clean contiguous sequence has no anomalies", () => { + const files = ["001_a.sql", "002_b.sql", "003_c.sql"]; + const r = findMigrationAnomalies(files, EMPTY, EMPTY) as Anomalies; + assert.deepEqual(r.duplicates, []); + assert.deepEqual(r.gaps, []); + assert.deepEqual(r.badNames, []); +}); + +test("flags a filename without a zero-padded numeric prefix", () => { + const files = ["001_a.sql", "add_index.sql", "2_short.sql"]; + const r = findMigrationAnomalies(files, EMPTY, EMPTY) as Anomalies; + // "add_index.sql" has no numeric prefix; "2_short.sql" is not zero-padded (<3 digits). + assert.deepEqual(r.badNames.sort(), ["2_short.sql", "add_index.sql"]); +}); + +test("flags a real duplicate version prefix", () => { + const files = ["001_a.sql", "002_b.sql", "002_c.sql"]; + const r = findMigrationAnomalies(files, EMPTY, EMPTY) as Anomalies; + assert.equal(r.duplicates.length, 1); + assert.equal(r.duplicates[0].version, "002"); + assert.deepEqual(r.duplicates[0].names, ["002_b.sql", "002_c.sql"]); +}); + +test("does NOT flag a duplicate that is in the knownDuplicates allowlist", () => { + const files = ["001_a.sql", "002_b.sql", "002_c.sql"]; + const known = new Set(["002"]); + const r = findMigrationAnomalies(files, known, EMPTY) as Anomalies; + assert.deepEqual(r.duplicates, []); +}); + +test("flags an unexplained sequence gap", () => { + const files = ["001_a.sql", "002_b.sql", "004_d.sql"]; + const r = findMigrationAnomalies(files, EMPTY, EMPTY) as Anomalies; + assert.deepEqual(r.gaps, ["003"]); +}); + +test("does NOT flag a gap that is in the knownGaps allowlist", () => { + const files = ["001_a.sql", "002_b.sql", "004_d.sql"]; + const known = new Set(["003"]); + const r = findMigrationAnomalies(files, EMPTY, known) as Anomalies; + assert.deepEqual(r.gaps, []); +}); + +test("gaps at the boundaries are not counted (only interior gaps)", () => { + // No phantom gap below min or above max. + const files = ["003_a.sql", "004_b.sql"]; + const r = findMigrationAnomalies(files, EMPTY, EMPTY) as Anomalies; + assert.deepEqual(r.gaps, []); +}); + +test("ignores non-.sql files entirely", () => { + const files = ["001_a.sql", "002_b.sql", "README.md", ".keep"]; + const r = findMigrationAnomalies(files, EMPTY, EMPTY) as Anomalies; + assert.deepEqual(r.badNames, []); + assert.deepEqual(r.gaps, []); + assert.deepEqual(r.duplicates, []); +}); + +test("a NEW gap is flagged even when a known gap is allowlisted", () => { + // Simulate the real frozen gaps plus a fresh hole that must NOT be tolerated. + const files = ["001_a.sql", "003_c.sql", "005_e.sql"]; + const known = new Set(["004"]); // 004 allowlisted, 002 is the new hole + const r = findMigrationAnomalies(files, EMPTY, known) as Anomalies; + assert.deepEqual(r.gaps, ["002"]); +}); + +// --- Real dataset: the frozen allowlists must keep the live dir green --- + +test("the real migrations dir produces ZERO anomalies under the frozen allowlists", () => { + const dir = path.resolve(import.meta.dirname, "../../src/lib/db/migrations"); + const filenames = fs.readdirSync(dir).filter((f) => f.endsWith(".sql")); + assert.ok(filenames.length > 0, "expected migration files to exist"); + const r = findMigrationAnomalies(filenames, KNOWN_DUPLICATE_VERSIONS, KNOWN_GAPS) as Anomalies; + assert.deepEqual(r.badNames, [], `unexpected bad migration names: ${r.badNames.join(", ")}`); + assert.deepEqual( + r.duplicates, + [], + `unexpected duplicate versions: ${JSON.stringify(r.duplicates)}` + ); + assert.deepEqual(r.gaps, [], `unexpected sequence gaps: ${r.gaps.join(", ")}`); +}); + +test("frozen allowlists match the documented audit (026 & 055 gaps, 041 dup)", () => { + assert.ok(KNOWN_GAPS.has("026")); + assert.ok(KNOWN_GAPS.has("055")); + assert.ok(KNOWN_DUPLICATE_VERSIONS.has("041")); +}); diff --git a/tests/unit/check-openapi-routes.test.ts b/tests/unit/check-openapi-routes.test.ts new file mode 100644 index 0000000000..5ddfbdcbb0 --- /dev/null +++ b/tests/unit/check-openapi-routes.test.ts @@ -0,0 +1,27 @@ +import { test } from "node:test"; +import assert from "node:assert"; +import { + normalizeParams, + findSpecPathsWithoutRoute, +} from "../../scripts/check/check-openapi-routes.mjs"; + +test("normalizeParams collapses any {param} name to {}", () => { + assert.equal(normalizeParams("/api/providers/{providerId}/models"), "/api/providers/{}/models"); +}); + +test("documented path with a real route is not flagged", () => { + assert.deepEqual(findSpecPathsWithoutRoute(["/api/usage"], ["/api/usage"]), []); +}); + +test("param name mismatch still matches (param-insensitive)", () => { + assert.deepEqual( + findSpecPathsWithoutRoute(["/api/providers/{id}"], ["/api/providers/{providerId}"]), + [] + ); +}); + +test("flags a documented path that has no real route (invented endpoint)", () => { + assert.deepEqual(findSpecPathsWithoutRoute(["/api/ghost", "/api/usage"], ["/api/usage"]), [ + "/api/ghost", + ]); +}); diff --git a/tests/unit/check-provider-consistency.test.ts b/tests/unit/check-provider-consistency.test.ts new file mode 100644 index 0000000000..7f269e234f --- /dev/null +++ b/tests/unit/check-provider-consistency.test.ts @@ -0,0 +1,25 @@ +import { test } from "node:test"; +import assert from "node:assert"; +import { findOrphanRegistryIds } from "../../scripts/check/check-provider-consistency.ts"; + +const known = new Set(["openai", "anthropic", "gemini"]); +const isKnown = (id: string) => known.has(id); + +test("no orphans when every registry id is a known provider", () => { + assert.deepEqual(findOrphanRegistryIds(["openai", "anthropic"], isKnown, {}), []); +}); + +test("flags a registry id that is not a canonical provider (hallucinated/half-registered)", () => { + assert.deepEqual(findOrphanRegistryIds(["openai", "ghostprovider"], isKnown, {}), ["ghostprovider"]); +}); + +test("allowlisted ids are not flagged", () => { + assert.deepEqual( + findOrphanRegistryIds(["openai", "krutrim"], isKnown, { krutrim: "pré-existente" }), + [] + ); +}); + +test("flags multiple orphans, preserves order", () => { + assert.deepEqual(findOrphanRegistryIds(["a", "openai", "b"], isKnown, {}), ["a", "b"]); +}); diff --git a/tests/unit/check-public-creds.test.ts b/tests/unit/check-public-creds.test.ts new file mode 100644 index 0000000000..91f94edf87 --- /dev/null +++ b/tests/unit/check-public-creds.test.ts @@ -0,0 +1,121 @@ +import { test } from "node:test"; +import assert from "node:assert"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { findLiteralCreds, KNOWN_LITERAL_CREDS } from "../../scripts/check/check-public-creds.mjs"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); + +test("flags a clientIdDefault assigned to a string literal", () => { + const src = `oauth: {\n clientIdDefault: "deadbeef-leaked-client-id",\n}`; + const v = findLiteralCreds(src, new Set(), "x.ts"); + assert.equal(v.length, 1); + assert.match(v[0], /clientIdDefault/); + assert.match(v[0], /deadbeef-leaked-client-id/); +}); + +test("flags a clientId behind a process.env fallback (env || literal)", () => { + const src = `clientId: process.env.X_OAUTH_CLIENT_ID || "leaked-via-fallback",`; + const v = findLiteralCreds(src, new Set(), "x.ts"); + assert.equal(v.length, 1); + assert.match(v[0], /leaked-via-fallback/); +}); + +test("flags clientSecret and apiKey literals too", () => { + const src = [ + `clientSecret: "GOCSPX-secret-literal",`, + `apiKey: "AIzaSyLeakedFirebaseKey",`, + ].join("\n"); + const v = findLiteralCreds(src, new Set(), "x.ts"); + assert.equal(v.length, 2); +}); + +test("does NOT flag resolvePublicCred() — the correct embedding pattern", () => { + const src = `clientIdDefault: resolvePublicCred("gemini_id"),`; + assert.deepEqual(findLiteralCreds(src, new Set(), "x.ts"), []); +}); + +test("does NOT flag resolvePublicCredMulti() with literal env-name args", () => { + const src = `clientId: resolvePublicCredMulti("gemini_id", ["GEMINI_OAUTH_CLIENT_ID", "ALT"]),`; + assert.deepEqual(findLiteralCreds(src, new Set(), "x.ts"), []); +}); + +test("does NOT flag empty-string fallback (process.env || \"\")", () => { + const src = `clientIdDefault: process.env.GITLAB_OAUTH_CLIENT_ID || "",`; + assert.deepEqual(findLiteralCreds(src, new Set(), "x.ts"), []); +}); + +test("does NOT flag an *Env key — it carries the env-var NAME, not the secret", () => { + const src = `clientIdEnv: "QWEN_OAUTH_CLIENT_ID",`; + assert.deepEqual(findLiteralCreds(src, new Set(), "x.ts"), []); +}); + +test("does NOT flag a member-access reference (CODEX_CONFIG.clientId)", () => { + const src = `clientId: CODEX_CONFIG.clientId,`; + assert.deepEqual(findLiteralCreds(src, new Set(), "x.ts"), []); +}); + +test("allowlist freezes a literal by VALUE", () => { + const src = `clientIdDefault: "frozen-value-123",`; + const allow = new Set(["frozen-value-123"]); + assert.deepEqual(findLiteralCreds(src, allow, "x.ts"), []); +}); + +test("allowlist freezes a literal by file:line:value key", () => { + const src = `\nclientIdDefault: "site-specific-123",`; + const allow = new Set(["x.ts:2:site-specific-123"]); + assert.deepEqual(findLiteralCreds(src, allow, "x.ts"), []); +}); + +test("a NEW literal is still flagged even with the real frozen allowlist", () => { + const src = `clientIdDefault: "brand-new-leaked-client-id",`; + const v = findLiteralCreds(src, KNOWN_LITERAL_CREDS, "x.ts"); + assert.equal(v.length, 1); +}); + +test("real scanned files produce ZERO violations with the frozen allowlist (gate exits 0)", () => { + const scanned = [ + "open-sse/config/providerRegistry.ts", + "src/lib/oauth/constants/oauth.ts", + ]; + for (const rel of scanned) { + const src = fs.readFileSync(path.join(repoRoot, rel), "utf8") as string; + const v = findLiteralCreds(src, KNOWN_LITERAL_CREDS, rel); + assert.deepEqual(v, [], `expected no live violations in ${rel}, got: ${v.join(", ")}`); + } +}); + +test("every frozen literal is actually present in a scanned file (no dead allowlist entries)", () => { + const scanned = [ + "open-sse/config/providerRegistry.ts", + "src/lib/oauth/constants/oauth.ts", + ]; + const blob = scanned + .map((rel) => fs.readFileSync(path.join(repoRoot, rel), "utf8") as string) + .join("\n"); + for (const entry of KNOWN_LITERAL_CREDS) { + // Plain value entries (no file:line: prefix) must appear verbatim in the source. + const value = entry.includes(":") && /:\d+:/.test(entry) + ? entry.replace(/^.*?:\d+:/, "") + : entry; + assert.ok(blob.includes(value), `frozen literal not found in any scanned file: ${value}`); + } +}); + +test("with an empty allowlist the real files surface the known live violations", () => { + const reg = fs.readFileSync( + path.join(repoRoot, "open-sse/config/providerRegistry.ts"), + "utf8" + ) as string; + const oauth = fs.readFileSync( + path.join(repoRoot, "src/lib/oauth/constants/oauth.ts"), + "utf8" + ) as string; + const regViolations = findLiteralCreds(reg, new Set(), "providerRegistry.ts"); + const oauthViolations = findLiteralCreds(oauth, new Set(), "oauth.ts"); + // 4 in providerRegistry (Claude/Codex/Qwen/Kimi clientIdDefault), + // 5 in oauth.ts (the same four + GitHub). + assert.equal(regViolations.length, 4); + assert.equal(oauthViolations.length, 5); +}); diff --git a/tests/unit/check-route-guard-membership.test.ts b/tests/unit/check-route-guard-membership.test.ts new file mode 100644 index 0000000000..24483be636 --- /dev/null +++ b/tests/unit/check-route-guard-membership.test.ts @@ -0,0 +1,74 @@ +import { test } from "node:test"; +import assert from "node:assert"; +import { + routeFileToApiPath, + findUnclassifiedSpawnRoutes, +} from "../../scripts/check/check-route-guard-membership.ts"; + +// Synthetic isLocalOnlyPath: classifies anything under the three spawn-capable +// prefixes via startsWith. Mirrors the real predicate's prefix semantics without +// importing routeGuard.ts (keeps this test DB-free / pure). +const SYNTHETIC_PREFIXES = ["/api/mcp/", "/api/cli-tools/runtime/", "/api/services/"]; +const isLocalOnly = (path: string): boolean => + SYNTHETIC_PREFIXES.some((p) => path === p || path.startsWith(p)); + +test("routeFileToApiPath maps a Next App Router route.ts to its URL path", () => { + assert.equal( + routeFileToApiPath("src/app/api/services/9router/install/route.ts"), + "/api/services/9router/install" + ); +}); + +test("routeFileToApiPath resolves dynamic [param] segments to a concrete placeholder", () => { + assert.equal( + routeFileToApiPath("src/app/api/services/[name]/logs/route.ts"), + "/api/services/_name_/logs" + ); + assert.equal( + routeFileToApiPath("src/app/api/cli-tools/runtime/[toolId]/route.ts"), + "/api/cli-tools/runtime/_toolId_" + ); +}); + +test("no unclassified routes when every spawn-capable route is local-only", () => { + const routes = [ + "/api/mcp/tools", + "/api/services/9router/start", + "/api/cli-tools/runtime/_toolId_", + ]; + assert.deepEqual(findUnclassifiedSpawnRoutes(routes, isLocalOnly, {}), []); +}); + +test("flags a spawn-capable route that is NOT classified local-only (RCE-via-tunnel gap)", () => { + // Synthetic predicate that forgot to cover /api/services/ — the exact regression + // this gate guards against. + const leaky = (path: string): boolean => path.startsWith("/api/mcp/"); + assert.deepEqual( + findUnclassifiedSpawnRoutes( + ["/api/mcp/tools", "/api/services/cliproxy/install"], + leaky, + {} + ), + ["/api/services/cliproxy/install"] + ); +}); + +test("allowlisted routes are not flagged (frozen pre-existing exceptions)", () => { + const leaky = (path: string): boolean => path.startsWith("/api/mcp/"); + assert.deepEqual( + findUnclassifiedSpawnRoutes( + ["/api/mcp/tools", "/api/services/legacy/route"], + leaky, + { "/api/services/legacy/route": "frozen pre-existing exception" } + ), + [] + ); +}); + +test("flags multiple unclassified routes, preserves input order", () => { + const leaky = (): boolean => false; + assert.deepEqual( + findUnclassifiedSpawnRoutes(["/api/services/a", "/api/mcp/b", "/api/services/c"], leaky, {}), + ["/api/services/a", "/api/mcp/b", "/api/services/c"] + ); +}); diff --git a/tests/unit/check-test-masking.test.ts b/tests/unit/check-test-masking.test.ts new file mode 100644 index 0000000000..8bbb9b57bc --- /dev/null +++ b/tests/unit/check-test-masking.test.ts @@ -0,0 +1,33 @@ +import { test } from "node:test"; +import assert from "node:assert"; +import { + countAssertions, + countTautologies, + evaluateMasking, +} from "../../scripts/check/check-test-masking.mjs"; + +test("countAssertions counts assert.* and expect() calls", () => { + const src = `assert.equal(a, b);\nassert.ok(x);\nexpect(y).toBe(z);`; + assert.equal(countAssertions(src), 3); +}); + +test("countTautologies counts assert.ok(true)", () => { + assert.equal(countTautologies(`assert.ok(true);\nassert.ok( true );`), 2); +}); + +test("net removal of assertions in a changed test file is flagged", () => { + const r = evaluateMasking([{ file: "a.test.ts", baseAsserts: 5, headAsserts: 3, baseTaut: 0, headTaut: 0 }]); + assert.equal(r.length, 1); + assert.match(r[0], /a\.test\.ts/); +}); + +test("adding assertions is not flagged", () => { + const r = evaluateMasking([{ file: "a.test.ts", baseAsserts: 5, headAsserts: 7, baseTaut: 0, headTaut: 0 }]); + assert.deepEqual(r, []); +}); + +test("new assert.ok(true) tautology is flagged even if assert count is stable", () => { + const r = evaluateMasking([{ file: "a.test.ts", baseAsserts: 5, headAsserts: 5, baseTaut: 0, headTaut: 1 }]); + assert.equal(r.length, 1); + assert.match(r[0], /tautolog/i); +}); diff --git a/tests/unit/quality-ratchet.test.ts b/tests/unit/quality-ratchet.test.ts new file mode 100644 index 0000000000..9e37afc56c --- /dev/null +++ b/tests/unit/quality-ratchet.test.ts @@ -0,0 +1,70 @@ +import { test } from "node:test"; +import assert from "node:assert"; +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const SCRIPT = path.resolve("scripts/quality/check-quality-ratchet.mjs"); + +function run(baseline: unknown, metrics: unknown, extraArgs: string[] = []) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ratchet-")); + const bPath = path.join(dir, "baseline.json"); + const mPath = path.join(dir, "metrics.json"); + fs.writeFileSync(bPath, JSON.stringify(baseline)); + fs.writeFileSync(mPath, JSON.stringify(metrics)); + try { + const out = execFileSync("node", [SCRIPT, "--baseline", bPath, "--metrics", mPath, ...extraArgs], { + encoding: "utf8", + }); + return { code: 0, out, dir, bPath }; + } catch (e) { + const err = e as { status?: number; stdout?: string; stderr?: string }; + return { code: err.status as number, out: (err.stdout || "") + (err.stderr || ""), dir, bPath }; + } +} + +test("passes when metrics equal baseline", () => { + const b = { + metrics: { + eslintWarnings: { value: 100, direction: "down" }, + "coverage.lines": { value: 80, direction: "up" }, + }, + }; + assert.equal(run(b, { eslintWarnings: 100, "coverage.lines": 80 }).code, 0); +}); + +test("fails when a 'down' metric regresses (more warnings)", () => { + const b = { metrics: { eslintWarnings: { value: 100, direction: "down" } } }; + const r = run(b, { eslintWarnings: 101 }); + assert.equal(r.code, 1); + assert.match(r.out, /eslintWarnings/); +}); + +test("fails when an 'up' metric regresses (coverage drops)", () => { + const b = { metrics: { "coverage.lines": { value: 80, direction: "up" } } }; + assert.equal(run(b, { "coverage.lines": 79 }).code, 1); +}); + +test("passes on improvement; --update ratchets the baseline", () => { + const b = { metrics: { eslintWarnings: { value: 100, direction: "down" } } }; + const r = run(b, { eslintWarnings: 90 }, ["--update"]); + assert.equal(r.code, 0); + const updated = JSON.parse(fs.readFileSync(r.bPath, "utf8")); + assert.equal(updated.metrics.eslintWarnings.value, 90); +}); + +test("fails when a baseline metric is missing from collected metrics", () => { + const b = { metrics: { eslintWarnings: { value: 100, direction: "down" } } }; + assert.equal(run(b, {}).code, 1); +}); + +test("--allow-missing skips absent metrics instead of failing", () => { + const b = { + metrics: { + eslintWarnings: { value: 100, direction: "down" }, + "coverage.lines": { value: 80, direction: "up" }, + }, + }; + assert.equal(run(b, { eslintWarnings: 100 }, ["--allow-missing"]).code, 0); +});