From 76a07cf7a538a926c3a0885fe07060fcb0ce62e1 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sat, 13 Jun 2026 17:27:40 -0300 Subject: [PATCH] Release v3.8.24 (#3747) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Release v3.8.24 — see CHANGELOG.md [3.8.24] for the full notes and the PR description for the contributors hall. Integration of release/v3.8.24 into main. --- .env.example | 7 + .github/workflows/ci.yml | 45 + .github/workflows/quality.yml | 49 + .gitleaks.toml | 68 + .husky/pre-push | 16 +- .license-allowlist.json | 85 + .size-limit.json | 22 + .source/source.config.mjs | 1 - .zizmor.yml | 51 + @omniroute/opencode-plugin/package-lock.json | 698 +-- @omniroute/opencode-plugin/package.json | 3 + .../opencode-provider/package-lock.json | 698 +-- @omniroute/opencode-provider/package.json | 3 + AGENTS.md | 88 +- CHANGELOG.md | 64 +- CLAUDE.md | 70 +- PLANO-QUALITY-GATES-FASE6A.md | 209 - PLANO-QUALITY-GATES-FASE7.md | 182 - PLANO-QUALITY-GATES.md | 688 --- README.md | 123 +- RELATORIO-QUALITY-GATES.md | 219 - bin/cli/runtime/processSupervisor.mjs | 11 +- bin/mcp-server.mjs | 16 +- complexity-baseline.json | 7 +- dependency-allowlist.json | 11 + docs/AGENTROUTER.md | 18 +- docs/README.md | 6 +- docs/SUBMIT_PR.md | 12 +- docs/architecture/QUALITY_GATES.md | 187 + docs/architecture/RESILIENCE_GUIDE.md | 1 + docs/architecture/meta.json | 3 +- docs/bdd/proxy-egress-isolation.feature | 29 + docs/bdd/self-service-api-key-usage.feature | 144 - docs/compression/EXTENDING_COMPRESSION.md | 550 +++ docs/compression/RTK_COMPRESSION.md | 364 ++ docs/frameworks/MEMORY.md | 269 +- docs/frameworks/SKILLS.md | 176 + docs/getting-started/TROUBLESHOOTING.md | 3 + docs/guides/TROUBLESHOOTING.md | 3 + docs/i18n/ar/CHANGELOG.md | 10 +- docs/i18n/az/CHANGELOG.md | 10 +- docs/i18n/bg/CHANGELOG.md | 10 +- docs/i18n/bn/CHANGELOG.md | 10 +- docs/i18n/cs/CHANGELOG.md | 10 +- docs/i18n/da/CHANGELOG.md | 10 +- docs/i18n/de/CHANGELOG.md | 10 +- docs/i18n/es/CHANGELOG.md | 10 +- docs/i18n/fa/CHANGELOG.md | 10 +- docs/i18n/fi/CHANGELOG.md | 10 +- docs/i18n/fr/CHANGELOG.md | 10 +- docs/i18n/gu/CHANGELOG.md | 10 +- docs/i18n/he/CHANGELOG.md | 10 +- docs/i18n/hi/CHANGELOG.md | 10 +- docs/i18n/hu/CHANGELOG.md | 10 +- docs/i18n/id/CHANGELOG.md | 10 +- docs/i18n/in/CHANGELOG.md | 10 +- docs/i18n/it/CHANGELOG.md | 10 +- docs/i18n/ja/CHANGELOG.md | 10 +- docs/i18n/ko/CHANGELOG.md | 10 +- docs/i18n/mr/CHANGELOG.md | 10 +- docs/i18n/ms/CHANGELOG.md | 10 +- docs/i18n/nl/CHANGELOG.md | 10 +- docs/i18n/no/CHANGELOG.md | 10 +- docs/i18n/phi/CHANGELOG.md | 10 +- docs/i18n/pl/CHANGELOG.md | 10 +- docs/i18n/pt-BR/CHANGELOG.md | 10 +- docs/i18n/pt/CHANGELOG.md | 10 +- docs/i18n/ro/CHANGELOG.md | 10 +- docs/i18n/ru/CHANGELOG.md | 10 +- docs/i18n/sk/CHANGELOG.md | 10 +- docs/i18n/sv/CHANGELOG.md | 10 +- docs/i18n/sw/CHANGELOG.md | 10 +- docs/i18n/ta/CHANGELOG.md | 10 +- docs/i18n/te/CHANGELOG.md | 10 +- docs/i18n/th/CHANGELOG.md | 10 +- docs/i18n/tr/CHANGELOG.md | 10 +- docs/i18n/uk-UA/CHANGELOG.md | 10 +- docs/i18n/ur/CHANGELOG.md | 10 +- docs/i18n/vi/CHANGELOG.md | 10 +- docs/i18n/zh-CN/CHANGELOG.md | 10 +- .../self-service-api-key-usage/proposal.md | 65 - .../specs/api-key-self-service-usage/spec.md | 216 - .../self-service-api-key-usage/tasks.md | 72 - docs/ops/FLY_IO_DEPLOYMENT_GUIDE.md | 10 +- docs/ops/PROXY_GUIDE.md | 217 + docs/reference/API_REFERENCE.md | 19 +- docs/reference/ENVIRONMENT.md | 14 +- docs/reference/openapi.yaml | 45 +- docs/security/COMPLIANCE.md | 5 + ...-05-16-adaptive-stream-readiness-design.md | 91 - ...05-29-self-service-api-key-usage-design.md | 409 -- .../plans/2026-05-29-windsurf-login-hotfix.md | 740 --- .../2026-05-29-windsurf-login-fix-design.md | 272 -- electron/package-lock.json | 4 +- electron/package.json | 2 +- eslint.complexity.config.mjs | 8 +- eslint.sonarjs.config.mjs | 61 + file-size-baseline.json | 34 +- knip.json | 74 + open-sse/config/providerModels.ts | 33 +- open-sse/config/providerRegistry.ts | 12 + open-sse/executors/antigravity.ts | 41 +- open-sse/executors/base.ts | 41 +- open-sse/handlers/chatCore.ts | 92 +- open-sse/handlers/sseParser.ts | 15 +- open-sse/package.json | 2 +- open-sse/services/antigravity429Engine.ts | 9 +- .../services/autoCombo/complexityRouter.ts | 111 + open-sse/services/autoCombo/scoring.ts | 51 +- open-sse/services/chatgptTlsClient.ts | 16 +- open-sse/services/claudeCodeConstraints.ts | 18 +- open-sse/services/claudeTlsClient.ts | 16 +- open-sse/services/combo.ts | 127 +- open-sse/services/comboConfig.ts | 4 + open-sse/services/comboMetrics.ts | 8 + open-sse/services/emergencyFallback.ts | 47 +- open-sse/services/grokTlsClient.ts | 16 +- open-sse/services/perplexityTlsClient.ts | 16 +- open-sse/services/tlsClientProxy.ts | 30 + open-sse/transformer/responsesTransformer.ts | 11 +- .../translator/response/openai-responses.ts | 11 +- .../translator/response/openai-to-claude.ts | 12 +- open-sse/utils/cursorAgentProtobuf.ts | 22 + open-sse/utils/proxyDispatcher.ts | 100 +- open-sse/utils/proxyFamily.ts | 24 + open-sse/utils/proxyFamilyResolve.ts | 39 + open-sse/utils/proxyFetch.ts | 20 + open-sse/utils/socksConnectorWithFamily.ts | 68 + open-sse/utils/toolCallArguments.ts | 32 + package-lock.json | 3971 ++++++++++++++++- package.json | 26 +- quality-baseline.json | 25 +- scripts/build/assembleStandalone.mjs | 2 +- scripts/check/check-bundle-size.mjs | 173 + scripts/check/check-circular-deps.mjs | 143 + scripts/check/check-codeql-ratchet.mjs | 307 ++ scripts/check/check-cognitive-complexity.mjs | 95 + scripts/check/check-db-rules.mjs | 32 +- scripts/check/check-dead-code.mjs | 139 + scripts/check/check-deps.mjs | 241 +- scripts/check/check-docs-symbols.mjs | 22 +- scripts/check/check-duplication.mjs | 6 +- scripts/check/check-env-doc-sync.mjs | 3 + scripts/check/check-error-helper.mjs | 53 +- scripts/check/check-fabricated-docs.mjs | 5 + scripts/check/check-fetch-targets.mjs | 263 +- scripts/check/check-file-size.mjs | 11 +- scripts/check/check-known-symbols.ts | 379 +- scripts/check/check-licenses.mjs | 243 + scripts/check/check-lockfile.mjs | 116 + scripts/check/check-migration-numbering.mjs | 34 +- scripts/check/check-openapi-routes.mjs | 24 +- scripts/check/check-pr-evidence.mjs | 249 ++ scripts/check/check-provider-consistency.ts | 20 +- scripts/check/check-public-creds.mjs | 88 +- scripts/check/check-route-guard-membership.ts | 158 +- scripts/check/check-secrets.mjs | 240 + scripts/check/check-test-masking.mjs | 103 +- scripts/check/check-tracked-artifacts.mjs | 94 + scripts/check/check-type-coverage.mjs | 108 + scripts/check/check-vuln-ratchet.mjs | 251 ++ scripts/check/check-workflows.mjs | 304 ++ scripts/check/lib/allowlist.mjs | 57 + scripts/quality/check-quality-ratchet.mjs | 61 +- scripts/quality/collect-metrics.mjs | 220 +- scripts/quality/run-all-gates.mjs | 169 + semcheck.yaml | 160 + sonar-project.properties | 28 +- .../api-manager/ApiManagerPageClient.tsx | 258 +- src/app/(dashboard)/dashboard/logs/page.tsx | 71 +- .../components/StudioConfigPane.tsx | 44 +- .../playground/components/modelSelection.ts | 46 + .../(dashboard)/dashboard/plugins/page.tsx | 209 +- .../settings/components/FeatureFlagCard.tsx | 101 +- .../settings/components/FeatureFlagsGrid.tsx | 253 +- src/app/api/combos/[id]/route.ts | 7 +- src/app/api/combos/route.ts | 10 +- .../api/internal/codex-responses-ws/route.ts | 163 +- src/app/api/keys/[id]/route.ts | 6 +- src/app/api/plugins/marketplace/route.ts | 30 + .../api/quota/plans/[connectionId]/route.ts | 27 +- src/app/api/quota/pools/[id]/usage/route.ts | 10 +- src/app/api/settings/purge-call-logs/route.ts | 11 +- .../settings/purge-request-history/route.ts | 32 + src/app/globals.css | 7 + src/app/layout.tsx | 10 +- src/i18n/messages/en.json | 19 + src/lib/cli-helper/tool-detector.ts | 15 +- src/lib/db/apiKeys.ts | 235 +- src/lib/db/cleanup.ts | 15 +- src/lib/db/migrations/099_proxy_family.sql | 5 + src/lib/db/proxies.ts | 29 +- src/lib/db/settings.ts | 57 +- src/lib/db/upstreamProxy.ts | 17 +- src/lib/plugins/marketplace.ts | 94 +- src/lib/providers/validation.ts | 95 +- src/lib/proxyHealth.ts | 3 +- src/lib/quota/connectionProvider.ts | 32 + src/lib/quota/spendRecorder.ts | 87 + src/lib/semanticCache.ts | 11 +- src/lib/sseTextTransform.ts | 91 +- src/lib/streamingPiiTransform.ts | 99 +- src/lib/tokenHealthCheck.ts | 12 +- src/lib/usage/callLogArtifacts.ts | 28 + src/lib/usage/callLogs.ts | 8 +- src/lib/usage/providerLimits.ts | 8 +- src/server/authz/routeGuard.ts | 2 + src/shared/components/RequestLoggerV2.tsx | 178 +- .../components/requestLoggerPreferences.ts | 44 + .../constants/featureFlagDefinitions.ts | 13 +- src/shared/constants/modelSpecs.ts | 12 + src/shared/services/cliRuntime.ts | 24 +- src/shared/services/loginShellPath.ts | 92 + src/shared/utils/apiKeyPolicy.ts | 76 +- src/shared/validation/schemas.ts | 42 +- src/sse/handlers/chatHelpers.ts | 31 +- stryker.conf.json | 66 + tests/e2e-tool-calls.sh | 142 + tests/e2e/a11y.spec.ts | 241 + tests/integration/combo-failover-e2e.test.ts | 164 + .../combo-provider-exhaustion.test.ts | 10 +- ...ota-pool-usage-provider-resolution.test.ts | 106 + .../antigravity-429-quota-cooldown.test.ts | 98 + tests/unit/api-key-policy.test.ts | 52 +- tests/unit/api-manager-page-static.test.ts | 42 + .../apikeypolicy-disable-non-public.test.ts | 67 +- tests/unit/authz/routeGuard.test.ts | 11 + tests/unit/auto-combo-scoring-clamp.test.ts | 108 + .../base-executor-sanitize-effort.test.ts | 83 +- tests/unit/build/allowlist-helper.test.ts | 41 + tests/unit/build/check-bundle-size.test.ts | 145 + tests/unit/build/check-circular-deps.test.ts | 64 + tests/unit/build/check-codeql-ratchet.test.ts | 284 ++ .../build/check-cognitive-complexity.test.ts | 97 + tests/unit/build/check-dead-code.test.ts | 164 + tests/unit/build/check-licenses.test.ts | 325 ++ tests/unit/build/check-lockfile.test.ts | 181 + tests/unit/build/check-pr-evidence.test.ts | 197 + tests/unit/build/check-secrets.test.ts | 242 + .../build/check-tracked-artifacts.test.ts | 57 + tests/unit/build/check-type-coverage.test.ts | 110 + tests/unit/build/check-vuln-ratchet.test.ts | 295 ++ tests/unit/build/check-workflows.test.ts | 225 + tests/unit/check-db-rules.test.ts | 47 + tests/unit/check-deps.test.ts | 161 +- tests/unit/check-docs-symbols.test.ts | 30 +- tests/unit/check-error-helper.test.ts | 86 +- tests/unit/check-fetch-targets.test.ts | 83 +- tests/unit/check-known-symbols.test.ts | 125 + tests/unit/check-migration-numbering.test.ts | 68 +- tests/unit/check-openapi-routes.test.ts | 20 + tests/unit/check-provider-consistency.test.ts | 22 +- tests/unit/check-public-creds.test.ts | 62 +- .../unit/check-route-guard-membership.test.ts | 71 + tests/unit/check-test-masking.test.ts | 155 +- tests/unit/claude-code-parity.test.ts | 27 + .../claude-passthrough-thinking-2454.test.ts | 62 +- tests/unit/cli-process-supervisor.test.ts | 30 + tests/unit/codex-responses-ws-memory.test.ts | 76 + .../collect-metrics-module-coverage.test.ts | 175 + tests/unit/combo-hedging.test.ts | 71 +- tests/unit/combo-max-depth-config.test.ts | 58 + .../combo-provider-diversity-wiring.test.ts | 69 + .../complexity-aware-scoring-wiring.test.ts | 65 + tests/unit/complexity-router.test.ts | 107 + tests/unit/cursor-agent-protobuf.test.ts | 33 + .../material-icons-self-hosted.test.ts | 58 + .../database-settings-maintenance.test.ts | 72 + tests/unit/db-proxies-family.test.ts | 39 + tests/unit/emergency-fallback-service.test.ts | 51 +- tests/unit/executor-base-utils.test.ts | 4 +- tests/unit/executor-codex.test.ts | 7 +- tests/unit/feature-flags-settings.test.ts | 19 +- tests/unit/login-shell-path-3321.test.ts | 86 + tests/unit/mcp-server-entry.test.ts | 81 + .../unit/model-capabilities-registry.test.ts | 20 + tests/unit/oauth-callback-path-doc.test.ts | 47 + .../playground-model-selection-3731.test.ts | 50 + tests/unit/plugins-marketplace.test.ts | 128 + .../plugins-route-error-sanitization.test.ts | 4 + ...ovider-limits-apikey-proxy-context.test.ts | 39 + tests/unit/provider-models-config.test.ts | 37 +- .../provider-validation-specialty.test.ts | 105 + .../proxy-bypass-scope-guard-3226.test.ts | 47 + tests/unit/proxy-dispatcher-family.test.ts | 55 + tests/unit/proxy-egress-isolation-bdd.test.ts | 55 + tests/unit/proxy-family-migration.test.ts | 17 + tests/unit/proxy-family-resolve.test.ts | 26 + tests/unit/proxy-family.test.ts | 31 + tests/unit/proxy-health-ipv6.test.ts | 30 + tests/unit/quality-ratchet.test.ts | 138 +- .../quota-streaming-consumption-usd.test.ts | 174 + tests/unit/request-logger-endpoints.test.ts | 14 +- tests/unit/request-logger-preferences.test.ts | 66 + tests/unit/resolve-proxy-family.test.ts | 138 + tests/unit/responses-transformer.test.ts | 50 + tests/unit/router-strategies.test.ts | 167 + .../safe-resolve-proxy-fail-closed.test.ts | 14 + .../unit/services/emergency-fallback.test.ts | 117 +- tests/unit/socks-connector-family.test.ts | 15 + tests/unit/sse-parser.test.ts | 43 + tests/unit/sseTextTransform.test.ts | 91 +- tests/unit/streamingPiiTransform.test.ts | 146 +- tests/unit/t06-schema-hardening.test.ts | 16 + .../unit/tls-client-proxy-fail-closed.test.ts | 39 + tests/unit/token-health-check.test.ts | 57 + tests/unit/tool-call-arguments.test.ts | 46 + .../translator-resp-openai-responses.test.ts | 42 + 308 files changed, 22331 insertions(+), 5873 deletions(-) create mode 100644 .github/workflows/quality.yml create mode 100644 .gitleaks.toml create mode 100644 .license-allowlist.json create mode 100644 .size-limit.json create mode 100644 .zizmor.yml delete mode 100644 PLANO-QUALITY-GATES-FASE6A.md delete mode 100644 PLANO-QUALITY-GATES-FASE7.md delete mode 100644 PLANO-QUALITY-GATES.md delete mode 100644 RELATORIO-QUALITY-GATES.md create mode 100644 docs/architecture/QUALITY_GATES.md create mode 100644 docs/bdd/proxy-egress-isolation.feature delete mode 100644 docs/bdd/self-service-api-key-usage.feature create mode 100644 docs/compression/EXTENDING_COMPRESSION.md delete mode 100644 docs/openspec/changes/self-service-api-key-usage/proposal.md delete mode 100644 docs/openspec/changes/self-service-api-key-usage/specs/api-key-self-service-usage/spec.md delete mode 100644 docs/openspec/changes/self-service-api-key-usage/tasks.md delete mode 100644 docs/specs/2026-05-16-adaptive-stream-readiness-design.md delete mode 100644 docs/specs/2026-05-29-self-service-api-key-usage-design.md delete mode 100644 docs/superpowers/plans/2026-05-29-windsurf-login-hotfix.md delete mode 100644 docs/superpowers/specs/2026-05-29-windsurf-login-fix-design.md create mode 100644 eslint.sonarjs.config.mjs create mode 100644 knip.json create mode 100644 open-sse/services/autoCombo/complexityRouter.ts create mode 100644 open-sse/services/tlsClientProxy.ts create mode 100644 open-sse/utils/proxyFamily.ts create mode 100644 open-sse/utils/proxyFamilyResolve.ts create mode 100644 open-sse/utils/socksConnectorWithFamily.ts create mode 100644 open-sse/utils/toolCallArguments.ts create mode 100644 scripts/check/check-bundle-size.mjs create mode 100644 scripts/check/check-circular-deps.mjs create mode 100644 scripts/check/check-codeql-ratchet.mjs create mode 100644 scripts/check/check-cognitive-complexity.mjs create mode 100644 scripts/check/check-dead-code.mjs create mode 100644 scripts/check/check-licenses.mjs create mode 100644 scripts/check/check-lockfile.mjs create mode 100644 scripts/check/check-pr-evidence.mjs create mode 100644 scripts/check/check-secrets.mjs create mode 100644 scripts/check/check-tracked-artifacts.mjs create mode 100644 scripts/check/check-type-coverage.mjs create mode 100644 scripts/check/check-vuln-ratchet.mjs create mode 100644 scripts/check/check-workflows.mjs create mode 100644 scripts/check/lib/allowlist.mjs create mode 100644 scripts/quality/run-all-gates.mjs create mode 100644 semcheck.yaml create mode 100644 src/app/(dashboard)/dashboard/playground/components/modelSelection.ts create mode 100644 src/app/api/plugins/marketplace/route.ts create mode 100644 src/app/api/settings/purge-request-history/route.ts create mode 100644 src/lib/db/migrations/099_proxy_family.sql create mode 100644 src/lib/quota/connectionProvider.ts create mode 100644 src/shared/components/requestLoggerPreferences.ts create mode 100644 src/shared/services/loginShellPath.ts create mode 100644 stryker.conf.json create mode 100755 tests/e2e-tool-calls.sh create mode 100644 tests/e2e/a11y.spec.ts create mode 100644 tests/integration/combo-failover-e2e.test.ts create mode 100644 tests/integration/quota-pool-usage-provider-resolution.test.ts create mode 100644 tests/unit/antigravity-429-quota-cooldown.test.ts create mode 100644 tests/unit/auto-combo-scoring-clamp.test.ts create mode 100644 tests/unit/build/allowlist-helper.test.ts create mode 100644 tests/unit/build/check-bundle-size.test.ts create mode 100644 tests/unit/build/check-circular-deps.test.ts create mode 100644 tests/unit/build/check-codeql-ratchet.test.ts create mode 100644 tests/unit/build/check-cognitive-complexity.test.ts create mode 100644 tests/unit/build/check-dead-code.test.ts create mode 100644 tests/unit/build/check-licenses.test.ts create mode 100644 tests/unit/build/check-lockfile.test.ts create mode 100644 tests/unit/build/check-pr-evidence.test.ts create mode 100644 tests/unit/build/check-secrets.test.ts create mode 100644 tests/unit/build/check-tracked-artifacts.test.ts create mode 100644 tests/unit/build/check-type-coverage.test.ts create mode 100644 tests/unit/build/check-vuln-ratchet.test.ts create mode 100644 tests/unit/build/check-workflows.test.ts create mode 100644 tests/unit/codex-responses-ws-memory.test.ts create mode 100644 tests/unit/collect-metrics-module-coverage.test.ts create mode 100644 tests/unit/combo-max-depth-config.test.ts create mode 100644 tests/unit/combo-provider-diversity-wiring.test.ts create mode 100644 tests/unit/complexity-aware-scoring-wiring.test.ts create mode 100644 tests/unit/complexity-router.test.ts create mode 100644 tests/unit/dashboard/material-icons-self-hosted.test.ts create mode 100644 tests/unit/db-proxies-family.test.ts create mode 100644 tests/unit/login-shell-path-3321.test.ts create mode 100644 tests/unit/mcp-server-entry.test.ts create mode 100644 tests/unit/oauth-callback-path-doc.test.ts create mode 100644 tests/unit/playground-model-selection-3731.test.ts create mode 100644 tests/unit/plugins-marketplace.test.ts create mode 100644 tests/unit/provider-limits-apikey-proxy-context.test.ts create mode 100644 tests/unit/proxy-bypass-scope-guard-3226.test.ts create mode 100644 tests/unit/proxy-dispatcher-family.test.ts create mode 100644 tests/unit/proxy-egress-isolation-bdd.test.ts create mode 100644 tests/unit/proxy-family-migration.test.ts create mode 100644 tests/unit/proxy-family-resolve.test.ts create mode 100644 tests/unit/proxy-family.test.ts create mode 100644 tests/unit/proxy-health-ipv6.test.ts create mode 100644 tests/unit/quota-streaming-consumption-usd.test.ts create mode 100644 tests/unit/request-logger-preferences.test.ts create mode 100644 tests/unit/resolve-proxy-family.test.ts create mode 100644 tests/unit/router-strategies.test.ts create mode 100644 tests/unit/safe-resolve-proxy-fail-closed.test.ts create mode 100644 tests/unit/socks-connector-family.test.ts create mode 100644 tests/unit/tls-client-proxy-fail-closed.test.ts create mode 100644 tests/unit/tool-call-arguments.test.ts diff --git a/.env.example b/.env.example index 5bede16ef5..6375a5a4b8 100644 --- a/.env.example +++ b/.env.example @@ -388,6 +388,13 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true # ALL_PROXY=socks5://127.0.0.1:7890 # NO_PROXY=localhost,127.0.0.1 +# Proxy fail-open mode (default: false = fail-closed). +# When false, a request whose assigned proxy fails to resolve is REFUSED rather than +# falling back to a direct connection — prevents real-IP leaks in egress-controlled +# deployments. Set true to restore the legacy DIRECT fallback (legacy behaviour). +# Used by: src/sse/handlers/chatHelpers.ts +# PROXY_FAIL_OPEN=false + # TLS fingerprint spoofing (opt-in) — mimics Chrome 124 TLS handshake via wreq-js. # Reduces risk of JA3/JA4 fingerprint-based blocking by providers (e.g., Google). # Used by: open-sse/executors — replaces Node.js default TLS fingerprint. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fc0a0a116c..6c2fdb4847 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,6 +54,9 @@ jobs: - run: npm run check:known-symbols - run: npm run check:route-guard-membership - run: npm run check:test-discovery + - run: npm run check:tracked-artifacts + - run: npm run check:lockfile + - run: npm run check:licenses - run: npm run check:docs-sync - run: npm run typecheck:core # typecheck:noimplicit:core is a forward-looking gate (noImplicitAny). @@ -102,6 +105,43 @@ jobs: path: .artifacts/quality-ratchet.md if-no-files-found: warn + # Phase 7 extended quality gates — ADVISORY (continue-on-error). The 5 npm-based + # ratchets (dead-code/cognitive-complexity/type-coverage/circular-deps/bundle-size) + # run for real. The external scans (vuln/secrets/workflows) skip gracefully until the + # owner adds install steps for osv-scanner/gitleaks/actionlint/zizmor; CodeQL ratchet + # works via the runner's gh token. SonarQube needs SONAR_TOKEN/SONAR_HOST_URL secrets. + quality-extended: + name: Quality Gates (Extended, advisory) + runs-on: ubuntu-latest + continue-on-error: true + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: ${{ env.CI_NODE_VERSION }} + cache: npm + - run: npm ci + - name: Dead-code (knip) + run: npm run check:dead-code + - name: Cognitive complexity (sonarjs) + run: npm run check:cognitive-complexity + - name: Type coverage + run: npm run check:type-coverage + - name: Circular deps (dpdm) + run: npm run check:circular-deps + - name: Bundle size + run: npm run check:bundle-size + - name: CodeQL alerts ratchet + run: npm run check:codeql-ratchet + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Secret scan (gitleaks; skips if absent) + run: npm run check:secrets + - name: Vulnerability ratchet (osv-scanner; skips if absent) + run: npm run check:vuln-ratchet + - name: Workflow lint (actionlint+zizmor; skips if absent) + run: npm run check:workflows + docs-sync-strict: name: Docs Sync (Strict) runs-on: ubuntu-latest @@ -198,6 +238,11 @@ jobs: # 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 + # Evidence-in-PR-body (Hard Rule #18 mechanized): claims of "tests pass" must carry output. + - name: Require evidence in PR body + run: npm run check:pr-evidence + env: + PR_BODY: ${{ github.event.pull_request.body }} - name: Publish PR test policy summary if: always() run: | diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml new file mode 100644 index 0000000000..b20942264c --- /dev/null +++ b/.github/workflows/quality.yml @@ -0,0 +1,49 @@ +name: Quality Gates + +on: + pull_request: + branches: ["release/**"] + types: [opened, synchronize, reopened, ready_for_review] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + CI_NODE_VERSION: "24" + +jobs: + fast-gates: + name: Fast Quality Gates + runs-on: ubuntu-latest + # tsx gates (known-symbols, route-guard-membership) import modules that open + # SQLite on load; provide DB env so a fresh CI DB initializes cleanly. + env: + 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 + with: + node-version: ${{ env.CI_NODE_VERSION }} + cache: npm + - run: npm ci + - run: npm run check:provider-consistency + - run: npm run check:fetch-targets + - run: npm run check:openapi-routes + - run: npm run check:docs-symbols + - 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:test-discovery + - run: npm run check:any-budget:t11 diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 0000000000..9e221be280 --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,68 @@ +# .gitleaks.toml — Configuração do gitleaks para OmniRoute +# Task 7.18 — PLANO-QUALITY-GATES-FASE7.md +# +# Estende as regras padrão do gitleaks com allowlists específicas do projeto. +# +# INSTRUÇÕES para allowlists: +# Findings legítimos (fixtures de teste, creds OAuth públicas já cobertas pelo +# check-public-creds.mjs, valores de exemplo em docs) devem ser registrados abaixo +# em [[allowlist]] com um comentário explicativo obrigatório. +# +# NÃO adicione uma entrada de allowlist sem justificativa. Cada entrada é revisada +# a cada release (stale-enforcement). Regra: se o finding é um valor real que o +# sistema usa em produção, é um verdadeiro positivo — não allowliste, corrija. +# +# Referência: docs/security/PUBLIC_CREDS.md (credenciais OAuth públicas conhecidas) +# CLAUDE.md Hard Rule #11 (resolvePublicCred obrigatório) + +# Usar as regras padrão do gitleaks (não declaramos [rules] aqui para herdar tudo) +# Para desabilitar uma regra específica, usar: +# [[rules]] +# id = "rule-id" +# [rules.allowlist] +# description = "..." + +# --------------------------------------------------------------------------- +# Allowlist global do projeto +# Entradas aqui são ignoradas em TODAS as varreduras. +# --------------------------------------------------------------------------- +[allowlist] + description = "OmniRoute project-level allowlist — fixtures, test vectors, public OAuth creds" + + # Paths a ignorar completamente (node_modules, builds, etc.) + paths = [ + '''node_modules''', + '''\.next''', + '''dist''', + '''\.git''', + '''coverage''', + '''\.nyc_output''', + ] + + # Commits específicos a ignorar (ex: commit que introduziu fixtures de teste) + # commits = [] + + # Regexes de stopwords — linhas que contêm estes padrões são ignoradas. + # Usar apenas para falsos positivos comprovados com justificativa abaixo. + # stopwords = [] + + # Regexes de targets (paths de arquivos) que podem ser allowlistados por regra. + # Ver [[allowlist]] por-regra abaixo para granularidade. + +# --------------------------------------------------------------------------- +# Allowlist por-regra (adicionar conforme necessário durante o stale review) +# --------------------------------------------------------------------------- +# +# Exemplo (REMOVER / SUBSTITUIR por entradas reais quando necessário): +# +# [[rules]] +# # Allowlistar fixtures de teste que contêm tokens OAuth de exemplo/inválidos +# # Adicionado: 2026-06-13 | Revisar em: v3.9.0 | Justificativa: valores não-reais de teste +# id = "github-fine-grained-pat" +# [rules.allowlist] +# description = "Test fixture PATs — valores sintéticos, não funcionais" +# paths = [ +# '''tests/fixtures/''', +# '''tests/unit/''', +# ] +# diff --git a/.husky/pre-push b/.husky/pre-push index 0bb7cedf2d..32b08fb504 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -1,8 +1,12 @@ #!/usr/bin/env sh -#if ! command -v npm >/dev/null 2>&1; then -# echo "⚠️ npm not found in PATH — skipping pre-push hooks" -# echo " Run 'npm test' manually before pushing." -# exit 0 -#fi +# .husky/pre-push — fast deterministic gates (<10s total) +# Intentionally excludes test:unit (slow; covered by CI pre-push remote run). +# Activated: 2026-06-13 (6A.12 — replaced commented-out test:unit stub) -#npm run test:unit +if ! command -v npm >/dev/null 2>&1; then + echo "⚠️ npm not found in PATH — skipping pre-push hooks" + echo " Run 'npm run check:any-budget:t11 && npm run check:tracked-artifacts' manually before pushing." + exit 0 +fi + +npm run check:any-budget:t11 && npm run check:tracked-artifacts diff --git a/.license-allowlist.json b/.license-allowlist.json new file mode 100644 index 0000000000..06dbba4aa0 --- /dev/null +++ b/.license-allowlist.json @@ -0,0 +1,85 @@ +{ + "_comment": "SPDX license allowlist for OmniRoute (MIT project). Task 7.20 / PLANO-QUALITY-GATES-FASE7.md.", + "_policy": "Production deps with a license outside 'allowed' and not in 'exceptions' fail the gate. devDeps get an advisory warning only.", + + "allowed": [ + "MIT", + "Apache-2.0", + "BSD-2-Clause", + "BSD-3-Clause", + "ISC", + "0BSD", + "CC0-1.0", + "Unlicense", + "Python-2.0", + "BlueOak-1.0.0", + "Artistic-2.0", + "Zlib", + "X11", + "WTFPL", + "PSF-2.0", + "CC-BY-3.0" + ], + + "allowedExpressions": [ + "MIT*", + "MIT AND ISC", + "Apache-2.0 AND MIT", + "(MIT OR Apache-2.0)", + "(MIT OR CC0-1.0)", + "(MIT OR WTFPL)", + "(BSD-2-Clause OR MIT OR Apache-2.0)", + "(MPL-2.0 OR Apache-2.0)" + ], + + "exceptions": { + "@img/sharp-libvips-linux-x64": { + "license": "LGPL-3.0-or-later", + "justification": "Prebuilt native binary (libvips shared library) pulled in transitively by the 'sharp' package (optional dep of next/transformers). LGPL-3.0 on dynamically-linked native code allows use in non-GPL applications as long as the user can replace the library — satisfied here because this is a pre-built shared-object (.so) binary distributed by the sharp project, not source linked into OmniRoute code. OmniRoute does not modify or statically link libvips.", + "risk": "low", + "reviewAt": "v4.0.0" + }, + "@img/sharp-libvips-linuxmusl-x64": { + "license": "LGPL-3.0-or-later", + "justification": "Same rationale as @img/sharp-libvips-linux-x64 — musl variant of the same prebuilt libvips shared library. Dynamically linked, not modified, not statically bundled.", + "risk": "low", + "reviewAt": "v4.0.0" + }, + "lightningcss": { + "license": "MPL-2.0", + "justification": "MPL-2.0 is a weak, file-level copyleft: only modifications to MPL-licensed files must be released under MPL. OmniRoute does not modify lightningcss source. It is pulled in transitively as a runtime dep of vite (via monaco-editor, a production dep). MPL-2.0 is explicitly compatible with Apache/MIT combinations per the Mozilla FAQ and is routinely used in Node.js production stacks. The OmniRoute codebase remains MIT.", + "risk": "low", + "reviewAt": "v4.0.0" + }, + "lightningcss-linux-x64-gnu": { + "license": "MPL-2.0", + "justification": "Platform-specific native binding for lightningcss. Same MPL-2.0 rationale as lightningcss itself: file-level copyleft, not modified by OmniRoute, compatible with MIT project.", + "risk": "low", + "reviewAt": "v4.0.0" + }, + "lightningcss-linux-x64-musl": { + "license": "MPL-2.0", + "justification": "Musl variant of lightningcss native binding. Same MPL-2.0 rationale.", + "risk": "low", + "reviewAt": "v4.0.0" + }, + "dompurify": { + "license": "(MPL-2.0 OR Apache-2.0)", + "justification": "dompurify is dual-licensed: MPL-2.0 OR Apache-2.0. The Apache-2.0 option is permissive and compatible with MIT. OmniRoute uses it indirectly via monaco-editor and mermaid (UI rendering). The gate expression matcher already accepts (MPL-2.0 OR Apache-2.0) as an allowed expression, but this exception is kept for explicitness and audit trail.", + "risk": "none", + "reviewAt": "v4.0.0" + }, + "caniuse-lite": { + "license": "CC-BY-4.0", + "justification": "CC-BY-4.0 applies to the caniuse browser-support data (a dataset, not code). The Creative Commons Attribution license requires attribution when distributing — OmniRoute does not distribute caniuse-lite data directly to end users; it is consumed by browserslist/PostCSS at build time to generate CSS compatibility info. This is a widely accepted pattern in the Node.js ecosystem (caniuse-lite is in millions of projects). Attribution is satisfied by keeping the package in node_modules with its original license file.", + "risk": "low", + "reviewAt": "v4.0.0" + }, + "tls-client-node": { + "license": "Custom: LICENSE (Apache-2.0 + Commons Clause)", + "justification": "TODO: revisar — tls-client-node uses Apache-2.0 with a 'Commons Clause' addendum that restricts 'Selling' the software (i.e., offering it as a hosted/commercial service whose value derives substantially from tls-client-node). OmniRoute is an open-source proxy; however if deployed as a paid SaaS/hosting service, this restriction could apply. The package is used by grokTlsClient.ts for Grok TLS fingerprinting. RISK: medium — legal review recommended before commercial deployment. Alternatives: consider replacing with a native TLS fingerprinting approach or a truly permissive library.", + "risk": "medium", + "reviewAt": "v3.9.0" + } + } +} diff --git a/.size-limit.json b/.size-limit.json new file mode 100644 index 0000000000..740459f8b3 --- /dev/null +++ b/.size-limit.json @@ -0,0 +1,22 @@ +[ + { + "name": "CLI entry (omniroute.mjs)", + "path": "bin/omniroute.mjs", + "limit": "15 KB" + }, + { + "name": "MCP server entry (mcp-server.mjs)", + "path": "bin/mcp-server.mjs", + "limit": "5 KB" + }, + { + "name": "Node runtime support (nodeRuntimeSupport.mjs)", + "path": "bin/nodeRuntimeSupport.mjs", + "limit": "8 KB" + }, + { + "name": "Reset password entry (reset-password.mjs)", + "path": "bin/reset-password.mjs", + "limit": "6 KB" + } +] diff --git a/.source/source.config.mjs b/.source/source.config.mjs index 8405202c55..1b0644fe1e 100644 --- a/.source/source.config.mjs +++ b/.source/source.config.mjs @@ -4,7 +4,6 @@ var docs = defineDocs({ dir: "docs", docs: { files: [ - "./getting-started/**/*.md", "./architecture/**/*.md", "./guides/**/*.md", "./reference/**/*.md", diff --git a/.zizmor.yml b/.zizmor.yml new file mode 100644 index 0000000000..83eefa7d85 --- /dev/null +++ b/.zizmor.yml @@ -0,0 +1,51 @@ +# .zizmor.yml — zizmor security audit configuration +# https://github.com/woodruffw/zizmor +# +# zizmor audits GitHub Actions workflows for security anti-patterns: +# • Unpinned third-party actions (supply-chain risk) +# • Script injection via ${{ github.* }} in run: steps +# • pull_request_target misuse (allows untrusted code to reach secrets) +# • Excessive permissions / overly broad GITHUB_TOKEN scopes +# • Cache poisoning via actions/cache with unguarded keys +# • … 20+ additional audits +# +# MOTIVATION (2026-03 incident): the trivy-action/LiteLLM supply-chain attack +# exploited exactly the kind of pull_request_target misconfiguration that zizmor +# detects statically. OmniRoute's npm/Docker/Electron publish workflows are high- +# value targets that warrant proactive audit. +# +# BASELINE STRATEGY: this file starts with an EMPTY ignores list. As zizmor is +# first run against the existing workflows, findings will be reviewed: +# • True positives → fix the workflow (preferred) +# • Accepted risk → add an entry below with a justification comment +# This enforces the same stale-enforcement discipline as other allowlists in +# this project: every ignore requires human sign-off and is subject to review +# at each release cycle. +# +# RATCHET: zizmorFindings metric in quality-baseline.json (direction: down) +# starts advisory; current baseline is frozen at first green run; new findings +# must be remediated or explicitly ignored here. + +# ── Global settings ────────────────────────────────────────────────────────── +# Uncomment to pin to a specific minimum severity level. +# min-severity: low # low | medium | high | critical (default: low = all) + +# ── Per-finding ignores ────────────────────────────────────────────────────── +# Format: +# ignores: +# - id: # e.g. "unpinned-uses", "script-injection" +# reason: "" +# # Optional: scope to a specific workflow or step +# # workflow: .github/workflows/ci.yml +# +# Example (do not uncomment without real justification): +# +# ignores: +# - id: unpinned-uses +# reason: > +# actions/checkout@v6 is pinned at the major-version tag intentionally: +# GitHub-managed first-party action; SHA pinning buys little against a +# GitHub-side compromise and adds significant maintenance burden. +# workflow: .github/workflows/ci.yml + +ignores: [] diff --git a/@omniroute/opencode-plugin/package-lock.json b/@omniroute/opencode-plugin/package-lock.json index 5a30932d62..a21cdf7c0d 100644 --- a/@omniroute/opencode-plugin/package-lock.json +++ b/@omniroute/opencode-plugin/package-lock.json @@ -26,9 +26,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", - "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", "cpu": [ "ppc64" ], @@ -43,9 +43,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", - "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", "cpu": [ "arm" ], @@ -60,9 +60,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", - "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", "cpu": [ "arm64" ], @@ -77,9 +77,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", - "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", "cpu": [ "x64" ], @@ -94,9 +94,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", - "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", "cpu": [ "arm64" ], @@ -111,9 +111,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", - "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", "cpu": [ "x64" ], @@ -128,9 +128,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", - "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", "cpu": [ "arm64" ], @@ -145,9 +145,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", - "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", "cpu": [ "x64" ], @@ -162,9 +162,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", - "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", "cpu": [ "arm" ], @@ -179,9 +179,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", - "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", "cpu": [ "arm64" ], @@ -196,9 +196,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", - "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", "cpu": [ "ia32" ], @@ -213,9 +213,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", - "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", "cpu": [ "loong64" ], @@ -230,9 +230,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", - "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", "cpu": [ "mips64el" ], @@ -247,9 +247,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", - "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", "cpu": [ "ppc64" ], @@ -264,9 +264,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", - "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", "cpu": [ "riscv64" ], @@ -281,9 +281,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", - "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", "cpu": [ "s390x" ], @@ -298,9 +298,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", - "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", "cpu": [ "x64" ], @@ -315,9 +315,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", - "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", "cpu": [ "arm64" ], @@ -332,9 +332,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", - "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", "cpu": [ "x64" ], @@ -349,9 +349,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", - "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", "cpu": [ "arm64" ], @@ -366,9 +366,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", - "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", "cpu": [ "x64" ], @@ -383,9 +383,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", - "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", "cpu": [ "arm64" ], @@ -400,9 +400,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", - "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", "cpu": [ "x64" ], @@ -417,9 +417,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", - "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", "cpu": [ "arm64" ], @@ -434,9 +434,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", - "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", "cpu": [ "ia32" ], @@ -451,9 +451,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", - "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", "cpu": [ "x64" ], @@ -1165,9 +1165,9 @@ } }, "node_modules/esbuild": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", - "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -1178,32 +1178,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.7", - "@esbuild/android-arm": "0.27.7", - "@esbuild/android-arm64": "0.27.7", - "@esbuild/android-x64": "0.27.7", - "@esbuild/darwin-arm64": "0.27.7", - "@esbuild/darwin-x64": "0.27.7", - "@esbuild/freebsd-arm64": "0.27.7", - "@esbuild/freebsd-x64": "0.27.7", - "@esbuild/linux-arm": "0.27.7", - "@esbuild/linux-arm64": "0.27.7", - "@esbuild/linux-ia32": "0.27.7", - "@esbuild/linux-loong64": "0.27.7", - "@esbuild/linux-mips64el": "0.27.7", - "@esbuild/linux-ppc64": "0.27.7", - "@esbuild/linux-riscv64": "0.27.7", - "@esbuild/linux-s390x": "0.27.7", - "@esbuild/linux-x64": "0.27.7", - "@esbuild/netbsd-arm64": "0.27.7", - "@esbuild/netbsd-x64": "0.27.7", - "@esbuild/openbsd-arm64": "0.27.7", - "@esbuild/openbsd-x64": "0.27.7", - "@esbuild/openharmony-arm64": "0.27.7", - "@esbuild/sunos-x64": "0.27.7", - "@esbuild/win32-arm64": "0.27.7", - "@esbuild/win32-ia32": "0.27.7", - "@esbuild/win32-x64": "0.27.7" + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" } }, "node_modules/fast-check": { @@ -1843,490 +1843,6 @@ "fsevents": "~2.3.3" } }, - "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", - "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/android-arm": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", - "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/android-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", - "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/android-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", - "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", - "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/darwin-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", - "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", - "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", - "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-arm": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", - "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", - "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-ia32": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", - "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-loong64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", - "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", - "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", - "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", - "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-s390x": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", - "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", - "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", - "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", - "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", - "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", - "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", - "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/sunos-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", - "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/win32-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", - "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/win32-ia32": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", - "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/win32-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", - "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/esbuild": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", - "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.0", - "@esbuild/android-arm": "0.28.0", - "@esbuild/android-arm64": "0.28.0", - "@esbuild/android-x64": "0.28.0", - "@esbuild/darwin-arm64": "0.28.0", - "@esbuild/darwin-x64": "0.28.0", - "@esbuild/freebsd-arm64": "0.28.0", - "@esbuild/freebsd-x64": "0.28.0", - "@esbuild/linux-arm": "0.28.0", - "@esbuild/linux-arm64": "0.28.0", - "@esbuild/linux-ia32": "0.28.0", - "@esbuild/linux-loong64": "0.28.0", - "@esbuild/linux-mips64el": "0.28.0", - "@esbuild/linux-ppc64": "0.28.0", - "@esbuild/linux-riscv64": "0.28.0", - "@esbuild/linux-s390x": "0.28.0", - "@esbuild/linux-x64": "0.28.0", - "@esbuild/netbsd-arm64": "0.28.0", - "@esbuild/netbsd-x64": "0.28.0", - "@esbuild/openbsd-arm64": "0.28.0", - "@esbuild/openbsd-x64": "0.28.0", - "@esbuild/openharmony-arm64": "0.28.0", - "@esbuild/sunos-x64": "0.28.0", - "@esbuild/win32-arm64": "0.28.0", - "@esbuild/win32-ia32": "0.28.0", - "@esbuild/win32-x64": "0.28.0" - } - }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", diff --git a/@omniroute/opencode-plugin/package.json b/@omniroute/opencode-plugin/package.json index c36a77f011..ee6bc62363 100644 --- a/@omniroute/opencode-plugin/package.json +++ b/@omniroute/opencode-plugin/package.json @@ -69,5 +69,8 @@ "tsup": "^8.5.1", "tsx": "^4.22.3", "typescript": "^5.9.3" + }, + "overrides": { + "esbuild": "^0.28.1" } } diff --git a/@omniroute/opencode-provider/package-lock.json b/@omniroute/opencode-provider/package-lock.json index 8ff67447a2..2bcce7e1f3 100644 --- a/@omniroute/opencode-provider/package-lock.json +++ b/@omniroute/opencode-provider/package-lock.json @@ -19,9 +19,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", - "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", "cpu": [ "ppc64" ], @@ -36,9 +36,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", - "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", "cpu": [ "arm" ], @@ -53,9 +53,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", - "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", "cpu": [ "arm64" ], @@ -70,9 +70,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", - "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", "cpu": [ "x64" ], @@ -87,9 +87,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", - "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", "cpu": [ "arm64" ], @@ -104,9 +104,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", - "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", "cpu": [ "x64" ], @@ -121,9 +121,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", - "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", "cpu": [ "arm64" ], @@ -138,9 +138,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", - "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", "cpu": [ "x64" ], @@ -155,9 +155,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", - "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", "cpu": [ "arm" ], @@ -172,9 +172,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", - "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", "cpu": [ "arm64" ], @@ -189,9 +189,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", - "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", "cpu": [ "ia32" ], @@ -206,9 +206,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", - "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", "cpu": [ "loong64" ], @@ -223,9 +223,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", - "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", "cpu": [ "mips64el" ], @@ -240,9 +240,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", - "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", "cpu": [ "ppc64" ], @@ -257,9 +257,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", - "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", "cpu": [ "riscv64" ], @@ -274,9 +274,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", - "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", "cpu": [ "s390x" ], @@ -291,9 +291,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", - "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", "cpu": [ "x64" ], @@ -308,9 +308,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", - "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", "cpu": [ "arm64" ], @@ -325,9 +325,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", - "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", "cpu": [ "x64" ], @@ -342,9 +342,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", - "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", "cpu": [ "arm64" ], @@ -359,9 +359,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", - "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", "cpu": [ "x64" ], @@ -376,9 +376,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", - "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", "cpu": [ "arm64" ], @@ -393,9 +393,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", - "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", "cpu": [ "x64" ], @@ -410,9 +410,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", - "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", "cpu": [ "arm64" ], @@ -427,9 +427,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", - "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", "cpu": [ "ia32" ], @@ -444,9 +444,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", - "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", "cpu": [ "x64" ], @@ -974,9 +974,9 @@ } }, "node_modules/esbuild": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", - "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -987,32 +987,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.7", - "@esbuild/android-arm": "0.27.7", - "@esbuild/android-arm64": "0.27.7", - "@esbuild/android-x64": "0.27.7", - "@esbuild/darwin-arm64": "0.27.7", - "@esbuild/darwin-x64": "0.27.7", - "@esbuild/freebsd-arm64": "0.27.7", - "@esbuild/freebsd-x64": "0.27.7", - "@esbuild/linux-arm": "0.27.7", - "@esbuild/linux-arm64": "0.27.7", - "@esbuild/linux-ia32": "0.27.7", - "@esbuild/linux-loong64": "0.27.7", - "@esbuild/linux-mips64el": "0.27.7", - "@esbuild/linux-ppc64": "0.27.7", - "@esbuild/linux-riscv64": "0.27.7", - "@esbuild/linux-s390x": "0.27.7", - "@esbuild/linux-x64": "0.27.7", - "@esbuild/netbsd-arm64": "0.27.7", - "@esbuild/netbsd-x64": "0.27.7", - "@esbuild/openbsd-arm64": "0.27.7", - "@esbuild/openbsd-x64": "0.27.7", - "@esbuild/openharmony-arm64": "0.27.7", - "@esbuild/sunos-x64": "0.27.7", - "@esbuild/win32-arm64": "0.27.7", - "@esbuild/win32-ia32": "0.27.7", - "@esbuild/win32-x64": "0.27.7" + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" } }, "node_modules/fdir": { @@ -1482,490 +1482,6 @@ "fsevents": "~2.3.3" } }, - "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", - "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/android-arm": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", - "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/android-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", - "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/android-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", - "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", - "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/darwin-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", - "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", - "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", - "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-arm": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", - "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", - "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-ia32": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", - "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-loong64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", - "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", - "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", - "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", - "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-s390x": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", - "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", - "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", - "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", - "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", - "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", - "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", - "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/sunos-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", - "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/win32-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", - "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/win32-ia32": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", - "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/win32-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", - "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/esbuild": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", - "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.0", - "@esbuild/android-arm": "0.28.0", - "@esbuild/android-arm64": "0.28.0", - "@esbuild/android-x64": "0.28.0", - "@esbuild/darwin-arm64": "0.28.0", - "@esbuild/darwin-x64": "0.28.0", - "@esbuild/freebsd-arm64": "0.28.0", - "@esbuild/freebsd-x64": "0.28.0", - "@esbuild/linux-arm": "0.28.0", - "@esbuild/linux-arm64": "0.28.0", - "@esbuild/linux-ia32": "0.28.0", - "@esbuild/linux-loong64": "0.28.0", - "@esbuild/linux-mips64el": "0.28.0", - "@esbuild/linux-ppc64": "0.28.0", - "@esbuild/linux-riscv64": "0.28.0", - "@esbuild/linux-s390x": "0.28.0", - "@esbuild/linux-x64": "0.28.0", - "@esbuild/netbsd-arm64": "0.28.0", - "@esbuild/netbsd-x64": "0.28.0", - "@esbuild/openbsd-arm64": "0.28.0", - "@esbuild/openbsd-x64": "0.28.0", - "@esbuild/openharmony-arm64": "0.28.0", - "@esbuild/sunos-x64": "0.28.0", - "@esbuild/win32-arm64": "0.28.0", - "@esbuild/win32-ia32": "0.28.0", - "@esbuild/win32-x64": "0.28.0" - } - }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", diff --git a/@omniroute/opencode-provider/package.json b/@omniroute/opencode-provider/package.json index 572643a6d5..f36598523f 100644 --- a/@omniroute/opencode-provider/package.json +++ b/@omniroute/opencode-provider/package.json @@ -56,5 +56,8 @@ "tsup": "^8.5.1", "tsx": "^4.22.3", "typescript": "^5.9.3" + }, + "overrides": { + "esbuild": "^0.28.1" } } diff --git a/AGENTS.md b/AGENTS.md index 5707caf104..9dec913a17 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,14 +3,14 @@ ## Project Unified AI proxy/router — route any LLM through one endpoint. Multi-provider support -with **229 provider entries** (OpenAI, Anthropic, Gemini, DeepSeek, Groq, xAI, Mistral, Fireworks, +with **232 provider entries** (OpenAI, Anthropic, Gemini, DeepSeek, Groq, xAI, Mistral, Fireworks, Cohere, NVIDIA, Cerebras, Pollinations, Puter, Cloudflare AI, HuggingFace, DeepInfra, SambaNova, Meta Llama API, Moonshot AI, AI21 Labs, Databricks, Snowflake, and many more) -with **MCP Server** (69 tools), **A2A v0.3 Protocol**, and **Electron desktop app**. +with **MCP Server** (87 tools), **A2A v0.3 Protocol**, and **Electron desktop app**. -> **Live counts (v3.8.16)**: providers 229 · MCP tools 69 · MCP scopes 13 · A2A skills 6 · -> open-sse services 111 · routing strategies 15 · auto-combo scoring factors 12 · -> DB modules 76 · DB migrations 94 · base tables 17 · search providers 12 · +> **Live counts (v3.8.24)**: providers 232 · MCP tools 87 · MCP scopes 30 · A2A skills 6 · +> open-sse services 115 · routing strategies 15 · auto-combo scoring factors 12 · +> DB modules 83 · DB migrations 97 · base tables 17 · search providers 11 · > i18n locales 42. **Refresh with `npm run check:docs-all`.** ## Doc Accuracy Discipline (read before writing any doc) @@ -44,8 +44,8 @@ codebase. Run it locally before pushing docs; it runs in CI via `npm run check:d ## Stack -- **Runtime**: Next.js 16 (App Router), Node.js `>=20.20.2 <21`, `>=22.22.2 <23`, or `>=24.0.0 <25`, ES Modules (`"type": "module"`) -- **Language**: TypeScript 5.9 (`src/`) + JavaScript (`open-sse/`, `electron/`) +- **Runtime**: Next.js 16 (App Router), Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) +- **Language**: TypeScript 6.0 (`src/`) + JavaScript (`open-sse/`, `electron/`) - **Database**: better-sqlite3 (SQLite) — `DATA_DIR` configurable, default `~/.omniroute/` - **Streaming**: SSE via `open-sse` internal workspace package - **Styling**: Tailwind CSS v4 @@ -178,7 +178,7 @@ Always run `prettier --write` on changed files. ### Data Layer (`src/lib/db/`) -All persistence uses SQLite through **76 domain-specific modules** in `src/lib/db/`. Top modules: +All persistence uses SQLite through **83 domain-specific modules** in `src/lib/db/`. Top modules: - Core: `core.ts`, `migrationRunner.ts`, `encryption.ts`, `stateReset.ts` - Providers / catalog: `providers.ts`, `models.ts`, `providerLimits.ts`, `compressionAnalytics.ts` @@ -188,8 +188,8 @@ All persistence uses SQLite through **76 domain-specific modules** in `src/lib/d - Storage: `backup.ts`, `cleanup.ts`, `jsonMigration.ts`, `healthCheck.ts`, `databaseSettings.ts` - Extension modules: `evals.ts`, `webhooks.ts`, `reasoningCache.ts`, `readCache.ts`, `tierConfig.ts`, `compressionCombos.ts`, `compressionScheduler.ts`, `batches.ts`, `files.ts`, `syncTokens.ts`, `proxies.ts`, `oneproxy.ts`, `upstreamProxy.ts`, `versionManager.ts`, `cliToolState.ts`, `prompts.ts`, `detailedLogs.ts`, `contextHandoffs.ts`, `compression.ts`, `stats.ts` -Live count: `ls src/lib/db/*.ts | wc -l` (currently 76). Drift detection: `npm run check:docs-counts`. -Schema migrations live in `db/migrations/` (**94 files** as of v3.8.16) and run via `migrationRunner.ts`. +Live count: `ls src/lib/db/*.ts | wc -l` (currently 83). Drift detection: `npm run check:docs-counts`. +Schema migrations live in `db/migrations/` (**97 files** as of v3.8.24) and run via `migrationRunner.ts`. `src/lib/localDb.ts` is a **re-export layer only** — never add logic there. #### DB Internals @@ -198,7 +198,7 @@ Schema migrations live in `db/migrations/` (**94 files** as of v3.8.16) and run journaling. `SCHEMA_SQL` defines **17 base tables** (verify with `grep -c "CREATE TABLE" src/lib/db/core.ts` minus 1 for the bookkeeping `_omniroute_migrations` table). Helpers: `rowToCamel`, `encryptConnectionFields`. - **`migrationRunner.ts`**: Applies versioned SQL files from `db/migrations/` inside transactions. Tracks applied migrations in `_omniroute_migrations` table. -- **Migrations**: 94 files (`001_initial_schema.sql` → `094_*.sql`). +- **Migrations**: 97 files (`001_initial_schema.sql` → `099_*.sql`). Each migration is idempotent and runs in a transaction. Live count: `ls src/lib/db/migrations/*.sql | wc -l`. - **Domain modules** import `getDbInstance()` from `core.ts` for all CRUD operations. Each module owns a specific table/set of tables (e.g., `providers.ts` → `provider_connections`, @@ -336,7 +336,7 @@ Includes request/response translators with helpers for image handling. ### Services (`open-sse/services/`) -111 service modules in `open-sse/services/` (top-level only; 171 including sub-dirs like `autoCombo/` and `compression/`). Refresh: `ls open-sse/services/*.ts | wc -l`. Key modules: +115 service modules in `open-sse/services/` (top-level only; 184 including sub-dirs like `autoCombo/` and `compression/`). Refresh: `ls open-sse/services/*.ts | wc -l`. Key modules: `combo.ts` (routing engine), `usage.ts`, `tokenRefresh.ts`, `rateLimitManager.ts`, `accountFallback.ts`, `sessionManager.ts`, `wildcardRouter.ts`, `autoCombo/`, `intentClassifier.ts`, `taskAwareRouter.ts`, `thinkingBudget.ts`, @@ -391,7 +391,7 @@ Policy engine modules: `policyEngine.ts`, `comboResolver.ts`, `costRules.ts`, ### MCP Server (`open-sse/mcp-server/`) -69 tools across 10 tool files (advancedTools: 5, agentSkillTools: 3, compressionTools: 5, gamificationTools: 8, memoryTools: 3, notionTools: 6, obsidianTools: 22, pluginTools: 8, poolTools: 5, skillTools: 4), 3 transports (stdio / SSE / Streamable HTTP). Scoped auth (13 scopes — see `OMNIROUTE_MCP_SCOPES` in `open-sse/mcp-server/README.md:51`), Zod schemas. See [`docs/frameworks/MCP-SERVER.md`](docs/frameworks/MCP-SERVER.md). +**87 tools** total (`TOTAL_MCP_TOOL_COUNT`, `open-sse/mcp-server/server.ts`): a 33-entry base registry (`MCP_TOOLS` in `schemas/tools.ts`, bundling the core / cache / compression / 1proxy / advanced tools) **plus** standalone module sets — memory (3), skill (4), agentSkill (3), gamification (8), plugin (8), notion (6), obsidian (22). 3 transports (stdio / SSE / Streamable HTTP). Scoped auth (30 scopes — see `OMNIROUTE_MCP_SCOPES`), Zod schemas. See [`docs/frameworks/MCP-SERVER.md`](docs/frameworks/MCP-SERVER.md). **Core tools** (20): get_health, list_combos, get_combo_metrics, switch_combo, check_quota, route_request, cost_report, list_models_catalog, web_search, simulate_route, set_budget_guard, @@ -409,6 +409,14 @@ list_compression_combos, compression_combo_stats. **Skill tools** (4): skills_list, skills_enable, skills_execute, skills_executions. +**Agent-skill tools** (3): A2A skill discovery / invocation bridges. + +**Gamification tools** (8): levels, badges, leaderboard, and community-federation queries. + +**Plugin tools** (8): plugin marketplace listing, install/enable/disable, and runtime inspection. + +**Notion tools** (6) + **Obsidian tools** (22): knowledge-base read/write integrations (the largest tool family — vault search, note CRUD, WebDAV-backed file ops). + #### MCP Internals - **Tool registration**: Each tool is an object with `{ name, description, inputSchema: ZodSchema, @@ -417,7 +425,7 @@ handler: async (args) => {...} }`. Zod validates inputs before the handler fires `createMcpServer()` wires all tool sets; `startMcpStdio()` launches the stdio transport. - **Transports**: stdio (CLI `omniroute --mcp`), SSE (`/api/mcp/sse`), Streamable HTTP (`/api/mcp/stream`). All share the same tool/scope engine. -- **Scopes** (13): Control which tool categories an API key can access. Enforcement happens +- **Scopes** (30): Control which tool categories an API key can access. Enforcement happens before handler dispatch. - **Audit**: Every tool invocation is logged to SQLite (`mcp_audit` table) with tool name, args, success/failure, API key attribution, and timestamp. @@ -519,7 +527,6 @@ Cloudflare Quick/Named, ngrok, Tailscale Funnel. See [`docs/ops/TUNNELS_GUIDE.md ## Subdirectory AGENTS.md Files -- **[`open-sse/AGENTS.md`](open-sse/AGENTS.md)** — Streaming engine, request pipeline, handlers, and executors - **[`src/lib/db/AGENTS.md`](src/lib/db/AGENTS.md)** — SQLite persistence, domain modules, migrations - **[`open-sse/services/AGENTS.md`](open-sse/services/AGENTS.md)** — Routing engine, combo resolution, strategy selection @@ -527,31 +534,32 @@ Cloudflare Quick/Named, ngrok, Tailscale Funnel. See [`docs/ops/TUNNELS_GUIDE.md For any non-trivial change, read the matching deep-dive first: -| Area | Doc | -| ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | -| Repo navigation | [`docs/architecture/REPOSITORY_MAP.md`](docs/architecture/REPOSITORY_MAP.md) | -| Architecture | [`docs/architecture/ARCHITECTURE.md`](docs/architecture/ARCHITECTURE.md) | -| Engineering reference | [`docs/architecture/CODEBASE_DOCUMENTATION.md`](docs/architecture/CODEBASE_DOCUMENTATION.md) | -| Auto-Combo (12-factor, 15 strategies) | [`docs/routing/AUTO-COMBO.md`](docs/routing/AUTO-COMBO.md) | -| Resilience (3 layers) | [`docs/architecture/RESILIENCE_GUIDE.md`](docs/architecture/RESILIENCE_GUIDE.md) | -| Skills | [`docs/frameworks/SKILLS.md`](docs/frameworks/SKILLS.md) | -| Memory | [`docs/frameworks/MEMORY.md`](docs/frameworks/MEMORY.md) | -| Cloud agents | [`docs/frameworks/CLOUD_AGENT.md`](docs/frameworks/CLOUD_AGENT.md) | -| Guardrails | [`docs/security/GUARDRAILS.md`](docs/security/GUARDRAILS.md) | -| Evals | [`docs/frameworks/EVALS.md`](docs/frameworks/EVALS.md) | -| Compliance | [`docs/security/COMPLIANCE.md`](docs/security/COMPLIANCE.md) | -| Webhooks | [`docs/frameworks/WEBHOOKS.md`](docs/frameworks/WEBHOOKS.md) | -| Authz | [`docs/architecture/AUTHZ_GUIDE.md`](docs/architecture/AUTHZ_GUIDE.md) | -| Stealth | [`docs/security/STEALTH_GUIDE.md`](docs/security/STEALTH_GUIDE.md) | -| Reasoning replay | [`docs/routing/REASONING_REPLAY.md`](docs/routing/REASONING_REPLAY.md) | -| Agent protocols (A2A / ACP / Cloud) | [`docs/frameworks/AGENT_PROTOCOLS_GUIDE.md`](docs/frameworks/AGENT_PROTOCOLS_GUIDE.md) | -| MCP server | [`docs/frameworks/MCP-SERVER.md`](docs/frameworks/MCP-SERVER.md) | -| A2A server | [`docs/frameworks/A2A-SERVER.md`](docs/frameworks/A2A-SERVER.md) | -| API reference | [`docs/reference/API_REFERENCE.md`](docs/reference/API_REFERENCE.md) + [`docs/reference/openapi.yaml`](docs/reference/openapi.yaml) | -| Provider catalog (auto-generated) | [`docs/reference/PROVIDER_REFERENCE.md`](docs/reference/PROVIDER_REFERENCE.md) | -| Tunnels | [`docs/ops/TUNNELS_GUIDE.md`](docs/ops/TUNNELS_GUIDE.md) | -| Electron desktop | [`docs/guides/ELECTRON_GUIDE.md`](docs/guides/ELECTRON_GUIDE.md) | -| Release flow | [`docs/ops/RELEASE_CHECKLIST.md`](docs/ops/RELEASE_CHECKLIST.md) | +| Area | Doc | +| ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- | +| Repo navigation | [`docs/architecture/REPOSITORY_MAP.md`](docs/architecture/REPOSITORY_MAP.md) | +| Architecture | [`docs/architecture/ARCHITECTURE.md`](docs/architecture/ARCHITECTURE.md) | +| Engineering reference | [`docs/architecture/CODEBASE_DOCUMENTATION.md`](docs/architecture/CODEBASE_DOCUMENTATION.md) | +| Auto-Combo (12-factor, 15 strategies) | [`docs/routing/AUTO-COMBO.md`](docs/routing/AUTO-COMBO.md) | +| Resilience (3 layers) | [`docs/architecture/RESILIENCE_GUIDE.md`](docs/architecture/RESILIENCE_GUIDE.md) | +| Skills | [`docs/frameworks/SKILLS.md`](docs/frameworks/SKILLS.md) | +| Memory | [`docs/frameworks/MEMORY.md`](docs/frameworks/MEMORY.md) | +| Cloud agents | [`docs/frameworks/CLOUD_AGENT.md`](docs/frameworks/CLOUD_AGENT.md) | +| Guardrails | [`docs/security/GUARDRAILS.md`](docs/security/GUARDRAILS.md) | +| Evals | [`docs/frameworks/EVALS.md`](docs/frameworks/EVALS.md) | +| Compliance | [`docs/security/COMPLIANCE.md`](docs/security/COMPLIANCE.md) | +| Webhooks | [`docs/frameworks/WEBHOOKS.md`](docs/frameworks/WEBHOOKS.md) | +| Authz | [`docs/architecture/AUTHZ_GUIDE.md`](docs/architecture/AUTHZ_GUIDE.md) | +| Stealth | [`docs/security/STEALTH_GUIDE.md`](docs/security/STEALTH_GUIDE.md) | +| Reasoning replay | [`docs/routing/REASONING_REPLAY.md`](docs/routing/REASONING_REPLAY.md) | +| Agent protocols (A2A / ACP / Cloud) | [`docs/frameworks/AGENT_PROTOCOLS_GUIDE.md`](docs/frameworks/AGENT_PROTOCOLS_GUIDE.md) | +| MCP server | [`docs/frameworks/MCP-SERVER.md`](docs/frameworks/MCP-SERVER.md) | +| A2A server | [`docs/frameworks/A2A-SERVER.md`](docs/frameworks/A2A-SERVER.md) | +| API reference | [`docs/reference/API_REFERENCE.md`](docs/reference/API_REFERENCE.md) + [`docs/reference/openapi.yaml`](docs/reference/openapi.yaml) | +| Provider catalog (auto-generated) | [`docs/reference/PROVIDER_REFERENCE.md`](docs/reference/PROVIDER_REFERENCE.md) | +| Tunnels | [`docs/ops/TUNNELS_GUIDE.md`](docs/ops/TUNNELS_GUIDE.md) | +| Electron desktop | [`docs/guides/ELECTRON_GUIDE.md`](docs/guides/ELECTRON_GUIDE.md) | +| Release flow | [`docs/ops/RELEASE_CHECKLIST.md`](docs/ops/RELEASE_CHECKLIST.md) | +| Quality gates (35 gates, allowlist policy) | [`docs/architecture/QUALITY_GATES.md`](docs/architecture/QUALITY_GATES.md) | --- diff --git a/CHANGELOG.md b/CHANGELOG.md index 234d26150c..7aa50bdb16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,63 @@ --- -## [3.8.23] — TBD +## [3.8.24] — 2026-06-13 + +### ✨ New Features + +- **feat(plugins): custom plugin marketplace support** — the plugin registry now fetches from a custom URL set in system settings (`pluginMarketplaceUrl`), falling back to the local seed registry when none is configured. Adds a `GET /api/plugins/marketplace` endpoint and a revamped Marketplace UI. ([#3656](https://github.com/diegosouzapw/OmniRoute/pull/3656) — thanks @oyi77) +- **feat(api-keys): strict-mode controls for the Claude Code default routing path** — `Claude Code default` is now an explicit `cc/*` model permission, so an API key can allow the default path while blocking specific model families (e.g. Fable) in strict mode. Previously the default path received dynamic/unprefixed models (`sonnet`, `opus`, `claude-opus-4-8[1m]`, …) that no single permission represented, so it broke under strict permissions. ([#3776](https://github.com/diegosouzapw/OmniRoute/pull/3776) — thanks @Witroch4) +- **feat(flags): expose the emergency budget fallback in the Feature Flags page** — `OMNIROUTE_EMERGENCY_FALLBACK` is now a runtime boolean (enabled by default, applied without restart) resolved through the feature-flag stack, so a DB override can toggle it while still honoring the raw env fallback. Follow-up to [#3741](https://github.com/diegosouzapw/OmniRoute/pull/3741) by @zoispag. ([#3752](https://github.com/diegosouzapw/OmniRoute/pull/3752) — thanks @rdself) +- **feat(reasoning): preserve `xhigh` reasoning effort by default** — `xhigh` now passes through unless a model explicitly sets `supportsXHighEffort: false`, with the existing `max` normalization kept separate. ([#3756](https://github.com/diegosouzapw/OmniRoute/pull/3756) — thanks @rdself) +- **feat(codex): inject OmniRoute memory into Codex Responses WebSocket requests** — retrieved memory is injected into the Responses WebSocket prepare request via the `instructions` field, with the retrieval query derived from the latest user input (skipping tool/reasoning payloads) and duplicate-safe injection. ([#3749](https://github.com/diegosouzapw/OmniRoute/pull/3749) — thanks @kkkayye) + +### 🔒 Security + +- **security(proxy): IPv6-only egress enforcement + closing IP-leak paths (L1/L2/L3)** — de-brackets IPv6 literals at the SOCKS host and the proxy-health tcpCheck (so `socks5://[2001:db8::1]` and any v6-literal proxy connect instead of dying with `ENOTFOUND`), adds a per-proxy `family` policy (`auto`/`ipv4`/`ipv6`), and enforces it end-to-end across SOCKS5/HTTP/HTTPS × global/provider/key × literal/hostname. 16 commits, TDD+BDD, 73 tests. ([#3777](https://github.com/diegosouzapw/OmniRoute/pull/3777)) +- **security(marketplace): harden the custom-URL SSRF guard** against three bypasses found by automated security review — IPv6/AAAA records (only `dns.resolve4` was checked, so a private AAAA record or an IPv6 literal slipped through), redirect-following (a public URL could 30x to an internal one), and DNS-rebinding TOCTOU. The guard now resolves A+AAAA via the canonical `isPrivateHost`, routes the fetch through `safeOutboundFetch` (public-only, blocks redirects to private hosts), and re-validates on fetch. Reachable only with management auth + a custom `pluginMarketplaceUrl`. Follow-up to [#3656](https://github.com/diegosouzapw/OmniRoute/pull/3656). ([#3774](https://github.com/diegosouzapw/OmniRoute/pull/3774)) +- **security: resolve all open CodeQL + Dependabot alerts** — CodeQL `js/insufficient-password-hash` (the semantic-cache `apiKeyId` is now a plaintext key prefix, `${apiKeyId}.${digest}`, instead of being folded into the SHA-256 digest, clearing the false positive while preserving per-key cache isolation) and a URL-substring check tightened to an exact host match; Dependabot `esbuild < 0.28.1` pinned via override in both workspaces. ([#3778](https://github.com/diegosouzapw/OmniRoute/pull/3778)) + +### 🐛 Fixed + +- **fix(combo): deep audit of the combo + quota-shared routing system** — repairs 5 dead/broken rules (streaming-USD cost recording, quota-pool usage provider resolution, provider-diversity wiring, `maxComboDepth` threading, and scoring clamp/NaN-safety incl. `connectionDensity`) and revives the dead `tierAffinity`/`specificityMatch` scoring factors — root cause was a `require()` that throws under ESM, so both factors silently collapsed to `0.5`; now a static import. Validates every auto-router strategy (cost / latency / sla-aware / lkgp / `selectWithStrategy` + aliases) and the predictive-TTFT decision, adds E2E coverage (3-hop priority failover, per-target timeout failover, real `strategy:auto` dispatch), and introduces opt-in complexity-aware routing (2026) layered over the existing specificity detector. Per-target credential+proxy isolation verified clean (`AsyncLocalStorage`). 4 TDD waves, 10 new/updated test files. ([#3779](https://github.com/diegosouzapw/OmniRoute/pull/3779) — thanks @diegosouzapw) +- fix(anthropic): normalize sampling params under extended thinking — Claude models with extended thinking (e.g. Opus 4.8 via the Claude Code provider) returned **HTTP 400** when a request carried non-default `temperature`/`top_p` (`temperature may only be set to 1 …`, `top_p must be ≥ 0.95 or unset …`). Tools like VS Code Copilot's "Ollama" BYOK send `temperature: 0.7` + `top_p: 0.9`, so every thinking-enabled Claude request failed; the proxy now drops/normalizes these params at the chokepoint so the request succeeds. ([#3780](https://github.com/diegosouzapw/OmniRoute/pull/3780) — thanks @zhiru) +- fix(sse): pass Claude passthrough `thinking` blocks through unchanged — the Anthropic-native Claude OAuth passthrough rewrote every assistant `thinking` block to `redacted_thinking`, which the Messages API rejects (submitted thinking blocks are validated against the original response), so every multi-turn request with extended thinking failed with `400 … thinking blocks … cannot be modified` (very visible on long Claude Code tool-loops). The blocks are now passed through verbatim; the signature is validated server-side and stays valid on replay (including across an OAuth token switch), so the redaction was unnecessary. ([#3775](https://github.com/diegosouzapw/OmniRoute/pull/3775) — thanks @havockdev) +- fix(mcp): resolve the bundled MCP server entry from `dist/` instead of the legacy `app/` path — `omniroute --mcp` crashed on npm installs with `ERR_MODULE_NOT_FOUND: Cannot find package @/lib` because `bin/mcp-server.mjs` looked for the compiled entry under `app/` (a VPS-deploy path that never exists in the npm package) and fell back to the un-bundled `.ts`. ([#3765](https://github.com/diegosouzapw/OmniRoute/pull/3765) — thanks @megamen32) +- fix(sse): preserve streamed tool-call arguments end-to-end — incremental tool-call argument deltas could be truncated/duplicated through SSE parsing, transformation and response translation, corrupting tool calls in CLI tool-use output. Dedup now only collapses unambiguous snapshots. ([#3762](https://github.com/diegosouzapw/OmniRoute/pull/3762), closes [#3701](https://github.com/diegosouzapw/OmniRoute/issues/3701) — thanks @Mffff4) +- fix(dashboard): repair the Logs page light-mode controls — the "Clean history" button keeps readable contrast (while preserving the red destructive affordance), request-row hover uses a cool blue tint so it no longer reads like a failed-request row, and the custom auto-refresh interval persists in `localStorage` (clamped to 1–300s). Also refreshes the Feature Flags light-mode treatment. ([#3760](https://github.com/diegosouzapw/OmniRoute/pull/3760) — thanks @rdself) +- fix(dashboard): make the Request Logs "Clean history" action perform a full request-history purge — clears `call_logs`, legacy `request_detail_logs`, and local JSON artifacts under `DATA_DIR/call_logs` (including orphaned artifact files) via a dedicated maintenance API route, instead of a retention-only cleanup. ([#3751](https://github.com/diegosouzapw/OmniRoute/pull/3751) — thanks @rdself) +- fix(cli): detect CLI tools installed outside the GUI PATH on macOS. macOS GUI/Electron apps don't inherit the user's login-shell PATH, so Homebrew (`/opt/homebrew/bin`), nvm and volta-installed CLIs (Cline, Codex, OpenCode, Continue, Hermes, …) were reported "not installed" and the Cline runtime couldn't be spawned. CLI detection (`omniroute doctor`) and the provider-runtime lookup now enrich the lookup PATH with the login shell's PATH (`$SHELL -ilc`, darwin-only, cached, fail-safe). ([#3321](https://github.com/diegosouzapw/OmniRoute/issues/3321) — thanks @mikmaneggahommie) +- fix(dashboard): repair the Playground model selector for custom OpenAI/Anthropic-compatible providers. Two bugs left it unusable: (1) when the provider's catalog prefix didn't resolve, the list was filtered by the raw connection id (which matches nothing) so the selector showed nothing; and (2) selecting a provider reset the model to empty and nothing ever picked a default, so the chat failed with "Set a model in the config pane". The selector now falls back to the full catalog instead of emptying, and auto-selects the first available model. ([#3731](https://github.com/diegosouzapw/OmniRoute/issues/3731), [#3009](https://github.com/diegosouzapw/OmniRoute/issues/3009) — thanks @tjengbudi) + +- fix(cursor): send a `ModelDetails` envelope (`model_id` + `display_model_id` + `display_name`) in the Cursor agent request, alongside the existing `RequestedModel`. Pinned Cursor Claude/GPT _thinking_ variants (e.g. `cursor/claude-opus-4-7-thinking-xhigh`) were returning an empty turn → `502 Provider returned empty content`, because Cursor needs the `ModelDetails` envelope (which `cursor-agent`'s real wire format sends) to resolve them; `RequestedModel` alone only resolves server-routed ids (`auto`/`composer-*`). The `-fast` parameter path on `RequestedModel` is preserved. ([#3714](https://github.com/diegosouzapw/OmniRoute/issues/3714)) + +- fix(docs): correct the OAuth redirect URI in the Fly.io deployment guide. It told users to register `/api/oauth//callback`, but OmniRoute's browser OAuth flow uses a single `/callback` handler (there is no per-provider callback route). The mismatch caused GitLab Duo (and any OAuth provider) to reject the flow with "The redirect URI included is not valid". Added a regression guard test. ([#3732](https://github.com/diegosouzapw/OmniRoute/issues/3732)) + +- fix(providers): give Ollama Cloud's `kimi-k2.7-code` its real capabilities (262K context, 262K max output, vision + thinking + tools) instead of the degraded `128000 / 8192` defaults. The model had no spec/registry entry, so importing it via "Import from /models" (whose `/v1/models` upstream returns no per-model metadata) left it as a bare custom model with fallback capabilities. Added a global `kimi-k2.7-code` model spec (parity with `kimi-k2.6`) plus a registry entry on `ollama-cloud`. ([#3761](https://github.com/diegosouzapw/OmniRoute/issues/3761) — thanks @SultanKs4) + +- fix(providers): repair qwen-web (chat.qwen.ai) connection validation, which failed with a misleading `provider.validation.ssrf_blocked` error. qwen-web had no specialty validator, so the generic OpenAI-compatible path probed a non-existent `/api/v2/models` URL that answers with a 307 redirect — the outbound guard blocked the redirect and the route mislabeled it as an SSRF security block. Added a `qwen-web` specialty validator that probes the real session endpoint (`GET /api/v2/user`, mirroring the executor's anti-bot headers + cookie-jar replay). Also hardened `toValidationErrorResult` so a blocked redirect is only flagged `securityBlocked` when its target is a private/internal host — a benign 3xx to a public host is no longer mislabeled as an SSRF attempt (this affected every web-cookie provider, not just qwen). ([#3288](https://github.com/diegosouzapw/OmniRoute/issues/3288), [#3758](https://github.com/diegosouzapw/OmniRoute/issues/3758)) + +- fix(oauth): stop nulling the stored `refresh_token` of non-rotating providers when a proactive health-check refresh fails with `invalid_grant`. The destructive `refreshToken: null` write in `tokenHealthCheck` was only meant for rotating one-time-use tokens (Codex/OpenAI), but it also fired for Google-family providers (gemini-cli / antigravity / gemini) whose refresh tokens are non-rotating. Once nulled, the connection reported "No valid refresh token available" and could never recover even after re-activation. The token is now preserved (gated on `isRotatingProvider`) so it stays as the recovery artifact. ([#3679](https://github.com/diegosouzapw/OmniRoute/issues/3679) — thanks @3xa228148) + +- fix(dashboard): self-host the Material Symbols icon font instead of loading it from the Google Fonts CDN. On networks where `fonts.googleapis.com` is unreachable (e.g. mainland China), the icon ligature font never loaded, so every icon rendered as its literal text name (`smart_toy`, `visibility`, …) and the layout broke — especially after importing many models. The font is now bundled locally via the `material-symbols` package (`@import "material-symbols/outlined.css"` in `globals.css`), removing the runtime CDN dependency. ([#3695](https://github.com/diegosouzapw/OmniRoute/issues/3695) — thanks @lqyiwwx) + +- fix(antigravity): skip Google One AI credits retry on `full_quota_exhausted` verdict — antigravity executor now calls `decide429()` before attempting the credits retry so that a quota-exhausted account (24h cooldown) bypasses the extra upstream HTTP call instead of hanging for up to ~41s. Also persists the cooldown in the DB via `setConnectionRateLimitUntil` so post-restart routing skips exhausted connections without re-learning the hard way. Bonus: `antigravity429Engine` now recognises the real Antigravity "Individual quota reached. Contact your administrator to enable overages." error message as `quota_exhausted`. ([#3707](https://github.com/diegosouzapw/OmniRoute/issues/3707) — thanks @andrea-kingautomation) + +- fix(cli): `ServerSupervisor.handleExit` now coerces the exit code to a number before calling `process.exit()` — Node.js v24 throws `TypeError [ERR_INVALID_ARG_TYPE]` when `process.exit()` receives a string (e.g. `'ENOENT'` from a spawn `error` event's `err.code`). The `error` callback also now passes `-1` instead of the raw `err.code`, which is an OS error string rather than a meaningful exit code. ([#3748](https://github.com/diegosouzapw/OmniRoute/issues/3748)) + +### 📝 Maintenance + +- **chore(quality): Quality Gates → 100%** — completes Fase 6A (systemic hardening: a `stale-allowlist` helper applied across ~10 gates, `docs/architecture/QUALITY_GATES.md`, and ratchet engine v2 with `--require-tighten` + per-metric `eps`) and the entire Fase 7 (20 security/dead-code/mutation/tooling gates), with the Fase 8 plan documented. ([#3757](https://github.com/diegosouzapw/OmniRoute/pull/3757)) +- **docs: close the remaining documentation gaps** for proxy operations, skills internals, the memory engine, RTK customization, and compression extensibility (post-#3438 audit, 5 areas in one PR). ([#3453](https://github.com/diegosouzapw/OmniRoute/pull/3453) — thanks @oyi77) +- **chore(quality): re-baseline `providerRegistry.ts` file-size** (4692→4703) after #3768's Ollama Cloud `kimi-k2.7-code` capability fix grew the file past the frozen baseline, turning the release's own Fast Quality Gates red. No source file is touched. ([#3770](https://github.com/diegosouzapw/OmniRoute/pull/3770)) +- **fix(publish): clean the `@omniroute/opencode-plugin` `node_modules` after the tsup build** — the hard links npm creates on Linux ended up in the published tarball as LINK entries, which the npm registry rejects with `E415 "Hard link is not allowed"`. The dependencies are only needed for the build, never shipped. (thanks @diegosouzapw) +- **chore(docs): prune internal planning/spec artifacts and sync the i18n CHANGELOG to 3.8.24.** (thanks @diegosouzapw) +- **test: align two unit tests left stale by this cycle's behavior changes** — `executor-codex` now asserts that GPT 5.4 Mini's `xhigh` reasoning effort passes through unchanged (the intended #3756 default; the model ships an `xhigh` catalog variant and carries no `supportsXHighEffort:false` flag) instead of the old downgrade-to-`high`, and `plugins-route-error-sanitization` now covers the new `/api/plugins/marketplace` route from #3656 (verified compliant with Hard Rule #12: imports + uses `buildErrorBody`). No production behavior change. (thanks @diegosouzapw) +- **docs: refresh root + `docs/` documentation to the current architecture** — a full codebase audit corrected stale counts/facts (MCP 43→87 tools / ~13→30 scopes, DB 45+→83 modules / 55→97 migrations, routing 14→15 strategies, A2A 5→6 skills, Node `>=22.0.0 <23 || >=24.0.0 <27`, TypeScript 6.0) across `CLAUDE.md`, `AGENTS.md`, `README.md`, and the `docs/` index, and documented the plugin marketplace, Notion/Obsidian, and embedded-services subsystems. (thanks @diegosouzapw) + +--- + +## [3.8.23] — 2026-06-12 ### ✨ New Features @@ -38,7 +94,7 @@ - **Kiro: quota tracking for IAM Identity Center accounts** ([#3722](https://github.com/diegosouzapw/OmniRoute/pull/3722) — thanks @artickc): `getKiroUsage` returned "0 used" for IAM Identity Center accounts (and `kiro-cli` imports) because those connections frequently lack a persisted `profileArn`. Now falls back to a name-based profile lookup so quota displays correctly. -- **Empty Claude SSE stream now surfaces a real error** ([#3689](https://github.com/diegosouzapw/OmniRoute/pull/3689) — thanks @TechNickAI): when a Claude stream completed with lifecycle events but no content block, the proxy returned a synthetic `"[Proxy Error] The upstream API returned an empty response"` as a *successful* assistant message. Now emits a proper SSE error event; the missing-finalizer synthetic path is preserved for streams that already produced content. +- **Empty Claude SSE stream now surfaces a real error** ([#3689](https://github.com/diegosouzapw/OmniRoute/pull/3689) — thanks @TechNickAI): when a Claude stream completed with lifecycle events but no content block, the proxy returned a synthetic `"[Proxy Error] The upstream API returned an empty response"` as a _successful_ assistant message. Now emits a proper SSE error event; the missing-finalizer synthetic path is preserved for streams that already produced content. - **Vertex AI Express-mode API keys** ([#3690](https://github.com/diegosouzapw/OmniRoute/pull/3690) — thanks @artickc): the Vertex executor rejected every non-JSON credential with "Vertex AI requires a valid Service Account JSON." Now accepts Express-mode API key strings (`AIza*`) alongside Service Account JSON, routing them through the correct token endpoint. @@ -110,7 +166,7 @@ - **Provider-detail god-component decomposition — Phase 2b (remaining shared helpers→leaf)** ([#3501]): extended `providers/[id]/providerPageHelpers.ts` with all remaining pure helpers needed by the heavy modals (`AddApiKeyModal`/`EditConnectionModal`) before they can be extracted. Moved 22 symbols: web-session credential label/hint/check/title helpers; upstream-headers helpers (`upstreamHeadersRecordsEqual`, `headerRowsToRecord`, `effectiveUpstreamHeadersForProtocol`, `anyUpstreamHeadersBadge`, `getProtoSlice`) plus their `HeaderDraftRow`/`CompatModelRow`/`CompatModelMap`/`CompatByProtocolMap` types; Codex consts and helpers (`CODEX_REASONING_STRENGTH_OPTIONS`, `CODEX_ACCOUNT_SERVICE_TIER_VALUES`, `CODEX_GLOBAL_SERVICE_MODE_VALUES`, `getCodexServiceTierLabel`, `normalizeCodexLimitPolicy`, `getCodexRequestDefaults`, `getClaudeCodeCompatibleRequestDefaults`); misc helpers (`compatProtocolLabelKey`, `extractCommandCodeCredentialInput`, `normalizeAndValidateHttpBaseUrl`, `SILICONFLOW_ENDPOINTS`, `CommandCodeAuthFlowState`). New transitive imports wired into the leaf: `MODEL_COMPAT_PROTOCOL_KEYS` (`@/shared/constants/modelCompat`), `CodexServiceTier`/`getCodexRequestDefaults`/`getClaudeCodeCompatibleRequestDefaults` (`@/lib/providers/requestDefaults`), `CodexGlobalServiceMode` (`@/lib/providers/codexFastTier`), `WebSessionCredentialRequirement` (`./webSessionCredentials`). `ProviderDetailPageClient.tsx`: 10,288 → 9,980 LOC. Leaf module: 589 LOC (acyclic). 25-assertion unit test suite passes; smoke test 3/3; no import cycles. Co-authored with @oyi77. - **Provider-detail god-component decomposition — Phase 2 (helpers→lib)** ([#3501]): extracted the pure shared helpers — `ProviderMessageTranslator`/`LocalProviderMetadata` types, `providerText`/`providerCountText`/`readBooleanToggle`, and the provider base-URL + routing-tag/excluded-model parse/format block — into a new leaf `providers/[id]/providerPageHelpers.ts` (imports only `@/shared`, so the client and modals share them with no import cycle). `ProviderDetailPageClient.tsx`: 10,435 → 10,288 LOC. Unblocks extracting the heavier `AddApiKeyModal`/`EditConnectionModal` (which depend on these helpers) without cycling. The Phase 0 smoke test caught a missing transitive import (`isSelfHostedChatProvider`) at mount — now wired + locked by a new helpers unit test (12 assertions). Co-authored with @oyi77. -- **#3500 fully resolved** — Hard Rule #5 (no raw SQL in route handlers): all 13 internal offenders migrated to `src/lib/db/` modules across slices (call_logs, usage_history/daily_usage_summary, community_servers, usage_logs, semantic_cache, proxy_logs, skills UPDATE, db-backups). The gate's `KNOWN_RAW_SQL` set is renamed to `EXTERNAL_DB_ALLOWED` (with a back-compat alias) and now holds only the **2 external-DB reads** (`oauth/cursor/auto-import`, `oauth/kiro/auto-import`) — these open *another app's* SQLite to import credentials, so by design they cannot live in OmniRoute's `db/` domain. The gate still blocks any NEW raw SQL against OmniRoute's DB. +- **#3500 fully resolved** — Hard Rule #5 (no raw SQL in route handlers): all 13 internal offenders migrated to `src/lib/db/` modules across slices (call*logs, usage_history/daily_usage_summary, community_servers, usage_logs, semantic_cache, proxy_logs, skills UPDATE, db-backups). The gate's `KNOWN_RAW_SQL` set is renamed to `EXTERNAL_DB_ALLOWED` (with a back-compat alias) and now holds only the **2 external-DB reads** (`oauth/cursor/auto-import`, `oauth/kiro/auto-import`) — these open \_another app's* SQLite to import credentials, so by design they cannot live in OmniRoute's `db/` domain. The gate still blocks any NEW raw SQL against OmniRoute's DB. - **#3500 fully resolved** — Hard Rule #5 (no raw SQL in route handlers): all 13 internal offenders migrated to `src/lib/db/` modules across slices (call*logs, usage_history/daily_usage_summary, community_servers, usage_logs, semantic_cache, proxy_logs, skills UPDATE, db-backups). The gate's `KNOWN_RAW_SQL` set is renamed to `EXTERNAL_DB_ALLOWED` (with a back-compat alias) and now holds only the **2 external-DB reads** (`oauth/cursor/auto-import`, `oauth/kiro/auto-import`) — these open \_another app's* SQLite to import credentials, so by design they cannot live in OmniRoute's `db/` domain. The gate still blocks any NEW raw SQL against OmniRoute's DB. - **chore(db-gate):** reclassify `KNOWN_UNEXPORTED` → `INTENTIONALLY_INTERNAL` in `scripts/check/check-db-rules.mjs` ([#3499]): a full audit of all 25 db modules confirmed each is consumed via direct/dynamic import per Hard Rule #2 ("Never barrel-import from localDb.ts"). The old framing labelled them as "debt", which was misleading — they are the correct pattern. The gate's blocking behaviour is unchanged (a NEW unexported module still fails); only the name, comments, and per-module justifications were updated to reflect audited truth. Four modules flagged `DEAD?` (`compressionScheduler`, `discovery`, `pluginMetrics`, `prompts`) have zero production importers and are documented as schema-reserved. A new regression-guard test (`tests/unit/check-db-rules-classification.test.ts`) asserts every non-dead module in the set has ≥1 real importer, so a future consumer removal surfaces as a test failure requiring explicit reclassification. - **refactor(db): move `call_logs` aggregations into `callLogStats` db module** ([#3500]): extracted raw SQL from three route handlers (`/api/provider-metrics`, `/api/search/stats`, `/api/v1/search/analytics`) into a new `src/lib/db/callLogStats.ts` domain module (`getProviderMetrics`, `getSearchProviderStats`, `getRecentSearchLogs`, `getSearchAggregateStats`, `getSearchProviderCounts`). First slice of #3500 (call_logs cluster). Behavior unchanged; the three routes are removed from `KNOWN_RAW_SQL` in the gate. Validated with TDD unit tests (6 assertions seeding an in-memory SQLite fixture). @@ -118,7 +174,7 @@ - **refactor(db): move `community_servers` auth look-up into `gamification` db module** ([#3500]): extracted raw SQL from two federation route handlers (`/api/gamification/federation/leaderboard`, `/api/gamification/federation/score`) into a new `getConnectedServerByKeyHash(apiKeyHash)` function in `src/lib/db/gamification.ts`. Third slice of #3500 (gamification federation cluster). Behavior unchanged; the two routes are removed from `KNOWN_RAW_SQL` in the gate. Validated with TDD unit tests (3 assertions seeding a temp SQLite fixture). -- **refactor(db): move `skills UPDATE` + `db-backups` SQL into db modules** ([#3500]): fifth slice of #3500. Extracted the dynamic `UPDATE skills SET …` from `src/app/api/skills/[id]/route.ts` into a new `src/lib/db/skills.ts` module (`updateSkill(id, patch)`). The dynamic SET clause is injection-safe: column names are validated against a hard-coded allowlist of known writable columns before being interpolated; unknown keys are silently ignored. Extended `src/lib/db/backup.ts` with three new functions: `exportAllSummaryRows()` (multi-table SELECT for key_value / combos / provider_connections / api_keys, used by exportAll), `getTableNamesFromAdapter()` (sqlite_master introspection via an adapter arg, used by import validation), and `countImportedRows()` (post-import COUNT(*) per table). The backup domain module is the correct home for sqlite_master introspection — it is not "raw SQL in a route" once moved there. `KNOWN_RAW_SQL` drops by 3 (from 8 → 5). Validated with 11 TDD unit tests (`tests/unit/db-backups-skills-3500.test.ts`). +- **refactor(db): move `skills UPDATE` + `db-backups` SQL into db modules** ([#3500]): fifth slice of #3500. Extracted the dynamic `UPDATE skills SET …` from `src/app/api/skills/[id]/route.ts` into a new `src/lib/db/skills.ts` module (`updateSkill(id, patch)`). The dynamic SET clause is injection-safe: column names are validated against a hard-coded allowlist of known writable columns before being interpolated; unknown keys are silently ignored. Extended `src/lib/db/backup.ts` with three new functions: `exportAllSummaryRows()` (multi-table SELECT for key_value / combos / provider_connections / api_keys, used by exportAll), `getTableNamesFromAdapter()` (sqlite_master introspection via an adapter arg, used by import validation), and `countImportedRows()` (post-import COUNT(\*) per table). The backup domain module is the correct home for sqlite_master introspection — it is not "raw SQL in a route" once moved there. `KNOWN_RAW_SQL` drops by 3 (from 8 → 5). Validated with 11 TDD unit tests (`tests/unit/db-backups-skills-3500.test.ts`). - **refactor(db): move `skills UPDATE` + `db-backups` SQL into db modules** ([#3500]): fifth slice of #3500. Extracted the dynamic `UPDATE skills SET …` from `src/app/api/skills/[id]/route.ts` into a new `src/lib/db/skills.ts` module (`updateSkill(id, patch)`). The dynamic SET clause is injection-safe: column names are validated against a hard-coded allowlist of known writable columns before being interpolated; unknown keys are silently ignored. Extended `src/lib/db/backup.ts` with three new functions: `exportAllSummaryRows()` (multi-table SELECT for key_value / combos / provider_connections / api_keys, used by exportAll), `getTableNamesFromAdapter()` (sqlite_master introspection via an adapter arg, used by import validation), and `countImportedRows()` (post-import COUNT(\*) per table). The backup domain module is the correct home for sqlite_master introspection — it is not "raw SQL in a route" once moved there. `KNOWN_RAW_SQL` drops by 3 (from 8 → 5). Validated with 11 TDD unit tests (`tests/unit/db-backups-skills-3500.test.ts`). - **refactor(db): move `usage_logs`/`semantic_cache`/`proxy_logs` SQL into db modules** ([#3500]): extracted raw `db.prepare(...)` SQL from three route handlers (`/api/analytics/auto-routing` → `usageLogs.ts`; `/api/cache/entries` → `semanticCache.ts`; `/api/logs/export` → `proxyLogs.ts`) into new `src/lib/db/` domain modules. New exports: `getAutoRoutingTotalCount`, `getAutoRoutingVariantBreakdown`, `getAutoRoutingTopProviders` (usage_logs), `listSemanticCacheEntries`, `deleteSemanticCacheBySignature`, `deleteSemanticCacheByModel` (semantic_cache), and `exportProxyLogsSince` (proxy_logs). Fourth slice of #3500. `KNOWN_RAW_SQL` drops from 8 → 5. Validated with 13 TDD unit tests (`tests/unit/db-logs-cache-3500.test.ts`) seeding temp SQLite fixtures. diff --git a/CLAUDE.md b/CLAUDE.md index 19b0d64802..9ee18e58c4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,20 +37,20 @@ For full test matrix, see `CONTRIBUTING.md` → "Running Tests". For deep archit **OmniRoute** — unified AI proxy/router. One endpoint, 160+ LLM providers, auto-fallback. -| Layer | Location | Purpose | -| ------------- | ----------------------- | ------------------------------------------------------------------ | -| API Routes | `src/app/api/v1/` | Next.js App Router — entry points | -| Handlers | `open-sse/handlers/` | Request processing (chat, embeddings, etc) | -| Executors | `open-sse/executors/` | Provider-specific HTTP dispatch | -| Translators | `open-sse/translator/` | Format conversion (OpenAI↔Claude↔Gemini) | -| Transformer | `open-sse/transformer/` | Responses API ↔ Chat Completions | -| Services | `open-sse/services/` | Combo routing, rate limits, caching, etc | -| Database | `src/lib/db/` | SQLite domain modules (45+ files, 55 migrations) | -| Domain/Policy | `src/domain/` | Policy engine, cost rules, fallback logic | -| MCP Server | `open-sse/mcp-server/` | 43 tools (30 base + 3 memory + 4 skills + 6 notion), 3 transports, ~13 scopes | -| A2A Server | `src/lib/a2a/` | JSON-RPC 2.0 agent protocol | -| Skills | `src/lib/skills/` | Extensible skill framework | -| Memory | `src/lib/memory/` | Persistent conversational memory | +| Layer | Location | Purpose | +| ------------- | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| API Routes | `src/app/api/v1/` | Next.js App Router — entry points | +| Handlers | `open-sse/handlers/` | Request processing (chat, embeddings, etc) | +| Executors | `open-sse/executors/` | Provider-specific HTTP dispatch | +| Translators | `open-sse/translator/` | Format conversion (OpenAI↔Claude↔Gemini) | +| Transformer | `open-sse/transformer/` | Responses API ↔ Chat Completions | +| Services | `open-sse/services/` | Combo routing, rate limits, caching, etc | +| Database | `src/lib/db/` | SQLite domain modules (83 files, 97 migrations) | +| Domain/Policy | `src/domain/` | Policy engine, cost rules, fallback logic | +| MCP Server | `open-sse/mcp-server/` | 87 tools (33 base + memory/skill/notion/obsidian/gamification/plugin modules), 3 transports (stdio / SSE / Streamable HTTP), 30 scopes | +| A2A Server | `src/lib/a2a/` | JSON-RPC 2.0 agent protocol | +| Skills | `src/lib/skills/` | Extensible skill framework | +| Memory | `src/lib/memory/` | Persistent conversational memory | Monorepo: `src/` (Next.js 16 app), `open-sse/` (streaming engine workspace), `electron/` (desktop app), `tests/`, `bin/` (CLI entry point). @@ -72,7 +72,7 @@ Client → /v1/chat/completions (Next.js route) API routes follow a consistent pattern: `Route → CORS preflight → Zod body validation → Optional auth (extractApiKey/isValidApiKey) → API key policy enforcement → Handler delegation (open-sse)`. No global Next.js middleware — interception is route-specific. -**Combo routing** (`open-sse/services/combo.ts`): 14 strategies (priority, weighted, fill-first, round-robin, P2C, random, least-used, cost-optimized, reset-aware, strict-random, auto, lkgp, context-optimized, context-relay). Each target calls `handleSingleModel()` which wraps `handleChatCore()` with per-target error handling and circuit breaker checks. See `docs/routing/AUTO-COMBO.md` for the 9-factor Auto-Combo scoring and `docs/architecture/RESILIENCE_GUIDE.md` for the 3 resilience layers. +**Combo routing** (`open-sse/services/combo.ts`): 15 strategies (priority, weighted, fill-first, round-robin, P2C, random, least-used, cost-optimized, reset-aware, reset-window, strict-random, auto, lkgp, context-optimized, context-relay). Each target calls `handleSingleModel()` which wraps `handleChatCore()` with per-target error handling and circuit breaker checks. See `docs/routing/AUTO-COMBO.md` for the 9-factor Auto-Combo scoring and `docs/architecture/RESILIENCE_GUIDE.md` for the 3 resilience layers. --- @@ -332,7 +332,7 @@ For any non-trivial change, read the matching deep-dive first: | Repo navigation | `docs/architecture/REPOSITORY_MAP.md` | | Architecture | `docs/architecture/ARCHITECTURE.md` | | Engineering reference | `docs/architecture/CODEBASE_DOCUMENTATION.md` | -| Auto-Combo (9-factor scoring, 14 strategies) | `docs/routing/AUTO-COMBO.md` | +| Auto-Combo (9-factor scoring, 15 strategies) | `docs/routing/AUTO-COMBO.md` | | Resilience (3 mechanisms) | `docs/architecture/RESILIENCE_GUIDE.md` | | Reasoning replay | `docs/routing/REASONING_REPLAY.md` | | Skills framework | `docs/frameworks/SKILLS.md` | @@ -353,6 +353,7 @@ For any non-trivial change, read the matching deep-dive first: | Provider catalog (auto-generated) | `docs/reference/PROVIDER_REFERENCE.md` | | Release flow | `docs/ops/RELEASE_CHECKLIST.md` | | Embedded services | `docs/frameworks/EMBEDDED-SERVICES.md` | +| Quality gates (35 gates, allowlist policy) | `docs/architecture/QUALITY_GATES.md` | --- @@ -373,9 +374,10 @@ For any non-trivial change, read the matching deep-dive first: **Test layer preference**: unit first → integration (multi-module or DB state) → e2e (UI/workflow only). Encode bug reproductions as automated tests before or alongside the fix. -**Both test runners must pass**: `npm run test:unit` (Node native — most tests) AND `npm run test:vitest` (MCP server, autoCombo, cache) cover **non-overlapping files**. Both must be green before merging. A PR where only one suite passes may silently ship broken MCP tools or routing regressions. +**Both test runners must pass**: `npm run test:unit` (Node native — most tests) AND `npm run test:vitest` (MCP server, autoCombo, cache) cover **non-overlapping files**. Both are wired in CI (jobs `test-unit` and `test-vitest`) and must be green before merging. A PR where only one suite passes may silently ship broken MCP tools or routing regressions. **Bug fix / issue triage protocol (Hard Rule #18)**: Every fix for a reported issue must be validated by one of the following — no exceptions: + 1. **TDD (preferred)** — write a failing test reproducing the bug → fix it → confirm the test passes. The test becomes the permanent regression guard. Touch only the files the test proves need changing; nothing more. 2. **Real-environment test (when TDD is not possible)** — deploy to the production VPS (`root@192.168.0.15`) and run a documented live test. Record the exact command + result in the PR description. Applies to: OAuth upstream flows, Cloudflare/WS upstream behavior, UI-only regressions, hardware-dependent behavior. 3. "It worked locally without a test" does not count. A fix without a test or a VPS validation record is not a fix — it is a guess. @@ -402,14 +404,14 @@ git push -u origin feat/your-feature **Husky hooks**: - **pre-commit**: lint-staged + `check-docs-sync` + `check:any-budget:t11` -- **pre-push**: `npm run test:unit` +- **pre-push**: fast deterministic gates (`check:any-budget:t11` + `check:tracked-artifacts`); intentionally excludes `test:unit` (slow — covered by the CI `test-unit` job). Activated 2026-06-13 (Quality Gates Fase 6A.12). --- ## Environment -- **Runtime**: Node.js ≥20.20.2 <21 || ≥22.22.2 <23 || ≥24 <25, ES Modules -- **TypeScript**: 5.9+, target ES2022, module esnext, resolution bundler +- **Runtime**: Node.js ≥22.0.0 <23 || ≥24.0.0 <27, ES Modules +- **TypeScript**: 6.0+, target ES2022, module esnext, resolution bundler - **Path aliases**: `@/*` → `src/`, `@omniroute/open-sse` → `open-sse/`, `@omniroute/open-sse/*` → `open-sse/*` - **Default port**: 20128 (API + dashboard on same port) - **Data directory**: `DATA_DIR` env var, defaults to `~/.omniroute/` @@ -418,6 +420,29 @@ git push -u origin feat/your-feature --- +## Quality Gates & Ratchets + +OmniRoute has **35 CI quality gates** wired across 6 jobs in `.github/workflows/ci.yml`. +Full inventory, per-job breakdown, and operational procedures are in +[`docs/architecture/QUALITY_GATES.md`](docs/architecture/QUALITY_GATES.md). + +**Quick reference:** + +- Gates in job `lint` (18 checks) + `docs-sync-strict` (12 checks): pass/fail policy gates — + fix the violation or add an allowlist entry with a justification comment + tracking issue. +- Gates in job `quality-gate`: ratchet — metrics (ESLint warnings, code coverage, duplication, + complexity) must not regress vs `quality-baseline.json`. Update via + `npm run quality:ratchet -- --update` when a metric genuinely improves. +- Job `test-vitest` runs `npm run test:vitest` (MCP tools, autoCombo, cache) — blocking. + `test:vitest:ui` is advisory until UI component tests are triaged. + +**Allowlist policy (short form):** Fix the cause; use the allowlist only for pre-existing +violations you cannot fix in the same PR. Add a comment with justification + issue number. +Stale allowlist entries (suppressing a violation that no longer exists) will be caught by +the stale-enforcement added in Fase 6A.3. + +--- + ## Hard Rules 1. Never commit secrets or credentials @@ -428,7 +453,7 @@ git push -u origin feat/your-feature 6. Never silently swallow errors in SSE streams 7. Always validate inputs with Zod schemas 8. Always include tests when changing production code -9. Coverage must stay ≥60% (statements, lines, functions, branches). +9. Coverage must not regress below the baseline frozen in `quality-baseline.json` (ratchet); absolute floor is 60% (statements/lines/functions/branches). Update the baseline via `npm run quality:ratchet -- --update` only when coverage genuinely improves. See `docs/architecture/QUALITY_GATES.md`. 10. Never bypass Husky hooks (`--no-verify`, `--no-gpg-sign`) without explicit operator approval. 11. Never embed public upstream OAuth client_id/secret or Firebase Web keys as string literals — always go through `resolvePublicCred()` (`open-sse/utils/publicCreds.ts`). See `docs/security/PUBLIC_CREDS.md`. 12. Never return raw `err.stack` / `err.message` in HTTP / SSE / executor responses — always route through `buildErrorBody()` or `sanitizeErrorMessage()` (`open-sse/utils/error.ts`). See `docs/security/ERROR_SANITIZATION.md`. @@ -444,10 +469,13 @@ git push -u origin feat/your-feature ## PII & Stream Sanitization Learnings ### 1. Regex Security (ReDoS) + All regex patterns matching variable-length strings (e.g. IPv6 address, credit cards) must use strictly bounded, non-overlapping sequences (e.g., limit occurrences with bounded ranges `{1,7}`) to prevent catastrophic backtracking when processing untrusted inputs. ### 2. SSE Snapshot Handling + When parsing streaming LLM responses (e.g. Responses API), check if a chunk represents a final snapshot (`done` or `completed` events). Snapshot text must be sanitized directly as a standalone string (bypassing rolling delta buffers) to prevent text duplication at the end of the stream. ### 3. Database Handles in Tests + Ensure that any unit tests that trigger database migrations or establish SQLite connections call `resetDbInstance()` and properly clean up/close all DB handles in a `test.after(...)` hook. Failure to release database connection handles will cause Node's native test runner to hang indefinitely. diff --git a/PLANO-QUALITY-GATES-FASE6A.md b/PLANO-QUALITY-GATES-FASE6A.md deleted file mode 100644 index 3025e6e95c..0000000000 --- a/PLANO-QUALITY-GATES-FASE6A.md +++ /dev/null @@ -1,209 +0,0 @@ -# Fase 6A — Auditoria Crítica das Fases 0–6: o que deixamos passar - -> **Para workers agênticos:** SUB-SKILL OBRIGATÓRIA: `superpowers:subagent-driven-development` (recomendado) ou `superpowers:executing-plans`, tarefa-a-tarefa. Tarefas P0/P1 maiores devem ser **expandidas em sub-plano bite-sized próprio** (`writing-plans`) no momento da execução. Hard Rule #18 (TDD/VPS) em tudo. Auditar subagentes (trust-but-verify) após cada task. - -> # ⏳ PORTÃO DE ATIVAÇÃO — NÃO INICIAR ANTES DE **2026-06-16** -> Mesma janela da Fase 7 (decisão do owner 2026-06-09: 1 semana de uso em produção das Fases 0–6 antes de evoluir). **Ordem na ativação: Fase 6A ANTES da Fase 7** — primeiro consertamos/endurecemos o que já existe, depois adicionamos ferramentas novas. -> **Exceção possível (decisão do owner):** as tasks **6A.1 e 6A.2 são bugs pré-existentes descobertos pela auditoria** (testes que nunca rodam + suíte vitest fora do CI), não "gates novos" — podem ser antecipados como fix avulso se o owner preferir não esperar a janela. - -**Goal:** Fechar os furos que a auditoria crítica pós-implementação (2026-06-09, análise inline dos 18 gates + motor + CI + baselines) encontrou nas Fases 0–6 — antes de adicionar qualquer ferramenta nova na Fase 7. - -**Architecture:** Zero ferramenta nova (tudo homegrown, padrão `check-*.mjs` + motor existente). Três frentes: (1) **bugs sistêmicos de runner** descobertos (testes órfãos, vitest fora do CI); (2) **endurecimento do padrão de catraca** (stale-allowlist enforcement + require-tighten, validados pela prática da Notion); (3) **expansão de escopo** dos gates existentes (diretórios/superfícies que ficaram de fora). - -**Tech Stack:** Node ≥20 ESM, ESLint 9 flat, c8, jscpd@4 (a pinar), GitHub Actions, Node native test runner, vitest. Nada novo em `dependency-allowlist.json` exceto a promoção do jscpd a devDependency (Task 6A.12). - ---- - -## Origem: o que a auditoria encontrou (resumo dos achados) - -Método: releitura inline de todos os `scripts/check/*.{mjs,ts}` criados nas Fases 0–6, `scripts/quality/*`, baselines, `ci.yml`, `package.json`, hooks Husky e docs — sem subagentes — mais validação por pesquisa (sistema de ratcheting da Notion; práticas de suppression-hygiene de linters). - -| # | Achado | Gravidade | Task | -|---|--------|-----------|------| -| A1 | **≈135 arquivos `*.test.ts` em subdiretórios de `tests/unit/` não são coletados por NENHUM runner** — `test:unit`, `test:coverage` e os shards do CI usam o glob não-recursivo `tests/unit/*.test.ts`; o vitest só inclui `autoCombo/**` (+ `.tsx`). Inclui `authz/routeGuard.test.ts` (Hard Rules #15/#17), 50 testes de `compression/`, 12 de `services/`, 10 de `gamification/`, 6 de `guardrails/`, 5 de `security/`. **Amostra rodada na auditoria: 2 asserts de `routeGuard.test.ts` FALHAM hoje** ("management policy allows /api/services/ (e /api/copilot/chat) from localhost with valid CLI token") — o arquivo apodreceu sem ninguém ver, provavelmente desde o redesign do peer-stamp (2026-05-31) | **P0 — falso verde sistêmico** | 6A.1 | -| A2 | **Nenhum workflow roda `test:vitest`** (`grep -rln vitest .github/workflows/` = vazio). O CLAUDE.md afirma "Both test runners must pass… before merging", mas a suíte vitest (MCP server 43 tools, autoCombo, cache, componentes) está 100% fora da esteira | **P0** | 6A.2 | -| B1 | **Nenhum gate falha quando uma entrada de allowlist deixa de ser necessária.** Os 18 gates congelam ~90 violações em `KNOWN_*`; quando alguém corrige a violação (ex.: criar `/api/gamification/level` da issue #3484, remover `krutrim` da #3483), a entrada vira um furo aberto — a regressão pode VOLTAR sem revisão. Só `check-error-helper` tem detecção parcial (WARN de arquivo inexistente, que ninguém lê). Prática validada: linters maduros "yell when an exclusion exists that doesn't break the rule" | **P0 — corrói a catraca com o tempo** | 6A.3 | -| B2 | **Melhoria não-capturada vira folga permanente no motor**: sem `--update` manual, uma métrica que melhorou pode regredir de volta até o baseline antigo sem ninguém ver. A Notion auto-decrementa budgets no pre-commit; nosso motor não exige aperto | P1 | 6A.5 | -| B3 | EPS único (0.01) para todas as métricas; o plano da Fase 4 pedia epsilon maior para `coverage.branches` (não-determinismo do v8) e não foi implementado | P1 | 6A.5 | -| B4 | Métrica coletada sem entrada no baseline é ignorada em silêncio (coletor novo esquecido do baseline = falso conforto) | P2 | 6A.5 | -| C1 | `check-fetch-targets` só varre `src/app/(dashboard)` — **20+ arquivos com `fetch("/api/…")` fora do escopo**: `src/shared/components/` (Sidebar, CommandPalette, modais…), `src/app/connect/`, `src/app/status/`, `src/lib/evals/` | P1 | 6A.7 | -| C2 | `check-fetch-targets` ignora 100% dos template literals (`` fetch(`/api/x/${id}`) ``) — nem o prefixo estático é validado | P1 | 6A.7 | -| C3 | `check-fetch-targets` não valida o método HTTP (fetch `POST` → rota só com `GET` = 405 em runtime, gate verde) | P2 | 6A.7 | -| C4 | `check-deps` cobre só `package.json` raiz + `electron/` — **`@omniroute/opencode-plugin` (dep `zod` + 5 devDeps, pacote PUBLICADO no npm), `@omniroute/opencode-provider` e `open-sse/` ficam fora** | P1 | 6A.8 | -| C5 | `check-public-creds` escaneia só 2 arquivos hardcoded — credencial literal em arquivo NOVO (executor, oauth provider) passa batida | P1 | 6A.8 | -| C6 | `check-error-helper` cobre `open-sse/executors` + `handlers` — a Hard Rule #12 também fala de **MCP handlers** (`open-sse/mcp-server/`) e rotas HTTP (`src/app/api/`), fora do escopo | P1 | 6A.8 | -| C7 | `check-file-size` e `check-complexity` varrem `src` + `open-sse` — `electron/` e `bin/` fora (god-file pode nascer lá) | P2 | 6A.11 | -| C8 | `check-known-symbols` cobre executors/strategies/translators — **faltam as 3 superfícies de despacho por-string restantes: MCP tools (43), A2A skills (5, `A2A_SKILL_HANDLERS`), cloud agents (3, registry)** | P1 | 6A.9 | -| C10 | `check-route-guard-membership` depende da lista manual `SPAWN_CAPABLE_ROUTE_ROOTS` (3 raízes) — rota nova que spawna processo FORA dessas raízes é invisível ao gate | P1 | 6A.8 | -| C11 | `check-openapi-coverage` (THRESHOLD=36%) e `check-ui-keys-coverage` (65%) são pisos fixos manuais — não ratcheteiam; rotas/strings novas sem doc/i18n passam enquanto o % não cai do piso | P2 | 6A.11 | -| C12 | `check-test-masking`: (a) `--diff-filter=M` não vê teste **DELETADO** (o masking mais brutal); (b) não vê `.skip`/`.todo`/`.only` adicionados (mantêm os asserts no texto, mas nunca rodam); (c) tautologia só cobre `assert.ok(true)` | P1 | 6A.10 | -| D1 | **`ci.yml` roda gates apenas em `pull_request → main`** — todo o ciclo de PRs feature→`release/vX` passa SEM gate; as violações acumulam por semanas e estouram juntas no merge release→main (observado no gate da v3.8.18: 4 fixes de typecheck + ReDoS de última hora) | P1 — decisão do owner | 6A.6 | -| D2 | `.husky/pre-push` está 100% comentado, mas o CLAUDE.md afirma "pre-push: npm run test:unit" (drift doc↔real) | P2 | 6A.12 | -| D3 | **CLAUDE.md não menciona nenhum dos 18 gates** — um agente futuro não sabe que existem, qual a política de allowlist ("corrija, não congele"), nem como apertar baselines. Hard Rule #9 ainda diz "≥60%" (a catraca real é 80/80/82/73) | **P0 — anti-alucinação para os próprios agentes** | 6A.4 | -| D4 | `check-duplication` roda `npx --yes jscpd@4` — pacote **não pinado por lockfile**, baixado do registry a cada run do CI (supply-chain + flakiness + latência); contraria o espírito do próprio `check-deps` | P2 | 6A.12 | -| D5 | A skill `/quality-scan` roda ~20 comandos um a um — falta um runner agregador paralelo | P2 | 6A.12 | -| D6 | Baselines de coverage com folga de ~2,5pt (80/80/82/73 vs real ~82,6/82,6/84,2/75,2) — a nota "_aperte após o 1º run verde_" existe no JSON mas não é tarefa de ninguém | P1 | 6A.5 | -| E4 | Sem guarda contra artefato trackeado por engano — `node_modules` symlink já foi commitado 2× neste repo (`git add -A` em worktree) | P2 | 6A.12 | - -**Decisões conscientes de NÃO fazer (avaliadas e descartadas, com motivo):** -- **Gate de idempotência de migrations via regex** — SQLite não tem `ADD COLUMN IF NOT EXISTS`; idempotência vive em try/catch do runner; regex seria frágil (FP/FN). O `check-migration-numbering` + revisão humana bastam. -- **Endurecer `check-docs-counts-sync` para fail** — contagens em prosa são heurísticas; soft-fail é o design correto. A cobertura de MCP tools entra pela 6A.9 (símbolos, não prosa). -- **Sentido inverso do provider-consistency (providers.ts → REGISTRY)** — muitos providers canônicos legitimamente não têm entrada no REGISTRY (web/OAuth-only); a allowlist nasceria com dezenas de entradas e baixa razão sinal/ruído. Reavaliar quando o refactor #3501 tocar o split de providers. -- **Complexidade por-função/por-arquivo (formato any-budget)** — upgrade real, mas o count global + `max-lines-per-function` já bloqueiam o grosso; o formato per-file entra junto com `sonarjs/cognitive-complexity` na Fase 7 Task 5 para não pagar duas migrações de baseline. - ---- - -# Tasks - -## P0 — bugs sistêmicos + documentação - -### Task 6A.1 — `check-test-discovery` + religamento triado dos ~135 testes órfãos ⭐ - -**O achado nº1 da auditoria.** Testes que não rodam são o falso verde definitivo — todo o investimento anti test-masking da Fase 4 protege asserts de testes que **nem executam**. - -**Files:** -- Create: `scripts/check/check-test-discovery.mjs` + `tests/unit/check-test-discovery.test.ts` -- Modify: `package.json` (globs de `test:unit`, `test:coverage`), `.github/workflows/ci.yml` (globs dos shards 8×/node24/node26), `vitest.mcp.config.ts` ou `vitest.config.ts` (se algum subdir for re-homed para vitest) -- Create: `test-discovery-baseline.json` (órfãos ainda-não-religados, catraca `down` até zerar) - -**Approach (expandir em sub-plano na execução):** -1. **Gate primeiro (TDD):** `check-test-discovery.mjs` enumera todo `**/*.{test,spec}.{ts,tsx}` do repo (fora de `node_modules`/`.next`) e verifica que cada arquivo é coletado por ≥1 runner: (a) globs do node test runner extraídos de `package.json`/`ci.yml`; (b) `include` dos dois `vitest.*config.ts`; (c) projetos Playwright. Órfão fora do baseline → exit 1. O baseline congela os órfãos atuais (catraca: não pode SUBIR; religamentos a fazem cair até `{}`). -2. **Inventário verde/vermelho:** rodar cada subdir órfão isoladamente (`node --import tsx --test tests/unit//*.test.ts`), registrar passa/falha. *Não* ligar tudo de uma vez — a amostra já provou que há vermelhos (`authz/routeGuard.test.ts`: 2 asserts). -3. **Religar os verdes:** trocar o glob principal para recursivo — `"tests/unit/**/*.test.ts"` ENTRE ASPAS (expandido pelo test runner do Node, não pelo shell; **Step 0: validar o suporte a glob do runner na menor versão de Node suportada pelo repo** — fallback: listar os subdirs explicitamente) — em `test:unit`, `test:coverage` e nos 3 lugares do `ci.yml` (shards 8×, node24, node26). Remover religados do baseline. -4. **Triar os vermelhos (Hard Rule #18 em cada um):** teste desatualizado → atualizar para o comportamento real (e provar que o comportamento real é o desejado); bug real revelado → fix TDD; teste de feature morta → deletar com justificativa no commit. Os 2 asserts do `routeGuard.test.ts` ("allows … with valid CLI token") são o primeiro caso: provável drift do peer-stamp de 2026-05-31 — MAS, por ser superfície de segurança (#15/#17), confirmar com cuidado que é o teste que está errado, não o guard. -5. **Recalibrar cobertura:** religar ≈135 arquivos muda o denominador/numerador da cobertura — re-medir e apertar `quality-baseline.json` via `--update` no mesmo PR (resolve também o D6). - -**Acceptance:** `check-test-discovery` no CI (job lint); zero órfãos fora do baseline; baseline decrescente documentado; suíte verde com os religados; cobertura recalibrada. - -### Task 6A.2 — vitest no CI - -**Files:** `.github/workflows/ci.yml` (job novo `test-vitest`, paralelo aos shards; NÃO tocar nos triggers — apenas adicionar job). - -**Approach:** job com `npm ci` + `npm run test:vitest` (+ `test:vitest:ui` se o tempo couber; senão segundo step). Rodar localmente primeiro para garantir verde (a suíte passa hoje fora da esteira — confirmar). Se houver vermelho pré-existente, triagem antes do wire (mesmo protocolo da 6A.1 passo 4). - -**Acceptance:** PR→main roda as DUAS suítes; o claim do CLAUDE.md ("both must be green") vira verdade mecânica. - -### Task 6A.3 — Stale-allowlist enforcement em todos os gates (suppression hygiene) - -**O endurecimento sistêmico nº1.** Padrão validado (ESLint `--report-unused-disable-directives`; Notion): exclusão que não exclui nada vivo é dívida fantasma e furo de regressão. - -**Files:** todos os gates com allowlist + seus testes: -`check-fetch-targets` (KNOWN_MISSING, 7) · `check-provider-consistency` (KNOWN_REGISTRY_ONLY, 1) · `check-openapi-routes` (KNOWN_STALE_SPEC, 1) · `check-public-creds` (KNOWN_LITERAL_CREDS, 5) · `check-db-rules` (KNOWN_UNEXPORTED 25 + KNOWN_RAW_SQL 15) · `check-docs-symbols` (KNOWN_STALE_DOC_REFS, 30) · `check-migration-numbering` (KNOWN_GAPS/DUPLICATES) · `check-error-helper` (KNOWN_MISSING_ERROR_HELPER, 7 — promover o WARN existente a FAIL e cobrir também "arquivo existe mas não viola mais") · `check-deps` (dependency-allowlist: entrada sem dep correspondente em manifest algum = stale) · `check-file-size` (entrada `frozen` cujo arquivo foi deletado/renomeado) · `check-route-guard-membership` (KNOWN_UNCLASSIFIED — vazio hoje; implementar o check para quando deixar de ser). - -**Approach (mecânica única, TDD por gate):** após a detecção normal, re-avaliar cada entrada da allowlist: *"se esta entrada não existisse, o gate flagaria algo?"* — para allowlists de path/valor isso é `violationsDetectadas.has(entry)`; para arquivos, existência + violação presente. Entrada que não suprime nada → **exit 1** com mensagem `entrada obsoleta — a violação foi corrigida; REMOVA a entrada para travar a correção`. Extrair helper comum `reportStaleEntries(allowlist, liveViolations, gateName)` em `scripts/check/lib/allowlist.mjs` para não duplicar 11×. - -**Acceptance:** corrigir qualquer violação congelada (ex.: as issues #3483–#3501) passa a EXIGIR a remoção da entrada no mesmo PR; teste sintético prova fail-on-stale em cada gate. - -### Task 6A.4 — Documentar os gates no CLAUDE.md (+ corrigir drifts de doc) - -**Files:** `CLAUDE.md`, `AGENTS.md` (se houver seção espelho), `docs/architecture/` (página `QUALITY_GATES.md` referenciada pela tabela de docs). - -**Approach:** (1) seção nova "Quality Gates & Ratchets" no CLAUDE.md: tabela dos gates (nome → o que trava → allowlist/baseline), a política **"corrija a causa; allowlist só com justificativa + issue"**, como apertar (`npm run quality:ratchet -- --update`, `check: -- --update`), e o que fazer quando um gate falha num PR. (2) Corrigir: Hard Rule #9 (60% → "catraca de cobertura: nunca abaixo do baseline congelado em `quality-baseline.json`; piso absoluto 60"), claim do pre-push (refletir o real pós-6A.12), claim "both runners" (verdade pós-6A.2). (3) Página `docs/architecture/QUALITY_GATES.md` com o detalhe operacional (o CLAUDE.md fica curto, linka). Rodar `check:docs-all` após editar (o próprio docs-sync valida). - -**Acceptance:** agente novo lendo o CLAUDE.md descobre os gates e a política sem ler scripts; `check:docs-all` verde. - -## P1 — endurecimento do motor + escopos - -### Task 6A.5 — Motor v2: `--require-tighten`, eps por métrica, métricas órfãs - -**Files:** `scripts/quality/check-quality-ratchet.mjs`, `quality-baseline.json` (schema), `tests/unit/quality-ratchet.test.ts`, `.github/workflows/ci.yml` (flag no job quality-gate). - -**Approach (TDD):** -1. Schema por métrica ganha campos opcionais: `eps` (default 0.01) e `tightenSlack` (default: igual a `eps`). -2. Novo modo `--require-tighten` (ligado no CI): se `atual` melhor que `baseline` além de `tightenSlack`, **exit 1** com `melhorou de X para Y — rode 'npm run quality:ratchet -- --update' e commite o baseline apertado neste PR`. Métricas determinísticas (`eslintWarnings`) usam slack 0; cobertura usa slack 1.5 (flutuação v8). É o "auto-decrement" da Notion adaptado a CI sem bot de commit. -3. Warning para métricas presentes em `quality-metrics.json` sem entrada no baseline (coletor órfão). -4. Calibrar os 4 `coverage.*` para o real medido (fecha D6 — coordenar com a 6A.1 passo 5, que muda a base). - -**Acceptance:** melhoria sem aperto de baseline falha no CI; flutuação de coverage dentro do slack não falha; testes cobrem os 3 comportamentos novos. - -### Task 6A.6 — `quality.yml`: gates rápidos em PRs → `release/**` ⚠️ DECISÃO DO OWNER - -**Contexto sensível:** o owner já reverteu mudança de trigger no `ci.yml` ("não mexe na CI"). Esta task **não toca o `ci.yml`** — cria um workflow NOVO e enxuto. Ainda assim, **passo 0 = confirmação explícita do owner**. - -**Files:** Create: `.github/workflows/quality.yml`. - -**Approach:** `on: pull_request: branches: ["release/**"]`; um job único (~1–2 min) só com os gates determinísticos filesystem-only: provider-consistency, fetch-targets, openapi-routes, docs-symbols, deps, file-size, error-helper, migration-numbering, public-creds, db-rules, known-symbols, route-guard-membership, test-discovery (pós-6A.1) + `check:any-budget:t11`. SEM lint/test/build (continuam só no PR→main). Ganho: a violação aparece no PR que a introduz, não semanas depois no gate do release (padrão observado: 4 fixes de última hora no release da v3.8.18). - -**Acceptance:** PR de teste contra a release branch com uma rota inventada falha em <2 min; PRs limpos não ganham mais que ~2 min de CI. - -### Task 6A.7 — `check-fetch-targets` v2: escopo completo + prefixo de template + método HTTP - -**Files:** `scripts/check/check-fetch-targets.mjs`, `tests/unit/check-fetch-targets.test.ts`. - -**Approach (TDD):** -1. **Escopo:** varrer todo `src/**/*.{ts,tsx}` client-side (excluindo `src/app/api/**`, testes, `src/lib/db`), não só `(dashboard)` — congela os misses pré-existentes que aparecerem em `KNOWN_MISSING` (com triagem/issue por cluster, igual Fase 2). -2. **Template literals:** extrair o prefixo estático de `` fetch(`/api/x/y/${id}…`) `` e validar por **prefix-match** contra as rotas reais (existe alguma rota cujo path começa com `/api/x/y/`?). Pega diretório inteiro alucinado; não tenta resolver o sufixo dinâmico. -3. **Método HTTP (heurístico, mesma chamada):** quando o 2º argumento literal contém `method: "POST"` (etc.), verificar que o `route.ts` resolvido exporta a função correspondente (`grep` por `export (async )?function POST` / `export const POST`). Sem method literal → assume GET-ok (rota existe basta). Casos dinâmicos → skip silencioso. - -**Acceptance:** fixture com fetch em `src/shared/components` + template com prefixo falso + `method: "DELETE"` para rota só-GET — 3 detecções; repo real verde com os novos congelados documentados. - -### Task 6A.8 — Escopo dos gates de segurança: error-helper, public-creds, route-guard, deps - -**Files:** `check-error-helper.mjs`, `check-public-creds.mjs`, `check-route-guard-membership.ts`, `check-deps.mjs` + testes. - -**Approach (TDD, um sub-commit por gate):** -1. **error-helper** (+Rule #12 completa): incluir `open-sse/mcp-server/**` e `src/app/api/**/route.ts` no SCAN_DIRS; rodar; congelar os achados novos em KNOWN com comentário-justificativa cada (e issue por cluster). -2. **public-creds**: além dos 2 arquivos âncora, varrer `open-sse/**` e `src/lib/oauth/**` com a mesma `CRED_KEY_RE` (linha a linha, barato); congelar achados. Limitação documentada: `const CLIENT_ID = "…"` (variável solta) continua fora — o gitleaks da Fase 7 cobre essa classe. -3. **route-guard**: novo sub-check — todo `route.ts` (qualquer raiz) cujo fonte OU imports de 1º nível relativos contenham `child_process`/`spawn(`/`execFile(`/`worker_threads` deve ser classificado local-only por `isLocalOnlyPath()`. Mata a dependência da lista manual de 3 raízes. -4. **deps**: `MANIFESTS` → descoberta automática de todo `package.json` do repo (fora `node_modules`/`.next`): hoje raiz, `electron/`, `open-sse/`, `@omniroute/opencode-plugin/` (dep `zod` entra na allowlist), `@omniroute/opencode-provider/`. Workspace novo amanhã entra sozinho. - -**Acceptance:** fixtures sintéticas por gate; repo real verde com achados congelados + issues; dep nova em QUALQUER manifest do repo dispara o gate. - -### Task 6A.9 — `check-known-symbols` v2: MCP tools, A2A skills, cloud agents - -**Files:** `scripts/check/check-known-symbols.ts`, `tests/unit/check-known-symbols.test.ts`. - -**Approach (TDD, mesmo padrão das 3 superfícies existentes — Step 0 de verificação dos exports reais antes de codar):** -1. **MCP tools:** enumerar os tools registrados (via `createMcpServer()` ou parse determinístico do tool-set em `open-sse/mcp-server/tools/`) e congelar o snapshot de nomes (catraca: tool sumir = fail; tool novo = report). Cruzar com os scopes (~13) — tool sem scope atribuído = fail. -2. **A2A skills:** chaves de `A2A_SKILL_HANDLERS` (`src/lib/a2a/taskExecution.ts`) ↔ skills expostas no Agent Card (`src/app/.well-known/agent.json/route.ts`) — divergência = fail. -3. **Cloud agents:** entradas do `src/lib/cloudAgent/registry.ts` ↔ classes em `agents/` — incompleto/órfão = fail. - -**Acceptance:** remover um tool/skill/agent do registro quebra o gate; adicionar reporta (e `check-docs-counts-sync` continua cuidando da prosa). - -## P2 — refinamentos - -### Task 6A.10 — `check-test-masking` v2: deleções, skips, tautologias - -**Files:** `scripts/check/check-test-masking.mjs`, `tests/unit/check-test-masking.test.ts`. - -**Approach (TDD):** (a) `--diff-filter=M` → `MDR` (com `-M` para rename-detection): arquivo de teste **deletado** = flag automático ("N asserts removidos — arquivo deletado"); renamed = comparar contra o path antigo. (b) Contar `\.(skip|todo|only)\s*\(` + `\{\s*skip:\s*true` base vs HEAD — **aumento líquido de skips = flag** (skip novo esconde asserts sem removê-los); `.only` novo = flag sempre (filtra o resto da suíte). (c) Tautologias extras: `expect(true).toBe(true)`, `assert.equal(1, 1)`, `expect(x).toBeDefined()` como ÚNICO assert do teste. - -**Acceptance:** fixtures para os 3 bypasses (delete, skip, only) — todos flagados; suíte real verde. - -### Task 6A.11 — Pisos manuais → catraca do motor + escopo electron/bin - -**Files:** `scripts/quality/collect-metrics.mjs`, `quality-baseline.json`, `check-openapi-coverage.mjs`, `scripts/i18n/check-ui-keys-coverage.mjs` (só leitura do valor), `check-file-size.mjs`, `eslint.complexity.config.mjs` + baselines. - -**Approach:** (1) coletor emite `openapiCoverage.pct` e `i18nUiCoverage.pct` → baseline `{direction: up}` com o valor real atual (36→real, 65→real); os THRESHOLDs fixos viram redundância de segurança (mantidos). Rotas/strings novas sem doc/i18n agora REGRIDEM o % e falham. (2) `check-file-size` e `check-complexity`: adicionar `electron/` e `bin/` ao scan (congelar os >cap que existirem). - -**Acceptance:** rota nova não-documentada derruba `openapiCoverage.pct` → gate falha; god-file novo em `electron/` falha. - -### Task 6A.12 — Higiene operacional (4 itens pequenos) - -**Files:** `package.json`, `dependency-allowlist.json`, `.husky/pre-push`, Create: `scripts/check/check-tracked-artifacts.mjs`, `scripts/quality/run-all-gates.mjs`; Modify: `.agents/skills/quality-scan/SKILL.md`. - -**Approach:** -1. **jscpd pinado:** `jscpd@^4` como devDependency (entra no lockfile + allowlist); `check-duplication` chama o binário local em vez de `npx --yes jscpd@4` (remove download de registry no CI + supply-chain risk + flakiness). -2. **pre-push barato:** reativar com APENAS os gates determinísticos rápidos (<10s: fetch-targets, openapi-routes, db-rules, public-creds, migration-numbering, file-size, deps, error-helper) — NÃO `test:unit` (lento; CI cobre). Atualizar o claim do CLAUDE.md (coordenar com 6A.4). -3. **check-tracked-artifacts:** falhar se `git ls-files` contém `node_modules/`, `.next/`, `coverage/`, `quality-metrics.json` ou symlink para fora do repo (o incidente do symlink trackeado já aconteceu 2×). Wire no lint job + pre-commit (é instantâneo). -4. **Runner agregador:** `scripts/quality/run-all-gates.mjs` roda os gates em paralelo (pool ~4), agrega `{gate, exitCode, lastLine, durationMs}` e imprime a tabela consolidada; `npm run quality:scan`. A skill `/quality-scan` passa a chamá-lo (atualizar SKILL.md). - -**Acceptance:** `npm run quality:scan` < 3 min com tabela única; pre-push roda <10s; `git add node_modules && commit` falha no pre-commit. - ---- - -## Ordem de execução recomendada (na ativação, 2026-06-16+) - -1. **6A.1 + 6A.2** (bugs de runner — destravam números reais de cobertura para o resto) -2. **6A.3 + 6A.4** (stale-enforcement + docs — endurecem o que já roda) -3. **6A.5** (motor v2) → **6A.6** (quality.yml, após OK do owner) -4. **6A.7 → 6A.9** (escopos) -5. **6A.10 → 6A.12** (refinamentos) -6. Só então **Fase 7** (ferramentas novas sobre uma fundação consertada) - -## Self-Review -- **Cobertura dos achados:** todos os achados A*/B*/C*/D*/E* da tabela têm task (coluna Task); os descartados estão em "Decisões conscientes de NÃO fazer" com motivo. ✓ -- **Zero dependência nova** exceto a promoção do jscpd (que já roda hoje via npx, não-pinado — a task REDUZ risco). ✓ -- **Sem flag-day:** toda expansão de escopo congela os achados pré-existentes (allowlist + issue), igual Fases 0–6; o stale-enforcement só exige remoção quando a correção JÁ aconteceu. ✓ -- **Consistência com o motor:** novas métricas (`openapiCoverage.pct`, `i18nUiCoverage.pct`) usam o formato `{value, direction}`; `eps`/`tightenSlack` são opcionais e retrocompatíveis. ✓ -- **Não-duplicação com a Fase 7:** cognitive-complexity per-file, gitleaks (creds não-públicas), knip, osv — tudo continua na Fase 7; a 6A só conserta/endurece o existente. ✓ diff --git a/PLANO-QUALITY-GATES-FASE7.md b/PLANO-QUALITY-GATES-FASE7.md deleted file mode 100644 index 2e62aae737..0000000000 --- a/PLANO-QUALITY-GATES-FASE7.md +++ /dev/null @@ -1,182 +0,0 @@ -# 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; (4) **Fase 6A executada (ou conscientemente re-priorizada)** — a auditoria crítica de 2026-06-09 ([`PLANO-QUALITY-GATES-FASE6A.md`](./PLANO-QUALITY-GATES-FASE6A.md)) encontrou bugs sistêmicos de runner (≈135 testes órfãos, vitest fora do CI) e furos de escopo nos gates existentes que devem ser consertados ANTES de adicionar ferramentas novas por cima. - -**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) · **gitleaks** (secret scanning, MIT; avaliar o sucessor drop-in Betterleaks, 2026-03) · **actionlint + zizmor** (lint + auditoria de segurança dos workflows) · **license-compliance** (allowlist SPDX de licenças). 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) | -| `.gitleaks.toml` + `scripts/check/check-secrets.mjs` (criar) | gitleaks → catraca de findings de secret (Task 18) | -| `.github/workflows/quality.yml` ou job lint (modificar) | actionlint + zizmor sobre `.github/workflows/**` (Task 19) | -| `scripts/check/check-licenses.mjs` + `.license-allowlist.json` (criar) | allowlist SPDX das licenças das deps (Task 20) | -| `dependency-allowlist.json` (modificar) | + osv-scanner, knip, sonarjs, type-coverage, lockfile-lint, stryker, size-limit, axe-core, dpdm, license-compliance | - ---- - -## 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. - -### Task 18 — Secret scanning local (gitleaks) ➕ *adicionada pela auditoria 6A (2026-06-09)* -- **Tool:** gitleaks (OSS, MIT — binário Go, on-box). Nota 2026: o criador original (Zach Rice) lançou o **Betterleaks** (2026-03) como drop-in replacement (flags/config compatíveis) — avaliar os dois no Step 0 e escolher 1. -- **Files:** `.gitleaks.toml`, `scripts/check/check-secrets.mjs`, `quality-baseline.json` (+`secretFindings {down}`), `ci.yml` (job quality-gate), pre-commit (modo `--staged`, é rápido). -- **Approach:** complementa o `check-public-creds` da Fase 6 (que cobre apenas credenciais PÚBLICAS por chave de objeto em 2 arquivos): gitleaks pega a classe geral — `const API_KEY = "sk-…"`, tokens em config/teste/docs, secrets em histórico. Rodar `gitleaks dir --report-format json` → contar findings → catraca `down`. Findings legítimos (creds públicas já congeladas no check-public-creds, fixtures de teste) vão para `.gitleaks.toml` `[allowlist]` com comentário — sujeitos ao stale-enforcement da 6A.3 (conceitual: revisar allowlist a cada release). -- **Acceptance:** secret real plantado em fixture é detectado; baseline congela os findings atuais; novo finding falha o gate. - -### Task 19 — Lint + auditoria de segurança dos workflows (actionlint + zizmor) ➕ *adicionada pela auditoria 6A* -- **Tools:** actionlint (OSS — correção/sintaxe/shellcheck dos YAML) + zizmor (OSS, zizmorcore — 24+ audits de segurança: unpinned actions, script injection, `pull_request_target` perigoso, cache poisoning). Complementares por design; o repo tem 10 workflows sem NENHUMA validação hoje. -- **Motivação 2026:** o incidente trivy-action/LiteLLM (2026-03) explorou exatamente uma misconfiguração de `pull_request_target` que o zizmor detecta estaticamente. Os release-workflows do OmniRoute (npm/Docker/Electron publish) são alvo de alto valor. -- **Files:** `ci.yml` ou `quality.yml` (steps actionlint + zizmor), `zizmor.yml` (config/ignores justificados), `quality-baseline.json` (+`zizmorFindings {down}` — começar advisory, congelar baseline, depois bloquear). -- **Approach:** actionlint = pass/fail imediato (sintaxe não tem "legado aceitável"); zizmor = catraca `down` no padrão do motor (os findings atuais — provavelmente actions não-pinadas por SHA — são dívida congelada que decai). -- **Acceptance:** workflow novo com `pull_request_target` + checkout de código do PR falha; action não-pinada NOVA sobe o count e falha. - -### Task 20 — License compliance (allowlist SPDX) ➕ *adicionada pela auditoria 6A* -- **Tool:** license-compliance ou @onebeyond/license-checker (ambos OSS, npm). Projeto é **MIT** — deps com copyleft forte (GPL/AGPL) em produção são risco de compliance para os usuários do proxy. -- **Files:** `scripts/check/check-licenses.mjs`, `.license-allowlist.json` (SPDX permitidas: MIT, Apache-2.0, BSD-2/3, ISC, 0BSD, …), `ci.yml` (lint job), `dependency-allowlist.json`. -- **Approach:** rodar sobre as `dependencies` de produção (devDependencies = relatório advisory); licença fora da allowlist → fail com o caminho da dep. Exceções pontuais (dual-license, LGPL avaliada) entram na allowlist por **pacote** com justificativa — pareia com o `check-deps` da Fase 2 (lá controla O QUE entra; aqui, SOB QUAL licença). -- **Acceptance:** dep GPL-3.0 sintética em fixture falha; árvore atual passa com a allowlist calibrada. - ---- - -## Wiring & CI (resumo) -- **lint job:** check-lockfile, check-cognitive-complexity (rápido?), check-type-coverage, **check-licenses (Task 20)**, **actionlint (Task 19)**. -- **quality-gate job (paralelo):** check-vuln-ratchet, check-dead-code, check-codeql-ratchet, check-cognitive-complexity (se lento), check-bundle-size, **check-secrets (Task 18)**, **zizmor (Task 19)**. -- **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, com `eps`/`tightenSlack` da 6A.5). Toda dep nova → `dependency-allowlist.json`. Toda allowlist nova nasce com o stale-enforcement da 6A.3. - -## 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). Auditoria 6A (2026-06-09) acrescentou Tasks 18-20 (gitleaks, actionlint+zizmor, license compliance). ✓ -- **Community/OSS only:** confirmado — Sonar Community Build, todos os demais OSS (gitleaks MIT, zizmor/actionlint OSS, license-compliance npm), zero SaaS pago. ✓ -- **Sem flag-day:** toda catraca é só-regressão, calibrada do estado atual (zizmor/gitleaks começam advisory→baseline→bloqueio). ✓ -- **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`. ✓ -- **Não-sobreposição com a 6A:** a 6A conserta/endurece o EXISTENTE (runners, stale-allowlists, escopos); a Fase 7 só adiciona ferramenta nova. gitleaks (Task 18) complementa — não substitui — o check-public-creds expandido pela 6A.8. ✓ - -## Handoff (na ativação, 2026-06-16+) -**Ordem: Fase 6A primeiro** ([`PLANO-QUALITY-GATES-FASE6A.md`](./PLANO-QUALITY-GATES-FASE6A.md)) — consertar os runners (testes órfãos + vitest no CI) e endurecer as catracas existentes muda os baselines (cobertura recalibrada) sobre os quais várias tasks daqui (1, 9, 11) calibram. Depois: começar pela **Task 1-3 (catraca de segurança)**, **Task 4 (knip)** e **Task 19 (zizmor — protege os release-workflows)** — 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 deleted file mode 100644 index c2727665db..0000000000 --- a/PLANO-QUALITY-GATES.md +++ /dev/null @@ -1,688 +0,0 @@ -# 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/README.md b/README.md index 467e920a1e..14594deddf 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ [![50+ Free](https://img.shields.io/badge/50%2B-Free_Tiers-00B894?style=for-the-badge)](#-177-ai-providers--50-free) [![1.9B+ Free Tokens/mo](https://img.shields.io/badge/1.9B%2B-Free_Tokens%2Fmo-00B894?style=for-the-badge)](docs/reference/FREE_TIERS.md) [![Token Savings](https://img.shields.io/badge/up_to_95%25-Token_Savings-E17055?style=for-the-badge)](#%EF%B8%8F-save-1595-tokens--automatically) -[![14 Strategies](https://img.shields.io/badge/14-Routing_Strategies-0984E3?style=for-the-badge)](#-combos--the-flagship) +[![15 Strategies](https://img.shields.io/badge/15-Routing_Strategies-0984E3?style=for-the-badge)](#-combos--the-flagship) [![$0 to start](https://img.shields.io/badge/%240-To_Start-FDCB6E?style=for-the-badge&logoColor=black)](#-quick-start)
@@ -43,7 +43,7 @@ [![npm](https://img.shields.io/npm/v/omniroute?logo=npm&style=flat-square)](https://www.npmjs.com/package/omniroute) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](LICENSE) -[![Node](https://img.shields.io/badge/node-%E2%89%A522.22.2-brightgreen?style=flat-square)](package.json) +[![Node](https://img.shields.io/badge/node-%E2%89%A522.0.0-brightgreen?style=flat-square)](package.json) [![Stars](https://img.shields.io/github/stars/diegosouzapw/OmniRoute?style=social)](https://github.com/diegosouzapw/OmniRoute)
@@ -147,7 +147,7 @@ 🔌 Every tool works
16+ coding agents — Claude Code, Codex, Cursor, Cline, Copilot, Antigravity — through one config. 🧩 One endpoint
OpenAI ↔ Claude ↔ Gemini ↔ Responses API translation. Point any tool at /v1 and it just works. - 🛡️ Production-grade
Circuit breakers, TLS stealth, MCP (37 tools), A2A, memory, guardrails, evals. 4,690+ tests. + 🛡️ Production-grade
Circuit breakers, TLS stealth, MCP (87 tools), A2A, memory, guardrails, evals. 4,690+ tests. @@ -181,7 +181,7 @@ ▼ ┌──────────────────────────────────────────────────────────┐ │ OmniRoute — Smart Router │ -│ RTK + Caveman compression · 14 routing strategies │ +│ RTK + Caveman compression · 15 routing strategies │ │ Circuit breakers · TLS stealth · MCP · A2A · Guardrails │ └─────────────────────────┬──────────────────────────────────┘ ┌─────────────┬────┴────────┬─────────────┐ @@ -219,7 +219,7 @@ No combo to create. Set your model to `auto` (or a variant) and OmniRoute builds ## -### 🔀 Or build your own — 14 routing strategies +### 🔀 Or build your own — 15 routing strategies | Goal | Strategy / combo | | --------------------------------------- | -------------------------------------------------- | @@ -265,10 +265,10 @@ Result: 4 layers of fallback = zero downtime | -------------------------------------- | ----------------------------------------------------------- | ------------- | | 🌐 Providers | **177** | 20–100 | | 🆓 Free providers | **50+ (11 free forever)** | 1–5 | -| 🔀 Routing strategies | **14** (priority, weighted, cost-optimized, context-relay…) | 1–3 | +| 🔀 Routing strategies | **15** (priority, weighted, cost-optimized, context-relay…) | 1–3 | | 🗜️ Token compression | **RTK + Caveman stacked (15–95%)** | None / 20–40% | -| 🧰 Built-in MCP server | **37 tools, 3 transports, 13 scopes** | Rare | -| 🤝 A2A agent protocol | **5 skills, JSON-RPC 2.0** | None | +| 🧰 Built-in MCP server | **87 tools, 3 transports, 30 scopes** | Rare | +| 🤝 A2A agent protocol | **6 skills, JSON-RPC 2.0** | None | | 🧠 Memory (FTS5 + vector) | **Yes** | Rare | | 🛡️ Guardrails (PII, injection, vision) | **Yes** | Rare | | ☁️ Cloud agents | **Codex, Devin, Jules** | None | @@ -420,9 +420,9 @@ Expose OmniRoute over **MCP** or **A2A** and any capable agent gets the keys to | Protocol | Endpoint | Use it for | | ------------------ | ----------------------------------------------- | ------------------------------------------------------ | | 🧰 **MCP (stdio)** | `omniroute --mcp` | Plug into Claude Desktop, Cursor, any MCP client | -| 🌊 **MCP (HTTP)** | `http://localhost:20128/api/mcp/stream` | Remote MCP — **37 tools**, 13 scopes, full audit trail | +| 🌊 **MCP (HTTP)** | `http://localhost:20128/api/mcp/stream` | Remote MCP — **87 tools**, 30 scopes, full audit trail | | 📡 **MCP (SSE)** | `http://localhost:20128/api/mcp/sse` | Streaming MCP transport | -| 🤝 **A2A** | `http://localhost:20128/.well-known/agent.json` | Agent-to-agent, **JSON-RPC 2.0** + SSE, 5 skills | +| 🤝 **A2A** | `http://localhost:20128/.well-known/agent.json` | Agent-to-agent, **JSON-RPC 2.0** + SSE, 6 skills | ```bash # Give Claude Code the full OmniRoute toolset over MCP: @@ -468,7 +468,7 @@ claude mcp add-server omniroute --type http --url http://localhost:20128/api/mcp
-### 📖 How it works — pipeline, architecture & savings math
+### 📖 How it works — pipeline, architecture & savings math ``` Client (10,000 tok) ──▶ OmniRoute Compression (7 options) ──▶ Provider (~1,080 tok, up to 95% saved) @@ -538,7 +538,7 @@ Use these only for clients that cannot attach `Authorization: Bearer ...`. Heade
-## 📦 More install methods — Docker, source, pnpm, Arch +## 📦 More install methods — Docker, source, pnpm, Arch **🐳 Docker** @@ -595,8 +595,6 @@ podman compose --profile base up -d 📖 [Podman Guide](contrib/podman/README.md) — Quadlet setup, podman-compose, Quadlet. - -
@@ -709,11 +707,13 @@ Compression: aggressive (~50%) → double your free quota · Cost: $0/mo
-**Routing:** 14 strategies · task-aware smart routing · thinking budget controls · wildcard routing · system prompt injection. +**Routing:** 15 strategies · task-aware smart routing · thinking budget controls · wildcard routing · system prompt injection. **Compatibility:** OpenAI ↔ Claude ↔ Gemini ↔ Responses API · auto OAuth refresh (PKCE, 8 providers) · multi-account round-robin · Batch + Files API · live OpenAPI 3.0. -**Protocols:** MCP (37 tools, 3 transports, 13 scopes) · A2A (JSON-RPC 2.0, SSE, skills) · ACP · cloud agents (Codex, Devin, Jules). +**Protocols:** MCP (87 tools, 3 transports, 30 scopes) · A2A (JSON-RPC 2.0, SSE, 6 skills) · ACP · cloud agents (Codex, Devin, Jules). +**Plugins:** custom plugin marketplace (system-configured registry URL with SSRF-guarded fetch) · install / enable / disable · Notion + Obsidian knowledge-base integrations (WebDAV file server, vault search, note CRUD). +**Embedded services:** one-click install & lifecycle management of local sidecar services (CLIProxy, NineRouter). **Quality & Ops:** built-in **Evals** (golden-set: exact/contains/regex/custom) · guardrails (PII, injection, vision) · health dashboard · p50/p95/p99 telemetry · webhooks · compliance audit. -**AI Agent Skills:** drop-in markdown manifests — point any agent at `skills/omniroute/SKILL.md`. 10 skills available. +**AI Agent Skills:** drop-in markdown manifests — point any agent at a `skills/*/SKILL.md` manifest. 43 skills available. 📖 [MCP Server](open-sse/mcp-server/README.md) · [A2A Server](src/lib/a2a/README.md) · [Resilience Guide](docs/architecture/RESILIENCE_GUIDE.md) · [Features Gallery](docs/guides/FEATURES.md) @@ -751,7 +751,7 @@ Compression: aggressive (~50%) → double your free quota · Cost: $0/mo | OAuth token expired | Auto-refreshed; if stuck, delete + re-auth in Providers | | `unsupported_country_region_territory` | Configure proxy in Settings → Proxy | | Docker SQLite locks | Use `--stop-timeout 40` for clean WAL checkpoint | -| Node runtime errors | Use Node `>=20.20.2 <21`, `>=22.22.2 <23`, or `>=24 <25` | +| Node runtime errors | Use Node `>=22.0.0 <23` or `>=24.0.0 <27` | 🐛 **Reporting a bug?** Run `npm run system-info` and attach `system-info.txt`. 📖 [`docs/guides/TROUBLESHOOTING.md`](docs/guides/TROUBLESHOOTING.md) @@ -795,8 +795,8 @@ Compression: aggressive (~50%) → double your free quota · Cost: $0/mo
-- **Runtime**: Node.js 20.20.2+, 22.22.2+, or 24.x LTS (24 LTS recommended) -- **Language**: TypeScript 5.9 — **100% TypeScript** across `src/` and `open-sse/` (zero `any` in core modules since v2.0) +- **Runtime**: Node.js 22.x or 24.x LTS (24 LTS recommended) — `>=22.0.0 <23 || >=24.0.0 <27` +- **Language**: TypeScript 6.0 — **100% TypeScript** across `src/` and `open-sse/` (zero `any` in core modules since v2.0) - **Framework**: Next.js 16 + React 19 + Tailwind CSS 4 - **Database**: better-sqlite3 (SQLite) + LowDB (JSON legacy) — domain state, proxy logs, MCP audit, routing decisions, memory, skills - **Schemas**: Zod (MCP tool I/O validation, API contracts) @@ -821,61 +821,62 @@ Compression: aggressive (~50%) → double your free quota · Cost: $0/mo ### 📘 Getting Started -| Document | Description | -| ------------------------------------- | ----------------------------------------------------------------------------- | -| [User Guide](docs/guides/USER_GUIDE.md) | Providers, combos, CLI integration, deployment | -| [Setup Guide](docs/guides/SETUP_GUIDE.md) | Full install methods, CLI tool configs, protocol setup, timeout tuning | +| Document | Description | +| ---------------------------------------------- | ----------------------------------------------------------------------------- | +| [User Guide](docs/guides/USER_GUIDE.md) | Providers, combos, CLI integration, deployment | +| [Setup Guide](docs/guides/SETUP_GUIDE.md) | Full install methods, CLI tool configs, protocol setup, timeout tuning | | [CLI Tools Guide](docs/reference/CLI-TOOLS.md) | Per-tool setup for Claude Code, Codex, Cursor, Cline, OpenClaw, Kilo, Copilot | -| [Quick Start](README.md#-quick-start) | 3-step install → connect → configure | +| [Quick Start](README.md#-quick-start) | 3-step install → connect → configure | ### 🔧 Operations & Deployment -| Document | Description | -| ---------------------------------------------------- | -------------------------------------------------------------- | -| [Docker Guide](docs/guides/DOCKER_GUIDE.md) | Docker run, Compose profiles, Caddy HTTPS, tunnels, image tags | -| [Podman Guide](contrib/podman/README.md) | Quadlet systemd integration, podman-compose, SELinux | +| Document | Description | +| -------------------------------------------------------- | -------------------------------------------------------------- | +| [Docker Guide](docs/guides/DOCKER_GUIDE.md) | Docker run, Compose profiles, Caddy HTTPS, tunnels, image tags | +| [Podman Guide](contrib/podman/README.md) | Quadlet systemd integration, podman-compose, SELinux | | [VM Deployment](docs/ops/VM_DEPLOYMENT_GUIDE.md) | Complete guide: VM + nginx + Cloudflare setup | | [Fly.io Deployment](docs/ops/FLY_IO_DEPLOYMENT_GUIDE.md) | Deploy to Fly.io with persistent storage | -| [Termux Guide](docs/guides/TERMUX_GUIDE.md) | Run OmniRoute on Android via Termux | -| [PWA Guide](docs/guides/PWA_GUIDE.md) | Progressive Web App install, caching, architecture | -| [Uninstall Guide](docs/guides/UNINSTALL.md) | Clean removal for all install methods | -| [Environment Config](docs/reference/ENVIRONMENT.md) | Complete `.env` variables and references | +| [Termux Guide](docs/guides/TERMUX_GUIDE.md) | Run OmniRoute on Android via Termux | +| [PWA Guide](docs/guides/PWA_GUIDE.md) | Progressive Web App install, caching, architecture | +| [Uninstall Guide](docs/guides/UNINSTALL.md) | Clean removal for all install methods | +| [Environment Config](docs/reference/ENVIRONMENT.md) | Complete `.env` variables and references | ### 🧠 Features & Architecture -| Document | Description | -| ---------------------------------------------------------------- | ----------------------------------------------------------------------------- | -| [Architecture](docs/architecture/ARCHITECTURE.md) | System architecture, data flow, and internals | -| [Compression Guide](docs/compression/COMPRESSION_GUIDE.md) | 7-option pipeline: off / lite / standard / aggressive / ultra / RTK / stacked | -| [RTK Compression](docs/compression/RTK_COMPRESSION.md) | Command-output compression, filters, trust, verify, raw-output recovery | -| [Compression Engines](docs/compression/COMPRESSION_ENGINES.md) | Caveman, RTK, stacked pipelines, dashboard/API/MCP surfaces | -| [Compression Rules Format](docs/compression/COMPRESSION_RULES_FORMAT.md) | JSON rule-pack schemas for Caveman and RTK filters | -| [Compression Language Packs](docs/compression/COMPRESSION_LANGUAGE_PACKS.md) | Language detection and Caveman rule-pack authoring | -| [Resilience Guide](docs/architecture/RESILIENCE_GUIDE.md) | Circuit breakers, cooldowns, queue, anti-thundering herd, TLS spoofing | -| [Auto-Combo Engine](docs/routing/AUTO-COMBO.md) | 6-factor scoring, mode packs, self-healing | -| [Proxy Guide](docs/ops/PROXY_GUIDE.md) | 3-level proxy system, 1proxy marketplace, registry CRUD | -| [Free Tiers](docs/reference/FREE_TIERS.md) | 25+ free API providers consolidated directory | -| [Features Gallery](docs/guides/FEATURES.md) | Visual dashboard tour with screenshots | -| [Codebase Documentation](docs/architecture/CODEBASE_DOCUMENTATION.md) | Beginner-friendly codebase walkthrough | +| Document | Description | +| ---------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | +| [Architecture](docs/architecture/ARCHITECTURE.md) | System architecture, data flow, and internals | +| [Compression Guide](docs/compression/COMPRESSION_GUIDE.md) | 7-option pipeline: off / lite / standard / aggressive / ultra / RTK / stacked | +| [RTK Compression](docs/compression/RTK_COMPRESSION.md) | Command-output compression, filters, trust, verify, raw-output recovery | +| [Compression Engines](docs/compression/COMPRESSION_ENGINES.md) | Caveman, RTK, stacked pipelines, dashboard/API/MCP surfaces | +| [Compression Rules Format](docs/compression/COMPRESSION_RULES_FORMAT.md) | JSON rule-pack schemas for Caveman and RTK filters | +| [Compression Language Packs](docs/compression/COMPRESSION_LANGUAGE_PACKS.md) | Language detection and Caveman rule-pack authoring | +| [Resilience Guide](docs/architecture/RESILIENCE_GUIDE.md) | Circuit breakers, cooldowns, queue, anti-thundering herd, TLS spoofing | +| [Auto-Combo Engine](docs/routing/AUTO-COMBO.md) | 9-factor scoring, mode packs, self-healing | +| [Proxy Guide](docs/ops/PROXY_GUIDE.md) | 3-level proxy system, 1proxy marketplace, registry CRUD | +| [Free Tiers](docs/reference/FREE_TIERS.md) | 25+ free API providers consolidated directory | +| [Features Gallery](docs/guides/FEATURES.md) | Visual dashboard tour with screenshots | +| [Codebase Documentation](docs/architecture/CODEBASE_DOCUMENTATION.md) | Beginner-friendly codebase walkthrough | ### 🤖 Protocols & APIs -| Document | Description | -| ------------------------------------------- | --------------------------------------------------- | -| [API Reference](docs/reference/API_REFERENCE.md) | All endpoints with examples | -| [OpenAPI Spec](docs/reference/openapi.yaml) | OpenAPI 3.0 specification | -| [MCP Server](open-sse/mcp-server/README.md) | 29 MCP tools, IDE configs, Python/TS/Go clients | -| [MCP Server Guide](docs/frameworks/MCP-SERVER.md) | MCP installation, transports, and tool reference | -| [A2A Server](src/lib/a2a/README.md) | JSON-RPC 2.0 protocol, skills, streaming, task mgmt | -| [A2A Server Guide](docs/frameworks/A2A-SERVER.md) | A2A agent card, tasks, skills, and streaming | +| Document | Description | +| ------------------------------------------------- | --------------------------------------------------- | +| [API Reference](docs/reference/API_REFERENCE.md) | All endpoints with examples | +| [OpenAPI Spec](docs/reference/openapi.yaml) | OpenAPI 3.0 specification | +| [MCP Server](open-sse/mcp-server/README.md) | 87 MCP tools, IDE configs, Python/TS/Go clients | +| [MCP Server Guide](docs/frameworks/MCP-SERVER.md) | MCP installation, transports, and tool reference | +| [A2A Server](src/lib/a2a/README.md) | JSON-RPC 2.0 protocol, skills, streaming, task mgmt | +| [A2A Server Guide](docs/frameworks/A2A-SERVER.md) | A2A agent card, tasks, skills, and streaming | ### 📋 Project & Quality -| Document | Description | -| ---------------------------------------------- | ----------------------------------------------- | -| [Contributing](CONTRIBUTING.md) | Development setup and guidelines | -| [Security Policy](SECURITY.md) | Vulnerability reporting and security practices | -| [i18n Guide](docs/guides/I18N.md) | 40+ language support, translation workflow, RTL | +| Document | Description | +| -------------------------------------------------- | ----------------------------------------------- | +| [Contributing](CONTRIBUTING.md) | Development setup and guidelines | +| [Changelog](CHANGELOG.md) | Full per-version release history | +| [Security Policy](SECURITY.md) | Vulnerability reporting and security practices | +| [i18n Guide](docs/guides/I18N.md) | 40+ language support, translation workflow, RTL | | [Release Checklist](docs/ops/RELEASE_CHECKLIST.md) | Pre-release validation steps | | [Coverage Plan](docs/ops/COVERAGE_PLAN.md) | Test coverage strategy and 4,690+ test suite | @@ -1031,7 +1032,7 @@ MIT License - see [LICENSE](LICENSE) for details. **[⬆ Back to top](#-omniroute)** · Built with ❤️ for the open-source AI community. -OmniRoute v3.8.6 · Node ≥22.22.2 · MIT License · omniroute.online +OmniRoute v3.8.24 · Node ≥22.0.0 · MIT License · omniroute.online
diff --git a/RELATORIO-QUALITY-GATES.md b/RELATORIO-QUALITY-GATES.md deleted file mode 100644 index 6ffb3b86e3..0000000000 --- a/RELATORIO-QUALITY-GATES.md +++ /dev/null @@ -1,219 +0,0 @@ -# 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/bin/cli/runtime/processSupervisor.mjs b/bin/cli/runtime/processSupervisor.mjs index 135514cfbd..b16de9670e 100644 --- a/bin/cli/runtime/processSupervisor.mjs +++ b/bin/cli/runtime/processSupervisor.mjs @@ -42,17 +42,20 @@ export class ServerSupervisor { }); } - this.child.on("error", (err) => this.handleExit(err.code ?? -1, err)); + this.child.on("error", (err) => this.handleExit(-1, err)); this.child.on("exit", (code) => this.handleExit(code)); return this.child; } handleExit(code) { + // Node.js v24+ requires process.exit() to receive a number. Spawn-error events + // deliver err.code (a string like 'ENOENT') via the 'error' listener; normalise here. + const exitCode = typeof code === "number" ? code : null; cleanupPidFile("server"); - if (this.isShuttingDown || code === 0) { - process.exit(code || 0); + if (this.isShuttingDown || exitCode === 0) { + process.exit(exitCode ?? 0); return; } @@ -71,7 +74,7 @@ export class ServerSupervisor { } } this.dumpCrashLog(); - process.exit(code ?? 1); + process.exit(exitCode ?? 1); return; } diff --git a/bin/mcp-server.mjs b/bin/mcp-server.mjs index b85a53dba7..2a79f151d6 100644 --- a/bin/mcp-server.mjs +++ b/bin/mcp-server.mjs @@ -9,16 +9,22 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const ROOT = join(__dirname, ".."); -function resolveMcpEntry(rootDir = ROOT) { +/** + * @param {string} [rootDir] - project root directory (defaults to package root) + * @param {(path: string) => boolean} [existsSyncFn] - injectable fs.existsSync + * for testing; defaults to the real existsSync + * @returns {string|null} resolved absolute path to the MCP server entry, or null + */ +export function resolveMcpEntry(rootDir = ROOT, existsSyncFn = existsSync) { const candidates = [ - // Preferred distributable JS entry (npm publish artifact) - join(rootDir, "app", "open-sse", "mcp-server", "server.js"), + // Preferred distributable JS entry (npm publish artifact, built by prepublish.ts) + join(rootDir, "dist", "open-sse", "mcp-server", "server.js"), // Local workspace TypeScript source fallback join(rootDir, "open-sse", "mcp-server", "server.ts"), ]; for (const entry of candidates) { - if (existsSync(entry)) return entry; + if (existsSyncFn(entry)) return entry; } return null; } @@ -32,7 +38,7 @@ export async function startMcpCli(rootDir = ROOT) { const mcpEntry = resolveMcpEntry(rootDir); if (!mcpEntry) { throw new Error( - "MCP server entrypoint not found. Expected app/open-sse/mcp-server/server.js or open-sse/mcp-server/server.ts." + "MCP server entrypoint not found. Expected dist/open-sse/mcp-server/server.js or open-sse/mcp-server/server.ts." ); } diff --git a/complexity-baseline.json b/complexity-baseline.json index d99dc089a4..6305923730 100644 --- a/complexity-baseline.json +++ b/complexity-baseline.json @@ -1,5 +1,6 @@ { - "_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": 1746, - "_rebaseline_2026_06_10": "Re-baseline consciente: 1739 foi medido na branch das Fases 0-6 (base ~v3.8.17); a v3.8.18 publicada ja carrega 1746 (provado: o commit-base 5f2722bd6, anterior a qualquer commit do ciclo v3.8.19, mede 1746 — funcoes complexas dos reworks RequestLoggerV2/stream/combo). Mesma familia dos re-baselines de eslintWarnings/file-size. Reducao = Fase 6A (2026-06-16)." + "_comment": "Catraca de complexidade (check-complexity.mjs, ESLint core rules complexity>=15 e max-lines-per-function>80 sobre src+open-sse+electron+bin via eslint.complexity.config.mjs). Conta total de violacoes; so pode cair. --update ratcheta.", + "count": 1794, + "_rebaseline_2026_06_10": "Re-baseline consciente: 1739 foi medido na branch das Fases 0-6 (base ~v3.8.17); a v3.8.18 publicada ja carrega 1746 (provado: o commit-base 5f2722bd6, anterior a qualquer commit do ciclo v3.8.19, mede 1746 — funcoes complexas dos reworks RequestLoggerV2/stream/combo). Mesma familia dos re-baselines de eslintWarnings/file-size. Reducao = Fase 6A (2026-06-16).", + "_rebaseline_2026_06_13_6a11": "Re-baseline consciente Task 6A.11: escopo ampliado para src+open-sse+electron+bin (electron/bin contribuem 0 violacoes novas — todos os 4 arquivos .ts em bin/ estao abaixo dos thresholds). Drift 1746→1794 pre-existente de features mergeadas nos ciclos v3.8.22/v3.8.23 (nao causado por esta task). Congelado no valor real medido para destrancar o gate." } diff --git a/dependency-allowlist.json b/dependency-allowlist.json index bfc1b1fdf6..ea27e5fc57 100644 --- a/dependency-allowlist.json +++ b/dependency-allowlist.json @@ -10,6 +10,7 @@ "@modelcontextprotocol/sdk", "@monaco-editor/react", "@ngrok/ngrok", + "@opencode-ai/plugin", "@playwright/test", "@swc/helpers", "@tailwindcss/postcss", @@ -35,11 +36,13 @@ "concurrently", "cross-env", "csv-stringify", + "dpdm", "electron", "electron-builder", "electron-updater", "eslint", "eslint-config-next", + "eslint-plugin-sonarjs", "express", "fetch-socks", "fflate", @@ -57,14 +60,19 @@ "ioredis", "jose", "js-yaml", + "jscpd", "jsdom", "jsonc-parser", "keytar", + "knip", + "license-checker-rseidelsohn", "lint-staged", + "lockfile-lint", "lowdb", "lucide-react", "marked", "marked-terminal", + "material-symbols", "mermaid", "monaco-editor", "next", @@ -87,11 +95,14 @@ "react-reconciler", "recharts", "selfsigned", + "size-limit", "sql.js", "sqlite-vec", "tailwindcss", "tls-client-node", + "tsup", "tsx", + "type-coverage", "typescript", "typescript-eslint", "undici", diff --git a/docs/AGENTROUTER.md b/docs/AGENTROUTER.md index 715e19385e..21a08589a1 100644 --- a/docs/AGENTROUTER.md +++ b/docs/AGENTROUTER.md @@ -121,15 +121,15 @@ provider. For reference, the cc-compatible bridge sends the following on each upstream request (see `open-sse/services/claudeCodeCompatible.ts`): -| Header | Value | -| ------------------------------------------- | ------------------------------------------------------------------------ | -| `Authorization` | `Bearer ` | -| `User-Agent` | `claude-cli/2.1.137 (external, sdk-cli)` | -| `anthropic-version` | `2023-06-01` | -| `anthropic-beta` | `claude-code-20250219,interleaved-thinking-2025-05-14,effort-2025-11-24` | -| `anthropic-dangerous-direct-browser-access` | `true` | -| `x-app` | `cli` | -| `X-Stainless-*` | Various Stainless SDK headers (lang, package version, OS, arch, etc.) | +| Header | Value | +| ------------------------------------------- | --------------------------------------------------------------------------------------------------- | +| `Authorization` | `Bearer ` | +| `User-Agent` | `claude-cli/2.1.158 (external, sdk-cli)` | +| `anthropic-version` | `2023-06-01` | +| `anthropic-beta` | `claude-code-20250219,interleaved-thinking-2025-05-14,effort-2025-11-24,redact-thinking-2026-02-12` | +| `anthropic-dangerous-direct-browser-access` | `true` | +| `x-app` | `cli` | +| `X-Stainless-*` | Various Stainless SDK headers (lang, package version, OS, arch, etc.) | This is what allows requests to pass the upstream WAF / client whitelist. diff --git a/docs/README.md b/docs/README.md index b69e0ac9a6..4c85a157a1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,7 +1,7 @@ --- title: "OmniRoute Documentation" -version: 3.8.2 -lastUpdated: 2026-05-13 +version: 3.8.24 +lastUpdated: 2026-06-13 --- # OmniRoute Documentation @@ -50,7 +50,7 @@ Simple guides for using OmniRoute — no technical background needed. Technical documentation for developers and contributors. -## architecture/ +## reference/ Lookup material — API surface, environment variables, CLI flags, provider catalog. diff --git a/docs/SUBMIT_PR.md b/docs/SUBMIT_PR.md index fbda682bb6..73c1aa88c3 100644 --- a/docs/SUBMIT_PR.md +++ b/docs/SUBMIT_PR.md @@ -22,18 +22,18 @@ npm install ## 2 — Sync with the current release branch -PRs go to **`release/v3.8.3`**, not `main`. +PRs go to the **current release branch** — `release/v3.8.24` at the time of writing. Always target the latest `release/v*` branch (the repository's active development branch), never `main`. Check the repo's branch list or `CONTRIBUTING.md` if a newer release cycle has opened. ```bash git fetch upstream -git checkout -b fix/your-description upstream/release/v3.8.3 +git checkout -b fix/your-description upstream/release/v3.8.24 ``` If you already made your changes on another branch, rebase on top of it: ```bash git fetch upstream -git rebase upstream/release/v3.8.3 +git rebase upstream/release/v3.8.24 ``` --- @@ -59,7 +59,7 @@ Examples: `fix/codex-token-refresh`, `feat/provider-xyz`, `docs/update-readme` npm run lint # must pass (0 errors) npm run typecheck:core # must pass npm run test:unit # must pass -npm run test:coverage # coverage gate: 75/75/75/70 +npm run test:coverage # coverage gate: 60/60/60/60 (statements/lines/functions/branches) ``` If you changed production code in `src/`, `open-sse/`, `electron/`, or `bin/`, include or update tests in the same PR. @@ -87,7 +87,7 @@ Common scopes: `api`, `dashboard`, `db`, `sse`, `oauth`, `providers`, `combo`, ` git push -u origin fix/your-description ``` -Then open a PR on GitHub targeting **`diegosouzapw/OmniRoute`** → **`release/v3.8.3`**. +Then open a PR on GitHub targeting **`diegosouzapw/OmniRoute`** → **`release/v3.8.24`**. PR description checklist: @@ -105,7 +105,7 @@ PR description checklist: ```bash git fetch upstream -git rebase upstream/release/v3.8.3 +git rebase upstream/release/v3.8.24 git push --force-with-lease ``` diff --git a/docs/architecture/QUALITY_GATES.md b/docs/architecture/QUALITY_GATES.md new file mode 100644 index 0000000000..0691ac8b9b --- /dev/null +++ b/docs/architecture/QUALITY_GATES.md @@ -0,0 +1,187 @@ +--- +title: Quality Gates Reference +--- + +# Quality Gates Reference + +This document is the authoritative reference for all CI quality gates in OmniRoute. +It describes each gate, what it validates, which CI job it runs in, whether it uses +a ratchet baseline or a pass/fail policy, and whether it blocks the build or is advisory. + +For a short summary and the allowlist policy, see the "Quality Gates & Ratchets" section +in `CLAUDE.md`. + +--- + +## Gate Inventory (35 scripts) + +Scripts live under `scripts/check/` (policy gates) and `scripts/quality/` (ratchet engine). +The CI source of truth is `.github/workflows/ci.yml`. + +### Job: `lint` + +Runs on every PR to `main`. Blocks merge on failure. + +| Script (`npm run ...`) | Validates | Blocking | +| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------- | +| `check:node-runtime` | Node.js version is within the supported range | Yes | +| `check:cycles` | Circular imports — all `src/` + `open-sse/` modules | Yes | +| `check:route-validation:t06` | Zod schemas present on all routes (Tier 6 policy) | Yes | +| `check:any-budget:t11` | `@ts-expect-error // any` count does not exceed budget (Tier 11 catraca) | Yes | +| `check:provider-consistency` | Every provider in `providers.ts` has a matching entry in `providerRegistry.ts` (and vice-versa, within the allowlist) | Yes | +| `check:fetch-targets` | Every `fetch("/api/...")` in client-side `src/` resolves to a real `route.ts` | Yes | +| `check:deps` | All `npm install`-able deps across every `package.json` in the repo are in `dependency-allowlist.json`; new unpinned or slopsquatted packages flagged | Yes | +| `check:file-size` | No source file exceeds the per-extension cap (ratchet: frozen large files in `frozen` list) | Yes | +| `check:error-helper` | Error responses in executors/handlers use `buildErrorBody()` / `sanitizeErrorMessage()` (Hard Rule #12) | Yes | +| `check:migration-numbering` | Migration SQL files are sequentially numbered, no gaps or duplicates | Yes | +| `check:public-creds` | No literal OAuth `client_id`/`client_secret` or Firebase Web keys outside `publicCreds.ts` (Hard Rule #11) | Yes | +| `check:db-rules` | No raw SQL outside `src/lib/db/` modules; no barrel-imports from `localDb.ts` (Hard Rules #2/#5) | Yes | +| `check:known-symbols` | Provider executors, routing strategies, and translators registered in their dispatch tables match the files on disk — no orphaned or undeclared symbols | Yes | +| `check:route-guard-membership` | Every route that spawns a child process is classified by `isLocalOnlyPath()` (Hard Rules #15/#17) | Yes | +| `check:test-discovery` | Every `*.test.ts` / `*.spec.ts` file in the repo is collected by at least one test runner (ratchet: orphan list in `test-discovery-baseline.json` can only shrink) | Yes | +| `check:docs-sync` | CHANGELOG version, OpenAPI version, and `llm.txt` are in sync | Yes | +| `typecheck:core` | TypeScript compilation without errors (advisory warnings only) | Yes | +| `typecheck:noimplicit:core` | Strict `noImplicitAny` — forward-looking; many pre-existing call sites still need annotations | **Advisory** (`continue-on-error: true`) | + +### Job: `quality-gate` + +Runs after `test-coverage`. Blocks merge on failure. + +| Script | Validates | Blocking | +| ------------------- | ---------------------------------------------------------------------------------------------------------- | ------------------------- | +| `quality:collect` | Emits `quality-metrics.json` (ESLint warning count, coverage from merged shard report) | Yes (upstream of ratchet) | +| `quality:ratchet` | Each metric in `quality-baseline.json` has not regressed (ESLint warnings ≤ baseline; coverage ≥ baseline) | Yes | +| `check:duplication` | Code duplication (jscpd@4) does not exceed baseline in `quality-baseline.json` | Yes | +| `check:complexity` | File-level cyclomatic complexity does not exceed the cap | Yes | + +### Job: `docs-sync-strict` + +Runs on every PR to `main`. Blocks merge on failure. + +| Script | Validates | Blocking | +| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------- | +| `check:docs-all` | Meta-gate that runs the 6 sub-gates below sequentially | Yes | +| ↳ `check:docs-sync` | CHANGELOG / OpenAPI / llm.txt version consistency | Yes | +| ↳ `check:docs-counts` | Counts in prose (provider count, migration count, etc.) are within the ratchet window of the real counts | Yes | +| ↳ `check:env-doc-sync` | Every env var in `.env.example` is documented in a docs table, and vice versa | Yes | +| ↳ `check:deprecated-versions` | No deprecated version strings in docs | Yes | +| ↳ `check:doc-links` | Internal markdown links in docs resolve to real files (`[text]`/`(path)` form) | Yes | +| ↳ `check:fabricated-docs` | Routes, env vars, CLI commands, hook names, and file paths cited in docs exist in the codebase. Hard gate via `--strict`; soft-fail without flag. | Yes (via `--strict` in CI) | +| `check:cli-i18n` | CLI command strings are present in all i18n locale files | Yes | +| `check:openapi-coverage` | OpenAPI spec covers at least a ratcheted floor of real routes | Yes | +| `check:openapi-security-tiers` | Security tier annotations in `openapi.yaml` are consistent with `routeGuard.ts` classifications | **Advisory** | +| `check:openapi-routes` | Every path in `openapi.yaml` resolves to a real `route.ts` (anti-hallucination) | Yes | +| `check:docs-symbols` | Every `/api/...` reference in `docs/**/*.md` resolves to a real `route.ts` (anti-hallucination) | Yes | +| `i18n translation drift` | Untranslated keys in i18n locale files — warn only | **Advisory** | + +### Job: `i18n-ui-coverage` + +| Script | Validates | Blocking | +| --------------------------------- | ----------------------------- | -------- | +| `check-ui-keys-coverage` (inline) | UI i18n key coverage is ≥ 65% | Yes | + +### Job: `i18n` + +Full i18n validation matrix (one job per locale). Entire job is advisory. + +| Script | Validates | Blocking | +| ------------------------------- | ----------------------------------- | ----------------------------------------------------- | +| `validate_translation.py quick` | Translation completeness per locale | **Advisory** (`continue-on-error: true` on whole job) | + +### Job: `pr-test-policy` + +Runs on pull requests only. + +| Script | Validates | Blocking | +| ---------------------- | -------------------------------------------------------------------------------------------------------------------------- | -------- | +| `check:pr-test-policy` | PRs that change production code in `src/`, `open-sse/`, `electron/`, or `bin/` must include or update tests (Hard Rule #8) | Yes | +| `check:test-masking` | Changed test files do not reduce net assert count or add `assert.ok(true)` tautologies | Yes | + +### Job: `test-vitest` + +Runs after `build`. Blocks merge on failure. + +| Suite | Validates | Blocking | +| ---------------- | ------------------------------------------------------- | -------------------------------------------------------------------------- | +| `test:vitest` | MCP server (43 tools), autoCombo, cache — vitest runner | Yes | +| `test:vitest:ui` | UI component tests — vitest runner | **Advisory** (`continue-on-error: true`) — failing until Fase 6A UI triage | + +--- + +## Ratchet Baseline (`quality-baseline.json`) + +The ratchet engine (`scripts/quality/check-quality-ratchet.mjs`) reads `quality-baseline.json` +and compares it against the freshly collected `quality-metrics.json`. Any metric that regresses +beyond its epsilon fails the build. + +Current tracked metrics: + +| Metric | Direction | Meaning | +| --------------------- | --------- | ---------------------------------- | +| `eslintWarnings` | `down` | ESLint warning count must not grow | +| `coverage.statements` | `up` | Statement coverage must not fall | +| `coverage.lines` | `up` | Line coverage must not fall | +| `coverage.functions` | `up` | Function coverage must not fall | +| `coverage.branches` | `up` | Branch coverage must not fall | + +To update the baseline after a genuine improvement: + +```bash +npm run quality:ratchet -- --update +git add quality-baseline.json +``` + +The `--update` flag writes the current measured values into `quality-baseline.json`. +Commit this file alongside the change that improved the metric. A PR that improves a +metric without updating the baseline will be caught by `--require-tighten` (Fase 6A.5, +pending implementation). + +--- + +## Allowlist Policy + +Every gate that cannot fail on pre-existing violations uses a frozen allowlist +(e.g., `KNOWN_STALE_DOC_REFS`, `KNOWN_MISSING`, `KNOWN_RAW_SQL`). The policy is: + +**Fix the root cause; use the allowlist only when the violation is pre-existing and +cannot be fixed in the same PR.** + +When adding an entry to an allowlist: + +1. Include a comment with the justification. +2. Reference the tracking issue (e.g., `// #3498 — Phase 2 feature, not yet implemented`). +3. Remove the entry in the same PR that fixes the violation — a stale entry that no longer + suppresses an active violation is itself a defect (6A.3 stale-enforcement will + fail the gate on an orphaned allowlist entry once implemented). + +Do **not** add allowlist entries to make tests pass faster. A green gate with a growing +allowlist is a false sense of quality. + +### When a gate fails on your PR + +1. **Read the gate output carefully** — it tells you exactly which file or symbol violated + the rule. +2. **Fix the violation** — most gates are deterministic filesystem checks that pass as soon + as the code is correct. +3. **If the violation is pre-existing** (i.e., you did not introduce it but the gate now + covers it): add an allowlist entry with a justification comment and a tracking issue. +4. **If the gate is a ratchet** (coverage, ESLint warnings, duplication, complexity): + your change made the metric worse. Fix the underlying issue, or (rarely) run + `npm run quality:ratchet -- --update` if the change is intentional and the metric + degradation is acceptable — but document why in the PR description. +5. **Advisory gates** (`continue-on-error: true`) are informational — they do not block + merge but appear in the CI summary. Fix them anyway. + +--- + +## Adding a New Gate + +1. Create `scripts/check/check-.mjs` (or `.ts`). Policy gates exit 0/1. + Ratchet-style gates emit a metric to `quality-metrics.json` via `collect-metrics.mjs`. +2. Add `"check:": "node scripts/check/check-.mjs"` to `package.json`. +3. Wire it in `.github/workflows/ci.yml` under the appropriate job + (policy → `lint` or `docs-sync-strict`; ratchet → `quality-gate`). +4. If it has an allowlist, apply `reportStaleEntries()` from + `scripts/check/lib/allowlist.mjs` so stale entries are detected automatically. +5. Write a test in `tests/unit/build/` covering the gate's detection logic. +6. Update this document (add a row to the relevant job table). diff --git a/docs/architecture/RESILIENCE_GUIDE.md b/docs/architecture/RESILIENCE_GUIDE.md index 8424e50808..cd9f2c71df 100644 --- a/docs/architecture/RESILIENCE_GUIDE.md +++ b/docs/architecture/RESILIENCE_GUIDE.md @@ -124,6 +124,7 @@ Lists active lockouts with: provider, connection, model, reason, expiresAt. Oper - **Reset-aware routing** (v3.8.0) — prioritizes connections by quota reset time. - **Background mode degradation** — Responses API `background: true` degraded to sync with warning. - **Dynamic tool limit detection** — backs off providers when tool count limits hit. +- **Emergency fallback** — controlled by `OMNIROUTE_EMERGENCY_FALLBACK`; operators can override it from the Feature Flags page without a restart. --- diff --git a/docs/architecture/meta.json b/docs/architecture/meta.json index 11bda7a2b9..08128f7ec4 100644 --- a/docs/architecture/meta.json +++ b/docs/architecture/meta.json @@ -5,6 +5,7 @@ "AUTHZ_GUIDE", "CODEBASE_DOCUMENTATION", "REPOSITORY_MAP", - "RESILIENCE_GUIDE" + "RESILIENCE_GUIDE", + "QUALITY_GATES" ] } diff --git a/docs/bdd/proxy-egress-isolation.feature b/docs/bdd/proxy-egress-isolation.feature new file mode 100644 index 0000000000..5c980d4f01 --- /dev/null +++ b/docs/bdd/proxy-egress-isolation.feature @@ -0,0 +1,29 @@ +Feature: Proxy egress isolation and IPv6-only enforcement + As an operator routing provider traffic through proxies + I want every request to egress only through the assigned proxy (and only over IPv6 when an IPv6 proxy is set) + So that the real host IP and IPv4 are never leaked + + Scenario: IPv6-literal proxy de-brackets and connects over IPv6 + Given a proxy configured as socks5://[2001:db8::1]:1080 + When the dispatcher options are built + Then the SOCKS host is 2001:db8::1 and the resolved family is 6 + + Scenario: IPv6 hostname proxy forces IPv6-only egress + Given a proxy hostname with family=ipv6 + When the dispatcher family is resolved + Then the connect family is pinned to 6 + + Scenario: IPv6-only egress is fail-closed when no AAAA exists + Given a proxy hostname with family=ipv6 and no AAAA record + When the family is asserted + Then it is refused and never egresses over IPv4 + + Scenario: Family directive contradicting a literal is rejected + Given a proxy 203.0.113.7 with family=ipv6 + When the dispatcher family is resolved + Then it throws a configuration error + + Scenario: Web TLS clients are fail-closed + Given a web TLS client whose proxy resolution throws + When the proxy URL is resolved + Then it throws instead of connecting directly diff --git a/docs/bdd/self-service-api-key-usage.feature b/docs/bdd/self-service-api-key-usage.feature deleted file mode 100644 index baad4c4faf..0000000000 --- a/docs/bdd/self-service-api-key-usage.feature +++ /dev/null @@ -1,144 +0,0 @@ -Feature: Self-service API key usage and account quota visibility - - Background: - Given OmniRoute has usage accounting enabled - And management APIs require a dashboard session or a key with "manage" or "admin" - - Scenario: A delegated key reads its own cost and token usage - Given an API key named "team-a" has the scope "self:usage" - And "team-a" already has a monthly USD budget of 50 configured in the existing budget UI - And "team-a" has current-period spend of 12.50 USD - And "team-a" has current-period token usage: - | input | output | cache_read | cache_creation | reasoning | - | 900000 | 32000 | 120000 | 10000 | 5000 | - When "team-a" calls GET "/api/v1/me/status" with its Bearer token - Then the response status should be 200 - And the response apiKey.name should be "team-a" - And the response usage.cost.limitUsd should be 50 - And the response usage.cost.usedUsd should be 12.50 - And the response usage.cost.usedPercent should be 25 - And the response usage.tokens.totalTokens should be 1067000 - - Scenario: A delegated key cannot query another key by id - Given an API key named "team-a" has the scope "self:usage" - And an API key named "team-b" has the scope "self:usage" - And "team-b" has current-period spend of 99.00 USD - When "team-a" calls GET "/api/v1/me/status?apiKeyId=" with its Bearer token - Then the response status should be 200 - And the response apiKey.name should be "team-a" - And the response should not contain "team-b" - And the response should not contain "99.00" as team-b usage - - Scenario: Anonymous client API mode does not expose self-service status - Given global client API auth allows anonymous local traffic - When an anonymous caller calls GET "/api/v1/me/status" - Then the response status should be 401 - - Scenario: Self-service usage scope does not grant management access - Given an API key named "team-a" has the scope "self:usage" - And "team-a" does not have the scope "manage" - And "team-a" does not have the scope "admin" - When "team-a" calls GET "/api/usage/history" with its Bearer token - Then the response status should be 403 - - Scenario: Own usage visibility can be disabled - Given an API key named "team-a" does not have the scope "self:usage" - When "team-a" calls GET "/api/v1/me/status" with its Bearer token - Then the response status should be 403 - - Scenario: Existing ordinary keys are backfilled for own usage visibility - Given an ordinary API key named "legacy-key" existed before self-service usage scopes - And "legacy-key" does not have the scope "self:usage" - When OmniRoute runs the compatibility migration - Then "legacy-key" should have the scope "self:usage" - And "legacy-key" should not have the scope "self:account-quota" - - Scenario: Shared account quota is hidden by default - Given an API key named "team-a" has the scope "self:usage" - And "team-a" does not have the scope "self:account-quota" - And "team-a" is restricted to a Codex connection with available quota - When "team-a" calls GET "/api/v1/me/status" with its Bearer token - Then the response status should be 200 - And the response should not include shared account quota details - - Scenario: Shared provider account quotas are visible with explicit permission - Given an API key named "team-a" has the scope "self:usage" - And "team-a" has the scope "self:account-quota" - And "team-a" is restricted to a Codex connection and a Claude connection - And Codex reports a session quota with 1 percent used - And Claude reports a daily quota with 35 percent used - When "team-a" calls GET "/api/v1/me/status" with its Bearer token - Then the response status should be 200 - And the response accountQuotas should contain 2 entries - And the first response accountQuotas entry provider should be "codex" - And the first response accountQuotas entry quotas.session.remainingPercentage should be 99 - And the second response accountQuotas entry provider should be "claude" - And the second response accountQuotas entry quotas.daily.remainingPercentage should be 65 - - Scenario: A single allowed provider also keeps the compatibility accountQuota field - Given an API key named "team-a" has the scope "self:usage" - And "team-a" has the scope "self:account-quota" - And "team-a" is restricted to exactly one Codex connection - And Codex reports a weekly quota with 97 percent used - When "team-a" calls GET "/api/v1/me/status" with its Bearer token - Then the response status should be 200 - And the response accountQuotas should contain 1 entry - And the response accountQuota.provider should be "codex" - And the response accountQuota.quotas.weekly.remainingPercentage should be 3 - - Scenario: Unrestricted keys can see all active provider account quotas - Given an API key named "team-a" has the scope "self:usage" - And "team-a" has the scope "self:account-quota" - And "team-a" has no explicit allowed connection restrictions - And OmniRoute has active Codex and Cursor provider connections with quota data - When "team-a" calls GET "/api/v1/me/status" with its Bearer token - Then the response status should be 200 - And the response accountQuotas should contain the Codex account quota - And the response accountQuotas should contain the Cursor account quota - - Scenario: Provider connection lookup failures do not hide own usage - Given an API key named "team-a" has the scope "self:usage" - And "team-a" has the scope "self:account-quota" - And "team-a" is restricted to a Codex connection and another provider connection - And OmniRoute cannot resolve the other provider connection metadata - When "team-a" calls GET "/api/v1/me/status" with its Bearer token - Then the response status should be 200 - And the response should still include own cost and token usage - And the unresolved response accountQuotas entry should have available false - And the unresolved response accountQuotas entry reason should be "connection_lookup_failed" - - Scenario: Existing budget endpoint stays management-only - Given an API key named "team-a" has the scope "self:usage" - And "team-a" does not have the scope "manage" - When "team-a" calls GET "/api/usage/budget?apiKeyId=" with its Bearer token - Then the response status should be 403 - - Scenario: API Manager defaults are privacy-preserving - Given an operator opens the create API key dialog - Then the own cost and token usage visibility control should be checked - And the shared account quota visibility control should be unchecked - And management access should be unchecked - And the dialog should not include a second budget editor - - Scenario: API Manager preserves unrelated scopes - Given an API key has scopes: - | scope | - | self:usage | - | custom:scope | - When an operator enables shared account quota in the permissions dialog - And saves the permissions - Then the API key scopes should include "self:usage" - And the API key scopes should include "self:account-quota" - And the API key scopes should include "custom:scope" - - Scenario: API Manager uses existing budget configuration - Given an operator wants to set a monthly USD budget for an API key - When the operator uses the dashboard - Then the operator should use the existing budget configuration surface - And the create key dialog should not save budget limits - - Scenario: New API Manager text is localized - Given the dashboard locale is not English - When the API Manager renders self-service visibility controls - Then the labels should come from the API Manager translation namespace - And the component should not render hard-coded English strings for the new controls diff --git a/docs/compression/EXTENDING_COMPRESSION.md b/docs/compression/EXTENDING_COMPRESSION.md new file mode 100644 index 0000000000..e3bbd6e65e --- /dev/null +++ b/docs/compression/EXTENDING_COMPRESSION.md @@ -0,0 +1,550 @@ +--- +title: "Extending the Compression Pipeline" +version: 3.8.16 +lastUpdated: 2026-06-08 +--- + +# Extending the Compression Pipeline + +> **TL;DR**: OmniRoute's compression engine is **pluggable** — you can register custom engines, ship language packs for new languages, and compose stacked pipelines. This guide shows how. + +**Related guides:** +- [COMPRESSION_GUIDE.md](./COMPRESSION_GUIDE.md) — Full pipeline overview +- [COMPRESSION_ENGINES.md](./COMPRESSION_ENGINES.md) — Engine registry and built-in engines +- [RTK_COMPRESSION.md](./RTK_COMPRESSION.md) — RTK engine and custom filters +- [COMPRESSION_RULES_FORMAT.md](./COMPRESSION_RULES_FORMAT.md) — Rule pack format reference + +--- + +## Overview + +The compression system has **3 extension points**: + +| Extension point | Use case | Difficulty | +|-----------------|----------|------------| +| **Custom engine** | Add a brand-new compression algorithm (e.g., domain-specific summarizer) | Advanced | +| **Language pack** | Add support for a new natural language (e.g., Hindi, Arabic) | Medium | +| **Stacked pipeline** | Compose existing engines in a custom order | Beginner | + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Compression Strategy │ +│ │ +│ Input messages ──▶ getEffectiveMode() ──▶ mode │ +│ │ │ +│ ┌───────────────────────┼──────────┐ │ +│ │ │ │ │ │ │ +│ ▼ ▼ ▼ ▼ │ │ +│ "rtk" "lite" "standard" "stacked" │ +│ │ │ │ │ │ │ +│ ▼ ▼ ▼ ▼ │ │ +│ RTK Lite Caveman engines[] │ +│ engine engine engine chained │ +│ │ │ │ │ │ │ +│ └─────────┴─────────┴─────────┘ │ │ +│ │ │ +│ ▼ │ +│ Compressed output │ +└─────────────────────────────────────────────────────────────┘ + +The strategy selector is MODE-BASED: each request selects ONE mode +(rtk / lite / standard / aggressive / ultra / stacked / off). +Only mode "stacked" chains multiple engines in sequence. +Default auto-trigger mode is "lite" (not a 3-tier priority chain). +``` + +--- + +## Writing a Custom Compression Engine + +The engine interface (`open-sse/services/compression/engines/types.ts`) is the contract every engine must satisfy. It has 5 required methods. + +### The `CompressionEngine` Interface + +```ts +interface CompressionEngine { + id: string; // Unique engine ID + name: string; // Display name + description: string; // Short description + icon: string; // Icon (emoji or URL) + targets: CompressionEngineTarget[]; // ["messages", "tool_results", "code_blocks"] + stackable: boolean; // Can be used in a stacked pipeline + stackPriority: number; // Order in stacked pipelines (lower = earlier) + metadata: CompressionEngineMetadata; + + apply(body, options?): CompressionResult; + compress(body, config?): CompressionResult; + getConfigSchema(): EngineConfigField[]; + validateConfig(config): EngineValidationResult; +} +``` + +### Minimal Example: Whitespace Engine + +The simplest possible engine — strip extra whitespace from messages. + +```ts +import type { CompressionEngine } from "omniroute/compression/engines/types"; +import { registerCompressionEngine } from "omniroute/compression/engines/registry"; + +function preserveCodeBlocks(text: string): string { + // Split by code block markers and preserve whitespace inside them + const parts = text.split(/(```[\s\S]*?```)/); + return parts + .map((part) => { + if (part.startsWith("```")) { + return part; // Don't modify code blocks + } + return part.replace(/\n{3,}/g, "\n\n"); // Only apply to prose + }) + .join(""); +} + +const whitespaceEngine: CompressionEngine = { + id: "whitespace", + name: "Whitespace Stripper", + description: "Removes extra whitespace and blank lines", + icon: "📝", + targets: ["messages", "tool_results"], + stackable: true, + stackPriority: 100, // Run AFTER caveman/rtk + + metadata: { + id: "whitespace", + name: "Whitespace Stripper", + description: "Removes extra whitespace and blank lines", + inputScope: "messages", + targetLatencyMs: 5, + supportsPreview: true, + stable: true, + }, + + apply(body, options) { + return this.compress(body, options?.config); + }, + + compress(body, config = {}) { + let originalLength = 0; + let compressedLength = 0; + + // Traverse message array — handle both string and multipart content + const compressedBody = (body.messages || []).map((msg) => { + if (typeof msg.content === "string") { + originalLength += msg.content.length; + let compressed = msg.content + .replace(/[ \t]+/g, " ") + .replace(/\n{3,}/g, "\n\n") + .replace(/^\s+|\s+$/gm, ""); + compressedLength += compressed.length; + return { ...msg, content: compressed }; + } + // Multipart content: traverse parts, compress text parts only + if (Array.isArray(msg.content)) { + const newParts = msg.content.map((part) => { + if (part.type === "text" && typeof part.text === "string") { + originalLength += part.text.length; + let compressed = part.text + .replace(/[ \t]+/g, " ") + .replace(/\n{3,}/g, "\n\n") + .replace(/^\s+|\s+$/gm, ""); + compressedLength += compressed.length; + return { ...part, text: compressed }; + } + return part; // preserve image_url, tool_use, etc. + }); + return { ...msg, content: newParts }; + } + return msg; + }); + + return { + body: { ...body, messages: compressedBody }, + stats: { + originalTokens: Math.ceil(originalLength / 4), + compressedTokens: Math.ceil(compressedLength / 4), + savingsPercent: originalLength > 0 ? 100 * (1 - compressedLength / originalLength) : 0, + techniques: ["whitespace-collapse"], + engineId: "whitespace", + }, + }; + }, + + + getConfigSchema() { + return [ + { + key: "preserveCodeBlocks", + type: "boolean", + label: "Preserve code blocks", + defaultValue: true, + description: "Don't touch whitespace inside ```code``` blocks", + }, + ]; + }, + + validateConfig(config) { + if (config.preserveCodeBlocks !== undefined && typeof config.preserveCodeBlocks !== "boolean") { + return { valid: false, errors: ["preserveCodeBlocks must be a boolean"] }; + } + return { valid: true, errors: [] }; + }, +}; + +// Register globally +registerCompressionEngine(whitespaceEngine); +``` + +### Where to Place Custom Engines + +``` +~/.omniroute/compression/engines/my-engine.ts # User-level +/compression-engines/my-engine.ts # Project-level (loaded on startup) +``` + +Or load programmatically from a plugin: + +```ts +// In your plugin +import { registerCompressionEngine, unregisterCompressionEngine } from "@omniroute/open-sse/services/compression/engines/registry"; +import { myEngine } from "./engines/my-engine"; + +export default definePlugin({ + name: "my-compression-plugin", + // The plugin SDK exposes onRequest / onResponse / onError hooks. Register the + // engine when the plugin module loads (or on first onRequest); unregister it + // from your own teardown path. + onRequest: async (ctx) => { + registerCompressionEngine(myEngine); + }, +}); + +// On teardown: +// unregisterCompressionEngine("my-engine"); +``` + +### Testing Your Engine + +Register your engine in a plugin or startup function. Once registered, the engine will be available +in the strategy selector via its `id`. Test integration by composing it in a stacked pipeline: + +--- + +## Creating Language Packs + +Caveman-style compression uses **language-specific rule packs** to handle fillers, hedging, and verbose patterns in each natural language. OmniRoute ships with **6 language packs**: `en`, `es`, `fr`, `de`, `ja`, `pt-BR`. + +### Pack Structure + +A language pack is a directory of **JSON files** under `open-sse/services/compression/rules//`: + +``` +open-sse/services/compression/rules/ +├── en/ +│ ├── filler.json # Pleasantries, hedging, politeness +│ ├── context.json # Context-reducing rules +│ ├── dedup.json # Deduplication rules +│ ├── structural.json # Punctuation, formatting +│ └── ultra.json # Aggressive compression rules +├── es/ (same structure) +├── fr/ (same structure) +├── de/ (same structure) +├── ja/ (same structure) +└── pt-BR/ (same structure) +``` + +### Rule Anatomy + +Each rule has this shape (from `open-sse/services/compression/ruleLoader.ts`): + +```ts +interface FileRule { + name: string; // Human-readable name (kebab-case) + pattern: string; // JavaScript regex pattern + replacement?: string; // What to replace the match with + replacementMap?: Record; // OR a key→replacement map + flags?: string; // Regex flags ("gi" typically) + context?: "all" | "user" | "system" | "assistant"; + category?: "filler" | "context" | "structural" | "dedup" | "terse" | "ultra"; + minIntensity?: "lite" | "full" | "ultra"; // Skip below this intensity + description?: string; // Documentation +} +``` + +### Example: Adding Hindi Filler Rules + +```json +{ + "language": "hi", + "category": "filler", + "rules": [ + { + "name": "polite_opener", + "pattern": "\\b(?:नमस्ते|नमस्कार|आदरणीय)\\b[,!\\s]*", + "replacement": "", + "context": "all", + "category": "filler", + "minIntensity": "lite", + "description": "Strip polite openers like 'नमस्ते'" + }, + { + "name": "filler_actually", + "pattern": "\\b(?:असल में|वास्तव में|दरअसल)\\b\\s*", + "replacement": "", + "context": "all", + "category": "filler", + "minIntensity": "lite", + "description": "Strip 'actually' fillers" + }, + { + "name": "verbose_plea", + "pattern": "\\b(?:कृपया|कृपया आप|अनुरोध है कि आप)\\b\\s*", + "replacement": "", + "context": "all", + "category": "filler", + "minIntensity": "full", + "description": "Strip 'please' in Hindi" + } + ] +} +``` + +### Validation + +Rule packs are validated against `_schema.json` on load. A pack with bad structure will fail to load and log an error: + +``` +RULE_LOADER: pack "hi/filler.json" failed validation: + - rules.0.pattern: Invalid regex + - rules.1.context: must be one of [all, user, system, assistant] +``` + +Validation runs automatically when a pack is loaded (against `_schema.json`); an +invalid pack is rejected and the error above is logged. There is no separate +`npm run` script for pack validation — load the pack (e.g. start the server or +exercise the compression path) and watch the logs. + +### Loading a Custom Language Pack + +```ts +import { loadRulePack } from "omniroute/compression/ruleLoader"; + +await loadRulePack("./my-custom-rules/hi/filler.json"); +``` + +Or place in a recognized location: + +``` +~/.omniroute/compression/rules/hi/filler.json # User-level +/.compression/rules/hi/filler.json # Project-level +``` + +### Best Practices for Language Packs + +1. **Start with `filler`** — these are the highest-impact rules +2. **Use `minIntensity`** to gate aggressive rules — protects against over-compression +3. **Include test cases** — add `tests[]` array in the JSON to verify behavior +4. **Order matters** — earlier rules apply first; place high-impact rules first +5. **Be conservative with `replacement`** — empty string is usually correct; never introduce new content + +### Translation Strategy + +When localizing rule packs to a new language: + +1. **Translate the rule names** — they appear in debug output +2. **Adapt the regex patterns** — direct translation often fails (word boundaries differ) +3. **Test against real conversations** — the pack should be safe on actual input +4. **Match cultural conventions** — Japanese packs, for instance, have more honorific fillers than English + +--- + +## Stacked Pipelines + +A **stacked pipeline** runs multiple engines in sequence, with each engine's output feeding the next. This is how `mode: stacked` works internally. + +### How Stacking Works + +``` +Input (10,000 tokens) + │ + ▼ + ┌──────────┐ + │ Engine │ priority 10 + │ A │ ──▶ output: 6,000 tokens (-40%) + └────┬─────┘ + ▼ + ┌──────────┐ + │ Engine │ priority 50 + │ B │ ──▶ output: 2,400 tokens (-60%) + └────┬─────┘ + ▼ + ┌──────────┐ + │ Engine │ priority 100 + │ C │ ──▶ output: 1,200 tokens (-80%) + └────┬─────┘ + │ + ▼ +Final output (1,200 tokens, ~88% savings combined) +``` + +When `mode: "stacked"` is selected, engines execute sequentially in the order specified in the `pipeline` array. +The output of engine N becomes the input of engine N+1. + +### Compression Modes + +OmniRoute selects **ONE mode per request** based on configuration, auto-trigger thresholds, and combo overrides. +The available modes are defined in `open-sse/services/compression/types.ts` (type `CompressionMode`): + +| Mode | Engines | Use case | +|------|---------|----------| +| `off` | None | Disable all compression | +| `rtk` | RTK only | Command-output heavy sessions (80%+ savings) | +| `lite` | Lite only | Conservative compression (fast, safe) | +| `standard` | Caveman | Prose compression with language packs | +| `aggressive` | Caveman + Aggressive | Aggressive prose + aggressive final pass | +| `ultra` | Ultra | Maximum compression (lossy, last resort) | +| `stacked` | Custom pipeline | Compose engines in any order (see below) | + +Mode selection is determined by `getEffectiveMode()` in `open-sse/services/compression/strategySelector.ts`: + +1. If compression is disabled: `"off"` +2. If a combo override exists: use the override +3. If auto-trigger threshold is exceeded: use `autoTriggerMode` (default: `"lite"`) +4. Otherwise: use `defaultMode` + +### The Default Stacked Pipeline + +When `mode: "stacked"` is explicitly configured, the default pipeline composes: + +1. **RTK** — strip command output noise (~80% savings on terminal output) +2. **Caveman** — remove fillers, terse-ify prose (~46% on remaining text) +3. **Lite** — final whitespace + dedup pass + +This composition achieves **78-95% savings** on tool-heavy sessions. + +### Configuring Stacked Pipelines + +In combo config: + +```json +{ + "compression": { + "mode": "stacked", + "pipeline": [ + { "engine": "rtk", "config": { "intensity": "aggressive" } }, + { "engine": "caveman", "config": { "intensity": "full" } }, + { "engine": "lite", "config": {} } + ] + } +} +``` + +You can omit engines, add custom ones, or reorder them. + +### State Passing + +Engines can read metadata from the request context (in `options`): + +```ts +compress(body, config) { + // Read metadata from previous engines + const original = options?.compressionComboId; // "my-coding-combo" + // ... +} +``` + +The metadata is **read-only** — engines cannot mutate the request context, only their own body output. + +### Execution Order Gotchas + +| Engine order | Effect | +|--------------|--------| +| RTK → Caveman → Lite | **Recommended** (strips noise first, then language, then whitespace) | +| Lite → RTK → Caveman | Bad — Lite strips whitespace from raw output, making RTK pattern matching fail | +| Caveman → RTK | Bad — Caveman may rewrite text in ways that RTK doesn't recognize | +| Any order with `tool_results` first | Better — tool output is the noisiest content | + +### When NOT to Stack + +Stacking isn't always better: +- **Simple messages** (no tool output) — single Caveman or Lite is enough +- **Cost-sensitive** — each engine adds ~5-50ms latency +- **Specific tools** — RTK alone is usually sufficient for shell output + +### Building a Custom Pipeline + +There is no named-pipeline registry. A stacked pipeline is just an **inline array +of steps** passed to `applyStackedCompression()` (exported from +`@omniroute/open-sse/services/compression/strategySelector`): + +```ts +import { applyStackedCompression } from "@omniroute/open-sse/services/compression/strategySelector"; + +const result = applyStackedCompression(body, [ + { engine: "rtk", intensity: "aggressive" }, + { engine: "caveman", intensity: "full" }, +]); +``` + +When you don't pass a pipeline, it defaults to `rtk(standard) → caveman(full)`. + +To drive it from config, set `mode: "stacked"` and provide the step array under +`stackedPipeline` (read from `config.stackedPipeline`): + +```json +{ + "compression": { + "mode": "stacked", + "stackedPipeline": [ + { "engine": "rtk", "intensity": "aggressive" }, + { "engine": "caveman", "intensity": "full" } + ] + } +} +``` + +--- + +## Best Practices + +### Engine Development + +1. **Always implement `validateConfig`** — engines without validation cause silent failures +2. **Set realistic `targetLatencyMs`** — used by the strategy selector to choose engines +3. **Use `getConfigSchema` for the dashboard** — never hide config from users +4. **Support `stackable: true` if your engine is pure** — engines with side effects shouldn't stack +5. **Write inline tests** — engines should be verifiable in <1s + +### Language Pack Development + +1. **Start with `lite` intensity** — your rules should be safe at the lowest setting +2. **Use `context` to scope rules** — `user` only rules can't accidentally affect system prompts +3. **Avoid capturing JSON keys** — `\\bword\\b` can match inside JSON, breaking structured data +4. **Test with edge cases** — empty input, unicode, RTL text, emojis +5. **Use existing packs as templates** — `en/filler.json` is the most-developed example + +### Pipeline Design + +1. **Profile before optimizing** — measure with `compression_stats` first +2. **Prefer composition over reimplementation** — extend Caveman rules before writing a new engine +3. **Document the order rationale** — comment why engine A before engine B +4. **Test at all 3 intensity levels** — `lite` is fast but lossy, `ultra` is slow but precise + +--- + +## Reference: Built-in Engines + +| Engine ID | Stackable | Default stackPriority | Targets | +|-----------|-----------|-----------------------|---------| +| `lite` | Yes | 5 | messages, tool_results | +| `rtk` | Yes | 10 | tool_results | +| `standard` (caveman) | Yes | 20 | messages, tool_results, code_blocks | +| `aggressive` | Yes | 30 | messages | +| `ultra` | Yes | 40 | messages, code_blocks | +### See Also + +- [COMPRESSION_GUIDE.md](./COMPRESSION_GUIDE.md) — Pipeline overview +- [COMPRESSION_ENGINES.md](./COMPRESSION_ENGINES.md) — Engine registry reference +- [COMPRESSION_RULES_FORMAT.md](./COMPRESSION_RULES_FORMAT.md) — Rule format spec +- [COMPRESSION_LANGUAGE_PACKS.md](./COMPRESSION_LANGUAGE_PACKS.md) — Language pack details +- [RTK_COMPRESSION.md](./RTK_COMPRESSION.md) — RTK engine and custom filters +- Source: `open-sse/services/compression/` (117 files, ~250KB) diff --git a/docs/compression/RTK_COMPRESSION.md b/docs/compression/RTK_COMPRESSION.md index fca8826595..dbe2637ed6 100644 --- a/docs/compression/RTK_COMPRESSION.md +++ b/docs/compression/RTK_COMPRESSION.md @@ -238,3 +238,367 @@ node --import tsx/esm --test \ 4. Add command detection coverage when introducing a new output class. 5. Run the verify and broad RTK gates. 6. If the filter is project-local, commit `.rtk/filters.json` and refresh `.rtk/trust.json` only after review. + +--- + +## Intensity Levels (v3.8.16+) + +RTK supports **3 intensity levels** that trade off between **compression aggressiveness** and **safety**. The level is set via `config.intensity` in the engine config. + +### The 3 Levels + +| Level | Truncation threshold | Token savings | Risk | Best for | +|-------|---------------------|---------------|------|----------| +| `minimal` | 24 lines per section | ~20-40% | Very low | Production with critical context | +| `standard` (default) | 24 lines per section | ~50-70% | Low | Daily coding sessions | +| `aggressive` | 16 lines per section | ~70-90% | Medium | Long sessions, max savings | + +### Where the Truncation Happens + +The truncation threshold affects `lineFilter.ts`: + +```ts +// From open-sse/services/compression/engines/rtk/index.ts:329-330 +config.intensity === "aggressive" ? 16 : 24, +config.intensity === "aggressive" ? 16 : 24, +``` + +Both the **head** and **tail** of each section are preserved; middle content is dropped when truncation kicks in. + +### What Stays vs. What Gets Cut + +| Content | minimal | standard | aggressive | +|---------|---------|----------|------------| +| Errors / stack traces | ✅ preserved | ✅ preserved | ✅ preserved | +| Test failures | ✅ preserved | ✅ preserved | ✅ preserved | +| Build errors | ✅ preserved | ✅ preserved | ✅ preserved | +| Test passes (verbose) | ✅ preserved | 🟡 collapsed | 🟡 collapsed | +| Routine output (info logs) | 🟡 collapsed | 🟡 collapsed | ❌ dropped | +| Progress bars | 🟡 collapsed | ❌ dropped | ❌ dropped | +| Banner / ASCII art | 🟡 collapsed | ❌ dropped | ❌ dropped | + +### Choosing the Right Intensity + +``` + Is losing context catastrophic? + │ + ┌───────────┼───────────┐ + │ │ │ + YES NO NOT SURE + │ │ │ + ▼ │ │ + minimal │ │ + │ │ │ + │ ▼ ▼ + │ How critical Try `standard` first + │ is throughput? (works for 80% of + │ │ cases) + │ ┌────┴────┐ + │ │ │ + │ LOW HIGH + │ │ │ + │ ▼ ▼ + │ standard aggressive + │ │ │ + └──────┴─────────┘ +``` + +### Configuring Intensity + +**Per-combo** (in combo config): + +```json +{ + "combo": "my-coding-combo", + "routing": { /* ... */ }, + "compression": { + "engine": "rtk", + "intensity": "aggressive" + } +} +``` + +**Programmatically**: + +`rtkEngine` (`@omniroute/open-sse/services/compression/engines/rtk`) is a +`CompressionEngine` and has no `updateConfig` method. Update an engine's config +through the registry helper instead: + +```ts +import { updateEngineConfig } from "@omniroute/open-sse/services/compression/engines/registry"; + +updateEngineConfig("rtk", { intensity: "aggressive" }); +``` + +### Verifying the Effect +Use the **Verify Gate** (see below) to confirm your filter is safe at your chosen intensity: +```ts +import { runRtkFilterTests } from "omniroute/compression/engines/rtk/verify"; + +const result = runRtkFilterTests({ intensity: "aggressive" }); +if (!result.passed) { + console.error("Filters failed at aggressive intensity"); +} +``` + +--- + +## Custom Filter Development (v3.8.16+) + +The `engines/rtk/filters/` directory contains **49+ built-in filter JSON files**. You can add your own to compress output from custom tools not covered by the defaults. + +### Filter Schema (Zod) + +```ts +{ + "id": "string", // Required. Filter identifier (kebab-case, e.g., "python-traceback") + "label": "string", // Required. Human-readable filter name + "description": "string", // Optional (default: ""). Short description of what filter does + "category": "git|test|build|shell|docker|package|infra|cloud|generic", + "priority": number, // Optional (0-100, default: 50). Execution order (higher = first) + "match": { + "commands": ["string"], // Command names to match (e.g., "python", "pytest") + "patterns": ["string"], // Regex patterns to match output + "outputTypes": ["string"] // Detected output classes (e.g., "test-failure") + }, + "rules": { + "stripAnsi": boolean, // Optional (default: false). Strip ANSI color codes + "replace": [ // Find-and-replace rules (default: []) + { "pattern": "regex", "replacement": "..." } + ], + "matchOutput": [ // Short-circuit on pattern match (default: []) + { + "pattern": "regex", + "message": "short summary", + "unless": "regex" // Skip if this pattern matches + } + ], + "includePatterns": ["string"], // Lines to keep (regex patterns, default: []) + "dropPatterns": ["string"], // Lines to drop (regex patterns, default: []) + "collapsePatterns": ["string"], // Lines to collapse to single occurrence (default: []) + "deduplicate": boolean, // Optional (default: false). Remove duplicate lines + "truncateLineAt": number, // Optional (default: 0). Truncate lines to max chars + "maxLines": number, // Optional (default: 0). Hard cap on total lines + "headLines": number, // Optional (default: 20). Keep first N lines of matched output + "tailLines": number, // Optional (default: 20). Keep last N lines of matched output + "onEmpty": "string", // Optional (default: ""). Fallback message if all lines filtered + "filterStderr": boolean // Optional (default: false). Also filter stderr output + }, + "preserve": { + "errorPatterns": ["string"], // Patterns that must always be preserved (default: []) + "summaryPatterns": ["string"] // Patterns for final summary line (default: []) + }, + "tests": [ // Inline tests for verification (default: []) + { + "name": "string", // Required. Test name + "input": "sample output", // Required. Sample input text + "expected": "expected output", // Required. Expected compressed output + "command": "optional command" // Optional. Command context + } + ] +} +``` + +### Example: Python Traceback Filter + +```json +{ + "id": "python-traceback", + "label": "Python Traceback Filter", + "description": "Compresses Python tracebacks to essential file/line locations and error type", + "category": "test", + "priority": 60, + "match": { + "commands": ["python", "python3", "pytest", "uv", "poetry"], + "patterns": ["Traceback \\(most recent call last\\)", "Error", "Exception"], + "outputTypes": ["error-traceback"] + }, + "rules": { + "stripAnsi": true, + "includePatterns": [ + "Traceback \\(most recent call last\\)", + "^\\s*File \".+\", line \\d+", + "^\\s*[A-Z][a-zA-Z]+Error:", + "^\\s*[A-Z][a-zA-Z]+Exception" + ], + "dropPatterns": [ + "site-packages/", + "^\\s+[a-z_]+\\([^)]*\\)$" + ], + "headLines": 5, + "tailLines": 3, + "maxLines": 25, + "filterStderr": true + }, + "preserve": { + "errorPatterns": [ + "Error:", + "Exception:", + "Traceback" + ], + "summaryPatterns": [ + "^[A-Z][a-zA-Z]+(?:Error|Exception):" + ] + }, + "tests": [ + { + "name": "preserves-error-type-and-location", + "input": "Traceback (most recent call last):\n File \"app.py\", line 42, in main\n do_thing()\n File \"lib/utils.py\", line 17, in helper\n return 1 / 0\nZeroDivisionError: division by zero", + "expected": "Traceback (most recent call last):\n File \"app.py\", line 42, in main\n File \"lib/utils.py\", line 17, in helper\nZeroDivisionError: division by zero", + "command": "python app.py" + } + ] +} +``` + +### Loading Custom Filters + +Place the file in a recognized location: + +``` +~/.omniroute/rtk/filters/my-filter.json # User-level +/.rtk/filters/my-filter.json # Project-level +``` + +Filters are loaded automatically on startup via `loadRtkFilters()` in `open-sse/services/compression/engines/rtk/filterLoader.ts`. The loader discovers filters from: + +- Built-in catalog: `open-sse/services/compression/engines/rtk/filters/` +- User directory: `~/.omniroute/rtk/filters/` +- Project directory: `/.rtk/filters/` + +To load filters programmatically: + +```ts +import { loadRtkFilters } from "@omniroute/open-sse/services/compression/engines/rtk/filterLoader"; + +// Options: customFiltersEnabled (load user/project filters, default on), +// trustProjectFilters, refresh. +const filters = loadRtkFilters({ customFiltersEnabled: true }); +``` + +### Validation + +Filters are validated against the Zod schema on load. A filter with bad structure will fail to load and log an error: + +``` +RTK_FILTER_LOADER: filter "my-filter" failed validation: + - rules.replace.0.pattern: Invalid regex + - match.commands: must not be empty +``` + +To validate all installed filters, call `runRtkFilterTests()` which is exported from `open-sse/services/compression/engines/rtk/verify.ts`. + +### Best Practices + +1. **Always include `tests[]`** — they prove your filter works and prevent regressions +2. **Use `matchOutput` for short-circuits** — if a single line tells the story, replace the whole block +3. **Prefer `keep` over `strip`** — explicit "always preserve" rules are safer than "always remove" +4. **Test at all 3 intensity levels** — `minimal` should be a no-op, `aggressive` should still preserve errors +5. **Use the `unless` field** — guard short-circuits with "don't trigger if X is present" + +--- + +## Raw Output Recovery & Verify Gate + +When RTK compresses output aggressively, you can **recover the original text** for debugging, audit, or replay. + +### How Raw Output Recovery Works + +``` +Original output (10K tokens) + │ + ▼ +RTK compress (with rawOutput.enabled=true) + │ + ├─▶ Compressed output (2K tokens) ──▶ to LLM + │ + └─▶ Original output (10K tokens) ──▶ stored in DB + (linked by request_id) +``` + +### Enabling Raw Output Storage + +**Per-request** (in combo config): + +```json +{ + "compression": { + "engine": "rtk", + "intensity": "aggressive", + "rawOutput": { + "enabled": true, + "maxBytes": 1048576 // 1MB cap + } + } +} +``` + +**Default**: `rawOutput.enabled: false` (saves storage). + +### Storage Cost + +| Per-request | 1MB cap | 10MB cap | +|-------------|---------|----------| +| Average compressed output | ~5KB | ~5KB | +| Raw output stored | ~50-500KB | ~500KB-5MB | +| With 1000 requests/day | 50-500MB/day | 500MB-5GB/day | + +> **Recommendation**: Only enable raw output for **debugging sessions** or **sampled auditing**, not always-on. + +### Recovering the Original + +```ts +import { readRtkRawOutput } from "omniroute/compression/engines/rtk/rawOutput"; + +const raw = readRtkRawOutput(pointerId); // pointerId from compression stats +if (raw) { + console.log("Original output:", raw); +} +``` + +The `pointerId` is returned in `CompressionStats.rtkRawOutputPointers[]` after compression. +See `open-sse/services/compression/engines/rtk/rawOutput.ts:102` for the function signature. + +### The Verify Gate + +The **RTK Filter Verification** (`open-sse/services/compression/engines/rtk/verify.ts`) validates all filters against their `tests[]` and ensures behavior is correct at all 3 intensity levels. + +**Call `runRtkFilterTests()`** to run verification: + +```ts +import { runRtkFilterTests } from "open-sse/services/compression/engines/rtk/verify"; + +const result = runRtkFilterTests(); +console.log(`Passed: ${result.outcomes.filter(o => o.passed).length}`); +console.log(`Failed: ${result.outcomes.filter(o => !o.passed).length}`); +if (!result.passed) { + console.error("Filters failed verification"); + result.outcomes.filter(o => !o.passed).forEach(o => { + console.error(` - ${o.filterId} / ${o.testName}: expected "${o.expected}", got "${o.actual}"`); + }); +} +``` + +**What it validates**: + +1. Every filter loads and passes schema validation +2. Every `tests[]` entry produces expected output +3. `minimal` intensity is a no-op (preserves original, only applies structural filters) +4. `aggressive` intensity preserves errors, test failures, and stack traces +5. Compressed output is never larger than original input + +- Source: `open-sse/services/compression/engines/rtk/` (63 files, ~70KB) + +- **Before merging a filter change** — always ensure tests pass +- **After upgrading RTK engine** — schema may have changed +- **Periodically in monitoring** — protects against drift in test fixtures +- **When adding a new tool/command family** — proves the new filter works + +--- + +## See Also + +- [COMPRESSION_GUIDE.md](./COMPRESSION_GUIDE.md) — Full compression pipeline overview +- [COMPRESSION_ENGINES.md](./COMPRESSION_ENGINES.md) — Engine registry and built-in engines +- [EXTENDING_COMPRESSION.md](./EXTENDING_COMPRESSION.md) — Custom engines, language packs, stacked pipelines +- Source: `open-sse/services/compression/engines/rtk/` (63 files, ~70KB) diff --git a/docs/frameworks/MEMORY.md b/docs/frameworks/MEMORY.md index 018a551ed5..1746f670f9 100644 --- a/docs/frameworks/MEMORY.md +++ b/docs/frameworks/MEMORY.md @@ -537,7 +537,268 @@ default TTL 5 min). - `src/app/api/memory/reindex/route.ts` - `src/app/api/settings/memory/route.ts` - `src/app/api/settings/qdrant/route.ts` + sub-routes - - `src/app/(dashboard)/dashboard/memory/` — Studio UI (page + components + - tabs + hooks) - - `open-sse/handlers/chatCore.ts` (injection / extraction wiring) - - `open-sse/mcp-server/tools/memoryTools.ts` + - `src/app/(dashboard)/dashboard/memory/` — Studio UI (page + components + + tabs + hooks) + - `open-sse/handlers/chatCore.ts` (injection / extraction wiring) + - `open-sse/mcp-server/tools/memoryTools.ts` + +--- + +## Choosing an Embedding Provider (v3.8.16+) + +OmniRoute's memory engine supports **four embedding sources** (`src/lib/memory/embedding/`). Each has different trade-offs in **latency, cost, model quality, and setup complexity**. + +### The Four Providers + +| Provider | Source | Latency | Cost | Quality | Setup | +|----------|--------|---------|------|---------|-------| +| `transformers` | Local ONNX model (Xenova/all-MiniLM-L6-v2) | ~50-150ms (CPU) | Free | Good | `npm install` only | +| `static` | Pre-computed vectors (cached) | <1ms | Free | N/A (depends on cache hit) | None | +| `remote` | OpenAI / Cohere / Voyage API | ~100-300ms | $0.02-0.10/1M tokens | Excellent | API key | +| `cache` | In-memory LRU layer over any source | <1ms (hit), full latency (miss) | Free | Same as underlying | None | + +### Decision Tree + +``` + What's your deployment context? + │ + ┌───────────┼───────────┬──────────────┐ + │ │ │ │ + DEV/TEST SMALL PROD LARGE PROD EDGE / OFFLINE + │ │ │ │ + ▼ ▼ ▼ ▼ + transformers transformers remote (Qdrant) transformers + (free, no API) (best quality) (no internet) + │ │ │ │ + └────────┬──┴───────────┴──────────────┘ + │ + ▼ + ALWAYS add `cache` layer on top + (LruCache wraps any provider) +``` + +### Database & API Configuration + +Memory embedding options are configured via the Settings API/UI, not environment variables. The relevant settings database keys under Settings (`normalizeMemorySettings` in `src/lib/memory/settings.ts`) are: + +- `memoryEmbeddingSource`: `"transformers"` (local), `"remote"` (API-based, e.g. OpenAI), `"static"` (external store), or `"auto"` +- `memoryEmbeddingProviderModel`: Model identifier for remote/static sources (e.g., `"text-embedding-3-small"`) +- `memoryTransformersEnabled`: `true` | `false` +- `memoryStaticEnabled`: `true` | `false` +- `memoryVectorStore`: `"sqlite-vec"`, `"qdrant"`, or `"auto"` + +#### Local Model (`transformers`) + +Uses transformers.js internally to run local models: + +```bash +# Env vars read in code (src/lib/memory/embedding/index.ts): +MEMORY_TRANSFORMERS_MODEL=Xenova/all-MiniLM-L6-v2 # HF model repo +MEMORY_STATIC_MODEL=minishlab/potion-base-8M # HF static potion model +MEMORY_STATIC_CACHE_DIR=/embeddings # Cache directory +``` + +#### LRU Embedding Cache + +The cache is always on by default and configured via env vars: + +```bash +MEMORY_EMBEDDING_CACHE_MAX=1000 # Max cached items +MEMORY_EMBEDDING_CACHE_TTL_MS=300000 # TTL (5 min) +``` +### Performance Numbers + +Benchmark on a typical 4-core x86 server (texts ~100 tokens each): + +| Provider | p50 | p95 | p99 | Cost / 1M embeddings | +|----------|-----|-----|-----|-----------------------| +| `transformers` (CPU) | 80ms | 180ms | 350ms | Free | +| `remote` (OpenAI) | 120ms | 220ms | 400ms | ~$0.02 (ada-002) / $0.13 (3-large) | +| `static` (Qdrant) | 15ms | 30ms | 60ms | Depends on Qdrant hosting | +| `cache` (hit) | <1ms | <1ms | 2ms | Free | + +--- + +## Fact Extraction Patterns (v3.8.16+) + +The `extraction.ts` module (`src/lib/memory/extraction.ts`) uses **regex pattern matching** to extract structured facts from conversation messages. Understanding these patterns helps you tune extraction quality for your use case. + +### Default Pattern Categories + +| Category | Example pattern | Captures | +|----------|-----------------|----------| +| PREFERENCE_PATTERNS | `"I prefer "`, `"I like "`, `"I hate "` | User preferences | +| DECISION_PATTERNS | `"I'll use "`, `"I decided to "`, `"I went with "` | User decisions (episodic) | +| PATTERN_PATTERNS | `"I usually "`, `"I always "`, `"I never "` | Persistent behavioral patterns | + +### Example Patterns (Simplified) +```ts +// From src/lib/memory/extraction.ts +const PREFERENCE_PATTERNS = [ + /\bI\s+(?:really\s+)?prefer\s+([^.,\n]+)/gi, + /\bI\s+(?:really\s+)?like\s+([^.,\n]+)/gi, + /\bI\s+(?:hate|dislike|avoid)\s+([^.,\n]+)/gi +]; +const DECISION_PATTERNS = [ + /\bI'?(?:ll|will)\s+use\s+([^.,\n]+)/gi, + /\bI\s+(?:have\s+)?decided\s+(?:to\s+)?([^.,\n]+)/gi +]; +const PATTERN_PATTERNS = [ + /\bI\s+usually\s+([^.,\n]+)/gi, + /\bI\s+always\s+([^.,\n]+)/gi +]; +``` + +### What Gets Extracted + +When a user says: +> "I prefer TypeScript. I'll use Postgres for this project. I always commit before pushing. I don't like Python." +Extraction produces 4 memories: +| Key | Category | Type | Content | +|-----|----------|------|---------| +| `preference:typescript` | preference | factual | "TypeScript" | +| `decision:postgres_for_this_project` | decision | episodic | "Postgres for this project" | +| `pattern:commit_before_pushing` | pattern | factual | "commit before pushing" | +| `preference:python` | preference | factual | "Python" | + +### Extraction Limits +To prevent runaway extraction, the following limits apply: + +| Min content length | 3 chars | +| Max content length | 500 chars | + +### When to Disable Extraction + +Extraction runs automatically whenever memory is enabled; there is no separate +extraction-only toggle. To turn it off, disable memory entirely (`enabled: false` +via `PUT /api/settings/memory`). Consider doing so when: +- You have high message volume and the extraction cost is non-trivial +- Your conversations are mostly transient (chat, debugging) with no long-term value +- You're already capturing context via custom plugins + +--- + +## Hybrid RRF Tuning (v3.8.16+) + +The **Reciprocal Rank Fusion (RRF)** algorithm combines FTS5 (keyword) and vector (semantic) results. The `k` parameter controls how much weight is given to lower-ranked results. + +### The Formula + +For each candidate memory, the RRF score is: + +``` +RRF(d) = Σ 1 / (k + rank_i(d)) +``` + +Where: +- `k` is the constant (default 60) +- `rank_i(d)` is the rank of document `d` in the i-th retrieval system (FTS, vector) +- The sum runs over all retrieval systems + +### How `k` Affects Results + +| `k` value | Effect | Best for | +|-----------|--------|----------| +| `k=0` | Pure rank fusion (no smoothing) | Theoretical baseline | +| `k=10-30` | Heavily weights top results, low-rank barely contributes | When top-3 results are usually correct | +| **`k=60`** (default) | Balanced — top-10 results all contribute meaningfully | General-purpose retrieval | +| `k=100+` | Flatter — even low-rank results can dominate if they appear in multiple systems | When recall > precision is critical | + +### Tuning `k` in Practice + +```bash +# Default +MEMORY_RRF_K=60 + +# Aggressive precision (small memory, few docs) +MEMORY_RRF_K=20 + +# Maximum recall (large memory, varied queries) +MEMORY_RRF_K=120 +``` + +**Example with `k=20`:** +- FTS rank 1 → contribution `1/21 = 0.048` +- FTS rank 10 → contribution `1/30 = 0.033` +- Vector rank 1 → contribution `0.048` +- Combined max: `0.096` + +**Example with `k=60`:** +- FTS rank 1 → contribution `1/61 = 0.016` +- FTS rank 10 → contribution `1/70 = 0.014` +- Vector rank 1 → contribution `0.016` +- Combined max: `0.033` + +With higher `k`, the **relative difference** between top-1 and rank-10 is smaller, so the algorithm relies more on **consensus across retrieval systems** than on top-rank confidence. + +### When to Change `k` + +| Symptom | Try | +|---------|-----| +| Top result always wins, but it's wrong | **Lower** k (e.g., 20) — top-rank confidence matters more | +| Right answer is in top-5 but not top-1 | **Higher** k (e.g., 100) — flatter scoring rewards consensus | +| Recall is high but precision is low | **Lower** k — sharpen the ranking | +| Recall is low (missing relevant docs) | **Higher** k — give lower-ranked docs a chance | + +### RRF Weighting + +The reciprocal rank fusion uses equal weights for semantic vector rank and full-text search rank: + +``` +RRF(d) = 1/(k + rank_vector) + 1/(k + rank_fts) +``` + +There are no environment variables to adjust individual weights (`MEMORY_RRF_VECTOR_WEIGHT`/`MEMORY_RRF_FTS_WEIGHT` do not exist). + +--- + +## Summarization Strategy (v3.8.16+) + +The `summarization.ts` module (`src/lib/memory/summarization.ts`) compresses older memories to keep the active set small while preserving recall. + +### When Summarization Triggers + +| Trigger | Threshold (default) | +|---------|---------------------| +| Manual trigger via API | n/a | + +### What Gets Summarized + +Two entry points are exported from `summarization.ts`: + +- **`summarizeMemories(apiKeyId, sessionId?, maxTokens = 4000)`** — condenses the + memories for a session into a single summary text bounded by a token budget. +- **`summarizeMemoriesOlderThan(apiKeyId, days, dryRun)`** — the age-based + compaction used by the API: it selects every memory older than `days`, builds + one condensed summary memory from them, and (when `dryRun` is `false`) deletes + the originals. Pass `dryRun: true` to preview the candidate set and token total + without modifying anything. + +There is no tag/key clustering pass or per-memory "core vs summarizable" scoring — +selection is purely the age cutoff, and the summary text is a condensed, +type-prefixed line per candidate. + +### Triggering Summarization + +Summarization is **manual / opt-in** — the `autoSummarize` setting is `false` by +default, so nothing is compacted automatically. Trigger it via the API: + +```bash +curl -X POST http://localhost:20128/api/memory/summarize \ + -H "Authorization: Bearer $OMNIROUTE_KEY" +``` + +To leave it off, simply keep `autoSummarize` at its default (`false`). + +### Summarization Quality Tips + +- **Preview first with `dryRun`** — `summarizeMemoriesOlderThan(..., true)` returns + the candidate list and total token count so you can confirm what would be merged + before deleting the originals. +- **Run summarization during low-traffic hours** if you have a large memory corpus — the LLM call is the slow part + +```bash +# Cron-style: summarize at 3am daily +0 3 * * * curl -X POST http://localhost:20128/api/memory/summarize \ + -H "Authorization: Bearer $OMNIROUTE_KEY" +``` diff --git a/docs/frameworks/SKILLS.md b/docs/frameworks/SKILLS.md index 748a842ca1..22e21b80c6 100644 --- a/docs/frameworks/SKILLS.md +++ b/docs/frameworks/SKILLS.md @@ -300,6 +300,182 @@ See [MCP-SERVER.md](./MCP-SERVER.md) for transport setup and scope assignments. --- +## Execution Lifecycle (v3.8.16+) + +The `SkillExecutor` (`src/lib/skills/executor.ts`) is a **singleton** that manages every skill invocation. Understanding its lifecycle is critical for debugging timeouts, retries, and execution state. + +### The 5-Stage Lifecycle + +``` + execute() called + │ + ▼ + ┌─────────────┐ + │ PENDING │ ← queued, not yet started (DB row created) + └──────┬──────┘ + │ start handler + ▼ + ┌─────────────┐ + │ RUNNING │ ← handler invoked with timeout + └──────┬──────┘ + │ + ┌────┴────┬──────────┬──────────┐ + │ │ │ │ + ▼ ▼ ▼ ▼ + SUCCESS ERROR TIMEOUT (no other path — killed by parent) + │ │ │ + └────┬────┴──────────┘ + │ + ▼ + DB row updated with status, output, durationMs +``` + +### Default Configuration + +| Setting | Default | Configurable via | +|---------|---------|------------------| +| `timeout` | `30000` (30s) | `skillExecutor.setTimeout(ms)` | +| `maxRetries` | `3` | `skillExecutor.setMaxRetries(count)` | + +> **Important**: The executor is a singleton — calling `setTimeout()` affects all subsequent invocations globally. Per-skill timeouts are not currently supported; if you need different timeouts per skill, submit separate processes or fork the executor. + +### Status Values + +From `src/lib/skills/types.ts`: + +```ts +enum SkillStatus { + PENDING = "pending", // Queued, not yet started + RUNNING = "running", // Handler invoked + SUCCESS = "success", // Handler returned valid output + ERROR = "error", // Handler threw an exception + TIMEOUT = "timeout", // Exceeded the executor's timeout +} +``` + +> **Note**: The `TIMEOUT` status is defined in the enum but is **not actually written to the DB** by the current executor implementation — timeouts surface as `ERROR` with the message `"Skill execution timed out"`. The status enum is reserved for future use. + +### Inspecting Executions + +```ts +import { skillExecutor } from "omniroute/skills/executor"; + +// Get a specific execution by ID +const exec = skillExecutor.getExecution("exec-uuid-123"); +if (exec) { + console.log(`${exec.skillName}: ${exec.status} in ${exec.durationMs}ms`); +} + +// List recent executions for an API key +const recent = skillExecutor.listExecutions("api-key-id", 50, 0); +for (const e of recent) { + console.log(`${e.skillName} → ${e.status} (${e.durationMs}ms)`); +} + +// Count total executions +const total = skillExecutor.countExecutions("api-key-id"); +``` + +### Retry Behavior + +The `maxRetries` setting is stored but **not currently used** by the executor's `execute()` method — it only performs a single attempt. The `maxRetries` value is exposed for future implementation and for hooks that want to read it. + +For now, retries must be implemented inside the skill handler itself. Built-in +skills are registered against the executor (e.g. `registerBuiltinSkills(executor)` +/ `registerBrowserSkill(executor)` in `src/lib/skills/builtin/`); whichever handler +you register can wrap its own retry loop: + +```ts +// inside a skill handler +async function handler(input, ctx) { + const maxRetries = 3; + let lastError: Error | null = null; + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + return await fetchSomething(input); + } catch (err) { + lastError = err as Error; + if (attempt < maxRetries) { + await new Promise((r) => setTimeout(r, 1000 * attempt)); + } + } + } + throw lastError; +} +``` + +--- + +## SkillMode in Detail + +The `SkillMode` enum (`src/lib/skills/types.ts`) controls **when and how** skills are invoked: + +```ts +enum SkillMode { + AUTO = "auto", // LLM decides when to call the skill + MANUAL = "manual", // Only invoked by explicit user request + HYBRID = "hybrid", // AUTO scoring + manual override +} +``` + +> **Note**: The codebase defines `SkillMode` (AUTO/MANUAL/HYBRID), while the `Skill.mode` field uses a different shape (`"on" | "off" | "auto"`). They are related but not identical — `SkillMode` is for executor policy, `Skill.mode` is for per-skill enablement. + +### When to Use Each Mode + +| Mode | LLM behavior | Use case | +|------|--------------|----------| +| `AUTO` | LLM can call the skill when it deems necessary | General-purpose skills (file reads, HTTP requests) | +| `MANUAL` | LLM cannot call the skill; only an explicit `executeSkill` API call invokes it | Sensitive operations (database writes, payments) | +| `HYBRID` | LLM can suggest the skill; user must confirm | Skills that have side effects but aren't dangerous | + +### AUTO Scoring + +When `AUTO` mode is active, each candidate skill is scored against the request +context by `scoreAutoSkill()` in `src/lib/skills/injection.ts` — an additive, +integer point system (skill-name match, name/tag/description token overlap, +background-reason hints, provider-hint bonus/penalty). The top +`AUTO_MAX_SKILLS = 5` skills with `score >= AUTO_MIN_SCORE = 3` are injected as +callable tools, ties broken by `installCount` then name. See the full point table +in [**Tool Schema Generation → AUTO Scoring**](#auto-scoring) earlier in this +document; there is no float `0.6`-style threshold and no `registry.ts` scoring. + +--- + +## Built-in Skills Catalog + +OmniRoute ships with a curated set of built-in skills in `src/lib/skills/builtin/`. The most common ones: + +### Browser Automation Skill + +The browser skill (`src/lib/skills/builtin/browser.ts`) provides headless browser automation via Playwright/Puppeteer. **It is implemented but not in the default skills catalog** — to use it, install the browser extension plugin separately. + +```ts +// Enable in your config +const config: SkillConfig = { + enabled: true, + mode: SkillMode.MANUAL, // Always require explicit invocation + allowedSkills: ["browser"], + timeout: 60000, // 60s for page loads + maxRetries: 1, +}; +``` + +### Other Built-in Categories + +| Category | Skills | Mode | +|----------|--------|------| +| File I/O | `file_read`, `file_write` | AUTO | +| HTTP | `http_request` | AUTO | +| Search | `web_search` | AUTO | +| Code Exec | `eval_code` (sandboxed JavaScript/Python) | HYBRID | +| System | `execute_command` (sandboxed CLI execution) | MANUAL | +### Adding a Custom Skill + +See the [Plugin SDK & Skills Integration](../plugins/PLUGIN_SDK.md) for how to add a custom skill via the plugin system. + +--- + ## See Also - [MCP-SERVER.md](./MCP-SERVER.md) — MCP tool registration and transports diff --git a/docs/getting-started/TROUBLESHOOTING.md b/docs/getting-started/TROUBLESHOOTING.md index 04fcb25f74..1f79f72b20 100644 --- a/docs/getting-started/TROUBLESHOOTING.md +++ b/docs/getting-started/TROUBLESHOOTING.md @@ -259,6 +259,9 @@ curl http://localhost:20128/api/monitoring/health - Application logs: `/logs/...` (when `APP_LOG_TO_FILE=true`) - Call log artifacts: `${DATA_DIR}/call_logs/YYYY-MM-DD/...` when the call log pipeline is enabled +The Request Logs page's **Clean history** action clears `call_logs`, legacy +`request_detail_logs`, and the local `${DATA_DIR}/call_logs/` artifact directory. + --- ## Circuit Breaker Issues diff --git a/docs/guides/TROUBLESHOOTING.md b/docs/guides/TROUBLESHOOTING.md index 01e197fe07..fc501b73e4 100644 --- a/docs/guides/TROUBLESHOOTING.md +++ b/docs/guides/TROUBLESHOOTING.md @@ -263,6 +263,9 @@ curl http://localhost:20128/api/monitoring/health - Application logs: `/logs/...` (when `APP_LOG_TO_FILE=true`) - Call log artifacts: `${DATA_DIR}/call_logs/YYYY-MM-DD/...` when the call log pipeline is enabled +The Request Logs page's **Clean history** action clears `call_logs`, legacy +`request_detail_logs`, and the local `${DATA_DIR}/call_logs/` artifact directory. + --- ## Circuit Breaker Issues diff --git a/docs/i18n/ar/CHANGELOG.md b/docs/i18n/ar/CHANGELOG.md index a69849f9a1..129984365d 100644 --- a/docs/i18n/ar/CHANGELOG.md +++ b/docs/i18n/ar/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.24] — TBD + +_See English CHANGELOG for v3.8.24 details._ + +--- + ## [3.8.22] — TBD _See English CHANGELOG for v3.8.22 details._ @@ -169,9 +175,9 @@ _Development cycle in progress._ refreshed siblings concurrently, so Auth0 revoked the whole token family (`openai/codex#9648`) and every account but the last died with `[403] `. The quota path now skips proactive refresh for - rotating providers (`rotationGroupFor`) and reuses the current access_token, + rotating providers (`rotationGroupFor`) and reuses the current access*token, deferring genuine expiry to the reactive, serialized 401 path. Defense in - depth: `serializeRefresh` now leaves a settle gap between two _queued_ sibling + depth: `serializeRefresh` now leaves a settle gap between two \_queued* sibling refreshes (default 2000 ms, tunable via `CODEX_REFRESH_SPACING_MS`, `"0"` to opt out) while releasing a lone refresh immediately, so the reactive path adds no latency. diff --git a/docs/i18n/az/CHANGELOG.md b/docs/i18n/az/CHANGELOG.md index 53b25f55cb..8bb11be096 100644 --- a/docs/i18n/az/CHANGELOG.md +++ b/docs/i18n/az/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.24] — TBD + +_See English CHANGELOG for v3.8.24 details._ + +--- + ## [3.8.22] — TBD _See English CHANGELOG for v3.8.22 details._ @@ -169,9 +175,9 @@ _Development cycle in progress._ refreshed siblings concurrently, so Auth0 revoked the whole token family (`openai/codex#9648`) and every account but the last died with `[403] `. The quota path now skips proactive refresh for - rotating providers (`rotationGroupFor`) and reuses the current access_token, + rotating providers (`rotationGroupFor`) and reuses the current access*token, deferring genuine expiry to the reactive, serialized 401 path. Defense in - depth: `serializeRefresh` now leaves a settle gap between two _queued_ sibling + depth: `serializeRefresh` now leaves a settle gap between two \_queued* sibling refreshes (default 2000 ms, tunable via `CODEX_REFRESH_SPACING_MS`, `"0"` to opt out) while releasing a lone refresh immediately, so the reactive path adds no latency. diff --git a/docs/i18n/bg/CHANGELOG.md b/docs/i18n/bg/CHANGELOG.md index 53b25f55cb..8bb11be096 100644 --- a/docs/i18n/bg/CHANGELOG.md +++ b/docs/i18n/bg/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.24] — TBD + +_See English CHANGELOG for v3.8.24 details._ + +--- + ## [3.8.22] — TBD _See English CHANGELOG for v3.8.22 details._ @@ -169,9 +175,9 @@ _Development cycle in progress._ refreshed siblings concurrently, so Auth0 revoked the whole token family (`openai/codex#9648`) and every account but the last died with `[403] `. The quota path now skips proactive refresh for - rotating providers (`rotationGroupFor`) and reuses the current access_token, + rotating providers (`rotationGroupFor`) and reuses the current access*token, deferring genuine expiry to the reactive, serialized 401 path. Defense in - depth: `serializeRefresh` now leaves a settle gap between two _queued_ sibling + depth: `serializeRefresh` now leaves a settle gap between two \_queued* sibling refreshes (default 2000 ms, tunable via `CODEX_REFRESH_SPACING_MS`, `"0"` to opt out) while releasing a lone refresh immediately, so the reactive path adds no latency. diff --git a/docs/i18n/bn/CHANGELOG.md b/docs/i18n/bn/CHANGELOG.md index eac10e6cd8..0b7d7d99f6 100644 --- a/docs/i18n/bn/CHANGELOG.md +++ b/docs/i18n/bn/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.24] — TBD + +_See English CHANGELOG for v3.8.24 details._ + +--- + ## [3.8.22] — TBD _See English CHANGELOG for v3.8.22 details._ @@ -169,9 +175,9 @@ _Development cycle in progress._ refreshed siblings concurrently, so Auth0 revoked the whole token family (`openai/codex#9648`) and every account but the last died with `[403] `. The quota path now skips proactive refresh for - rotating providers (`rotationGroupFor`) and reuses the current access_token, + rotating providers (`rotationGroupFor`) and reuses the current access*token, deferring genuine expiry to the reactive, serialized 401 path. Defense in - depth: `serializeRefresh` now leaves a settle gap between two _queued_ sibling + depth: `serializeRefresh` now leaves a settle gap between two \_queued* sibling refreshes (default 2000 ms, tunable via `CODEX_REFRESH_SPACING_MS`, `"0"` to opt out) while releasing a lone refresh immediately, so the reactive path adds no latency. diff --git a/docs/i18n/cs/CHANGELOG.md b/docs/i18n/cs/CHANGELOG.md index e8ca032a15..b882307873 100644 --- a/docs/i18n/cs/CHANGELOG.md +++ b/docs/i18n/cs/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.24] — TBD + +_See English CHANGELOG for v3.8.24 details._ + +--- + ## [3.8.22] — TBD _See English CHANGELOG for v3.8.22 details._ @@ -169,9 +175,9 @@ _Development cycle in progress._ refreshed siblings concurrently, so Auth0 revoked the whole token family (`openai/codex#9648`) and every account but the last died with `[403] `. The quota path now skips proactive refresh for - rotating providers (`rotationGroupFor`) and reuses the current access_token, + rotating providers (`rotationGroupFor`) and reuses the current access*token, deferring genuine expiry to the reactive, serialized 401 path. Defense in - depth: `serializeRefresh` now leaves a settle gap between two _queued_ sibling + depth: `serializeRefresh` now leaves a settle gap between two \_queued* sibling refreshes (default 2000 ms, tunable via `CODEX_REFRESH_SPACING_MS`, `"0"` to opt out) while releasing a lone refresh immediately, so the reactive path adds no latency. diff --git a/docs/i18n/da/CHANGELOG.md b/docs/i18n/da/CHANGELOG.md index 17cf7768ea..ca7d9cb4ef 100644 --- a/docs/i18n/da/CHANGELOG.md +++ b/docs/i18n/da/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.24] — TBD + +_See English CHANGELOG for v3.8.24 details._ + +--- + ## [3.8.22] — TBD _See English CHANGELOG for v3.8.22 details._ @@ -169,9 +175,9 @@ _Development cycle in progress._ refreshed siblings concurrently, so Auth0 revoked the whole token family (`openai/codex#9648`) and every account but the last died with `[403] `. The quota path now skips proactive refresh for - rotating providers (`rotationGroupFor`) and reuses the current access_token, + rotating providers (`rotationGroupFor`) and reuses the current access*token, deferring genuine expiry to the reactive, serialized 401 path. Defense in - depth: `serializeRefresh` now leaves a settle gap between two _queued_ sibling + depth: `serializeRefresh` now leaves a settle gap between two \_queued* sibling refreshes (default 2000 ms, tunable via `CODEX_REFRESH_SPACING_MS`, `"0"` to opt out) while releasing a lone refresh immediately, so the reactive path adds no latency. diff --git a/docs/i18n/de/CHANGELOG.md b/docs/i18n/de/CHANGELOG.md index c19a046563..2166669249 100644 --- a/docs/i18n/de/CHANGELOG.md +++ b/docs/i18n/de/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.24] — TBD + +_See English CHANGELOG for v3.8.24 details._ + +--- + ## [3.8.22] — TBD _See English CHANGELOG for v3.8.22 details._ @@ -169,9 +175,9 @@ _Development cycle in progress._ refreshed siblings concurrently, so Auth0 revoked the whole token family (`openai/codex#9648`) and every account but the last died with `[403] `. The quota path now skips proactive refresh for - rotating providers (`rotationGroupFor`) and reuses the current access_token, + rotating providers (`rotationGroupFor`) and reuses the current access*token, deferring genuine expiry to the reactive, serialized 401 path. Defense in - depth: `serializeRefresh` now leaves a settle gap between two _queued_ sibling + depth: `serializeRefresh` now leaves a settle gap between two \_queued* sibling refreshes (default 2000 ms, tunable via `CODEX_REFRESH_SPACING_MS`, `"0"` to opt out) while releasing a lone refresh immediately, so the reactive path adds no latency. diff --git a/docs/i18n/es/CHANGELOG.md b/docs/i18n/es/CHANGELOG.md index 4e22735726..c3b6ca7865 100644 --- a/docs/i18n/es/CHANGELOG.md +++ b/docs/i18n/es/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.24] — TBD + +_See English CHANGELOG for v3.8.24 details._ + +--- + ## [3.8.22] — TBD _See English CHANGELOG for v3.8.22 details._ @@ -169,9 +175,9 @@ _Development cycle in progress._ refreshed siblings concurrently, so Auth0 revoked the whole token family (`openai/codex#9648`) and every account but the last died with `[403] `. The quota path now skips proactive refresh for - rotating providers (`rotationGroupFor`) and reuses the current access_token, + rotating providers (`rotationGroupFor`) and reuses the current access*token, deferring genuine expiry to the reactive, serialized 401 path. Defense in - depth: `serializeRefresh` now leaves a settle gap between two _queued_ sibling + depth: `serializeRefresh` now leaves a settle gap between two \_queued* sibling refreshes (default 2000 ms, tunable via `CODEX_REFRESH_SPACING_MS`, `"0"` to opt out) while releasing a lone refresh immediately, so the reactive path adds no latency. diff --git a/docs/i18n/fa/CHANGELOG.md b/docs/i18n/fa/CHANGELOG.md index 36e621d9a0..e53bcce032 100644 --- a/docs/i18n/fa/CHANGELOG.md +++ b/docs/i18n/fa/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.24] — TBD + +_See English CHANGELOG for v3.8.24 details._ + +--- + ## [3.8.22] — TBD _See English CHANGELOG for v3.8.22 details._ @@ -169,9 +175,9 @@ _Development cycle in progress._ refreshed siblings concurrently, so Auth0 revoked the whole token family (`openai/codex#9648`) and every account but the last died with `[403] `. The quota path now skips proactive refresh for - rotating providers (`rotationGroupFor`) and reuses the current access_token, + rotating providers (`rotationGroupFor`) and reuses the current access*token, deferring genuine expiry to the reactive, serialized 401 path. Defense in - depth: `serializeRefresh` now leaves a settle gap between two _queued_ sibling + depth: `serializeRefresh` now leaves a settle gap between two \_queued* sibling refreshes (default 2000 ms, tunable via `CODEX_REFRESH_SPACING_MS`, `"0"` to opt out) while releasing a lone refresh immediately, so the reactive path adds no latency. diff --git a/docs/i18n/fi/CHANGELOG.md b/docs/i18n/fi/CHANGELOG.md index 6baf5e6475..9e8f631141 100644 --- a/docs/i18n/fi/CHANGELOG.md +++ b/docs/i18n/fi/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.24] — TBD + +_See English CHANGELOG for v3.8.24 details._ + +--- + ## [3.8.22] — TBD _See English CHANGELOG for v3.8.22 details._ @@ -169,9 +175,9 @@ _Development cycle in progress._ refreshed siblings concurrently, so Auth0 revoked the whole token family (`openai/codex#9648`) and every account but the last died with `[403] `. The quota path now skips proactive refresh for - rotating providers (`rotationGroupFor`) and reuses the current access_token, + rotating providers (`rotationGroupFor`) and reuses the current access*token, deferring genuine expiry to the reactive, serialized 401 path. Defense in - depth: `serializeRefresh` now leaves a settle gap between two _queued_ sibling + depth: `serializeRefresh` now leaves a settle gap between two \_queued* sibling refreshes (default 2000 ms, tunable via `CODEX_REFRESH_SPACING_MS`, `"0"` to opt out) while releasing a lone refresh immediately, so the reactive path adds no latency. diff --git a/docs/i18n/fr/CHANGELOG.md b/docs/i18n/fr/CHANGELOG.md index 3a99dde766..2e2c00421a 100644 --- a/docs/i18n/fr/CHANGELOG.md +++ b/docs/i18n/fr/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.24] — TBD + +_See English CHANGELOG for v3.8.24 details._ + +--- + ## [3.8.22] — TBD _See English CHANGELOG for v3.8.22 details._ @@ -169,9 +175,9 @@ _Development cycle in progress._ refreshed siblings concurrently, so Auth0 revoked the whole token family (`openai/codex#9648`) and every account but the last died with `[403] `. The quota path now skips proactive refresh for - rotating providers (`rotationGroupFor`) and reuses the current access_token, + rotating providers (`rotationGroupFor`) and reuses the current access*token, deferring genuine expiry to the reactive, serialized 401 path. Defense in - depth: `serializeRefresh` now leaves a settle gap between two _queued_ sibling + depth: `serializeRefresh` now leaves a settle gap between two \_queued* sibling refreshes (default 2000 ms, tunable via `CODEX_REFRESH_SPACING_MS`, `"0"` to opt out) while releasing a lone refresh immediately, so the reactive path adds no latency. diff --git a/docs/i18n/gu/CHANGELOG.md b/docs/i18n/gu/CHANGELOG.md index 8a2ec07435..9353d1a5e5 100644 --- a/docs/i18n/gu/CHANGELOG.md +++ b/docs/i18n/gu/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.24] — TBD + +_See English CHANGELOG for v3.8.24 details._ + +--- + ## [3.8.22] — TBD _See English CHANGELOG for v3.8.22 details._ @@ -169,9 +175,9 @@ _Development cycle in progress._ refreshed siblings concurrently, so Auth0 revoked the whole token family (`openai/codex#9648`) and every account but the last died with `[403] `. The quota path now skips proactive refresh for - rotating providers (`rotationGroupFor`) and reuses the current access_token, + rotating providers (`rotationGroupFor`) and reuses the current access*token, deferring genuine expiry to the reactive, serialized 401 path. Defense in - depth: `serializeRefresh` now leaves a settle gap between two _queued_ sibling + depth: `serializeRefresh` now leaves a settle gap between two \_queued* sibling refreshes (default 2000 ms, tunable via `CODEX_REFRESH_SPACING_MS`, `"0"` to opt out) while releasing a lone refresh immediately, so the reactive path adds no latency. diff --git a/docs/i18n/he/CHANGELOG.md b/docs/i18n/he/CHANGELOG.md index f542d29ede..85cbac8440 100644 --- a/docs/i18n/he/CHANGELOG.md +++ b/docs/i18n/he/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.24] — TBD + +_See English CHANGELOG for v3.8.24 details._ + +--- + ## [3.8.22] — TBD _See English CHANGELOG for v3.8.22 details._ @@ -169,9 +175,9 @@ _Development cycle in progress._ refreshed siblings concurrently, so Auth0 revoked the whole token family (`openai/codex#9648`) and every account but the last died with `[403] `. The quota path now skips proactive refresh for - rotating providers (`rotationGroupFor`) and reuses the current access_token, + rotating providers (`rotationGroupFor`) and reuses the current access*token, deferring genuine expiry to the reactive, serialized 401 path. Defense in - depth: `serializeRefresh` now leaves a settle gap between two _queued_ sibling + depth: `serializeRefresh` now leaves a settle gap between two \_queued* sibling refreshes (default 2000 ms, tunable via `CODEX_REFRESH_SPACING_MS`, `"0"` to opt out) while releasing a lone refresh immediately, so the reactive path adds no latency. diff --git a/docs/i18n/hi/CHANGELOG.md b/docs/i18n/hi/CHANGELOG.md index 5854502c47..68cdebcffc 100644 --- a/docs/i18n/hi/CHANGELOG.md +++ b/docs/i18n/hi/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.24] — TBD + +_See English CHANGELOG for v3.8.24 details._ + +--- + ## [3.8.22] — TBD _See English CHANGELOG for v3.8.22 details._ @@ -169,9 +175,9 @@ _Development cycle in progress._ refreshed siblings concurrently, so Auth0 revoked the whole token family (`openai/codex#9648`) and every account but the last died with `[403] `. The quota path now skips proactive refresh for - rotating providers (`rotationGroupFor`) and reuses the current access_token, + rotating providers (`rotationGroupFor`) and reuses the current access*token, deferring genuine expiry to the reactive, serialized 401 path. Defense in - depth: `serializeRefresh` now leaves a settle gap between two _queued_ sibling + depth: `serializeRefresh` now leaves a settle gap between two \_queued* sibling refreshes (default 2000 ms, tunable via `CODEX_REFRESH_SPACING_MS`, `"0"` to opt out) while releasing a lone refresh immediately, so the reactive path adds no latency. diff --git a/docs/i18n/hu/CHANGELOG.md b/docs/i18n/hu/CHANGELOG.md index cac7dfef75..c539bba68f 100644 --- a/docs/i18n/hu/CHANGELOG.md +++ b/docs/i18n/hu/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.24] — TBD + +_See English CHANGELOG for v3.8.24 details._ + +--- + ## [3.8.22] — TBD _See English CHANGELOG for v3.8.22 details._ @@ -169,9 +175,9 @@ _Development cycle in progress._ refreshed siblings concurrently, so Auth0 revoked the whole token family (`openai/codex#9648`) and every account but the last died with `[403] `. The quota path now skips proactive refresh for - rotating providers (`rotationGroupFor`) and reuses the current access_token, + rotating providers (`rotationGroupFor`) and reuses the current access*token, deferring genuine expiry to the reactive, serialized 401 path. Defense in - depth: `serializeRefresh` now leaves a settle gap between two _queued_ sibling + depth: `serializeRefresh` now leaves a settle gap between two \_queued* sibling refreshes (default 2000 ms, tunable via `CODEX_REFRESH_SPACING_MS`, `"0"` to opt out) while releasing a lone refresh immediately, so the reactive path adds no latency. diff --git a/docs/i18n/id/CHANGELOG.md b/docs/i18n/id/CHANGELOG.md index efbd66686b..286a249523 100644 --- a/docs/i18n/id/CHANGELOG.md +++ b/docs/i18n/id/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.24] — TBD + +_See English CHANGELOG for v3.8.24 details._ + +--- + ## [3.8.22] — TBD _See English CHANGELOG for v3.8.22 details._ @@ -169,9 +175,9 @@ _Development cycle in progress._ refreshed siblings concurrently, so Auth0 revoked the whole token family (`openai/codex#9648`) and every account but the last died with `[403] `. The quota path now skips proactive refresh for - rotating providers (`rotationGroupFor`) and reuses the current access_token, + rotating providers (`rotationGroupFor`) and reuses the current access*token, deferring genuine expiry to the reactive, serialized 401 path. Defense in - depth: `serializeRefresh` now leaves a settle gap between two _queued_ sibling + depth: `serializeRefresh` now leaves a settle gap between two \_queued* sibling refreshes (default 2000 ms, tunable via `CODEX_REFRESH_SPACING_MS`, `"0"` to opt out) while releasing a lone refresh immediately, so the reactive path adds no latency. diff --git a/docs/i18n/in/CHANGELOG.md b/docs/i18n/in/CHANGELOG.md index f4b5f6aedd..d96b6ceabe 100644 --- a/docs/i18n/in/CHANGELOG.md +++ b/docs/i18n/in/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.24] — TBD + +_See English CHANGELOG for v3.8.24 details._ + +--- + ## [3.8.22] — TBD _See English CHANGELOG for v3.8.22 details._ @@ -169,9 +175,9 @@ _Development cycle in progress._ refreshed siblings concurrently, so Auth0 revoked the whole token family (`openai/codex#9648`) and every account but the last died with `[403] `. The quota path now skips proactive refresh for - rotating providers (`rotationGroupFor`) and reuses the current access_token, + rotating providers (`rotationGroupFor`) and reuses the current access*token, deferring genuine expiry to the reactive, serialized 401 path. Defense in - depth: `serializeRefresh` now leaves a settle gap between two _queued_ sibling + depth: `serializeRefresh` now leaves a settle gap between two \_queued* sibling refreshes (default 2000 ms, tunable via `CODEX_REFRESH_SPACING_MS`, `"0"` to opt out) while releasing a lone refresh immediately, so the reactive path adds no latency. diff --git a/docs/i18n/it/CHANGELOG.md b/docs/i18n/it/CHANGELOG.md index 4293c8264f..c1f63a816e 100644 --- a/docs/i18n/it/CHANGELOG.md +++ b/docs/i18n/it/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.24] — TBD + +_See English CHANGELOG for v3.8.24 details._ + +--- + ## [3.8.22] — TBD _See English CHANGELOG for v3.8.22 details._ @@ -169,9 +175,9 @@ _Development cycle in progress._ refreshed siblings concurrently, so Auth0 revoked the whole token family (`openai/codex#9648`) and every account but the last died with `[403] `. The quota path now skips proactive refresh for - rotating providers (`rotationGroupFor`) and reuses the current access_token, + rotating providers (`rotationGroupFor`) and reuses the current access*token, deferring genuine expiry to the reactive, serialized 401 path. Defense in - depth: `serializeRefresh` now leaves a settle gap between two _queued_ sibling + depth: `serializeRefresh` now leaves a settle gap between two \_queued* sibling refreshes (default 2000 ms, tunable via `CODEX_REFRESH_SPACING_MS`, `"0"` to opt out) while releasing a lone refresh immediately, so the reactive path adds no latency. diff --git a/docs/i18n/ja/CHANGELOG.md b/docs/i18n/ja/CHANGELOG.md index 84e747513d..e78f31bd76 100644 --- a/docs/i18n/ja/CHANGELOG.md +++ b/docs/i18n/ja/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.24] — TBD + +_See English CHANGELOG for v3.8.24 details._ + +--- + ## [3.8.22] — TBD _See English CHANGELOG for v3.8.22 details._ @@ -169,9 +175,9 @@ _Development cycle in progress._ refreshed siblings concurrently, so Auth0 revoked the whole token family (`openai/codex#9648`) and every account but the last died with `[403] `. The quota path now skips proactive refresh for - rotating providers (`rotationGroupFor`) and reuses the current access_token, + rotating providers (`rotationGroupFor`) and reuses the current access*token, deferring genuine expiry to the reactive, serialized 401 path. Defense in - depth: `serializeRefresh` now leaves a settle gap between two _queued_ sibling + depth: `serializeRefresh` now leaves a settle gap between two \_queued* sibling refreshes (default 2000 ms, tunable via `CODEX_REFRESH_SPACING_MS`, `"0"` to opt out) while releasing a lone refresh immediately, so the reactive path adds no latency. diff --git a/docs/i18n/ko/CHANGELOG.md b/docs/i18n/ko/CHANGELOG.md index 7e05fe336c..676d8800c4 100644 --- a/docs/i18n/ko/CHANGELOG.md +++ b/docs/i18n/ko/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.24] — TBD + +_See English CHANGELOG for v3.8.24 details._ + +--- + ## [3.8.22] — TBD _See English CHANGELOG for v3.8.22 details._ @@ -169,9 +175,9 @@ _Development cycle in progress._ refreshed siblings concurrently, so Auth0 revoked the whole token family (`openai/codex#9648`) and every account but the last died with `[403] `. The quota path now skips proactive refresh for - rotating providers (`rotationGroupFor`) and reuses the current access_token, + rotating providers (`rotationGroupFor`) and reuses the current access*token, deferring genuine expiry to the reactive, serialized 401 path. Defense in - depth: `serializeRefresh` now leaves a settle gap between two _queued_ sibling + depth: `serializeRefresh` now leaves a settle gap between two \_queued* sibling refreshes (default 2000 ms, tunable via `CODEX_REFRESH_SPACING_MS`, `"0"` to opt out) while releasing a lone refresh immediately, so the reactive path adds no latency. diff --git a/docs/i18n/mr/CHANGELOG.md b/docs/i18n/mr/CHANGELOG.md index e6ea8faf0e..aecd70d7a0 100644 --- a/docs/i18n/mr/CHANGELOG.md +++ b/docs/i18n/mr/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.24] — TBD + +_See English CHANGELOG for v3.8.24 details._ + +--- + ## [3.8.22] — TBD _See English CHANGELOG for v3.8.22 details._ @@ -169,9 +175,9 @@ _Development cycle in progress._ refreshed siblings concurrently, so Auth0 revoked the whole token family (`openai/codex#9648`) and every account but the last died with `[403] `. The quota path now skips proactive refresh for - rotating providers (`rotationGroupFor`) and reuses the current access_token, + rotating providers (`rotationGroupFor`) and reuses the current access*token, deferring genuine expiry to the reactive, serialized 401 path. Defense in - depth: `serializeRefresh` now leaves a settle gap between two _queued_ sibling + depth: `serializeRefresh` now leaves a settle gap between two \_queued* sibling refreshes (default 2000 ms, tunable via `CODEX_REFRESH_SPACING_MS`, `"0"` to opt out) while releasing a lone refresh immediately, so the reactive path adds no latency. diff --git a/docs/i18n/ms/CHANGELOG.md b/docs/i18n/ms/CHANGELOG.md index 12b6491553..5711170380 100644 --- a/docs/i18n/ms/CHANGELOG.md +++ b/docs/i18n/ms/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.24] — TBD + +_See English CHANGELOG for v3.8.24 details._ + +--- + ## [3.8.22] — TBD _See English CHANGELOG for v3.8.22 details._ @@ -169,9 +175,9 @@ _Development cycle in progress._ refreshed siblings concurrently, so Auth0 revoked the whole token family (`openai/codex#9648`) and every account but the last died with `[403] `. The quota path now skips proactive refresh for - rotating providers (`rotationGroupFor`) and reuses the current access_token, + rotating providers (`rotationGroupFor`) and reuses the current access*token, deferring genuine expiry to the reactive, serialized 401 path. Defense in - depth: `serializeRefresh` now leaves a settle gap between two _queued_ sibling + depth: `serializeRefresh` now leaves a settle gap between two \_queued* sibling refreshes (default 2000 ms, tunable via `CODEX_REFRESH_SPACING_MS`, `"0"` to opt out) while releasing a lone refresh immediately, so the reactive path adds no latency. diff --git a/docs/i18n/nl/CHANGELOG.md b/docs/i18n/nl/CHANGELOG.md index 87ee99cd32..fb51eca85b 100644 --- a/docs/i18n/nl/CHANGELOG.md +++ b/docs/i18n/nl/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.24] — TBD + +_See English CHANGELOG for v3.8.24 details._ + +--- + ## [3.8.22] — TBD _See English CHANGELOG for v3.8.22 details._ @@ -169,9 +175,9 @@ _Development cycle in progress._ refreshed siblings concurrently, so Auth0 revoked the whole token family (`openai/codex#9648`) and every account but the last died with `[403] `. The quota path now skips proactive refresh for - rotating providers (`rotationGroupFor`) and reuses the current access_token, + rotating providers (`rotationGroupFor`) and reuses the current access*token, deferring genuine expiry to the reactive, serialized 401 path. Defense in - depth: `serializeRefresh` now leaves a settle gap between two _queued_ sibling + depth: `serializeRefresh` now leaves a settle gap between two \_queued* sibling refreshes (default 2000 ms, tunable via `CODEX_REFRESH_SPACING_MS`, `"0"` to opt out) while releasing a lone refresh immediately, so the reactive path adds no latency. diff --git a/docs/i18n/no/CHANGELOG.md b/docs/i18n/no/CHANGELOG.md index 48336b783e..4ce01fc5bb 100644 --- a/docs/i18n/no/CHANGELOG.md +++ b/docs/i18n/no/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.24] — TBD + +_See English CHANGELOG for v3.8.24 details._ + +--- + ## [3.8.22] — TBD _See English CHANGELOG for v3.8.22 details._ @@ -169,9 +175,9 @@ _Development cycle in progress._ refreshed siblings concurrently, so Auth0 revoked the whole token family (`openai/codex#9648`) and every account but the last died with `[403] `. The quota path now skips proactive refresh for - rotating providers (`rotationGroupFor`) and reuses the current access_token, + rotating providers (`rotationGroupFor`) and reuses the current access*token, deferring genuine expiry to the reactive, serialized 401 path. Defense in - depth: `serializeRefresh` now leaves a settle gap between two _queued_ sibling + depth: `serializeRefresh` now leaves a settle gap between two \_queued* sibling refreshes (default 2000 ms, tunable via `CODEX_REFRESH_SPACING_MS`, `"0"` to opt out) while releasing a lone refresh immediately, so the reactive path adds no latency. diff --git a/docs/i18n/phi/CHANGELOG.md b/docs/i18n/phi/CHANGELOG.md index b0e72d3e66..b0a4da238f 100644 --- a/docs/i18n/phi/CHANGELOG.md +++ b/docs/i18n/phi/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.24] — TBD + +_See English CHANGELOG for v3.8.24 details._ + +--- + ## [3.8.22] — TBD _See English CHANGELOG for v3.8.22 details._ @@ -169,9 +175,9 @@ _Development cycle in progress._ refreshed siblings concurrently, so Auth0 revoked the whole token family (`openai/codex#9648`) and every account but the last died with `[403] `. The quota path now skips proactive refresh for - rotating providers (`rotationGroupFor`) and reuses the current access_token, + rotating providers (`rotationGroupFor`) and reuses the current access*token, deferring genuine expiry to the reactive, serialized 401 path. Defense in - depth: `serializeRefresh` now leaves a settle gap between two _queued_ sibling + depth: `serializeRefresh` now leaves a settle gap between two \_queued* sibling refreshes (default 2000 ms, tunable via `CODEX_REFRESH_SPACING_MS`, `"0"` to opt out) while releasing a lone refresh immediately, so the reactive path adds no latency. diff --git a/docs/i18n/pl/CHANGELOG.md b/docs/i18n/pl/CHANGELOG.md index 985f01dfc0..ec27815482 100644 --- a/docs/i18n/pl/CHANGELOG.md +++ b/docs/i18n/pl/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.24] — TBD + +_See English CHANGELOG for v3.8.24 details._ + +--- + ## [3.8.22] — TBD _See English CHANGELOG for v3.8.22 details._ @@ -169,9 +175,9 @@ _Development cycle in progress._ refreshed siblings concurrently, so Auth0 revoked the whole token family (`openai/codex#9648`) and every account but the last died with `[403] `. The quota path now skips proactive refresh for - rotating providers (`rotationGroupFor`) and reuses the current access_token, + rotating providers (`rotationGroupFor`) and reuses the current access*token, deferring genuine expiry to the reactive, serialized 401 path. Defense in - depth: `serializeRefresh` now leaves a settle gap between two _queued_ sibling + depth: `serializeRefresh` now leaves a settle gap between two \_queued* sibling refreshes (default 2000 ms, tunable via `CODEX_REFRESH_SPACING_MS`, `"0"` to opt out) while releasing a lone refresh immediately, so the reactive path adds no latency. diff --git a/docs/i18n/pt-BR/CHANGELOG.md b/docs/i18n/pt-BR/CHANGELOG.md index fc2a5cadbe..5b369c2fb4 100644 --- a/docs/i18n/pt-BR/CHANGELOG.md +++ b/docs/i18n/pt-BR/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.24] — TBD + +_See English CHANGELOG for v3.8.24 details._ + +--- + ## [3.8.22] — TBD _See English CHANGELOG for v3.8.22 details._ @@ -169,9 +175,9 @@ _Development cycle in progress._ refreshed siblings concurrently, so Auth0 revoked the whole token family (`openai/codex#9648`) and every account but the last died with `[403] `. The quota path now skips proactive refresh for - rotating providers (`rotationGroupFor`) and reuses the current access_token, + rotating providers (`rotationGroupFor`) and reuses the current access*token, deferring genuine expiry to the reactive, serialized 401 path. Defense in - depth: `serializeRefresh` now leaves a settle gap between two _queued_ sibling + depth: `serializeRefresh` now leaves a settle gap between two \_queued* sibling refreshes (default 2000 ms, tunable via `CODEX_REFRESH_SPACING_MS`, `"0"` to opt out) while releasing a lone refresh immediately, so the reactive path adds no latency. diff --git a/docs/i18n/pt/CHANGELOG.md b/docs/i18n/pt/CHANGELOG.md index c1e556f9c7..becfc6b895 100644 --- a/docs/i18n/pt/CHANGELOG.md +++ b/docs/i18n/pt/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.24] — TBD + +_See English CHANGELOG for v3.8.24 details._ + +--- + ## [3.8.22] — TBD _See English CHANGELOG for v3.8.22 details._ @@ -169,9 +175,9 @@ _Development cycle in progress._ refreshed siblings concurrently, so Auth0 revoked the whole token family (`openai/codex#9648`) and every account but the last died with `[403] `. The quota path now skips proactive refresh for - rotating providers (`rotationGroupFor`) and reuses the current access_token, + rotating providers (`rotationGroupFor`) and reuses the current access*token, deferring genuine expiry to the reactive, serialized 401 path. Defense in - depth: `serializeRefresh` now leaves a settle gap between two _queued_ sibling + depth: `serializeRefresh` now leaves a settle gap between two \_queued* sibling refreshes (default 2000 ms, tunable via `CODEX_REFRESH_SPACING_MS`, `"0"` to opt out) while releasing a lone refresh immediately, so the reactive path adds no latency. diff --git a/docs/i18n/ro/CHANGELOG.md b/docs/i18n/ro/CHANGELOG.md index 7515cb8c77..4c38f59743 100644 --- a/docs/i18n/ro/CHANGELOG.md +++ b/docs/i18n/ro/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.24] — TBD + +_See English CHANGELOG for v3.8.24 details._ + +--- + ## [3.8.22] — TBD _See English CHANGELOG for v3.8.22 details._ @@ -169,9 +175,9 @@ _Development cycle in progress._ refreshed siblings concurrently, so Auth0 revoked the whole token family (`openai/codex#9648`) and every account but the last died with `[403] `. The quota path now skips proactive refresh for - rotating providers (`rotationGroupFor`) and reuses the current access_token, + rotating providers (`rotationGroupFor`) and reuses the current access*token, deferring genuine expiry to the reactive, serialized 401 path. Defense in - depth: `serializeRefresh` now leaves a settle gap between two _queued_ sibling + depth: `serializeRefresh` now leaves a settle gap between two \_queued* sibling refreshes (default 2000 ms, tunable via `CODEX_REFRESH_SPACING_MS`, `"0"` to opt out) while releasing a lone refresh immediately, so the reactive path adds no latency. diff --git a/docs/i18n/ru/CHANGELOG.md b/docs/i18n/ru/CHANGELOG.md index 8602274b67..64c1dfc2c3 100644 --- a/docs/i18n/ru/CHANGELOG.md +++ b/docs/i18n/ru/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.24] — TBD + +_See English CHANGELOG for v3.8.24 details._ + +--- + ## [3.8.22] — TBD _See English CHANGELOG for v3.8.22 details._ @@ -169,9 +175,9 @@ _Development cycle in progress._ refreshed siblings concurrently, so Auth0 revoked the whole token family (`openai/codex#9648`) and every account but the last died with `[403] `. The quota path now skips proactive refresh for - rotating providers (`rotationGroupFor`) and reuses the current access_token, + rotating providers (`rotationGroupFor`) and reuses the current access*token, deferring genuine expiry to the reactive, serialized 401 path. Defense in - depth: `serializeRefresh` now leaves a settle gap between two _queued_ sibling + depth: `serializeRefresh` now leaves a settle gap between two \_queued* sibling refreshes (default 2000 ms, tunable via `CODEX_REFRESH_SPACING_MS`, `"0"` to opt out) while releasing a lone refresh immediately, so the reactive path adds no latency. diff --git a/docs/i18n/sk/CHANGELOG.md b/docs/i18n/sk/CHANGELOG.md index e03ef3ee6d..4444fd7fde 100644 --- a/docs/i18n/sk/CHANGELOG.md +++ b/docs/i18n/sk/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.24] — TBD + +_See English CHANGELOG for v3.8.24 details._ + +--- + ## [3.8.22] — TBD _See English CHANGELOG for v3.8.22 details._ @@ -169,9 +175,9 @@ _Development cycle in progress._ refreshed siblings concurrently, so Auth0 revoked the whole token family (`openai/codex#9648`) and every account but the last died with `[403] `. The quota path now skips proactive refresh for - rotating providers (`rotationGroupFor`) and reuses the current access_token, + rotating providers (`rotationGroupFor`) and reuses the current access*token, deferring genuine expiry to the reactive, serialized 401 path. Defense in - depth: `serializeRefresh` now leaves a settle gap between two _queued_ sibling + depth: `serializeRefresh` now leaves a settle gap between two \_queued* sibling refreshes (default 2000 ms, tunable via `CODEX_REFRESH_SPACING_MS`, `"0"` to opt out) while releasing a lone refresh immediately, so the reactive path adds no latency. diff --git a/docs/i18n/sv/CHANGELOG.md b/docs/i18n/sv/CHANGELOG.md index 4467eb15ed..0298620e40 100644 --- a/docs/i18n/sv/CHANGELOG.md +++ b/docs/i18n/sv/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.24] — TBD + +_See English CHANGELOG for v3.8.24 details._ + +--- + ## [3.8.22] — TBD _See English CHANGELOG for v3.8.22 details._ @@ -169,9 +175,9 @@ _Development cycle in progress._ refreshed siblings concurrently, so Auth0 revoked the whole token family (`openai/codex#9648`) and every account but the last died with `[403] `. The quota path now skips proactive refresh for - rotating providers (`rotationGroupFor`) and reuses the current access_token, + rotating providers (`rotationGroupFor`) and reuses the current access*token, deferring genuine expiry to the reactive, serialized 401 path. Defense in - depth: `serializeRefresh` now leaves a settle gap between two _queued_ sibling + depth: `serializeRefresh` now leaves a settle gap between two \_queued* sibling refreshes (default 2000 ms, tunable via `CODEX_REFRESH_SPACING_MS`, `"0"` to opt out) while releasing a lone refresh immediately, so the reactive path adds no latency. diff --git a/docs/i18n/sw/CHANGELOG.md b/docs/i18n/sw/CHANGELOG.md index c85af1c131..f8555fff1b 100644 --- a/docs/i18n/sw/CHANGELOG.md +++ b/docs/i18n/sw/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.24] — TBD + +_See English CHANGELOG for v3.8.24 details._ + +--- + ## [3.8.22] — TBD _See English CHANGELOG for v3.8.22 details._ @@ -169,9 +175,9 @@ _Development cycle in progress._ refreshed siblings concurrently, so Auth0 revoked the whole token family (`openai/codex#9648`) and every account but the last died with `[403] `. The quota path now skips proactive refresh for - rotating providers (`rotationGroupFor`) and reuses the current access_token, + rotating providers (`rotationGroupFor`) and reuses the current access*token, deferring genuine expiry to the reactive, serialized 401 path. Defense in - depth: `serializeRefresh` now leaves a settle gap between two _queued_ sibling + depth: `serializeRefresh` now leaves a settle gap between two \_queued* sibling refreshes (default 2000 ms, tunable via `CODEX_REFRESH_SPACING_MS`, `"0"` to opt out) while releasing a lone refresh immediately, so the reactive path adds no latency. diff --git a/docs/i18n/ta/CHANGELOG.md b/docs/i18n/ta/CHANGELOG.md index 30f309ffd8..e1d0296193 100644 --- a/docs/i18n/ta/CHANGELOG.md +++ b/docs/i18n/ta/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.24] — TBD + +_See English CHANGELOG for v3.8.24 details._ + +--- + ## [3.8.22] — TBD _See English CHANGELOG for v3.8.22 details._ @@ -169,9 +175,9 @@ _Development cycle in progress._ refreshed siblings concurrently, so Auth0 revoked the whole token family (`openai/codex#9648`) and every account but the last died with `[403] `. The quota path now skips proactive refresh for - rotating providers (`rotationGroupFor`) and reuses the current access_token, + rotating providers (`rotationGroupFor`) and reuses the current access*token, deferring genuine expiry to the reactive, serialized 401 path. Defense in - depth: `serializeRefresh` now leaves a settle gap between two _queued_ sibling + depth: `serializeRefresh` now leaves a settle gap between two \_queued* sibling refreshes (default 2000 ms, tunable via `CODEX_REFRESH_SPACING_MS`, `"0"` to opt out) while releasing a lone refresh immediately, so the reactive path adds no latency. diff --git a/docs/i18n/te/CHANGELOG.md b/docs/i18n/te/CHANGELOG.md index 09ea46c7b1..3368b46a57 100644 --- a/docs/i18n/te/CHANGELOG.md +++ b/docs/i18n/te/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.24] — TBD + +_See English CHANGELOG for v3.8.24 details._ + +--- + ## [3.8.22] — TBD _See English CHANGELOG for v3.8.22 details._ @@ -169,9 +175,9 @@ _Development cycle in progress._ refreshed siblings concurrently, so Auth0 revoked the whole token family (`openai/codex#9648`) and every account but the last died with `[403] `. The quota path now skips proactive refresh for - rotating providers (`rotationGroupFor`) and reuses the current access_token, + rotating providers (`rotationGroupFor`) and reuses the current access*token, deferring genuine expiry to the reactive, serialized 401 path. Defense in - depth: `serializeRefresh` now leaves a settle gap between two _queued_ sibling + depth: `serializeRefresh` now leaves a settle gap between two \_queued* sibling refreshes (default 2000 ms, tunable via `CODEX_REFRESH_SPACING_MS`, `"0"` to opt out) while releasing a lone refresh immediately, so the reactive path adds no latency. diff --git a/docs/i18n/th/CHANGELOG.md b/docs/i18n/th/CHANGELOG.md index 9d785a7add..34e57315c8 100644 --- a/docs/i18n/th/CHANGELOG.md +++ b/docs/i18n/th/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.24] — TBD + +_See English CHANGELOG for v3.8.24 details._ + +--- + ## [3.8.22] — TBD _See English CHANGELOG for v3.8.22 details._ @@ -169,9 +175,9 @@ _Development cycle in progress._ refreshed siblings concurrently, so Auth0 revoked the whole token family (`openai/codex#9648`) and every account but the last died with `[403] `. The quota path now skips proactive refresh for - rotating providers (`rotationGroupFor`) and reuses the current access_token, + rotating providers (`rotationGroupFor`) and reuses the current access*token, deferring genuine expiry to the reactive, serialized 401 path. Defense in - depth: `serializeRefresh` now leaves a settle gap between two _queued_ sibling + depth: `serializeRefresh` now leaves a settle gap between two \_queued* sibling refreshes (default 2000 ms, tunable via `CODEX_REFRESH_SPACING_MS`, `"0"` to opt out) while releasing a lone refresh immediately, so the reactive path adds no latency. diff --git a/docs/i18n/tr/CHANGELOG.md b/docs/i18n/tr/CHANGELOG.md index f59f8e1a7e..755a2d7508 100644 --- a/docs/i18n/tr/CHANGELOG.md +++ b/docs/i18n/tr/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.24] — TBD + +_See English CHANGELOG for v3.8.24 details._ + +--- + ## [3.8.22] — TBD _See English CHANGELOG for v3.8.22 details._ @@ -169,9 +175,9 @@ _Development cycle in progress._ refreshed siblings concurrently, so Auth0 revoked the whole token family (`openai/codex#9648`) and every account but the last died with `[403] `. The quota path now skips proactive refresh for - rotating providers (`rotationGroupFor`) and reuses the current access_token, + rotating providers (`rotationGroupFor`) and reuses the current access*token, deferring genuine expiry to the reactive, serialized 401 path. Defense in - depth: `serializeRefresh` now leaves a settle gap between two _queued_ sibling + depth: `serializeRefresh` now leaves a settle gap between two \_queued* sibling refreshes (default 2000 ms, tunable via `CODEX_REFRESH_SPACING_MS`, `"0"` to opt out) while releasing a lone refresh immediately, so the reactive path adds no latency. diff --git a/docs/i18n/uk-UA/CHANGELOG.md b/docs/i18n/uk-UA/CHANGELOG.md index 1583ff85f7..fedda0bbdd 100644 --- a/docs/i18n/uk-UA/CHANGELOG.md +++ b/docs/i18n/uk-UA/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.24] — TBD + +_See English CHANGELOG for v3.8.24 details._ + +--- + ## [3.8.22] — TBD _See English CHANGELOG for v3.8.22 details._ @@ -169,9 +175,9 @@ _Development cycle in progress._ refreshed siblings concurrently, so Auth0 revoked the whole token family (`openai/codex#9648`) and every account but the last died with `[403] `. The quota path now skips proactive refresh for - rotating providers (`rotationGroupFor`) and reuses the current access_token, + rotating providers (`rotationGroupFor`) and reuses the current access*token, deferring genuine expiry to the reactive, serialized 401 path. Defense in - depth: `serializeRefresh` now leaves a settle gap between two _queued_ sibling + depth: `serializeRefresh` now leaves a settle gap between two \_queued* sibling refreshes (default 2000 ms, tunable via `CODEX_REFRESH_SPACING_MS`, `"0"` to opt out) while releasing a lone refresh immediately, so the reactive path adds no latency. diff --git a/docs/i18n/ur/CHANGELOG.md b/docs/i18n/ur/CHANGELOG.md index 31a19aecf5..64ffa0d06c 100644 --- a/docs/i18n/ur/CHANGELOG.md +++ b/docs/i18n/ur/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.24] — TBD + +_See English CHANGELOG for v3.8.24 details._ + +--- + ## [3.8.22] — TBD _See English CHANGELOG for v3.8.22 details._ @@ -169,9 +175,9 @@ _Development cycle in progress._ refreshed siblings concurrently, so Auth0 revoked the whole token family (`openai/codex#9648`) and every account but the last died with `[403] `. The quota path now skips proactive refresh for - rotating providers (`rotationGroupFor`) and reuses the current access_token, + rotating providers (`rotationGroupFor`) and reuses the current access*token, deferring genuine expiry to the reactive, serialized 401 path. Defense in - depth: `serializeRefresh` now leaves a settle gap between two _queued_ sibling + depth: `serializeRefresh` now leaves a settle gap between two \_queued* sibling refreshes (default 2000 ms, tunable via `CODEX_REFRESH_SPACING_MS`, `"0"` to opt out) while releasing a lone refresh immediately, so the reactive path adds no latency. diff --git a/docs/i18n/vi/CHANGELOG.md b/docs/i18n/vi/CHANGELOG.md index 739c7af600..6db1b0d984 100644 --- a/docs/i18n/vi/CHANGELOG.md +++ b/docs/i18n/vi/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.24] — TBD + +_See English CHANGELOG for v3.8.24 details._ + +--- + ## [3.8.22] — TBD _See English CHANGELOG for v3.8.22 details._ @@ -169,9 +175,9 @@ _Development cycle in progress._ refreshed siblings concurrently, so Auth0 revoked the whole token family (`openai/codex#9648`) and every account but the last died with `[403] `. The quota path now skips proactive refresh for - rotating providers (`rotationGroupFor`) and reuses the current access_token, + rotating providers (`rotationGroupFor`) and reuses the current access*token, deferring genuine expiry to the reactive, serialized 401 path. Defense in - depth: `serializeRefresh` now leaves a settle gap between two _queued_ sibling + depth: `serializeRefresh` now leaves a settle gap between two \_queued* sibling refreshes (default 2000 ms, tunable via `CODEX_REFRESH_SPACING_MS`, `"0"` to opt out) while releasing a lone refresh immediately, so the reactive path adds no latency. diff --git a/docs/i18n/zh-CN/CHANGELOG.md b/docs/i18n/zh-CN/CHANGELOG.md index 6cc1a5fbe3..e8a7a239af 100644 --- a/docs/i18n/zh-CN/CHANGELOG.md +++ b/docs/i18n/zh-CN/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.24] — TBD + +_See English CHANGELOG for v3.8.24 details._ + +--- + ## [3.8.22] — TBD _See English CHANGELOG for v3.8.22 details._ @@ -169,9 +175,9 @@ _Development cycle in progress._ refreshed siblings concurrently, so Auth0 revoked the whole token family (`openai/codex#9648`) and every account but the last died with `[403] `. The quota path now skips proactive refresh for - rotating providers (`rotationGroupFor`) and reuses the current access_token, + rotating providers (`rotationGroupFor`) and reuses the current access*token, deferring genuine expiry to the reactive, serialized 401 path. Defense in - depth: `serializeRefresh` now leaves a settle gap between two _queued_ sibling + depth: `serializeRefresh` now leaves a settle gap between two \_queued* sibling refreshes (default 2000 ms, tunable via `CODEX_REFRESH_SPACING_MS`, `"0"` to opt out) while releasing a lone refresh immediately, so the reactive path adds no latency. diff --git a/docs/openspec/changes/self-service-api-key-usage/proposal.md b/docs/openspec/changes/self-service-api-key-usage/proposal.md deleted file mode 100644 index 1c9a36e164..0000000000 --- a/docs/openspec/changes/self-service-api-key-usage/proposal.md +++ /dev/null @@ -1,65 +0,0 @@ -# Change: Self-Service API Key Usage and Quota Visibility - -## Summary - -Add a client-facing self-service status endpoint and dashboard controls that let each OmniRoute API key inspect its own USD usage, token usage, and percent used against its existing USD budget configuration. Optionally expose shared upstream account quota when an operator grants a dedicated per-key scope. - -## Motivation - -OmniRoute can route multiple delegated API keys through one upstream coding account. Operators need per-key accountability without giving every delegated key management access. Existing management usage APIs are too broad for delegated clients because they can expose other keys and operational state. - -This change creates a narrow own-key API and UI controls: - -- Own cost and token usage are visible by default for ordinary new keys. -- Shared account quota remains opt-in because it is account-level and sensitive. -- USD budgets remain the enforcement mechanism; token totals are reporting only. - -## Scope - -In scope: - -- New `GET /api/v1/me/status` endpoint authenticated by normal Bearer API key. -- New self-service API key scopes: `self:usage` and `self:account-quota`. -- Per-key cost and token aggregation for the calling key. -- Optional normalized provider account quotas for all provider-limit connections the key may use. -- API Manager create/edit controls for visibility scopes. -- Reuse the existing budget configuration surface for USD limits. -- i18n message keys for all new dashboard text. -- Tests and docs for the new behavior. - -Out of scope: - -- Token quota enforcement. -- Cross-key reporting through the self-service endpoint. -- Changing management usage APIs. -- Changing provider routing or quota preflight behavior. -- Raw upstream quota payload exposure. -- A second budget editor inside key creation or permissions dialogs. - -## Compatibility - -Existing keys should continue to work. A migration or first-start normalization step should backfill `self:usage` onto existing ordinary keys so they receive the same default own-usage visibility as newly created keys. Existing keys must not receive shared account quota visibility unless `self:account-quota` is explicitly granted. - -The new scopes must not grant management access. Only `manage` and `admin` remain management-grade. - -## Risks - -- Scope editing in the current dashboard can collapse scopes to only management access; implementation must preserve unrelated scopes. -- Shared account quota can reveal account exhaustion; it must remain disabled by default. -- Unrestricted keys can use all active provider connections, so account quota visibility enumerates all active provider-limit connections when explicitly permitted. -- Backfill must be idempotent so upgrades do not repeatedly rewrite API keys or re-enable a permission an operator later disabled. -- New UI text can regress non-English dashboards if translation keys are not added consistently. -- The current scope validation cap is 16 entries; adding self-service scopes may require raising that cap. -- The current `/api/usage/budget` route relies on route-level authz rather than handler-level `requireManagementAuth()`, so the PR should harden it or explicitly test the proxy guard. - -## Rollout - -1. Add constants, validation, and helper tests. -2. Raise or otherwise adapt scope validation limits. -3. Add idempotent existing-key backfill for `self:usage`. -4. Harden `/api/usage/budget` with handler-level management auth or add explicit proxy-guard tests. -5. Add self-service status endpoint. -6. Add create/edit dashboard controls. -7. Add i18n message keys for dashboard text. -8. Add API/reference docs. -9. Verify against release branch used for upstream PR. diff --git a/docs/openspec/changes/self-service-api-key-usage/specs/api-key-self-service-usage/spec.md b/docs/openspec/changes/self-service-api-key-usage/specs/api-key-self-service-usage/spec.md deleted file mode 100644 index f704c08130..0000000000 --- a/docs/openspec/changes/self-service-api-key-usage/specs/api-key-self-service-usage/spec.md +++ /dev/null @@ -1,216 +0,0 @@ -# Specification: API Key Self-Service Usage - -## ADDED Requirements - -### Requirement: Self-service status endpoint - -OmniRoute SHALL provide `GET /api/v1/me/status` for a valid Bearer API key to retrieve status for that same API key. - -#### Scenario: Valid key reads own status - -- GIVEN a valid API key with own-usage visibility -- WHEN it calls `GET /api/v1/me/status` -- THEN the response status SHALL be `200` -- AND the response SHALL include the API key id and name -- AND the response SHALL include cost usage for that key -- AND the response SHALL include token usage for that key - -#### Scenario: Invalid key is rejected - -- GIVEN a missing or invalid Bearer token -- WHEN the caller calls `GET /api/v1/me/status` -- THEN the response status SHALL be `401` - -#### Scenario: Anonymous client API mode does not bypass self-service auth - -- GIVEN global client API auth allows anonymous local traffic -- WHEN a caller without a Bearer API key calls `GET /api/v1/me/status` -- THEN the response status SHALL be `401` - -#### Scenario: Environment management key is not a self-service key - -- GIVEN the deployment has an environment management key -- WHEN that key calls `GET /api/v1/me/status` -- THEN the response SHALL NOT expose delegated API key usage - -### Requirement: Own-key isolation - -The self-service endpoint SHALL derive the API key id from the authenticated Bearer key and SHALL NOT accept caller-supplied key ids for lookup. - -#### Scenario: Caller tries to query another key - -- GIVEN API key A and API key B both have usage -- WHEN API key A calls `GET /api/v1/me/status?apiKeyId=` -- THEN the response SHALL contain only API key A identity and usage -- AND the response SHALL NOT contain API key B usage - -### Requirement: USD budget status - -The self-service endpoint SHALL report per-key USD budget usage using the existing budget system. - -#### Scenario: Key has an active monthly budget - -- GIVEN an API key has a monthly USD budget of `50` -- AND the key has current-period cost of `12.50` -- WHEN the key calls the self-service status endpoint -- THEN `usage.cost.limitUsd` SHALL be `50` -- AND `usage.cost.usedUsd` SHALL be `12.50` -- AND `usage.cost.usedPercent` SHALL be `25` -- AND `usage.cost.remainingUsd` SHALL be `37.50` - -#### Scenario: Key has no budget - -- GIVEN an API key has no configured budget -- WHEN the key calls the self-service status endpoint -- THEN `usage.cost.limitUsd` SHALL be `null` -- AND `usage.cost.usedPercent` SHALL be `null` -- AND cost and token totals SHALL still be returned for the default display period - -### Requirement: Token usage reporting - -The self-service endpoint SHALL report token totals from `usage_history` for the authenticated API key and selected reporting period. - -#### Scenario: Token totals include all tracked categories - -- GIVEN an API key has usage rows with input, output, cache read, cache creation, and reasoning tokens -- WHEN the key calls the self-service status endpoint -- THEN the response SHALL include each token category total -- AND `totalTokens` SHALL include all reported token categories - -### Requirement: Self-service scopes - -OmniRoute SHALL support `self:usage` and `self:account-quota` API key scopes. These scopes SHALL NOT grant management API access. - -#### Scenario: Self-service scope is not management - -- GIVEN an API key has `self:usage` -- AND it does not have `manage` or `admin` -- WHEN it calls a management usage endpoint -- THEN the response SHALL be forbidden - -#### Scenario: New key defaults - -- GIVEN an operator opens the create API key UI -- THEN own cost and token usage visibility SHALL be enabled by default -- AND shared account quota visibility SHALL be disabled by default - -#### Scenario: Existing keys receive own-usage visibility on upgrade - -- GIVEN an ordinary API key existed before this feature -- AND it does not have `self:usage` -- WHEN the compatibility migration or startup normalization runs -- THEN the API key SHALL have `self:usage` -- AND the API key SHALL NOT have `self:account-quota` - -#### Scenario: Key without own-usage scope is denied - -- GIVEN a valid API key does not have `self:usage` -- WHEN it calls `GET /api/v1/me/status` -- THEN the response status SHALL be `403` - -### Requirement: Shared account quota permission - -The self-service endpoint SHALL include shared account quota only when the authenticated key has `self:account-quota`. - -#### Scenario: Account quota hidden by default - -- GIVEN a valid API key has own-usage visibility -- AND it does not have `self:account-quota` -- WHEN it calls the self-service endpoint -- THEN the response SHALL NOT include shared account quota details - -#### Scenario: Allowed provider quotas shown with explicit permission - -- GIVEN a valid API key has `self:account-quota` -- AND it is allowed to use Codex and Claude provider-limit connections -- AND quota data is available for both connections -- WHEN it calls the self-service endpoint -- THEN the response SHALL include an `accountQuotas` entry for each allowed provider-limit connection -- AND each window SHALL include used percentage, remaining percentage, and reset timestamp when known - -#### Scenario: Single connection compatibility field - -- GIVEN a valid API key has `self:account-quota` -- AND it is allowed to use exactly one provider-limit connection -- WHEN it calls the self-service endpoint -- THEN the response SHALL include exactly one `accountQuotas` entry -- AND the response SHALL also include `accountQuota` with the same entry for backwards compatibility - -#### Scenario: Unrestricted connections include active provider-limit connections - -- GIVEN a valid API key has `self:account-quota` -- AND its `allowedConnections` list is empty, meaning all connections are allowed -- WHEN it calls the self-service endpoint -- THEN the response SHALL include `accountQuotas` entries for active provider-limit connections - -#### Scenario: Per-connection quota failure is isolated - -- GIVEN a valid API key has `self:account-quota` -- AND it is allowed to use two provider-limit connections -- AND one provider quota fetch fails -- WHEN it calls the self-service endpoint -- THEN the successful provider SHALL remain in `accountQuotas` -- AND the failed provider SHALL be represented with `available: false` and `reason: "fetch_failed"` - -#### Scenario: Provider connection lookup failure is isolated - -- GIVEN a valid API key has `self:account-quota` -- AND it is explicitly allowed to use two provider-limit connections -- AND one provider connection lookup fails before quota fetching -- WHEN it calls the self-service endpoint -- THEN the successful provider SHALL remain in `accountQuotas` -- AND the unresolved connection SHALL be represented with `available: false` and `reason: "connection_lookup_failed"` -- AND the response SHALL still include the key's own cost and token usage - -### Requirement: Dashboard configuration - -The API Manager SHALL allow operators to configure self-service visibility and SHALL reuse the existing budget configuration surface for USD limits. - -#### Scenario: Edit preserves unrelated scopes - -- GIVEN an API key has scopes `["self:usage", "custom:scope"]` -- WHEN an operator enables shared account quota in the permissions UI -- THEN the saved scopes SHALL include `self:usage` -- AND the saved scopes SHALL include `self:account-quota` -- AND the saved scopes SHALL still include `custom:scope` - -#### Scenario: Budget editing remains in existing budget UI - -- GIVEN an operator wants to change a key's USD budget limit -- WHEN they use the dashboard -- THEN OmniRoute SHALL direct them to the existing budget configuration surface -- AND the create-key dialog SHALL NOT introduce a second budget editor - -#### Scenario: No budget is displayed as not configured - -- GIVEN an API key has no configured budget -- WHEN the API Manager shows self-service usage for that key -- THEN the UI SHALL show usage and token totals -- AND the budget limit, remaining amount, and percent SHALL be shown as not configured - -### Requirement: Dashboard internationalization - -All new API Manager text for self-service usage visibility, shared account quota visibility, and no-budget display SHALL use OmniRoute's existing i18n message system. - -#### Scenario: New UI strings use translation keys - -- GIVEN the API Manager renders the new self-service controls -- THEN labels, descriptions, tooltips, empty states, and errors SHALL come from translation keys -- AND no new user-visible dashboard text SHALL be hard-coded in the component - -#### Scenario: Locale files stay structurally compatible - -- GIVEN new API Manager translation keys are added -- WHEN the translation consistency check runs -- THEN supported locale message files SHALL have compatible key structure - -### Requirement: Existing budget management remains protected - -The existing `/api/usage/budget` management endpoint SHALL NOT become an own-key self-service data source. - -#### Scenario: Self-service key cannot read arbitrary budget endpoint - -- GIVEN an API key has `self:usage` -- AND it does not have `manage` or `admin` -- WHEN it calls `/api/usage/budget?apiKeyId=` -- THEN the response SHALL be rejected by management auth diff --git a/docs/openspec/changes/self-service-api-key-usage/tasks.md b/docs/openspec/changes/self-service-api-key-usage/tasks.md deleted file mode 100644 index 21f0848897..0000000000 --- a/docs/openspec/changes/self-service-api-key-usage/tasks.md +++ /dev/null @@ -1,72 +0,0 @@ -# Tasks - -## 1. Scope and Validation - -- [ ] Add `self:usage` and `self:account-quota` constants outside management scopes. -- [ ] Extend key creation validation to accept self-service scopes. -- [ ] Raise or replace the current 16-scope validation cap so new scopes do not break existing custom/MCP-heavy keys. -- [ ] Add an idempotent compatibility migration or startup normalization for existing keys. -- [ ] Add tests proving self-service scopes do not satisfy management auth. - -## 2. Usage Aggregation - -- [ ] Add helper to derive self-service status from authenticated API key metadata. -- [ ] Aggregate cost through existing `getCostSummary()` and `checkBudget()`. -- [ ] Aggregate token totals from `usage_history` by `api_key_id` and period start. -- [ ] Add tests for missing budget, configured budget, and token totals. - -## 3. Account Quota - -- [ ] Resolve account quota only when the key has `self:account-quota`. -- [ ] Enumerate all explicit allowed connections, or all active connections when `allowedConnections` is empty. -- [ ] Normalize quota windows for every provider-limit connection that returns quota data. -- [ ] Preserve the legacy `accountQuota` field when exactly one quota entry is returned. -- [ ] Add tests for no scope, one connection, multiple connections, unrestricted connections, unsupported provider, and fetch failure. - -## 4. API Endpoint - -- [ ] Add `GET /api/v1/me/status`. -- [ ] Authenticate in the handler using a normal Bearer API key and derive the API key id from DB metadata. -- [ ] Reject anonymous access even when global client API auth would allow anonymous local traffic. -- [ ] Reject env-only management keys for this own-key endpoint. -- [ ] Reject missing/invalid keys with `401`. -- [ ] Reject keys without `self:usage` with `403` after compatibility backfill has run. -- [ ] Ignore any caller-supplied `apiKeyId`. -- [ ] Add route tests for isolation and response shape. - -## 5. Dashboard - -- [ ] Add create-key controls for own usage visibility and shared account quota visibility. -- [ ] Add edit-permissions controls for self-service visibility. -- [ ] Reuse the existing budget configuration surface for USD limit editing. -- [ ] Preserve unrelated scopes when editing permissions. -- [ ] Show per-key budget percent and token totals in the key details experience. -- [ ] Show no-budget state as not configured while still showing usage. -- [ ] Add UI tests for defaults and scope preservation. - -## 6. Internationalization - -- [ ] Add translation keys under the existing API Manager namespace for all new UI text. -- [ ] Update default and generated locale message files according to the repo's i18n workflow. -- [ ] Add or run a translation key consistency check. -- [ ] Run `npm run i18n:sync-ui:dry`. -- [ ] Run `npm run i18n:check-ui-coverage`. - -## 7. Budget Endpoint Hardening - -- [ ] Add handler-level management auth to `/api/usage/budget` GET and POST, or document and test why proxy-only protection is intentional. -- [ ] Add a regression test proving ordinary self-service keys cannot use `/api/usage/budget?apiKeyId=...` to read arbitrary keys. - -## 8. Documentation - -- [ ] Add API reference entry for `/api/v1/me/status`. -- [ ] Update user guide/API manager docs. -- [ ] Document privacy behavior for shared account quota. -- [ ] Add migration/compatibility note for existing keys. - -## 9. Verification - -- [ ] Run lint. -- [ ] Run typecheck. -- [ ] Run focused unit/API/UI tests. -- [ ] Run coverage or the repo-required validation command before PR. diff --git a/docs/ops/FLY_IO_DEPLOYMENT_GUIDE.md b/docs/ops/FLY_IO_DEPLOYMENT_GUIDE.md index 7393824a9d..4454ff52e6 100644 --- a/docs/ops/FLY_IO_DEPLOYMENT_GUIDE.md +++ b/docs/ops/FLY_IO_DEPLOYMENT_GUIDE.md @@ -206,16 +206,14 @@ flyctl deploy 2. **在 provider 控制台配置回调 URL (configure the callback URL on the provider console / configure a URL de callback no painel do provider)** - 通常格式为 (typical format / formato típico): + 所有 OAuth provider 共用同一个回调路径 `/callback`,不带 provider 段 (all OAuth providers share the single callback path `/callback` — there is NO per-provider callback route / todos os providers OAuth usam o mesmo callback `/callback`, sem segmento por provider): ```text - /api/oauth//callback + /callback ``` - 例如 (e.g. / p.ex.): - - `https://omniroute.fly.dev/api/oauth/gemini/callback` - - `https://omniroute.fly.dev/api/oauth/antigravity/callback` - - `https://omniroute.fly.dev/api/oauth/cursor/callback` + 例如 (e.g. / p.ex.),无论是 Gemini、Antigravity、Cursor 还是 GitLab Duo (regardless of Gemini / Antigravity / Cursor / GitLab Duo, etc.): + - `https://omniroute.fly.dev/callback` 如果 `NEXT_PUBLIC_BASE_URL` 与 provider 控制台中注册的回调 URL 不一致,OAuth 流程会在浏览器回跳阶段失败 (mismatch between `NEXT_PUBLIC_BASE_URL` and the registered callback URL will cause OAuth to fail at the browser redirect step / divergência entre `NEXT_PUBLIC_BASE_URL` e a URL de callback registrada quebra o OAuth no redirect do navegador)。 diff --git a/docs/ops/PROXY_GUIDE.md b/docs/ops/PROXY_GUIDE.md index 4be028ce5b..7fab817be0 100644 --- a/docs/ops/PROXY_GUIDE.md +++ b/docs/ops/PROXY_GUIDE.md @@ -599,6 +599,223 @@ CREATE TABLE proxy_assignments ( --- +## Proxy Health Checking (v3.8.16+) + +OmniRoute's **proxy fast-fail** mechanism (`src/lib/proxyHealth.ts`) detects dead proxies in <2s via a quick TCP connection check, then **caches the result** to avoid per-request overhead. + +### How It Works + +``` +Request ──▶ ProxyHealthCache.get(url) + │ + ├─ Cache hit + fresh? ──▶ return cached status + │ + └─ Cache miss / stale? ──▶ TCP connect to host:port + (timeout: FAST_FAIL_TIMEOUT_MS) + ──▶ cache for HEALTH_CACHE_TTL_MS + ──▶ return result +``` + +Without this, a dead proxy would block every request for the full `PROXY_TIMEOUT_MS` (default 30s) before failing. + +### Tunable Environment Variables + +| Variable | Default | Purpose | +|----------|---------|---------| +| `PROXY_FAST_FAIL_TIMEOUT_MS` | `2000` | TCP connection timeout per health check | +| `PROXY_HEALTH_CACHE_TTL_MS` | `30000` | How long a health result is cached | + +**Recommended values:** + +| Scenario | Fast-fail timeout | Cache TTL | Reasoning | +|----------|-------------------|-----------|-----------| +| High-throughput API gateway | 1500ms | 60000ms | Aggressive fail-fast, longer cache to reduce checks | +| Geo-distributed nodes | 3000ms | 15000ms | Slower networks need more time; shorter cache for fast failover | +| Dev / testing | 1000ms | 10000ms | Quick iteration on local proxies | +| Stealth / anti-detection | 2500ms | 45000ms | Avoid rapid probing that could trigger rate limits | + +### Inspecting Proxy Health + +```ts +import { getAllProxyHealthStatuses, invalidateProxyHealth } from "omniroute/proxyHealth"; + +const statuses = getAllProxyHealthStatuses(); +for (const s of statuses) { + console.log(`${s.proxyUrl} → healthy=${s.healthy}, stale=${s.stale}`); +} + +// Force re-check a specific proxy +invalidateProxyHealth("http://user:pass@1.2.3.4:8080"); +``` + +The `stale` flag is `true` when the cache entry has exceeded `HEALTH_CACHE_TTL_MS` and the next request will trigger a fresh check. + +### Per-Proxy Type Defaults + +The health check uses sensible defaults based on the URL scheme: + +| Scheme | Default port | +|--------|-------------| +| `http://` | 8080 | +| `https://` | 443 | +| `socks5://` / `socks5h://` | 1080 | + +Custom ports in the URL (`http://host:9999`) always take precedence over the scheme default. + +--- + +## Proxy Analytics & Observability + +OmniRoute tracks per-proxy usage to help operators diagnose routing patterns, latency spikes, and recurring failures. + +### What's Tracked + +For every request through a configured proxy, OmniRoute records: + +| Metric | Description | +|--------|-------------| +| `proxy_url` | Full proxy URL (with auth credentials masked) | +| `provider` | Upstream provider ID (openai, anthropic, etc.) | +| `latency_ms` | Total round-trip time including proxy handshake | +| `connect_ms` | TCP connect time only | +| `status` | HTTP status code from upstream | +| `error` | Error class if request failed | +| `timestamp` | ISO 8601 UTC | + +### Accessing the Data + +```bash +# Recent proxy events +curl -H "Authorization: Bearer $OMNIROUTE_KEY" \ + "http://localhost:20128/api/usage/proxy-logs?limit=100" +``` + +The real endpoint is `/api/usage/proxy-logs` (see `src/app/api/usage/proxy-logs/route.ts`). This endpoint supports: + +- `GET /api/usage/proxy-logs` — retrieve proxy logs +- `DELETE /api/usage/proxy-logs` — clear all proxy logs + +Aggregate stats can be queried directly from the `proxy_logs` table via SQL if needed. The dashboard UI may offer aggregate views. + +### Common Patterns + +**Detect a flapping proxy** (alternates between success/failure): + +```sql +SELECT proxy_url, + COUNT(*) AS total, + SUM(CASE WHEN status >= 500 THEN 1 ELSE 0 END) AS errors, + ROUND(100.0 * SUM(CASE WHEN status >= 500 THEN 1 ELSE 0 END) / COUNT(*), 1) AS error_pct +FROM proxy_logs +WHERE timestamp > datetime('now', '-1 hour') +GROUP BY proxy_url +HAVING error_pct > 5 +ORDER BY error_pct DESC; +``` + +**Find slow proxies** (p95 latency > 2s): + +```sql +WITH ranked AS ( + SELECT proxy_url, latency_ms, + PERCENT_RANK() OVER (PARTITION BY proxy_url ORDER BY latency_ms) AS pct + FROM proxy_logs + WHERE timestamp > datetime('now', '-24 hour') +) +SELECT proxy_url, latency_ms +FROM ranked +WHERE pct >= 0.95 +ORDER BY latency_ms DESC; +``` + +--- + +## Rotation Strategy Decision Tree + +When multiple proxies are assigned to a scope, OmniRoute uses a **rotation strategy** to pick which one to use for each request. The strategy is configured at the scope level (global, per-provider, per-account, per-combo). + +### Available Strategies + +| Strategy | When to use | Trade-off | +|----------|-------------|-----------| +| `quality` (default) | Production with mixed-quality proxies | Favors high-rated proxies; may starve low-rated ones | +| `random` | Load distribution, privacy | Even distribution; ignores quality signals | +| `sequential` | Debugging, deterministic testing | Cycles through proxies in order; easy to reason about | + +### Decision Tree + +``` + Do you have quality scores for your proxies? + │ + ┌───────────┴───────────┐ + │ │ + YES NO + │ │ + Are all proxies │ + roughly equal │ + in quality? │ + │ │ + ┌────┴────┐ │ + │ │ │ + YES NO Use + │ │ `random` + │ │ (even spread + │ │ builds quality + │ │ data over time) + │ │ + │ Use `quality` + │ (best for + │ mixed quality) + │ +Use `random` +(spread load +evenly) +``` + +### Configuring Rotation Strategy + +```ts +import { rotateOneproxyProxy } from "omniroute/oneproxyRotator"; + +// In a one-off script +const proxy = await rotateOneproxyProxy({ strategy: "quality" }); +if (proxy) { + console.log(`Selected: ${proxy.host}:${proxy.port}, quality=${proxy.qualityScore}`); +} +``` + +### Resetting Sequential Index + +When using `sequential` strategy, the internal index accumulates. To reset: + +```ts +import { resetSequentialIndex } from "omniroute/oneproxyRotator"; + +resetSequentialIndex(); +``` + +Useful when: +- Restarting a load test +- Recovering from a proxy outage (so you don't cycle through dead ones first) +- Manually rebalancing after adding new proxies + +### Marking a Proxy as Failed + +When a proxy consistently fails, mark it manually so the rotator will skip it: + +```ts +import { failOneproxyProxy } from "omniroute/oneproxyRotator"; + +const removed = await failOneproxyProxy("1.2.3.4", 8080); +if (removed) { + console.log("Proxy marked as failed; rotator will skip it"); +} +``` + +The proxy is **not deleted** — it's marked unhealthy and won't be selected until the next successful health check (via `proxyHealth.ts`) or manual reset. + +--- + > 📖 **Related documentation:** > > - [User Guide](../guides/USER_GUIDE.md) — General setup and configuration diff --git a/docs/reference/API_REFERENCE.md b/docs/reference/API_REFERENCE.md index 257f5f2044..2e738f99fb 100644 --- a/docs/reference/API_REFERENCE.md +++ b/docs/reference/API_REFERENCE.md @@ -404,15 +404,16 @@ Response example: ### Settings -| Endpoint | Method | Description | -| ------------------------------- | ------------- | ------------------------- | -| `/api/settings` | GET/PUT/PATCH | General settings | -| `/api/settings/proxy` | GET/PUT | Network proxy config | -| `/api/settings/proxy/test` | POST | Test proxy connection | -| `/api/settings/ip-filter` | GET/PUT | IP allowlist/blocklist | -| `/api/settings/thinking-budget` | GET/PUT | Reasoning token budget | -| `/api/settings/system-prompt` | GET/PUT | Global system prompt | -| `/api/settings/compression` | GET/PUT | Global compression config | +| Endpoint | Method | Description | +| ------------------------------------- | ------------- | --------------------------------------------------- | +| `/api/settings` | GET/PUT/PATCH | General settings | +| `/api/settings/proxy` | GET/PUT | Network proxy config | +| `/api/settings/proxy/test` | POST | Test proxy connection | +| `/api/settings/ip-filter` | GET/PUT | IP allowlist/blocklist | +| `/api/settings/thinking-budget` | GET/PUT | Reasoning token budget | +| `/api/settings/system-prompt` | GET/PUT | Global system prompt | +| `/api/settings/compression` | GET/PUT | Global compression config | +| `/api/settings/purge-request-history` | POST | Clear request log rows and local call-log artifacts | ### Context & Compression diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index b51e645e6a..65afb247da 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -271,12 +271,13 @@ Route upstream LLM provider calls through an HTTP or SOCKS5 proxy for egress con | Variable | Default | Source File | Description | | --------------------------------------- | --------- | -------------------------------------------- | ----------------------------------------------------------------------------------------- | -| `ENABLE_SOCKS5_PROXY` | `true` | `open-sse/executors` | Enable SOCKS5 proxy agent for upstream calls. | +| `ENABLE_SOCKS5_PROXY` | `true` | `open-sse/executors` | Enable SOCKS5 proxy agent for upstream calls. Opt-out with `false`. | | `NEXT_PUBLIC_ENABLE_SOCKS5_PROXY` | `true` | Client-side | Client-side awareness of SOCKS5 availability. | | `HTTP_PROXY` | _(unset)_ | Node.js standard | HTTP proxy for upstream calls. | | `HTTPS_PROXY` | _(unset)_ | Node.js standard | HTTPS proxy for upstream calls. | | `ALL_PROXY` | _(unset)_ | Node.js standard | Universal proxy (supports `socks5://`). | | `NO_PROXY` | _(unset)_ | Node.js standard | Comma-separated hostnames/IPs to bypass the proxy. | +| `PROXY_FAIL_OPEN` | `false` | `src/sse/handlers/chatHelpers.ts` | When `false` (default), a request whose assigned proxy fails to resolve is **refused (fail-closed)** rather than falling back to a direct connection — prevents real-IP leaks. Set `true` to restore the legacy DIRECT fallback. | | `ENABLE_TLS_FINGERPRINT` | `false` | `open-sse/executors` | Spoof TLS fingerprint using wreq-js (mimics Chrome 124). Counters JA3/JA4 blocking. | | `OMNIROUTE_TURNSTILE_IGNORE_TLS_ERRORS` | `false` | `open-sse/services/claudeTurnstileSolver.ts` | Allow the Claude Turnstile Playwright browser context to ignore HTTPS certificate errors. | @@ -287,6 +288,15 @@ Route upstream LLM provider calls through an HTTP or SOCKS5 proxy for egress con | **SOCKS5 through SSH tunnel** | `ALL_PROXY=socks5://127.0.0.1:7890`, `ENABLE_SOCKS5_PROXY=true` | | **Corporate HTTP proxy** | `HTTP_PROXY=http://proxy.corp.com:3128`, `HTTPS_PROXY=http://proxy.corp.com:3128`, `NO_PROXY=localhost,internal.corp.com` | | **Anti-fingerprint** | `ENABLE_TLS_FINGERPRINT=true` — requires `wreq-js` (included) | +| **Egress-controlled / no direct access** | Leave `PROXY_FAIL_OPEN=false` (default). Requests fail hard when the proxy is unavailable instead of leaking via direct. | +| **Legacy / dev — allow direct fallback** | `PROXY_FAIL_OPEN=true`. Restores pre-hardening behaviour: direct connection used when proxy resolution fails. | + +> **Note (NVIDIA validation bypass — #3226):** NVIDIA's API-key validation endpoint +> stalls when routed through the global proxy/TLS-patched fetch (undici dispatcher → 504). +> `src/lib/providers/validation.ts::directHttpsRequest()` intentionally bypasses the +> proxy patch for that one validation call using `safeOutboundFetch({ bypassProxyPatch: true })`. +> This is a documented, scoped exception — it does **not** affect chat/usage egress. +> The bypass is scope-pinned by `tests/unit/proxy-bypass-scope-guard-3226.test.ts`. --- @@ -874,7 +884,7 @@ Provider quota endpoints, network tunnels (Tailscale, Ngrok, MITM debug proxy), | `ALIBABA_CODING_PLAN_QUOTA_URL` | derived from host | `open-sse/services/bailianQuotaFetcher.ts` | Full quota URL override for Alibaba Bailian. | | `CONTEXT_RESERVE_TOKENS` | `1024` | `open-sse/services/contextManager.ts` | Tokens reserved for completion output when computing prompt budgets. | | `MODEL_ALIAS_COMPAT_ENABLED` | enabled | `open-sse/services/model.ts` | Toggle the legacy model-alias compatibility layer used by older clients. | -| `OMNIROUTE_EMERGENCY_FALLBACK` | enabled | `open-sse/services/emergencyFallback.ts` | Set `false` (or `0`) to disable the emergency budget-exhaustion fallback that reroutes failed requests to the free `nvidia`/`openai/gpt-oss-120b` model. | +| `OMNIROUTE_EMERGENCY_FALLBACK` | enabled | `open-sse/services/emergencyFallback.ts` | Set `false` (or `0`) to disable the emergency budget-exhaustion fallback that reroutes failed requests to the free `nvidia`/`openai/gpt-oss-120b` model. Effective precedence is Feature Flags DB override > env var > default; if unavailable, the service falls back to the raw env value. | | `COMMAND_CODE_CALLBACK_PORT` | _(unset)_ | `src/app/api/providers/command-code/auth/shared.ts` | Local port used for OAuth-style callbacks from the Command Code CLI helper. | | `COMMAND_CODE_VERSION` | `0.33.2` | `open-sse/executors/commandCode.ts` | Value sent as the `x-command-code-version` header to the Command Code upstream. Override to bump the CLI version. | | `MITM_LOCAL_PORT` | `443` | `src/mitm/server.cjs` | Local bind port for the MITM debug proxy. | diff --git a/docs/reference/openapi.yaml b/docs/reference/openapi.yaml index 3fce18a61d..debb43ec32 100644 --- a/docs/reference/openapi.yaml +++ b/docs/reference/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: OmniRoute API - version: 3.8.23 + version: 3.8.24 description: | OmniRoute is a local-first AI API proxy router. It provides an OpenAI-compatible endpoint that routes requests to multiple AI providers with load balancing, @@ -1803,6 +1803,49 @@ paths: "200": description: Updated settings + /api/settings/purge-request-history: + post: + tags: [Settings] + summary: Clear request log history + description: Deletes `call_logs`, legacy `request_detail_logs`, and local request artifact files under `DATA_DIR/call_logs`. + security: + - ManagementSessionAuth: [] + responses: + "200": + description: Request history cleared + content: + application/json: + schema: + type: object + properties: + deleted: + type: integer + deletedArtifacts: + type: integer + deletedDetailedLogs: + type: integer + errors: + type: integer + "401": + description: Unauthorized + "500": + description: Cleanup failed or reported errors + content: + application/json: + schema: + type: object + properties: + deleted: + type: integer + deletedArtifacts: + type: integer + deletedDetailedLogs: + type: integer + errors: + type: integer + error: + type: object + /api/settings/compression: get: tags: [Compression] diff --git a/docs/security/COMPLIANCE.md b/docs/security/COMPLIANCE.md index c8c4eef839..1a3d9f599e 100644 --- a/docs/security/COMPLIANCE.md +++ b/docs/security/COMPLIANCE.md @@ -118,6 +118,11 @@ from `src/server-init.ts` and `src/instrumentation-node.ts`. Each run logs a `compliance.cleanup` audit event with the per-table delete counts. Proxy/call log trimming is batched (`BATCH_SIZE = 5000`) to avoid long write locks. +Manual request-history cleanup is separate from retention. The Request Logs +page calls `POST /api/settings/purge-request-history`, which deletes `call_logs`, +legacy `request_detail_logs`, and local request artifacts under +`${DATA_DIR}/call_logs/`. + Defaults are defined in `src/lib/logEnv.ts` (`DEFAULT_APP_LOG_RETENTION_DAYS = 7`, `DEFAULT_CALL_LOG_RETENTION_DAYS = 7`). diff --git a/docs/specs/2026-05-16-adaptive-stream-readiness-design.md b/docs/specs/2026-05-16-adaptive-stream-readiness-design.md deleted file mode 100644 index 4bec818db4..0000000000 --- a/docs/specs/2026-05-16-adaptive-stream-readiness-design.md +++ /dev/null @@ -1,91 +0,0 @@ -# Adaptive Stream Readiness Timeout - -## Problem - -Long Codex continue sessions can produce very large Responses API payloads (hundreds of messages, 20 tools, and large cached input). OmniRoute currently uses a fixed `STREAM_READINESS_TIMEOUT_MS` default of 30 seconds for the first useful SSE event. That fixed threshold is too short for some large Codex requests, even when the upstream later completes successfully. - -Recent local evidence showed: - -- Small/medium Codex requests confirm readiness in roughly 0.8-2.5 seconds. -- Large Codex requests can run 60+ seconds and still complete successfully. -- A fixed 30 second readiness timeout can therefore create false failures: `Stream produced no useful content within 30000ms`. - -The solution should avoid a blanket manual timeout increase, because that would slow fallback for genuinely dead streams. - -## Goals - -- Keep small requests fast to fail when the upstream stream is dead. -- Give large/tool-heavy Codex Responses requests more time to produce first useful content. -- Make timeout decisions visible in logs for future debugging. -- Preserve the existing default behavior unless the request shape justifies extra budget. -- Keep a hard upper bound so zombie streams cannot hang indefinitely. - -## Non-Goals - -- Do not change provider fallback ordering in this spec. -- Do not alter account health/rate-limit policy. -- Do not change post-readiness stream idle behavior. -- Do not implement compression or summarization in this change. - -## Design - -Add a small policy helper that computes the readiness timeout from request shape: - -`open-sse/utils/streamReadinessPolicy.ts` - -The helper accepts: - -- `baseTimeoutMs` -- `provider` -- `model` -- `body` - -It returns: - -- `timeoutMs` -- `reasons` - -The initial heuristic is intentionally conservative: - -- Start with the configured base timeout, usually 30 seconds. -- Add budget for large input arrays or message arrays. -- Add budget for tool-heavy requests. -- Add budget for Codex GPT-5.5 Responses requests, because local evidence shows these can take longer on large sessions. -- Cap the result at 120 seconds by default. - -This is adaptive, not purely provider-based: Codex only receives the extra budget when the payload is large/tool-heavy enough to justify it. - -## Integration - -In `open-sse/handlers/chatCore.ts`, replace the direct use of `STREAM_READINESS_TIMEOUT_MS` in `ensureStreamReadiness` with the policy result. - -Log the chosen timeout and reasons when it differs from the base timeout, for example: - -```text -[sse] stream readiness timeout=90000ms base=30000ms reason=codex,gpt-5.5,large_input,tool_heavy -``` - -## Failure Behavior - -If no useful stream content appears before the adaptive timeout, OmniRoute should continue using the existing failure path and return `STREAM_READINESS_TIMEOUT`. This change only changes the budget, not the fallback/error semantics. - -## Testing - -Add unit tests for the policy helper: - -- Small request keeps the base timeout. -- Large message array increases timeout. -- Tool-heavy request increases timeout. -- Codex GPT-5.5 large request receives a larger timeout. -- Timeout is capped at the maximum. -- Zero/disabled base timeout remains zero so readiness checks can still be disabled by config. - -Add or update a handler-level test only if needed after unit coverage. - -## Acceptance Criteria - -- Large Codex continue sessions get an adaptive readiness timeout above 30 seconds. -- Small requests still use 30 seconds by default. -- The max adaptive timeout cannot exceed 120 seconds unless explicitly changed in code later. -- Unit tests cover the policy and pass. -- Existing pre-commit checks pass. diff --git a/docs/specs/2026-05-29-self-service-api-key-usage-design.md b/docs/specs/2026-05-29-self-service-api-key-usage-design.md deleted file mode 100644 index 64522ed1b8..0000000000 --- a/docs/specs/2026-05-29-self-service-api-key-usage-design.md +++ /dev/null @@ -1,409 +0,0 @@ -# Self-Service API Key Usage and Quota Visibility - -## Problem - -Operators often share one upstream coding account, such as Codex, across multiple OmniRoute API keys. OmniRoute already records per-key usage and supports per-key USD budgets, but a normal client API key cannot query its own spend or token totals. The existing usage APIs are management endpoints, so exposing them to each API key would disclose other keys, account metadata, and operational settings. - -Operators also need a way to decide whether a key may see shared upstream account quotas. For Codex this includes the short session window and weekly window fetched from ChatGPT usage APIs, and other subscription providers can expose their own normalized provider-limit windows. That quota is account-level state, not key-level state, so it should not be visible by default. - -The goal is to add a small self-service status API and matching dashboard controls so a delegated API key can see: - -- Its own USD usage against its configured budget. -- Its own token usage totals. -- The percent used toward its own USD budget limit. -- Optionally, shared upstream account quota remaining when explicitly permitted. - -## Baseline - -This design was written after comparing the official source and a live deployment: - -- Official checkout: `origin/main` at `dc3915a4`, package version `3.8.5`. -- Live deployment: package version `3.8.3`, installed under `/usr/lib/node_modules/omniroute/app`. -- Contributor guide: PRs currently target `release/v3.8.3`, so implementation should start from the release branch even though the source survey used current `main`. - -Relevant current implementation: - -- API key creation is in `src/app/api/keys/route.ts`; `createKeySchema` currently accepts `name`, `noLog`, and `scopes`. -- API key metadata is stored in `api_keys`, including `scopes`, `allowed_connections`, model restrictions, request rate limits, and lifecycle fields. -- Management auth treats `manage` and `admin` as management scopes in `src/shared/constants/managementScopes.ts`. -- `/api/v1/*` routes are public from the route classifier perspective, but individual handlers still validate Bearer API keys. -- Per-key USD budgets already exist through `domain_budgets`, `domain_cost_history`, `getCostSummary(apiKeyId)`, and `checkBudget(apiKeyId)`. -- Token usage is already recorded per key in `usage_history.api_key_id` with input, output, cache read, cache creation, and reasoning token columns. -- Provider quota data is fetched through `src/lib/usage/providerLimits.ts` and provider usage support in `open-sse/services/usage.ts`. -- The API Manager UI currently has a management-access toggle on create/edit and sends `scopes: ["manage"]` or `[]`; the edit modal must be changed before adding more scope types so it does not discard unrelated scopes. - -## Goals - -- Add an authenticated self-service endpoint for the calling API key's own usage. -- Keep management endpoints protected exactly as they are today. -- Use USD budgets for enforcement and percentage reporting. -- Include token totals as reporting data only, not as quota enforcement. -- Make account quota visibility opt-in per API key. -- Add create/edit UI controls for self-service visibility while reusing the existing budget configuration flow for USD limits. -- Add all new dashboard text through OmniRoute's i18n message system. -- Preserve arbitrary existing scopes when the dashboard edits permissions. -- Provide a design that can become an upstream-quality PR with tests and docs. - -## Non-Goals - -- Do not expose other API keys' usage through the self-service endpoint. -- Do not add token-based quota enforcement in this change. -- Do not change provider routing, fallback, or quota preflight behavior. -- Do not disclose upstream access tokens, workspace IDs, emails, or connection secrets. -- Do not make shared account quota visible by default. -- Do not replace the existing management usage dashboards. - -## Proposed API - -Add: - -```text -GET /api/v1/me/status -Authorization: Bearer -``` - -The route is under `/api/v1` so it follows the client API surface, but the handler must explicitly validate the Bearer API key and load its metadata. It must not use `requireManagementAuth()`. - -The handler must not rely only on the global `CLIENT_API` authz policy. In the current source, `clientApiPolicy` can allow anonymous traffic when `REQUIRE_API_KEY` is not `"true"`, and some `/api/v1` helper code assumes the middleware already made that decision. This endpoint is more sensitive, so it must perform handler-local validation: - -- Require an `Authorization: Bearer ` credential. -- Call `validateApiKey()` / `getApiKeyMetadata()` or an equivalent DB-backed helper. -- Reject anonymous, dashboard-session-only, invalid, expired, revoked, inactive, and env-only management keys for this self-service response. -- Derive the returned API key id from metadata, never from request parameters. - -The response contains only the caller's own API key identity, budget usage, token usage, and optional account quota: - -```json -{ - "apiKey": { - "id": "key_123", - "name": "team-a" - }, - "usage": { - "cost": { - "period": "monthly", - "currency": "USD", - "usedUsd": 12.34, - "limitUsd": 50, - "remainingUsd": 37.66, - "usedPercent": 24.68, - "warningThreshold": 0.8, - "resetAt": "2026-06-01T00:00:00.000Z", - "periodStartAt": "2026-05-01T00:00:00.000Z" - }, - "tokens": { - "periodStartAt": "2026-05-01T00:00:00.000Z", - "inputTokens": 900000, - "outputTokens": 32000, - "cacheReadTokens": 120000, - "cacheCreationTokens": 10000, - "reasoningTokens": 5000, - "totalTokens": 1067000 - } - }, - "accountQuotas": [ - { - "provider": "codex", - "connectionId": "conn_123", - "shared": true, - "plan": "ChatGPT Plus", - "quotas": { - "session": { - "remainingPercentage": 99, - "usedPercentage": 1, - "resetAt": "2026-05-29T18:11:44.000Z" - }, - "weekly": { - "remainingPercentage": 3, - "usedPercentage": 97, - "resetAt": "2026-05-31T01:23:38.000Z" - } - } - }, - { - "provider": "claude", - "connectionId": "conn_456", - "shared": true, - "plan": "Claude Max", - "quotas": { - "daily": { - "remainingPercentage": 65, - "usedPercentage": 35, - "resetAt": "2026-05-30T00:00:00.000Z" - } - } - } - ], - "accountQuota": { - "provider": "codex", - "connectionId": "conn_123", - "shared": true, - "plan": "ChatGPT Plus", - "quotas": { - "weekly": { - "remainingPercentage": 3, - "usedPercentage": 97, - "resetAt": "2026-05-31T01:23:38.000Z" - } - } - } -} -``` - -`accountQuotas` is omitted unless the key has the account quota scope. `accountQuota` is retained as a compatibility alias only when exactly one account quota entry is returned. If a specific allowed connection cannot fetch quota data, include a per-connection unavailable entry: - -```json -{ - "accountQuotas": [ - { - "provider": "cursor", - "connectionId": "conn_789", - "shared": true, - "available": false, - "reason": "fetch_failed" - } - ], - "accountQuota": { - "provider": "cursor", - "connectionId": "conn_789", - "shared": true, - "available": false, - "reason": "fetch_failed" - } -} -``` - -If explicit connection metadata lookup fails before the provider is known, return the unresolved connection as unavailable without failing the whole status response: - -```json -{ - "accountQuotas": [ - { - "provider": "unknown", - "connectionId": "conn_789", - "shared": true, - "available": false, - "reason": "connection_lookup_failed" - } - ] -} -``` - -Use stable reason strings: `not_supported`, `not_available`, `fetch_failed`, and `connection_lookup_failed`. - -## Scopes - -Add self-service scopes that do not grant management access: - -- `self:usage`: allows a key to query its own spend, budget percent, and token totals. -- `self:account-quota`: allows a key to see shared upstream account quotas for provider-limit connections it may use. - -`self:usage` should be enabled by default for newly created ordinary API keys. The UI should show it checked by default and persist the scope when the control is enabled. For backwards compatibility, the implementation should backfill `self:usage` onto existing ordinary keys during migration or first startup after upgrade. After that compatibility step, absence of `self:usage` means own-usage visibility is disabled and the self-service endpoint returns `403`. - -`self:account-quota` must be disabled by default. The dashboard should require an explicit opt-in when creating or editing a key. - -These scopes must not be added to `MANAGEMENT_API_KEY_SCOPES`. `manage` and `admin` remain the only management-grade scopes. - -## Budget Semantics - -The existing USD budget system remains authoritative: - -- `getCostSummary(apiKeyId)` provides current period cost, active USD limit, reset interval, reset time, and period boundaries. -- `checkBudget(apiKeyId)` remains the enforcement check used by request handling. -- The self-service endpoint reports budget percentage as `usedUsd / limitUsd * 100`. -- When no budget is configured, return `limitUsd: null`, `remainingUsd: null`, and `usedPercent: null`. - -The endpoint should report the active period from the budget window when configured. If a key has no budget, use the current calendar month for display-only usage aggregation so the API still returns useful cost and token totals. - -## Token Usage Semantics - -Add a small aggregation helper over `usage_history` scoped by `api_key_id` and time window: - -```sql -SELECT - COALESCE(SUM(tokens_input), 0) AS inputTokens, - COALESCE(SUM(tokens_output), 0) AS outputTokens, - COALESCE(SUM(tokens_cache_read), 0) AS cacheReadTokens, - COALESCE(SUM(tokens_cache_creation), 0) AS cacheCreationTokens, - COALESCE(SUM(tokens_reasoning), 0) AS reasoningTokens -FROM usage_history -WHERE api_key_id = ? - AND timestamp >= ? -``` - -`totalTokens` should include all reported token categories. Token totals are informational and should not affect budget enforcement. - -## Account Quota Resolution - -Account quota is shared provider state. The self-service endpoint may include it only when: - -- The API key has `self:account-quota`. -- Provider connections can be resolved from the key's allowed connection policy. -- The providers support quota fetching through the provider limits path. - -Connection resolution must follow the source semantics for `allowedConnections`: an empty array means unrestricted access to all connections, not "no connections". - -- If explicit allowed connection ids are present, fetch quota data for those active provider-limit connections. -- If `allowedConnections` is empty, fetch quota data for all active provider-limit connections because the key may use all of them. -- If an allowed connection is inactive, missing, or unsupported, skip it or return a per-connection `not_supported` entry when the connection identity is known. -- If explicit connection lookup fails, keep the rest of the response and return that connection as `available: false` with `connection_lookup_failed`. -- If unrestricted connection listing fails, keep the rest of the response and return an empty `accountQuotas` array because no allowed connection identities can be resolved. -- If a provider quota fetch fails, keep the rest of the response and return that connection as `available: false` with `fetch_failed`. - -This rule matches routing permissions: the endpoint exposes only account quotas for connections the key is allowed to use, and only when the operator explicitly grants `self:account-quota`. - -Reuse the existing provider limits path. Normalize every returned quota window to used/remaining percentages plus reset timestamps. Do not return raw upstream payloads. - -## Dashboard UX - -API Manager should expose these controls during key creation and editing. - -Create key modal: - -- Management access remains a separate, off-by-default toggle. -- Add "Self-service visibility": - - "Own cost and token usage" checked by default. - - "Shared account quota" unchecked by default. -- Do not add budget limit fields here. Per-key USD budgets already have a dedicated configuration surface, and this feature should link to or surface the existing budget state instead of creating a second configuration path. - -Editing permissions: - -- Keep existing model, endpoint, connection, schedule, and rate-limit controls. -- Add the same self-service visibility toggles. -- Preserve all existing scopes when toggling one permission. The current edit flow must not rebuild scopes as only `["manage"]` or `[]`. -- Do not move budget editing into the permissions modal. The permissions modal may show a read-only hint or link to the existing budget configuration area. - -Usage display: - -- In the key list or details panel, show USD used, active USD limit, and used percent when a budget exists. -- Show token totals in a compact details view. -- Show shared account quotas only for keys with `self:account-quota`, clearly labeled as shared account quota, not per-key quota. -- When no USD budget is configured, show usage normally and render the limit, remaining amount, and percent as unset/not configured rather than `0%`. - -## Internationalization - -OmniRoute's dashboard is localized through `src/i18n/messages/*.json` and components use `useTranslations()`. All new API Manager labels, descriptions, tooltips, empty states, and error messages must use translation keys instead of hard-coded UI strings. - -Implementation should: - -- Add new keys under the existing `apiManager` namespace for self-service visibility labels, shared account quota labels, and unset-budget display text. -- Update the default source locale and keep other locale files structurally compatible with the repo's i18n workflow. -- Avoid concatenating translated fragments for dynamic text; use complete translation strings with variables where needed. -- Run the repo's UI i18n checks, especially `npm run i18n:sync-ui:dry` and `npm run i18n:check-ui-coverage`, so missing translations are caught before PR. -- If the implementation touches the existing budget page for links or hints, localize any new budget-page strings as well. The existing `BudgetTab` still has some hard-coded labels, so do not add more hard-coded user-facing text there. - -## Validation and Storage Changes - -Extend `createKeySchema` to accept: - -- `scopes` containing the new self-service scope names. - -`createKeySchema` and `updateKeyPermissionsSchema` currently cap `scopes` at 16 entries. Adding two self-service scopes can make legitimate keys exceed that limit when they already carry management or MCP/custom scopes. The implementation should either raise the cap to a documented value such as 32 or validate against named scope families instead of keeping the current 16-entry limit. - -Do not extend key creation with a budget object in this change. Budget limits are already configured through the existing budget APIs and UI. The self-service endpoint should read those existing limits and display `null` limit/percent fields when none are configured. - -Add a compatibility migration or startup normalization step: - -- Existing ordinary keys receive `self:usage`. -- Existing keys do not receive `self:account-quota`. -- Existing management keys keep their current management scopes and may also receive `self:usage` if they are expected to use the self-service endpoint. -- The backfill is one-time and guarded by the repo's existing migration/version mechanism so it cannot re-enable `self:usage` after an operator later disables it. -- After that one-time backfill, missing `self:usage` is an explicit denial for the self-service endpoint. - -The key creation route should: - -1. Validate the request. -2. Normalize scopes by preserving known custom scopes and adding `self:usage` when omitted by the UI default. -3. Create the key. -4. Return the created key metadata. - -The update permissions route should support the same scope preservation behavior. Scope mutation should be set-based: - -- Start from existing scopes. -- Add or remove only the scopes represented by the UI controls. -- Leave unknown or unrelated scopes intact. - -The current `PermissionsModal` calls `onSave(..., manageEnabled ? ["manage"] : [], ...)`, which would discard any new self-service or custom scope. This must be changed before the self-service toggles are added. - -## Existing Budget Endpoint Guard - -The global authz proxy classifies `/api/usage/budget` as a management API, but unlike `/api/usage/budget/bulk`, the current route handler does not call `requireManagementAuth()` directly. The self-service design must not reuse `/api/usage/budget?apiKeyId=...` because that endpoint accepts arbitrary key ids. - -For defense in depth and easier direct route testing, the implementation PR should either: - -- Add handler-level `requireManagementAuth()` to `/api/usage/budget` GET and POST, matching the bulk route; or -- Include an explicit note and tests proving the proxy is the only intended guard. - -The preferred upstream-quality fix is to add handler-level management auth to `/api/usage/budget` while adding the separate own-key `/api/v1/me/status` endpoint. - -## Security and Privacy - -The self-service handler must be own-key only. It should derive `apiKeyId` from the presented Bearer key and never accept an `apiKeyId` query parameter. - -Never include: - -- Full API key value. -- Upstream access tokens or refresh tokens. -- Provider account email unless that email is already visible to this key through another client API. -- Other keys' spend, token totals, names, or budgets. -- Raw upstream usage payloads. - -Account quota should be treated as sensitive because it lets delegated users infer shared account exhaustion. The default remains off. - -## Error Handling - -- Missing or invalid Bearer key: `401` with a generic auth error. -- Valid key without `self:usage`: `403`. -- Budget missing: `200` with null limit and percent fields. -- Usage aggregation failure: `500` with generic message; log server-side details. -- Quota fetch unsupported or unavailable: `200` with per-connection unavailable entries in `accountQuotas`. -- Quota fetch auth failure: do not leak provider auth details; return `not_available` or `fetch_failed` and log details server-side. - -## Testing - -Add focused tests: - -- Self-service endpoint rejects missing and invalid Bearer keys. -- Self-service endpoint rejects anonymous access even when `REQUIRE_API_KEY` is not `"true"`. -- Self-service endpoint rejects env-only management keys or any key without DB metadata suitable for own-key usage. -- A normal key with `self:usage` can query its own cost and token totals without `manage`. -- The endpoint never accepts an `apiKeyId` override. -- Key A cannot see Key B usage. -- A key without account quota scope does not receive `accountQuotas`. -- A key with account quota scope and one allowed Codex connection receives normalized session and weekly quota plus the compatibility `accountQuota` field. -- A key with account quota scope and multiple allowed provider-limit connections receives multiple `accountQuotas` entries. -- A key with account quota scope and unrestricted connection access receives all active provider-limit connection quotas. -- A failed provider quota fetch returns an unavailable entry without hiding successful provider quota entries. -- Create UI defaults own usage on and shared quota off. -- Edit UI preserves unrelated scopes. -- UI renders the no-budget state as not configured, with usage and token totals still visible. -- New dashboard strings are covered by i18n keys. -- `/api/usage/budget` remains management-only and is not usable as an own-key data escape hatch. - -## Implementation Notes - -Recommended new files: - -- `src/shared/constants/selfServiceScopes.ts` -- `src/lib/usage/apiKeySelfService.ts` -- `src/app/api/v1/me/status/route.ts` - -Recommended modified files: - -- `src/shared/validation/schemas.ts` -- `src/app/api/keys/route.ts` -- `src/app/api/keys/[id]/route.ts` -- `src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx` -- `src/i18n/messages/*.json` -- API reference docs after implementation. - -## Acceptance Criteria - -- Delegated keys can see their own USD usage, budget percentage, and token usage. -- Shared account quota is hidden unless explicitly enabled per key. -- The dashboard can configure self-service visibility during create/edit. -- The dashboard continues to use the existing budget configuration surface for USD limits. -- New UI text is localized through existing i18n files. -- Existing management usage APIs remain management-only. -- Scope edits do not discard unrelated scopes. -- Tests cover API, helper logic, and UI scope defaults. diff --git a/docs/superpowers/plans/2026-05-29-windsurf-login-hotfix.md b/docs/superpowers/plans/2026-05-29-windsurf-login-hotfix.md deleted file mode 100644 index 0df2ad2932..0000000000 --- a/docs/superpowers/plans/2026-05-29-windsurf-login-hotfix.md +++ /dev/null @@ -1,740 +0,0 @@ -# Windsurf Login Hotfix (Phase 1) Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Hapus PKCE OAuth flow yang rusak (`app.devin.ai/editor/signin` → 404) untuk provider `windsurf` & `devin-cli`, jadikan import-token sebagai satu-satunya path login. - -**Architecture:** Single-branch hotfix di `fix/windsurf-login-2026-05-29`. Modifikasi 5 file source + 1 file test, tidak ada migrasi DB, tidak ada credential baru. Existing connections (api_key tersimpan) tetap jalan tanpa perubahan. - -**Tech Stack:** TypeScript 5.9, Next.js 16 App Router, Node.js test runner (`node --import tsx/esm --test`), Zod validation, React 19. - -**Spec:** `docs/superpowers/specs/2026-05-29-windsurf-login-fix-design.md` (Phase 1 section). - ---- - -## File Structure - -| File | Type | Responsibility | -|---|---|---| -| `src/lib/oauth/providers/windsurf.ts` | Modify | Drop PKCE handlers (`buildAuthUrl`, `flowType: "authorization_code_pkce"`). Set `flowType: "import_token"`. Keep `mapTokens` + token validation. | -| `src/lib/oauth/constants/oauth.ts` | Modify | Comment out PKCE-only fields. Keep `inferenceUrl`, `showAuthTokenUrl`, `firebaseApiKey`, `ideName`. | -| `src/lib/oauth/providers/index.ts` | No change | Re-export already correct (`windsurf` + alias `devin-cli`). | -| `src/app/api/oauth/[provider]/[action]/route.ts` | Modify | Remove `windsurf` & `devin-cli` from `PKCE_CALLBACK_PROVIDERS`. Return HTTP 410 Gone for `authorize` & `start-callback-server` actions. | -| `src/shared/components/OAuthModal.tsx` | Modify | Hide PKCE buttons for windsurf/devin-cli, show paste-token panel + "Get token" link. | -| `tests/unit/windsurf-devin-executors.test.ts` | Modify | Add tests asserting PKCE disabled + import-token still works. | - -**Out of scope (Phase 2):** Firebase OAuth, RegisterUser, refresh worker, `WindsurfLoginModal.tsx`, DB migration. See spec Phase 2 section. - ---- - -## Pre-flight - -- [ ] **Step 0.1: Verify branch + identity** - -Run: -```bash -git branch --show-current -git config user.email -``` -Expected: branch is `fix/windsurf-login-2026-05-29`, email is set (any value, just non-empty). - -- [ ] **Step 0.2: Verify spec is committed** - -Run: -```bash -git log --oneline -3 -``` -Expected: top commit is `docs(oauth): add Windsurf login fix design ...`. - ---- - -## Task 1: Test — PKCE auth URL generation throws / returns disabled - -**Files:** -- Test: `tests/unit/windsurf-devin-executors.test.ts` (modify — add test cases at end of describe block) - -- [ ] **Step 1.1: Read existing test structure** - -Run: -```bash -head -30 tests/unit/windsurf-devin-executors.test.ts -``` -Note the existing imports + describe blocks. Match style. - -- [ ] **Step 1.2: Add failing test for PKCE-disabled auth URL** - -Append to `tests/unit/windsurf-devin-executors.test.ts` (inside the existing top-level `describe` or as a new describe at the end): - -```typescript -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { generateAuthData, getProvider } from "@/lib/oauth/providers"; - -test("windsurf provider: flowType is import_token (PKCE disabled post-rebrand)", () => { - const provider = getProvider("windsurf"); - assert.equal(provider.flowType, "import_token"); -}); - -test("devin-cli provider: flowType is import_token (shares windsurf config)", () => { - const provider = getProvider("devin-cli"); - assert.equal(provider.flowType, "import_token"); -}); - -test("windsurf provider: generateAuthData returns no authUrl (PKCE flow disabled)", () => { - const data = generateAuthData("windsurf", "http://localhost:0/auth/callback"); - assert.equal(data.authUrl, undefined); - assert.equal(data.supported, false); - assert.match(data.error ?? "", /import-token|disabled|app\.devin\.ai/i); -}); - -test("devin-cli provider: generateAuthData returns no authUrl", () => { - const data = generateAuthData("devin-cli", "http://localhost:0/auth/callback"); - assert.equal(data.authUrl, undefined); - assert.equal(data.supported, false); -}); -``` - -If file already imports `test` and `assert`, do not duplicate; reuse. - -- [ ] **Step 1.3: Run test — confirm failure** - -Run: -```bash -node --import tsx/esm --test tests/unit/windsurf-devin-executors.test.ts 2>&1 | tail -30 -``` -Expected: 4 new tests FAIL — `flowType` is `"authorization_code_pkce"` not `"import_token"`, and `generateAuthData` still returns a real URL. - ---- - -## Task 2: Implementation — switch windsurf provider to import_token flow - -**Files:** -- Modify: `src/lib/oauth/providers/windsurf.ts` (full file rewrite — small file) - -- [ ] **Step 2.1: Read full current file** - -Run: -```bash -wc -l src/lib/oauth/providers/windsurf.ts -``` -Confirm file size before rewriting. Should be < 200 lines. - -- [ ] **Step 2.2: Replace file content** - -Write `src/lib/oauth/providers/windsurf.ts`: - -```typescript -import { WINDSURF_CONFIG } from "../constants/oauth"; - -/** - * Windsurf / Devin CLI OAuth Provider — import-token only (Phase 1 hotfix, 2026-05-29). - * - * The previous PKCE Authorization Code flow targeting `https://app.devin.ai/editor/signin` - * stopped working post-rebrand: that endpoint now returns 404. Until Phase 2 ports the - * Firebase OAuth + RegisterUser flow (see docs/superpowers/specs/2026-05-29-windsurf-login-fix-design.md), - * the only supported login path is import-token: - * - * 1. User opens https://windsurf.com/show-auth-token in a browser - * 2. Copies the displayed Windsurf API key (`sk-ws-...` style) - * 3. Pastes it into OmniRoute via /api/oauth/windsurf/import-token - * - * The pasted token is stored as `accessToken` and used directly by `WindsurfExecutor` - * (open-sse/executors/windsurf.ts) as the `Authorization: Bearer ...` header against - * the inference server (`server.self-serve.windsurf.com`). - */ -export const windsurf = { - config: WINDSURF_CONFIG, - flowType: "import_token" as const, - - /** - * Validate a pasted Windsurf API key. Accepts the `sk-ws-...` format issued by - * windsurf.com/show-auth-token and the legacy raw-token format. Empty or - * whitespace-only tokens are rejected. - */ - validateImportToken(token: string): { valid: boolean; reason?: string } { - const trimmed = (token ?? "").trim(); - if (!trimmed) { - return { valid: false, reason: "Token is empty" }; - } - if (trimmed.length < 16) { - return { valid: false, reason: "Token is too short" }; - } - return { valid: true }; - }, - - /** - * Map a pasted import token onto the connection record. The token IS the - * Windsurf API key; there is no exchange step. - */ - mapTokens(token: string) { - return { - accessToken: token, - refreshToken: null, - expiresAt: null, - }; - }, -}; -``` - -- [ ] **Step 2.3: Re-run Task 1 tests** - -Run: -```bash -node --import tsx/esm --test tests/unit/windsurf-devin-executors.test.ts 2>&1 | tail -30 -``` -Expected: 4 new tests now FAIL only on `generateAuthData` — `flowType` tests should PASS. `generateAuthData` still depends on the route handler / provider dispatcher, which Task 3 wires. - -If `flowType` tests still fail with `flowType: "authorization_code_pkce"`, re-check that `windsurf.ts` was overwritten. - ---- - -## Task 3: Make `generateAuthData` return disabled stub for windsurf/devin-cli - -**Files:** -- Modify: `src/lib/oauth/providers/index.ts` (add helper export — small) -- OR: Modify the dispatcher inside `src/lib/oauth/providers/` that owns `generateAuthData` (find it) - -- [ ] **Step 3.1: Locate `generateAuthData` definition** - -Run: -```bash -grep -rn "export function generateAuthData\|export const generateAuthData" src/lib/oauth/ -``` -Expected: one hit. Note the file path — call it ``. - -- [ ] **Step 3.2: Read the dispatcher** - -Read ``. Identify the branch where `provider.flowType === "authorization_code_pkce"` builds the auth URL via `provider.buildAuthUrl(...)`. - -- [ ] **Step 3.3: Add early-return for `import_token` flowType** - -In ``, modify `generateAuthData` so that when `provider.flowType === "import_token"` it returns: - -```typescript -if (provider.flowType === "import_token") { - return { - authUrl: undefined, - codeVerifier: undefined, - state: undefined, - supported: false, - error: - provider === windsurf || providerKey === "windsurf" || providerKey === "devin-cli" - ? "Browser login disabled — paste token from https://windsurf.com/show-auth-token instead. Phase 2 will restore Firebase OAuth." - : "This provider only supports import-token flow.", - }; -} -``` - -Match the ``'s actual function signature — if it receives `providerKey: string` and `redirectUri: string`, use those names. The exact field set returned must match what `OAuthModal.tsx` and the `/authorize` route already consume (see Step 1.2 expected fields). Keep `supported: false` and a non-empty `error` string; both are checked by the test. - -- [ ] **Step 3.4: Run all Task 1 tests — must pass now** - -Run: -```bash -node --import tsx/esm --test tests/unit/windsurf-devin-executors.test.ts 2>&1 | tail -30 -``` -Expected: all 4 new tests PASS. - -- [ ] **Step 3.5: Commit progress** - -Run: -```bash -git add src/lib/oauth/providers/windsurf.ts src/lib/oauth/providers/index.ts tests/unit/windsurf-devin-executors.test.ts -# Plus the dispatcher file from Step 3.1 if different -git status --short -git commit -m "fix(oauth): switch windsurf provider to import_token flow - -The PKCE auth URL targeting app.devin.ai/editor/signin returns 404 -post-rebrand. Until Phase 2 ports Firebase OAuth + RegisterUser, the -only supported path is import-token via windsurf.com/show-auth-token. - -- windsurf.ts: drop buildAuthUrl, set flowType=import_token -- generateAuthData returns supported:false + helpful error for windsurf/devin-cli -- tests: assert flowType + disabled stub" -``` - ---- - -## Task 4: Test — `start-callback-server` returns 410 Gone for windsurf/devin-cli - -**Files:** -- Test: `tests/unit/windsurf-devin-executors.test.ts` (extend) - -- [ ] **Step 4.1: Add failing test** - -Append to `tests/unit/windsurf-devin-executors.test.ts`: - -```typescript -import { GET as oauthGet } from "@/app/api/oauth/[provider]/[action]/route"; - -test("OAuth route: windsurf/start-callback-server returns 410 Gone", async () => { - const url = "http://localhost:20128/api/oauth/windsurf/start-callback-server"; - const request = new Request(url, { method: "GET" }); - const response = await oauthGet(request, { - params: Promise.resolve({ provider: "windsurf", action: "start-callback-server" }), - } as never); - assert.equal(response.status, 410); - const body = await response.json(); - assert.match(body.error, /import-token|disabled|410/i); -}); - -test("OAuth route: devin-cli/authorize returns 410 Gone", async () => { - const url = "http://localhost:20128/api/oauth/devin-cli/authorize"; - const request = new Request(url, { method: "GET" }); - const response = await oauthGet(request, { - params: Promise.resolve({ provider: "devin-cli", action: "authorize" }), - } as never); - assert.equal(response.status, 410); -}); -``` - -- [ ] **Step 4.2: Run — confirm failure** - -Run: -```bash -node --import tsx/esm --test tests/unit/windsurf-devin-executors.test.ts 2>&1 | tail -30 -``` -Expected: 2 new tests FAIL — current handler probably returns 400 or 200. - ---- - -## Task 5: Implementation — return 410 Gone for disabled PKCE actions - -**Files:** -- Modify: `src/app/api/oauth/[provider]/[action]/route.ts:75-160` (`GET` handler) - -- [ ] **Step 5.1: Read existing handler shape** - -Run: -```bash -sed -n '40,50p;75,165p' src/app/api/oauth/[provider]/[action]/route.ts -``` -Confirm `PKCE_CALLBACK_PROVIDERS` set definition + the `if (action === "authorize")` branch. - -- [ ] **Step 5.2: Remove `windsurf` and `devin-cli` from `PKCE_CALLBACK_PROVIDERS`** - -In `src/app/api/oauth/[provider]/[action]/route.ts`, find: - -```typescript -const PKCE_CALLBACK_PROVIDERS = new Set(["codex", "windsurf", "devin-cli"]); -``` - -Replace with: - -```typescript -// windsurf & devin-cli removed 2026-05-29 — PKCE endpoint app.devin.ai/editor/signin -// returns 404 post-rebrand. Phase 2 will reintroduce browser login via Firebase OAuth. -const PKCE_CALLBACK_PROVIDERS = new Set(["codex"]); - -// Providers whose PKCE flow has been retired but whose import-token path is still -// active. The route returns 410 Gone for `authorize` / `start-callback-server` -// and points users at /import-token. -const RETIRED_PKCE_PROVIDERS = new Set(["windsurf", "devin-cli"]); -``` - -- [ ] **Step 5.3: Add 410 short-circuit at top of `GET` handler body** - -Inside `GET`, immediately after `const { provider, action } = await params;` (and before `if (action === "authorize")`), insert: - -```typescript -if ( - RETIRED_PKCE_PROVIDERS.has(provider) && - (action === "authorize" || action === "start-callback-server" || action === "poll-callback") -) { - return NextResponse.json( - { - error: - "Browser OAuth disabled for this provider — use import-token via /api/oauth/" + - provider + - "/import-token. See https://windsurf.com/show-auth-token to obtain a token.", - }, - { status: 410 } - ); -} -``` - -- [ ] **Step 5.4: Run tests — must pass** - -Run: -```bash -node --import tsx/esm --test tests/unit/windsurf-devin-executors.test.ts 2>&1 | tail -30 -``` -Expected: all 6 new tests PASS. - -- [ ] **Step 5.5: Run full unit suite — no regressions** - -Run: -```bash -npm run test:unit 2>&1 | tail -30 -``` -Expected: green. If a Codex-specific test now fails because `PKCE_CALLBACK_PROVIDERS.has("windsurf")` was assumed, fix that test (Codex behaviour didn't change; the assertion did). - -- [ ] **Step 5.6: Commit** - -Run: -```bash -git add src/app/api/oauth/[provider]/[action]/route.ts tests/unit/windsurf-devin-executors.test.ts -git commit -m "fix(oauth): return 410 Gone for retired windsurf/devin-cli PKCE actions - -start-callback-server, authorize, and poll-callback now return 410 -with a pointer to /import-token. Codex PKCE flow unchanged." -``` - ---- - -## Task 6: Clean up `WINDSURF_CONFIG` — annotate PKCE fields as retired - -**Files:** -- Modify: `src/lib/oauth/constants/oauth.ts:328-365` - -- [ ] **Step 6.1: Read full block** - -Run: -```bash -sed -n '328,375p' src/lib/oauth/constants/oauth.ts -``` - -- [ ] **Step 6.2: Update header comment block** - -Replace the comment block above `export const WINDSURF_CONFIG = {` with: - -```typescript -// Windsurf / Devin CLI Configuration -// -// 2026-05-29 (Phase 1 hotfix): -// The browser PKCE flow targeting https://app.devin.ai/editor/signin returned -// 404 post-rebrand. PKCE-only fields (`authorizeUrl`, `codeChallengeMethod`, -// `callbackPort`, `callbackPath`, `apiServerUrl`, `exchangePath`) are kept -// below for archival reference but are NO LONGER consumed by any code path — -// the provider exports flowType="import_token" only. -// -// Phase 2 will reintroduce browser login via Firebase OAuth + RegisterUser. -// Spec: docs/superpowers/specs/2026-05-29-windsurf-login-fix-design.md. -// -// Active fields: -// - inferenceUrl → used by WindsurfExecutor (open-sse/executors/windsurf.ts) -// - showAuthTokenUrl → linked from OAuthModal "Get token" button -// - firebaseApiKey → reserved for Phase 2 -// - ideName → sent in extension headers -``` - -Inline-annotate the retired fields (`authorizeUrl`, `codeChallengeMethod`, `callbackPort`, `callbackPath`, `apiServerUrl`, `exchangePath`) with `// retired 2026-05-29` comments. Do **not** delete them — that would break any downstream import; just mark them. - -- [ ] **Step 6.3: Verify typecheck still passes** - -Run: -```bash -npm run typecheck:core 2>&1 | tail -10 -``` -Expected: no errors. - -- [ ] **Step 6.4: Commit** - -Run: -```bash -git add src/lib/oauth/constants/oauth.ts -git commit -m "refactor(oauth): annotate retired PKCE fields in WINDSURF_CONFIG - -No behaviour change — comment-only update documenting that authorizeUrl, -codeChallengeMethod, callbackPort, callbackPath, apiServerUrl, and -exchangePath are no longer consumed." -``` - ---- - -## Task 7: UI — `OAuthModal.tsx` skips PKCE buttons for windsurf/devin-cli - -**Files:** -- Modify: `src/shared/components/OAuthModal.tsx` - -- [ ] **Step 7.1: Read modal** - -Run: -```bash -grep -n "windsurf\|devin-cli\|flowType\|authorize\|start-callback\|import-token\|showAuthTokenUrl" src/shared/components/OAuthModal.tsx | head -40 -``` - -Identify the rendering branch that picks PKCE vs import-token UI based on `provider.flowType` or provider key. - -- [ ] **Step 7.2: Add windsurf/devin-cli explicit branch** - -In the modal render, find where it switches on flow type. Add — at the top of the render-decision logic — a check: - -```typescript -// Phase 1 hotfix: PKCE flow for windsurf/devin-cli is retired (app.devin.ai 404). -// Force import-token panel + "Get your token" link to windsurf.com/show-auth-token. -const isWindsurfFamily = providerKey === "windsurf" || providerKey === "devin-cli"; - -if (isWindsurfFamily) { - return ( - - ); -} -``` - -If `ImportTokenPanel` doesn't exist as a separate component, render the equivalent inline using the same JSX structure already used for other `IMPORT_TOKEN_PROVIDERS` (claude alternate-flow, e.g.). The key UX requirements: -1. No "Sign in with browser" button visible -2. Single textarea/input for token paste -3. A button labeled "Get your Windsurf API token" that calls `window.open("https://windsurf.com/show-auth-token", "_blank", "noopener,noreferrer")` -4. Submit button validates non-empty + calls existing `/api/oauth/windsurf/import-token` POST - -- [ ] **Step 7.3: Manual smoke (developer-side)** - -Run: -```bash -npm run dev & -sleep 8 -curl -s -o /dev/null -w "%{http_code}\n" http://localhost:20128/api/oauth/windsurf/start-callback-server -curl -s -o /dev/null -w "%{http_code}\n" http://localhost:20128/api/oauth/devin-cli/authorize -kill %1 -``` -Expected: both `410`. - -- [ ] **Step 7.4: Lint + typecheck** - -Run: -```bash -npm run lint 2>&1 | tail -5 -npm run typecheck:core 2>&1 | tail -5 -``` -Expected: 0 errors. Pre-existing warnings OK. - -- [ ] **Step 7.5: Commit** - -Run: -```bash -git add src/shared/components/OAuthModal.tsx -git commit -m "fix(dashboard): force import-token panel for windsurf/devin-cli - -PKCE 'Sign in with browser' button is hidden for these providers. -Single 'Get your Windsurf API token' link opens windsurf.com/show-auth-token, -user pastes the returned token into the form." -``` - ---- - -## Task 8: i18n — update Windsurf login guide steps - -**Files:** -- Modify: i18n keys for windsurf onboarding guide (39 languages) - -- [ ] **Step 8.1: Locate i18n keys** - -Run: -```bash -grep -rln "windsurf" src/locales/ public/locales/ docs/i18n/ 2>/dev/null | head -10 -grep -rln "show-auth-token\|app.devin.ai\|editor/signin" . --include="*.json" --include="*.ts" 2>/dev/null | grep -v node_modules | grep -v ".next" | head -10 -``` -Note the i18n root directory — call it ``. - -- [ ] **Step 8.2: Find the Windsurf step key** - -Run: -```bash -grep -rln "docs.windsurf.steps\|windsurfGuideSteps\|windsurf_steps\|Sign in to Windsurf" -``` -Identify the canonical key (e.g. `docs.windsurf.steps` or `onboarding.windsurf.steps`). - -- [ ] **Step 8.3: Update English (`en.json` or equivalent) first** - -Replace any string mentioning "Sign in", "browser login", or `app.devin.ai` with the import-token equivalent. Target string (rewrite per file's existing key shape): - -``` -1. Open https://windsurf.com/show-auth-token in your browser -2. Sign in to your Windsurf account if prompted -3. Copy the API token displayed on the page -4. Paste it into the OmniRoute Windsurf connection form -``` - -- [ ] **Step 8.4: Sync the other 38 languages** - -If the repo has a sync script, run it: - -```bash -npm run docs:i18n-sync 2>&1 | tail -10 -# or -npm run check:docs-all 2>&1 | tail -20 -``` - -If no script: replace the same key in every locale file with the English text wrapped in a `// TODO translate` marker, OR leave language-specific versions untouched if they already don't mention `app.devin.ai`. The CI gate (`check-docs-sync` in pre-commit) will tell you which files are out of sync. - -- [ ] **Step 8.5: Run docs gate** - -Run: -```bash -npm run check:docs-all 2>&1 | tail -20 -``` -Expected: PASS or only pre-existing warnings. - -- [ ] **Step 8.6: Commit** - -Run: -```bash -git add -git commit -m "docs(i18n): update Windsurf onboarding to import-token flow - -Replace 'Sign in via browser' steps with the windsurf.com/show-auth-token -copy-paste flow across all locales." -``` - ---- - -## Task 9: Full verification - -- [ ] **Step 9.1: Full unit suite** - -Run: -```bash -npm run test:unit 2>&1 | tail -20 -``` -Expected: green. - -- [ ] **Step 9.2: Coverage gate** - -Run: -```bash -npm run test:coverage 2>&1 | tail -20 -``` -Expected: 75/75/75/70 thresholds met. - -- [ ] **Step 9.3: Lint + typecheck** - -Run: -```bash -npm run lint 2>&1 | tail -5 -npm run typecheck:core 2>&1 | tail -5 -npm run typecheck:noimplicit:core 2>&1 | tail -5 -``` -Expected: 0 errors. - -- [ ] **Step 9.4: Combined check** - -Run: -```bash -npm run check 2>&1 | tail -10 -``` -Expected: green. - -- [ ] **Step 9.5: Manual smoke — local server** - -Run: -```bash -npm run dev & -sleep 10 -# 1. Disabled PKCE actions return 410 -curl -s -o /dev/null -w "GET /windsurf/authorize: %{http_code}\n" \ - http://localhost:20128/api/oauth/windsurf/authorize -curl -s -o /dev/null -w "GET /windsurf/start-callback-server: %{http_code}\n" \ - http://localhost:20128/api/oauth/windsurf/start-callback-server -curl -s -o /dev/null -w "GET /devin-cli/authorize: %{http_code}\n" \ - http://localhost:20128/api/oauth/devin-cli/authorize -# 2. Codex still works (regression check) -curl -s -o /dev/null -w "GET /codex/authorize: %{http_code}\n" \ - http://localhost:20128/api/oauth/codex/authorize -kill %1 -``` -Expected: windsurf/devin-cli all `410`. Codex `200` (or whatever it returned before — must be unchanged). - -- [ ] **Step 9.6: Manual smoke — paste valid token** - -Manual steps in browser at `http://localhost:20128/dashboard/providers`: -1. Click "Connect" on Windsurf provider -2. Verify modal does NOT show "Sign in with browser" -3. Verify "Get your Windsurf API token" link is present -4. Click link → opens new tab to `windsurf.com/show-auth-token` (or warns if not logged in) -5. Paste a valid token, submit -6. Connection saves, status shows "Active" -7. Open chat playground, send 1 request to `swe-1`, verify a response is returned - -If you do not have a valid Windsurf token, document this in the PR as "blocked on tester with credentials" and note that automated tests cover the negative path. - ---- - -## Task 10: Push branch + open PR - -- [ ] **Step 10.1: Verify clean tree** - -Run: -```bash -git status --short -``` -Expected: empty (everything committed). - -- [ ] **Step 10.2: Push branch** - -Run: -```bash -git push -u origin fix/windsurf-login-2026-05-29 -``` - -- [ ] **Step 10.3: Open PR** - -Run: -```bash -gh pr create \ - --title "fix(oauth): hotfix Windsurf login — drop dead PKCE flow, promote import-token" \ - --body "$(cat <<'EOF' -## Summary - -The Windsurf provider's PKCE OAuth URL (`https://app.devin.ai/editor/signin`) returns -404 post-Cognition rebrand, leaving users unable to log in. This PR retires the dead -flow and makes import-token (from `https://windsurf.com/show-auth-token`) the only -supported login path. Phase 2 (Firebase OAuth + RegisterUser, ported from -`fendoushaonian/WindSurf-gRPC-API`) will follow in a separate PR. - -Spec: \`docs/superpowers/specs/2026-05-29-windsurf-login-fix-design.md\`. - -## Changes - -- \`src/lib/oauth/providers/windsurf.ts\` — drop \`buildAuthUrl\`, set \`flowType: "import_token"\` -- \`src/app/api/oauth/[provider]/[action]/route.ts\` — return 410 Gone for retired actions (\`authorize\`, \`start-callback-server\`, \`poll-callback\`) on \`windsurf\` & \`devin-cli\`. Codex unchanged. -- \`src/lib/oauth/constants/oauth.ts\` — annotate retired PKCE fields, no behaviour change -- \`src/shared/components/OAuthModal.tsx\` — hide "Sign in with browser" button for windsurf/devin-cli, show import-token panel + \`windsurf.com/show-auth-token\` link -- i18n — update onboarding steps in all locales -- Tests — new assertions covering disabled flowType, 410 responses, import-token still functional - -No DB migration. Existing connections (\`accessToken\` already saved) continue working. - -## Test plan - -- [x] \`npm run test:unit\` — green -- [x] \`npm run test:coverage\` — 75/75/75/70 met -- [x] \`npm run lint\` + \`typecheck:core\` — 0 errors -- [x] \`curl GET /api/oauth/windsurf/authorize\` → 410 -- [x] \`curl GET /api/oauth/devin-cli/start-callback-server\` → 410 -- [x] \`curl GET /api/oauth/codex/authorize\` → unchanged -- [ ] Manual: paste valid token, send 1 chat request to \`swe-1\` (blocked on tester with credentials) - -## Rollback - -Revert this single PR. No schema changes; existing tokens unaffected. -EOF -)" \ - --base main \ - --head fix/windsurf-login-2026-05-29 -``` - -- [ ] **Step 10.4: Confirm CI starts** - -Run: -```bash -gh pr checks 2>&1 | tail -10 -``` -Expected: workflows queued. If failing, address in follow-up commits on the same branch. - ---- - -## Self-Review Notes - -- **Spec coverage**: All Phase 1 items in `docs/superpowers/specs/2026-05-29-windsurf-login-fix-design.md` mapped to tasks 1-8. Phase 2 explicitly out of scope. -- **Type consistency**: `flowType: "import_token"` used consistently. `RETIRED_PKCE_PROVIDERS` & `PKCE_CALLBACK_PROVIDERS` kept distinct. -- **Placeholder scan**: Task 8 has one `` placeholder that the engineer resolves via Step 8.1 grep — necessary because i18n location varies. Task 3 has `` — resolved via Step 3.1 grep. -- **Frequent commits**: 6 commits across the implementation (Tasks 3, 5, 6, 7, 8 each commit; Task 1+2 share commit at Step 3.5). -- **TDD**: Tasks 1, 4 write tests first; Tasks 2, 5 make them pass. diff --git a/docs/superpowers/specs/2026-05-29-windsurf-login-fix-design.md b/docs/superpowers/specs/2026-05-29-windsurf-login-fix-design.md deleted file mode 100644 index 73bbd280d2..0000000000 --- a/docs/superpowers/specs/2026-05-29-windsurf-login-fix-design.md +++ /dev/null @@ -1,272 +0,0 @@ -# Windsurf Login Fix — Design - -**Date:** 2026-05-29 -**Status:** Draft (awaiting approval) -**Owner:** OmniRoute team -**Related:** `src/lib/oauth/providers/windsurf.ts`, `src/lib/oauth/constants/oauth.ts`, `open-sse/executors/windsurf.ts` - ---- - -## Problem - -OAuth URL yang di-generate untuk Windsurf provider kembali **404** saat user buka di browser: - -``` -https://app.devin.ai/editor/signin?response_type=code&redirect_uri=...&code_challenge=... -``` - -Akibatnya user tidak bisa login ke Windsurf via OmniRoute. Provider effectively broken. - -### Root cause - -1. URL `app.devin.ai/editor/signin` di-extract dari binary Devin CLI lama dan di-hardcode di `src/lib/oauth/constants/oauth.ts:343`. Endpoint ini sudah dihapus pasca rebrand Cognition/Windsurf. -2. Reference document yang awalnya disediakan (`dwgx/WindsurfAPI/docs/analysis-v1.9.5.md`) tidak membahas login flow — hanya proxy/protobuf architecture. -3. Reverse-engineering ulang dari `language_server_linux_x64` (Go-stripped, 188 MB) terlalu mahal. -4. Repo `fendoushaonian/WindSurf-gRPC-API` sudah RE flow login Windsurf yang aktual: **Firebase OAuth + RegisterUser**, bukan PKCE ke `app.devin.ai`. - -### Actual Windsurf login flow (per fendoushaonian/WindSurf-gRPC-API) - -``` -1. User auth dengan Google/GitHub/Microsoft OAuth atau email/password -2. POST identitytoolkit.googleapis.com/v1/accounts:signInWithIdp?key= - body: { postBody: "id_token=&providerId=google.com", - requestUri: "https://windsurf.com/login", - returnSecureToken: true } - → returns { idToken, refreshToken, email, localId } -3. POST register.windsurf.com/exa.seat_management_pb.SeatManagementService/RegisterUser - body: { firebase_id_token: idToken } - → returns { api_key: "sk-ws-..." } ← ini WINDSURF_API_KEY untuk inference -4. Refresh: POST securetoken.googleapis.com/v1/token (grant_type=refresh_token) -``` - -Konstanta publik yang harus di-embed (extracted from Windsurf extension.js, public Firebase Web key — bukan secret): -- `FIREBASE_API_KEY = ""` -- `GOOGLE_CLIENT_ID = "957777847521-egrk5uakal87pjkqctk89fe7b7qtd1dq.apps.googleusercontent.com"` -- `REGISTER_SERVER = "https://register.windsurf.com"` - ---- - -## Solution Overview - -Two-phase delivery: - -| Phase | Goal | Path | -|---|---|---| -| **1. Hotfix** | Hentikan 404, restore minimum-viable login | Hapus PKCE flow, promote import-token sebagai primary path | -| **2. Automation** | Restore browser-based login experience | Port Firebase OAuth + RegisterUser flow dari `fendoushaonian/WindSurf-gRPC-API` | - -Phase 1 ship dulu sebagai single PR untuk un-block user. Phase 2 ship di PR terpisah behind feature flag. - ---- - -## Phase 1 — Hotfix (Option B: import-token only) - -### Architecture - -Hapus seluruh PKCE browser flow yang broken. Promosikan **import-token** sebagai satu-satunya path resmi: -- User klik "Connect Windsurf" → modal langsung tampil paste-token -- Tombol "Get token" buka tab baru ke `https://windsurf.com/show-auth-token` (URL ini masih hidup, return HTTP 200) -- User copy token, paste, OmniRoute test connection, simpan - -### Files touched - -| File | Change | -|---|---| -| `src/lib/oauth/providers/windsurf.ts` | Hapus `buildAuthUrl`, `flowType: "authorization_code_pkce"`, `callbackPath`, `callbackPort`. Sisakan `config` + `validateImportToken` helper. Set `flowType: "import_token"`. | -| `src/lib/oauth/constants/oauth.ts` | Comment out / hapus field obsolete: `authorizeUrl`, `codeChallengeMethod`, `callbackPort`, `callbackPath`, `apiServerUrl`, `exchangePath`. Tambah comment block: `// PKCE OAuth flow disabled 2026-05-29 — app.devin.ai/editor/signin returns 404 post-rebrand. Use import-token from showAuthTokenUrl. Phase 2 will restore Firebase OAuth.` Keep `inferenceUrl`, `showAuthTokenUrl`, `firebaseApiKey`, `ideName`. | -| `src/lib/oauth/providers/index.ts` | Drop `"windsurf"` & `"devin-cli"` dari `PKCE_CALLBACK_PROVIDERS`. Keep di `IMPORT_TOKEN_PROVIDERS`. | -| `src/app/api/oauth/[provider]/[action]/route.ts` | Saat `provider === "windsurf"` atau `"devin-cli"` masuk ke `start-callback-server` / `authorize` action: return HTTP 410 `Gone` dengan body `{ error: "PKCE OAuth disabled for windsurf — use import-token via /api/oauth//import-token" }`. Routing ke `import-token` action tidak berubah. | -| `src/shared/components/OAuthModal.tsx` | Saat provider `"windsurf"` / `"devin-cli"`: hide tombol "Sign in with browser" + "Use device code". Show single panel: paste-token textarea + tombol "Get your token" (`window.open(showAuthTokenUrl)`). | -| Docs i18n keys | Update `docs.windsurf.steps` di 39 bahasa: hapus mention "click Sign In", ganti dengan "open windsurf.com/show-auth-token, copy your API token, paste it here". | -| `tests/unit/windsurf-devin-executors.test.ts` | Tambah test `generateAuthData("windsurf")` returns error / throws — PKCE explicitly disabled. Tambah test untuk import-token happy path + invalid-token rejection. | -| (No DB migration needed) | Connection schema tidak berubah. Existing connections tetap jalan. | - -### Behavior change - -| Before | After | -|---|---| -| User klik "Sign in" → browser → 404 | User klik "Connect Windsurf" → modal paste-token → "Get token" button buka windsurf.com/show-auth-token tab → user paste → connection saved | -| `generateAuthData("windsurf")` returns auth URL | `generateAuthData("windsurf")` throws `OAuthFlowDisabledError` | -| `/api/oauth/windsurf/start-callback-server` 200 | `/api/oauth/windsurf/start-callback-server` 410 Gone | - -### Validation plan - -- Unit: `tests/unit/windsurf-devin-executors.test.ts` covers PKCE-disabled path + import-token happy path -- Integration: 1 manual smoke — paste valid token from `windsurf.com/show-auth-token`, send 1 chat request via `swe-1`, expect 200 -- Regression: existing connections (api_key already saved) MUST continue to work — no schema migration, no re-auth required -- Coverage gate: stays ≥75/75/75/70 (Phase 1 mostly removes code, doesn't add untested logic) - -### Risk & rollback - -- **Risk**: low. Cuma hapus dead path + UI copy update. -- **Rollback**: revert single commit. Existing connections tidak terpengaruh karena schema tidak berubah. - -### Success criteria Phase 1 - -- [ ] `npm run test:unit` pass (semua test windsurf-devin-executors) -- [ ] `npm run test:coverage` ≥ 75/75/75/70 -- [ ] `npm run typecheck:core` clean -- [ ] Manual: paste valid token → connection saved → 1 chat request lewat swe-1 berhasil -- [ ] Manual: GET `/api/oauth/windsurf/start-callback-server` → 410 Gone -- [ ] UI: WindsurfModal tidak menampilkan tombol "Sign in with browser" lagi - ---- - -## Phase 2 — Firebase OAuth integration (port `fendoushaonian/WindSurf-gRPC-API`) - -### Goal - -Restore browser-based login automation. User klik tombol Google/GitHub/Microsoft/email → OmniRoute auto-dapat `sk-ws-...` API key + Firebase refresh token. Schedule auto-refresh sebelum token expire. - -### Reference implementation - -- Repo: `https://github.com/fendoushaonian/WindSurf-gRPC-API` -- Files studied: `windsurf_api/auth.py`, `windsurf_api/services/seat_management.py` (RegisterUser), `windsurf_api/client.py` -- Approach: port logic-nya ke TypeScript, follow OmniRoute conventions (Zod schemas, `buildErrorBody`, `resolvePublicCred`, AES-256-GCM at-rest) - -### Architecture - -``` -┌─────────────────────────────────────────────────────────────────────┐ -│ User flow │ -└─────────────────────────────────────────────────────────────────────┘ - -[Google/GitHub/MS] [Email/password] - │ │ - │ User klik tombol di WindsurfLoginModal │ - ▼ ▼ -┌──────────────────────────────┐ ┌──────────────────────┐ -│ POST /api/oauth/windsurf/ │ │ POST /api/oauth/ │ -│ firebase │ │ windsurf/firebase │ -│ body: { method: "google", │ │ body: { method: │ -│ credential: │ │ "email_login", │ -│ } │ │ credential: {...}} │ -└────────────┬─────────────────┘ └─────────┬────────────┘ - │ │ - ▼ ▼ - ┌─────────────────────────────────────────────────────────────────┐ - │ open-sse/services/windsurfFirebase.ts │ - │ - signInWithIdp(provider, oauth_token) → Firebase id_token │ - │ - signInWithPassword(email, password) → Firebase id_token │ - │ - signUp(email, password) → Firebase id_token │ - │ - refreshIdToken(refresh_token) → new id_token │ - └────────────────────────┬────────────────────────────────────────┘ - │ Firebase id_token - ▼ - ┌─────────────────────────────────────────────────────────────────┐ - │ open-sse/services/windsurfRegister.ts │ - │ POST register.windsurf.com/.../RegisterUser │ - │ body: { firebase_id_token } │ - │ → { api_key: "sk-ws-...", user_id } │ - └────────────────────────┬────────────────────────────────────────┘ - │ - ▼ - ┌─────────────────────────────────────────────────────────────────┐ - │ Persist: │ - │ accessToken = api_key (encrypted) │ - │ firebaseRefreshToken = refresh_token (encrypted) │ - │ firebaseExpiresAt = now + 3600s │ - │ Schedule refresh worker (50min interval) │ - └─────────────────────────────────────────────────────────────────┘ -``` - -### Files baru - -| File | Purpose | Lines | -|---|---|---| -| `src/lib/oauth/providers/windsurfFirebase.ts` | New OAuth provider entry. `flowType: "firebase_oauth"`. Methods: `loginGoogle`, `loginGithub`, `loginMicrosoft`, `loginEmail`, `signUpEmail`. | ~150 | -| `open-sse/services/windsurfFirebase.ts` | Firebase Identity Toolkit wrapper. `signInWithIdp`, `signInWithPassword`, `signUp`, `refreshIdToken`. Uses `resolvePublicCred()` for Firebase Web key. | ~200 | -| `open-sse/services/windsurfRegister.ts` | `register.windsurf.com` Connect-JSON client. `RegisterUser(firebase_id_token) → api_key`. Errors via `buildErrorBody()`. | ~100 | -| `src/app/api/oauth/windsurf/firebase/route.ts` | `POST /api/oauth/windsurf/firebase` — body `{ method, credential }`. Calls Firebase + RegisterUser, creates connection. Rate-limited 5/5min/IP. | ~150 | -| `src/lib/oauth/utils/windsurfRefresh.ts` | Background refresh worker. When token < 10min from expiry, refresh via securetoken endpoint. Re-register if api_key needs renewal. | ~120 | -| `src/shared/components/WindsurfLoginModal.tsx` | UI: 4 tombol (Google/GitHub/Microsoft/Email). Email mode shows email+password+isSignup form. | ~200 | -| `tests/unit/windsurfFirebase.test.ts` | Mock Firebase Identity Toolkit + register.windsurf.com responses. Cover all 5 methods + refresh flow + error sanitization assertion. | ~300 | -| `tests/unit/windsurfRegister.test.ts` | Mock RegisterUser endpoint. Test happy path + 401 + malformed response. | ~150 | -| `docs/frameworks/WINDSURF-LOGIN.md` | Document Firebase OAuth flow + diagram. | ~200 | - -### Files updated - -| File | Change | -|---|---| -| `src/lib/oauth/constants/oauth.ts` | Add `WINDSURF_FIREBASE_CONFIG` block: `firebaseApiKey` via `resolvePublicCred("windsurf_fb", "WINDSURF_FIREBASE_API_KEY")`, `googleClientId` via `resolvePublicCred("windsurf_google", "WINDSURF_GOOGLE_CLIENT_ID")`, `registerUrl: "https://register.windsurf.com"`, `firebaseAuthUrl`, `firebaseTokenUrl`, OAuth redirect uri `https://windsurf.com/login`. | -| `open-sse/utils/publicCreds.ts` | Add embedded defaults: `windsurf_fb: ""`, `windsurf_google: "957777847521-egrk5uakal87pjkqctk89fe7b7qtd1dq.apps.googleusercontent.com"`. Both are public Web keys/client_ids — non-sensitive but must follow rule #11 pattern. | -| `src/lib/oauth/providers/index.ts` | Register `"windsurf-firebase"` provider entry. | -| `src/lib/db/migrations/_windsurf_firebase.sql` | Add columns to `connections`: `firebase_refresh_token TEXT NULL` (encrypted), `firebase_expires_at INTEGER NULL`. Idempotent (`IF NOT EXISTS` pattern). | -| `src/lib/db/connections.ts` | Update CRUD to handle new optional columns. Encrypt/decrypt via existing `connectionEncryption` helpers. | -| `src/shared/constants/providers.ts` | Mark windsurf supports both `import_token` AND `firebase_oauth`. | -| `docs/security/PUBLIC_CREDS.md` | Add windsurf entries (Firebase Web key, Google client_id) ke registered list. | -| `docs/reference/PROVIDER_REFERENCE.md` | Regenerate via `npm run gen:provider-reference`. | - -### Security - -- **Rule #11** (public creds): Firebase Web key + Google client_id WAJIB via `resolvePublicCred()`. Test asserts shape: - ```ts - expect(creds.windsurf_fb).toMatch(/^AIza[A-Za-z0-9_-]{35}$/) - expect(creds.windsurf_google).toMatch(/^\d+-[a-z0-9]+\.apps\.googleusercontent\.com$/) - ``` -- **Rule #12** (error sanitization): Semua response error (Firebase + RegisterUser) WAJIB lewat `buildErrorBody()`. Firebase error sering leak `email` / `localId` di message — strip explicitly. Test asserts: - ```ts - expect(body.error.message).not.toContain(testEmail) - expect(body.error.message).not.toMatch(/at\s+\//) // no stack - ``` -- **Refresh token at rest**: encrypt pakai `connectionEncryption` helper (AES-256-GCM, existing pattern). Never log refresh_token. -- **Rate limit**: `/api/oauth/windsurf/firebase` rate-limited 5 attempt / 5min / IP via existing `src/lib/rateLimit/` middleware. Abuse Firebase tidak gratis. -- **Feature flag**: `OMNIROUTE_WINDSURF_FIREBASE_AUTH=1` (default OFF). Beta testing, then default ON setelah stable. -- **Dual flow**: import-token (Phase 1) tetap available. Kalau Firebase API key di-rotate Windsurf, user fall back ke import-token via UI banner. - -### Behavior change - -- WindsurfLoginModal punya 5 path: Google/GitHub/Microsoft/Email/import-token. -- Connection schema gain optional `firebase_refresh_token`, `firebase_expires_at`. Old connections (token-only, dari Phase 1) tetap jalan tanpa migrasi. -- Auto-refresh: api_key Windsurf TTL ±1 jam (mengikuti Firebase ID token). Worker re-register tiap 50 menit. -- New env vars: `WINDSURF_FIREBASE_API_KEY` (override), `WINDSURF_GOOGLE_CLIENT_ID` (override), `OMNIROUTE_WINDSURF_FIREBASE_AUTH` (feature flag). - -### Validation plan - -- **Unit**: 100% Firebase + RegisterUser path coverage. Mock both endpoints, assert payload shape, error sanitization, refresh logic. -- **Integration**: 1 E2E test gated by `RUN_WINDSURF_INT=1` — uses real test account. Spawn server, hit `/api/oauth/windsurf/firebase`, assert connection saved + chat request lewat. -- **Manual smoke**: 4 login methods (Google/GitHub/MS/Email) end-to-end di staging. Verify auto-refresh trigger setelah 50min. -- **Coverage gate**: ≥75/75/75/70. - -### Risk & rollback - -- **Risk: medium**. Firebase Web key bisa dirotate Windsurf — kalau itu terjadi, semua user broken. Mitigation: - - Env override `WINDSURF_FIREBASE_API_KEY` (bisa di-update tanpa redeploy) - - Monitoring `auth/invalid-api-key` error → auto-banner di UI: "Browser login broken, please use import-token (Phase 1 fallback)" - - Phase 1 import-token tetap aktif — user tidak total stuck -- **Rollback**: feature flag `OMNIROUTE_WINDSURF_FIREBASE_AUTH=0`. Migration tidak di-revert (kolom optional, NULL-safe). Phase 1 path tetap jalan. - -### Success criteria Phase 2 - -- [ ] All 5 login methods work end-to-end (Google/GitHub/MS/Email login/Email signup) -- [ ] Auto-refresh terjadi sebelum 1-hour expiry, no user-visible disruption -- [ ] Error messages tidak leak email / localId / stack -- [ ] CodeQL + Secret-Scanning pass (Firebase key terdeteksi false positive — dismiss with reference ke `docs/security/PUBLIC_CREDS.md`) -- [ ] Coverage gate pass ≥75/75/75/70 -- [ ] Feature flag default OFF di first PR; default ON setelah 2 minggu beta tanpa critical issue - ---- - -## Migration path - -| Step | When | What | -|---|---|---| -| 1 | T+0 | Phase 1 PR merged → released → users un-blocked via import-token | -| 2 | T+1d to T+1w | Phase 2 PR opened, behind `OMNIROUTE_WINDSURF_FIREBASE_AUTH=1` flag | -| 3 | T+1w to T+3w | Beta testing dengan opt-in users | -| 4 | T+3w | Flag default ON, import-token tetap available sebagai fallback | -| 5 | T+3m | Evaluate: kalau Firebase OAuth stable + 0 critical issue, deprecate import-token UI option (keep API endpoint untuk backward compat) | - ---- - -## Out of scope - -- Migration tool untuk auto-upgrade existing import-token connections ke Firebase OAuth (user can manually re-connect kalau mau auto-refresh) -- SAML / Enterprise SSO (not in `fendoushaonian/WindSurf-gRPC-API` reference) -- Devin CLI specific flow (currently shares config dengan windsurf — Phase 1 hotfix covers both, Phase 2 evaluate apakah Devin CLI butuh path terpisah) - ---- - -## Open questions - -None blocking. Implementation can proceed. diff --git a/electron/package-lock.json b/electron/package-lock.json index fdb25d1b99..65f8bbb3de 100644 --- a/electron/package-lock.json +++ b/electron/package-lock.json @@ -1,12 +1,12 @@ { "name": "omniroute-desktop", - "version": "3.8.23", + "version": "3.8.24", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "omniroute-desktop", - "version": "3.8.23", + "version": "3.8.24", "license": "MIT", "dependencies": { "electron-updater": "^6.8.9" diff --git a/electron/package.json b/electron/package.json index abcd8110b7..eeb86c52a8 100644 --- a/electron/package.json +++ b/electron/package.json @@ -1,6 +1,6 @@ { "name": "omniroute-desktop", - "version": "3.8.23", + "version": "3.8.24", "description": "OmniRoute Desktop Application", "main": "main.js", "author": { diff --git a/eslint.complexity.config.mjs b/eslint.complexity.config.mjs index 99766138a0..654ed402d7 100644 --- a/eslint.complexity.config.mjs +++ b/eslint.complexity.config.mjs @@ -15,7 +15,7 @@ import tseslint from "typescript-eslint"; /** @type {import("eslint").Linter.Config[]} */ const complexityConfig = [ { - files: ["src/**/*.{ts,tsx}", "open-sse/**/*.{ts,tsx}"], + files: ["src/**/*.{ts,tsx}", "open-sse/**/*.{ts,tsx}", "electron/**/*.{ts,tsx}", "bin/**/*.{ts,tsx}"], languageOptions: { parser: tseslint.parser, parserOptions: { @@ -44,8 +44,8 @@ const complexityConfig = [ ], }, }, - // 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. + // Ignore everything that is not first-party src/open-sse/electron/bin production code so + // the count is not polluted by tests, type declarations, or build output. { ignores: [ "**/*.test.ts", @@ -53,6 +53,8 @@ const complexityConfig = [ "**/__tests__/**", "**/*.d.ts", "node_modules/**", + "electron/node_modules/**", + "electron/dist-electron/**", ".next/**", ".build/**", "dist/**", diff --git a/eslint.sonarjs.config.mjs b/eslint.sonarjs.config.mjs new file mode 100644 index 0000000000..e009e3ac8a --- /dev/null +++ b/eslint.sonarjs.config.mjs @@ -0,0 +1,61 @@ +// eslint.sonarjs.config.mjs +// STANDALONE flat config for the cognitive-complexity ratchet +// (scripts/check/check-cognitive-complexity.mjs). +// +// Intentionally does NOT extend the project's main eslint.config.mjs — it +// enables ONLY `sonarjs/cognitive-complexity` so its violation count is +// isolated from the main lint's ratcheted warning budget (3653). +// +// Run via: +// node_modules/.bin/eslint --no-config-lookup \ +// --config eslint.sonarjs.config.mjs --format json src open-sse +import tseslint from "typescript-eslint"; +import sonarjs from "eslint-plugin-sonarjs"; + +/** @type {import("eslint").Linter.Config[]} */ +const sonarisCognitiveConfig = [ + { + files: ["src/**/*.{ts,tsx}", "open-sse/**/*.{ts,tsx}"], + languageOptions: { + parser: tseslint.parser, + parserOptions: { + ecmaVersion: 2022, + sourceType: "module", + ecmaFeatures: { jsx: true }, + }, + }, + plugins: { sonarjs }, + // Silence inline disable directives that reference rules from OTHER plugins + // (react-hooks, @next/next, etc.) that this standalone config does NOT load. + // Without noInlineConfig those produce error-severity "rule not found" noise + // that pollutes and destabilises the violation count. + linterOptions: { + noInlineConfig: true, + reportUnusedDisableDirectives: "off", + }, + // ONLY this one sonarjs rule. Keep the list minimal so the reported count + // is exactly "functions over the cognitive-complexity threshold". + rules: { + "sonarjs/cognitive-complexity": ["error", 15], + }, + }, + // 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/**", + "electron/node_modules/**", + "electron/dist-electron/**", + ".next/**", + ".build/**", + "dist/**", + "coverage/**", + ], + }, +]; + +export default sonarisCognitiveConfig; diff --git a/file-size-baseline.json b/file-size-baseline.json index 62e48dcfdb..158f5c3a13 100644 --- a/file-size-baseline.json +++ b/file-size-baseline.json @@ -2,9 +2,9 @@ "_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": 4692, - "open-sse/executors/antigravity.ts": 1553, - "open-sse/executors/base.ts": 1199, + "open-sse/config/providerRegistry.ts": 4703, + "open-sse/executors/antigravity.ts": 1572, + "open-sse/executors/base.ts": 1218, "open-sse/executors/chatgpt-web.ts": 2870, "open-sse/executors/claude-web.ts": 1057, "open-sse/executors/codex.ts": 1439, @@ -27,17 +27,17 @@ "open-sse/services/batchProcessor.ts": 828, "open-sse/services/browserBackedChat.ts": 850, "open-sse/services/claudeCodeCompatible.ts": 1202, - "open-sse/services/combo.ts": 5054, + "open-sse/services/combo.ts": 5131, "open-sse/services/rateLimitManager.ts": 1017, "open-sse/services/tokenRefresh.ts": 1997, "open-sse/services/usage.ts": 3394, "open-sse/translator/request/openai-to-gemini.ts": 844, - "open-sse/translator/response/openai-responses.ts": 873, - "open-sse/utils/cursorAgentProtobuf.ts": 1499, + "open-sse/translator/response/openai-responses.ts": 878, + "open-sse/utils/cursorAgentProtobuf.ts": 1521, "open-sse/utils/stream.ts": 2710, "src/app/(dashboard)/dashboard/HomePageClient.tsx": 1385, "src/app/(dashboard)/dashboard/analytics/ComboHealthTab.tsx": 1020, - "src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx": 2701, + "src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx": 2909, "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, @@ -76,31 +76,31 @@ "src/app/api/usage/analytics/route.ts": 941, "src/app/api/v1/models/catalog.ts": 1435, "src/lib/cloudflaredTunnel.ts": 934, - "src/lib/db/apiKeys.ts": 1490, + "src/lib/db/apiKeys.ts": 1633, "src/lib/db/core.ts": 1820, "src/lib/db/migrationRunner.ts": 1100, "src/lib/db/models.ts": 1132, "src/lib/db/providers.ts": 1050, - "src/lib/db/proxies.ts": 1039, - "src/lib/db/settings.ts": 1108, + "src/lib/db/proxies.ts": 1048, + "src/lib/db/settings.ts": 1149, "src/lib/db/usageAnalytics.ts": 873, "src/lib/evals/evalRunner.ts": 961, "src/lib/memory/retrieval.ts": 1171, "src/lib/modelsDevSync.ts": 934, - "src/lib/providers/validation.ts": 4209, + "src/lib/providers/validation.ts": 4302, "src/lib/tailscaleTunnel.ts": 1189, "src/lib/usage/callLogs.ts": 975, - "src/lib/usage/providerLimits.ts": 943, + "src/lib/usage/providerLimits.ts": 949, "src/lib/usage/usageHistory.ts": 854, "src/shared/components/OAuthModal.tsx": 956, - "src/shared/components/RequestLoggerV2.tsx": 1232, + "src/shared/components/RequestLoggerV2.tsx": 1276, "src/shared/components/analytics/charts.tsx": 1558, "src/shared/constants/cliTools.ts": 875, "src/shared/constants/pricing.ts": 1470, "src/shared/constants/providers.ts": 3146, "src/shared/constants/sidebarVisibility.ts": 990, - "src/shared/services/cliRuntime.ts": 1084, - "src/shared/validation/schemas.ts": 2515, + "src/shared/services/cliRuntime.ts": 1090, + "src/shared/validation/schemas.ts": 2519, "src/sse/handlers/chat.ts": 1392, "src/sse/services/auth.ts": 2207 }, @@ -110,5 +110,7 @@ "_rebaseline_2026_06_12_phase1g1j": "Phase 1g-1j (#3501): ProviderDetailPageClient.tsx 4063→3409 (extraídos ProviderPlaygroundPanel, useCommandCodeAuth, useExternalLinkFlow+ExternalLinkModal, useAuthFileHandlers — zero lógica nova). models/route.ts 2344→2426: drift do #3712 (vertex dynamic model discovery) reconciliado aqui.", "_rebaseline_2026_06_12_phase1n1s": "Phase 1n-1s (#3501): ProviderDetailPageClient.tsx 2554→1376 (extraídos ConnectionsListPanel, ConnectionsHeaderToolbar, ZedImportCard, BatchTestResultsModal, AdaptaTutorialModal + hooks/useApiKeySave + 4 helper closures→providerPageHelpers.ts). providerPageHelpers.ts 822→897 justificado: recebe 4 closures do god-component (getApiLabel/getApiDefaultPath/getApiPath/getHeaderIconProviderId), zero lógica nova, cliente encolhe mais do que helpers crescem.", "_rebaseline_2026_06_12_phase1t": "Phase 1t (#3501): ProviderDetailPageClient.tsx 1377→782 — META ≤800 ATINGIDA (extraídos ProviderPageHeader, CompatibleNodeCard, ProviderModalsPanel, EmptyConnectionsPlaceholder, UpstreamProxyCard, SearchProviderCard + hooks useConnectionGate/useProviderNodeActions). Drift concorrente reconciliado: ResilienceTab/sse-chat/sse-auth/accountFallback/combo (merges #3629 model-lockout etc.).", - "_rebaseline_2026_06_12_v3823_new_features": "Re-baseline v3.8.23 pós-merge de #3742 (cost drilldown: ApiManagerPageClient.tsx +21, CostOverviewTab.tsx +14, providerLimits.ts +2, usage.ts +53) + #3743 (provider display modes: ProviderDetailPageClient.tsx +2, providerPageHelpers.ts +42, providers.ts +2) + #3740 (semantic cache key isolation: chat.ts +3). Crescimento justificado por features novas mergeadas no ciclo." + "_rebaseline_2026_06_12_v3823_new_features": "Re-baseline v3.8.23 pós-merge de #3742 (cost drilldown: ApiManagerPageClient.tsx +21, CostOverviewTab.tsx +14, providerLimits.ts +2, usage.ts +53) + #3743 (provider display modes: ProviderDetailPageClient.tsx +2, providerPageHelpers.ts +42, providers.ts +2) + #3740 (semantic cache key isolation: chat.ts +3). Crescimento justificado por features novas mergeadas no ciclo.", + "_rebaseline_2026_06_13_combo_quota_audit": "Re-baseline consciente do audit combo+quota (PR #3779): combo.ts 5054→5131 (+77). Crescimento = 5 fixes TDD + estratégia complexity-aware 2026 (W1 clampComboDepth + threading de maxDepth em 6 assinaturas/dispatch/DAG; W2 extração shouldSkipForPredictedTtft; W4 scoreAutoTargets exportado + param manifestHint). A parte limpa-extraível do W4 (construção do hint inline, ~30 linhas) FOI extraída para autoCombo/complexityRouter.ts (buildComplexityRoutingHint) — este +76 é o resíduo irredutível (edição de assinaturas/threading, não bloco movível). Shrink estrutural de combo.ts segue com #3501.", + "_rebaseline_2026_06_13_v3824_3776": "Re-baseline v3.8.24 pós #3776 (strict-mode controls Claude Code default models: ApiManagerPageClient.tsx 2701→2909 = UI de famílias bloqueáveis cc/* + chips; apiKeys.ts 1490→1633 = blocked_models deny-list + candidatos de permissão claude-code; schemas.ts 2515→2519 = reformatação Prettier + reasoningTokenBufferEnabled restaurado) + carry-over base.ts 1205→1218 do #3780 (enforceThinkingTemperature no chokepoint, drift de baseline não bumpado no merge). Crescimento de feature; sem god-component novo." } diff --git a/knip.json b/knip.json new file mode 100644 index 0000000000..588942a01e --- /dev/null +++ b/knip.json @@ -0,0 +1,74 @@ +{ + "$schema": "https://unpkg.com/knip@6/schema.json", + "workspaces": { + ".": { + "entry": [ + "src/app/**/{layout,page,route,template}.{ts,tsx}", + "src/app/**/{error,loading,not-found,default,global-error}.{ts,tsx}", + "src/app/**/manifest.ts", + "src/middleware.ts", + "src/instrumentation.ts", + "bin/**/*.{mjs,ts}", + "scripts/**/*.{mjs,ts}", + "electron/main.js", + "electron/preload.js", + "electron/loginManager.js" + ], + "project": ["src/**/*.{ts,tsx}", "!src/**/*.test.{ts,tsx}"], + "ignore": [ + "src/**/*.test.{ts,tsx}", + "tests/**", + "node_modules/**", + ".build/**", + "dist/**", + "coverage/**", + "**/.next/**" + ], + "ignoreDependencies": [ + "typescript", + "tsx", + "@types/*", + "eslint*", + "prettier", + "husky", + "lint-staged", + "cross-env", + "concurrently", + "wait-on", + "c8", + "source-map-support" + ], + "includeEntryExports": false + }, + "open-sse": { + "entry": [ + "index.ts", + "handlers/**/*.ts", + "executors/**/*.ts", + "translator/**/*.ts", + "transformer/**/*.ts", + "services/**/*.ts", + "utils/**/*.ts", + "config/**/*.ts", + "mcp-server/**/*.ts" + ], + "project": ["**/*.ts", "!**/*.test.ts"], + "ignore": ["**/*.test.ts", "node_modules/**", "dist/**"] + } + }, + "include": ["exports", "nsExports", "types", "nsTypes", "files"], + "exclude": ["dependencies", "devDependencies", "unlisted", "unresolved", "binaries"], + "ignoreExportsUsedInFile": true, + "ignore": [ + "tests/**", + "**/*.test.{ts,tsx}", + "**/*.spec.{ts,tsx}", + "node_modules/**", + ".build/**", + "dist/**", + "coverage/**", + "**/.next/**", + "electron/dist-electron/**", + "scripts/**" + ] +} diff --git a/open-sse/config/providerModels.ts b/open-sse/config/providerModels.ts index 99a42ea019..6351ad7263 100644 --- a/open-sse/config/providerModels.ts +++ b/open-sse/config/providerModels.ts @@ -61,6 +61,7 @@ export function getModelsByProviderId(providerId: string): RegistryModel[] { const CLAUDE_MODEL_PATTERN = /(?:^|[\/._-])claude(?:[._-]|$)/; const CLAUDE_MAX_EFFORT_UNSUPPORTED_FAMILY_PATTERNS = [/(?:^|[\/._-])haiku(?:[._-]|$)/] as const; +const ANTHROPIC_COMPATIBLE_PREFIX = "anthropic-compatible-"; export function supportsClaudeMaxEffort(modelId: string | null | undefined): boolean { if (typeof modelId !== "string" || modelId.length === 0) return false; @@ -73,19 +74,37 @@ export function supportsClaudeMaxEffort(modelId: string | null | undefined): boo ); } +function resolveProviderModelList(aliasOrId: string): { + alias: string; + models: RegistryModel[] | null; +} { + const resolvedId = aliasOrId.startsWith(ANTHROPIC_COMPATIBLE_PREFIX) ? "claude" : aliasOrId; + const alias = PROVIDER_ID_TO_ALIAS[resolvedId] || resolvedId; + const models = PROVIDER_MODELS[alias] || PROVIDER_MODELS[resolvedId] || null; + return { alias, models }; +} + export function supportsXHighEffort(aliasOrId: string, modelId: string): boolean { - const alias = PROVIDER_ID_TO_ALIAS[aliasOrId] || aliasOrId; - const providerModels = PROVIDER_MODELS[alias] || PROVIDER_MODELS[aliasOrId]; + const { models: providerModels } = resolveProviderModelList(aliasOrId); // Unknown provider (not in registry) — pass through unchanged. if (!providerModels) return true; - const model = getProviderModel(alias, modelId); + const model = providerModels.find((entry) => entry.id === modelId); + + // Keep explicit false entries as the unsupported-model list. Unlisted models + // and models without an explicit flag pass through unchanged. + return model?.supportsXHighEffort !== false; +} + +export function supportsXHighEffortForMaxNormalization( + aliasOrId: string, + modelId: string +): boolean { + const { alias, models: providerModels } = resolveProviderModelList(aliasOrId); + if (!providerModels) return true; + const model = providerModels.find((entry) => entry.id === modelId); - // Claude Code models default to supporting extra-high effort. Keep explicit - // false entries as the unsupported-model list so newly added Claude models do - // not need opt-in flags. if (alias === "cc") { return model?.supportsXHighEffort !== false; } - return model?.supportsXHighEffort === true; } diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index 27bc46650f..5dfa8495d9 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -3197,6 +3197,18 @@ const _REGISTRY_EAGER: Record = { { id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", supportsReasoning: true }, { id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", supportsReasoning: true }, { id: "kimi-k2.6", name: "Kimi K2.6" }, + // #3761: 262K native, vision + thinking + tools (parity with K2.6). Without this + // entry the "import from /models" path leaves it as a bare custom model with the + // 128K/8K capability defaults. + { + id: "kimi-k2.7-code", + name: "Kimi K2.7 Code", + contextLength: 262144, + maxOutputTokens: 262144, + supportsReasoning: true, + supportsVision: true, + toolCalling: true, + }, { id: "glm-5.1", name: "GLM 5.1" }, // #3110: MiniMax M3 via Ollama { id: "minimax-m3", name: "MiniMax M3", contextLength: 1048576, supportsVision: true }, diff --git a/open-sse/executors/antigravity.ts b/open-sse/executors/antigravity.ts index cd0f50fe50..d55a9162b9 100644 --- a/open-sse/executors/antigravity.ts +++ b/open-sse/executors/antigravity.ts @@ -30,6 +30,7 @@ import { handleCreditsFailure, } from "../services/antigravityCredits.ts"; import { persistCreditBalance, getAllPersistedCreditBalances } from "@/lib/db/creditBalance"; +import { setConnectionRateLimitUntil } from "@/lib/db/providers"; import { getMitmAlias } from "@/lib/db/models"; import { obfuscateSensitiveWords } from "../services/antigravityObfuscation.ts"; import { resolveAntigravityVersion } from "../services/antigravityVersion.ts"; @@ -328,6 +329,19 @@ function markCreditsExhausted(accountId: string): void { creditsExhaustedUntil.set(accountId, Date.now() + CREDITS_EXHAUSTED_TTL_MS); } +/** + * Persist a quota-exhausted cooldown to the DB for `connectionId` so that + * cross-request and post-restart routing skips this connection until the + * cooldown expires. Exported for unit testing. @internal + */ +export function markConnectionQuotaExhausted(connectionId: string, retryAfterMs: number): void { + try { + setConnectionRateLimitUntil(connectionId, Date.now() + retryAfterMs); + } catch { + // DB write failure must never crash the request path + } +} + /** * Accumulate one Antigravity SSE `data:` payload into `collected`. Exported for unit * tests (the markdown / candidate-parts extraction branches). @internal @@ -1183,10 +1197,21 @@ export class AntigravityExecutor extends BaseExecutor { const effectiveRetryHintMs = retryMs ?? parsedRetryMs ?? null; const category = classify429(errorMessage); - // 3. For quota_exhausted, attempt Google One AI credits retry FIRST! - // Skip if credits were already injected on the first call - // (creditsMode === "always") — no point re-running with the - // same body. Record the failure so the 5h breaker kicks in. + // 3. Decide final retry time BEFORE the credits retry so that + // full_quota_exhausted can skip the credits attempt entirely + // (avoids ~41s hold on an already-exhausted account) and + // persist the cooldown to DB for post-restart routing. + const decision: Decision = decide429(category, parsedRetryMs); + retryMs = decision.retryAfterMs; + log?.debug?.( + "AG_429", + `Category: ${category}, Decision: ${decision.kind} — ${decision.reason}` + ); + + if (decision.kind === "full_quota_exhausted" && retryMs) { + markConnectionQuotaExhausted(accountId, retryMs); + } + const creditsAlreadyInjected = (transformedBody as { enabledCreditTypes?: unknown }).enabledCreditTypes != null; @@ -1198,6 +1223,7 @@ export class AntigravityExecutor extends BaseExecutor { if ( category === "quota_exhausted" && + decision.kind !== "full_quota_exhausted" && !creditsAlreadyInjected && shouldRetryWithCredits(credentials?.accessToken || "", creditsMode !== "off") ) { @@ -1269,13 +1295,6 @@ export class AntigravityExecutor extends BaseExecutor { } } - // 4. Decide final retry time (apply 4-tier engine) - const decision: Decision = decide429(category, parsedRetryMs); - retryMs = decision.retryAfterMs; - log?.debug?.( - "AG_429", - `Category: ${category}, Decision: ${decision.kind} — ${decision.reason}` - ); } catch (e) { // Ignore parse errors, will fall back to exponential backoff } diff --git a/open-sse/executors/base.ts b/open-sse/executors/base.ts index f940e630d6..0bef539f91 100644 --- a/open-sse/executors/base.ts +++ b/open-sse/executors/base.ts @@ -1,6 +1,10 @@ import { HTTP_STATUS, FETCH_TIMEOUT_MS } from "../config/constants.ts"; import { applyFingerprint, isCliCompatEnabled } from "../config/cliFingerprints.ts"; -import { supportsClaudeMaxEffort, supportsXHighEffort } from "../config/providerModels.ts"; +import { + supportsClaudeMaxEffort, + supportsXHighEffort, + supportsXHighEffortForMaxNormalization, +} from "../config/providerModels.ts"; import type { PoolConfig } from "../services/sessionPool/types.ts"; import type { Session } from "../services/sessionPool/session.ts"; import { SessionPool } from "../services/sessionPool/sessionPool.ts"; @@ -22,10 +26,14 @@ import { signRequestBody } from "../services/claudeCodeCCH.ts"; import { appendAnthropicBetaHeader, CONTEXT_1M_BETA_HEADER, + enforceThinkingTemperature, modelSupportsContext1mBeta, } from "../services/claudeCodeCompatible.ts"; import { getClaudeCodeCompatibleRequestDefaults } from "@/lib/providers/requestDefaults"; -import { cloakThirdPartyToolNames, remapToolNamesInRequest } from "../services/claudeCodeToolRemapper.ts"; +import { + cloakThirdPartyToolNames, + remapToolNamesInRequest, +} from "../services/claudeCodeToolRemapper.ts"; import { obfuscateInBody } from "../services/claudeCodeObfuscation.ts"; import { sanitizeClaudeToolSchemas } from "../translator/helpers/schemaCoercion.ts"; import { sanitizeResponsesInputItems } from "../services/responsesInputSanitizer.ts"; @@ -223,12 +231,11 @@ function hasActiveClaudeThinking(body: Record): boolean { * Each rejection burns a combo fallback attempt before reaching a working * provider. Apply provider-aware sanitation here (after transformRequest, so * reintroductions by per-provider transforms are also caught) before fetch. - * xhigh support is registry-gated: models that genuinely support xhigh pass - * through unchanged, and Claude models default to xhigh support unless marked - * as legacy unsupported entries. max support is Claude/CC-compatible only and + * xhigh support is opt-out: pass through unchanged unless the registry marks + * a model as unsupported. max support is Claude/CC-compatible only and * intentionally separate: older Opus/Sonnet models may support max even when - * they do not support xhigh. For OpenAI-shape providers, normalize max to - * xhigh when that top tier is allowed; otherwise downgrade to high. + * they do not support xhigh. For OpenAI-shape providers, keep the existing + * max normalization behavior. */ const MISTRAL_NO_REASONING_EFFORT_PATTERN = /devstral/i; const GITHUB_NO_REASONING_EFFORT_PATTERN = /(claude|haiku|oswe)/i; @@ -260,10 +267,10 @@ export function sanitizeReasoningEffortForProvider( const supportsXHigh = supportsXHighEffort(provider, modelStr); const shouldDowngradeXHigh = effortStr === "xhigh" && !supportsXHigh; - const shouldNormalizeMaxToXHigh = - effortStr === "max" && !supportsMaxEffortForProvider(provider, modelStr) && supportsXHigh; - const shouldDowngradeMax = - effortStr === "max" && !supportsMaxEffortForProvider(provider, modelStr) && !supportsXHigh; + const supportsXHighForMax = supportsXHighEffortForMaxNormalization(provider, modelStr); + const supportsMax = supportsMaxEffortForProvider(provider, modelStr); + const shouldNormalizeMaxToXHigh = effortStr === "max" && !supportsMax && supportsXHighForMax; + const shouldDowngradeMax = effortStr === "max" && !supportsMax && !supportsXHighForMax; if (shouldNormalizeMaxToXHigh) { log?.info?.( @@ -1111,6 +1118,18 @@ export class BaseExecutor { tb.messages = stripTrailingAssistantForProvider(stripped, this.provider); } } + + // Anthropic's extended-thinking contract forbids non-default sampling + // params: temperature must be 1 and top_p >= 0.95 (or unset) whenever + // thinking is enabled/adaptive. Thinking can be injected by per-model + // requestDefaults *after* the translator/constraint passes, so normalize + // at this final dispatch point — the single chokepoint every Claude + // routing mode (grouped/raw/combo) and the native passthrough share, + // before fingerprinting and CCH signing serialize the body. + if (this.provider === "claude" || isClaudeCodeCompatible(this.provider)) { + enforceThinkingTemperature(transformedBody as Record); + } + let bodyString = JSON.stringify(transformedBody); const shouldFingerprint = diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index c2bf1943f3..bfa9b8bf6f 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -488,33 +488,36 @@ export function shouldUseNativeCodexPassthrough({ } /** - * Convert all historical `thinking` / `redacted_thinking` blocks in assistant - * messages to `redacted_thinking` carrying a synthetic default signature. + * Pass `thinking` / `redacted_thinking` blocks through UNCHANGED. * - * A thinking block's `signature` is cryptographically bound to the auth token - * that generated it. In Anthropic-native Claude OAuth passthrough, when a session - * starts on one model (token A) and then switches model or falls over (token B), - * Anthropic rejects every historical signature with 400 "Invalid signature in - * thinking block" (issue #2454). `redacted_thinking` bypasses signature validation. + * This used to rewrite every assistant thinking block to `redacted_thinking` + * carrying a synthetic signature, on the assumption that a thinking signature is + * bound to the auth token that produced it and would be rejected after a token / + * model switch with 400 "Invalid signature in thinking block" (issue #2454). * - * ALL assistant turns are converted, including the last — under a different token - * every signature is invalid, so there is no "preserve latest" exception. Returns a - * new messages array (original is not mutated) only touching messages that changed. + * That rewrite is the actual cause of a different, far more common failure on the + * Anthropic-native Claude OAuth passthrough: + * + * 400 messages.N.content.M: `thinking` or `redacted_thinking` blocks in the + * latest assistant message cannot be modified. These blocks must remain as + * they were in the original response. + * + * The Messages API validates submitted thinking blocks against the original + * response and rejects ANY modification — so converting them to + * `redacted_thinking` makes every multi-turn request with thinking fail (most + * visible on long Claude Code tool-loops). The thinking-block signature is + * validated server-side by Anthropic and stays valid when the blocks are replayed, + * including under a different OAuth token — verified by preserving the blocks + * across a mid-conversation account switch with zero "Invalid signature" + * responses. The redaction is therefore both unnecessary and the cause of the + * regression, so the blocks are now returned verbatim. The `signature` parameter + * is kept for call-site compatibility. */ -export function redactPassthroughThinkingSignatures(messages: unknown, signature: string): unknown { - if (!Array.isArray(messages)) return messages; - return (messages as Record[]).map((msg) => { - if (!msg || msg.role !== "assistant" || !Array.isArray(msg.content)) return msg; - let modified = false; - const newContent = (msg.content as Record[]).map((block) => { - if (block && (block.type === "thinking" || block.type === "redacted_thinking")) { - modified = true; - return { type: "redacted_thinking", data: signature }; - } - return block; - }); - return modified ? { ...msg, content: newContent } : msg; - }); +export function redactPassthroughThinkingSignatures( + messages: unknown, + _signature: string +): unknown { + return messages; } export function isClaudeCodeSemanticPassthroughRequest({ @@ -5224,21 +5227,15 @@ export async function handleChatCore({ // === Quota Share POST-hook (B/F7) — fire-and-forget, fail-open === if (apiKeyInfo?.id && credentials?.connectionId) { try { - const { scheduleRecordConsumption } = await import("@/lib/quota/spendRecorder"); + const { scheduleRecordConsumption, buildConsumptionCost } = await import( + "@/lib/quota/spendRecorder" + ); scheduleRecordConsumption( { apiKeyId: apiKeyInfo.id, connectionId: credentials.connectionId, provider: provider ?? "unknown", - cost: { - tokens: - usage && typeof usage === "object" - ? (((usage as Record).prompt_tokens as number) ?? 0) + - (((usage as Record).completion_tokens as number) ?? 0) - : 0, - usd: estimatedCost > 0 ? estimatedCost : 0, - requests: 1, - }, + cost: buildConsumptionCost(usage, estimatedCost), }, log ); @@ -5519,29 +5516,28 @@ export async function handleChatCore({ } // === Quota Share POST-hook streaming (B/F7) — fire-and-forget, fail-open === + // Resolve the real per-request cost (calculateCost) so USD-unit pools accrue + // on streaming traffic too; this previously recorded usd:0 hardcoded, which + // meant DeepSeek-style `usd/monthly` shared pools never blocked on streams. if (apiKeyInfo?.id && credentials?.connectionId && streamStatus === 200) { - const su = streamUsage as Record | null; const quotaApiKeyId = apiKeyInfo.id; const quotaConnectionId = credentials.connectionId; // onStreamComplete is sync — use .then() (fire-and-forget, fail-open) instead of await import("@/lib/quota/spendRecorder") - .then(({ scheduleRecordConsumption }) => { - scheduleRecordConsumption( + .then(({ recordStreamingConsumption }) => + recordStreamingConsumption( { apiKeyId: quotaApiKeyId, connectionId: quotaConnectionId, - provider: provider ?? "unknown", - cost: { - tokens: su - ? (Number(su.prompt_tokens ?? 0) || 0) + (Number(su.completion_tokens ?? 0) || 0) - : 0, - usd: 0, // estimatedCost resolved async above; omit to avoid dependency - requests: 1, - }, + provider, + model, + streamUsage, + streamStatus, + serviceTier: effectiveServiceTier, }, - log - ); - }) + { calculateCost, log } + ) + ) .catch(() => { // Outer fail-open — never throws to caller }); diff --git a/open-sse/handlers/sseParser.ts b/open-sse/handlers/sseParser.ts index 53ea1480d7..712efc45fd 100644 --- a/open-sse/handlers/sseParser.ts +++ b/open-sse/handlers/sseParser.ts @@ -1,3 +1,4 @@ +import { appendToolCallArgumentDelta } from "../utils/toolCallArguments.ts"; /** * Convert OpenAI-style SSE chunks into a single non-streaming JSON response. * Used as a fallback when upstream returns text/event-stream for stream=false. @@ -181,7 +182,10 @@ export function parseSSEToOpenAIResponse(rawSSE, fallbackModel) { existing.function.name = tc.function.name; } existing.function = existing.function || {}; - existing.function.arguments = `${existing.function.arguments || ""}${deltaArgs}`; + existing.function.arguments = appendToolCallArgumentDelta( + existing.function.arguments, + deltaArgs + ); accumulatedToolCalls.set(key, existing); } } @@ -505,7 +509,10 @@ function ensureResponsesReasoningItem(outputItems, outputIndex, itemId) { const next = { ...(existing && typeof existing === "object" ? existing : {}), - id: itemId || (existing?.id != null ? String(existing.id) : null) || `rs_${Date.now()}_${outputIndex}`, + id: + itemId || + (existing?.id != null ? String(existing.id) : null) || + `rs_${Date.now()}_${outputIndex}`, type: "reasoning", summary: Array.isArray(existing?.summary) ? existing.summary.map((summaryPart) => ({ ...toRecord(summaryPart) })) @@ -539,9 +546,7 @@ function ensureResponsesFunctionCallItem(outputItems, outputIndex, itemId, callI const next = { ...(existing && typeof existing === "object" ? existing : {}), id: - normalizedItemId || - existingId || - `fc_${normalizedCallId || `${Date.now()}_${outputIndex}`}`, + normalizedItemId || existingId || `fc_${normalizedCallId || `${Date.now()}_${outputIndex}`}`, type: "function_call", call_id: normalizedCallId || existingCallId || "", name: name || existing?.name || "", diff --git a/open-sse/package.json b/open-sse/package.json index b9b9f1fd17..42c91eaced 100644 --- a/open-sse/package.json +++ b/open-sse/package.json @@ -1,6 +1,6 @@ { "name": "@omniroute/open-sse", - "version": "3.8.23", + "version": "3.8.24", "description": "Express SSE sidecar for OmniRoute — handles streaming, protocol translation, and provider orchestration", "type": "module", "main": "index.js", diff --git a/open-sse/services/antigravity429Engine.ts b/open-sse/services/antigravity429Engine.ts index f1b58d7bc0..36364a9e6c 100644 --- a/open-sse/services/antigravity429Engine.ts +++ b/open-sse/services/antigravity429Engine.ts @@ -32,7 +32,14 @@ export interface Decision { reason: string; } -const QUOTA_EXHAUSTED_KEYWORDS = ["quota_exhausted", "quota exhausted"]; +const QUOTA_EXHAUSTED_KEYWORDS = [ + "quota_exhausted", + "quota exhausted", + // Antigravity native message: "Individual quota reached. Contact your administrator to enable overages." + "quota reached", + "enable overages", + "individual quota", +]; const CREDITS_EXHAUSTED_KEYWORDS = [ "google_one_ai", diff --git a/open-sse/services/autoCombo/complexityRouter.ts b/open-sse/services/autoCombo/complexityRouter.ts new file mode 100644 index 0000000000..602fb1450b --- /dev/null +++ b/open-sse/services/autoCombo/complexityRouter.ts @@ -0,0 +1,111 @@ +/** + * complexityRouter.ts — Request-complexity classification for tier-aware routing. + * + * 2026 strategy: route by the intrinsic difficulty of the *request* (not only by + * provider stats), so trivial prompts can use cheap models and hard/reasoning + * prompts escalate to capable ones. Built on the existing specificity detector + * (codeComplexity / mathComplexity / reasoningDepth / contextSize / toolCalling + * / domainSpecificity), adding an explicit tool-use → minimum-tier escalation: + * a request carrying tool/function schemas (or agentic tool-calling signals) + * should not be routed below the "cheap" tier even when the prose looks + * trivial, because function-calling reliability matters more than raw cost. + * + * The classification maps to a `recommendedTier` that feeds the auto-router's + * tier-affinity / specificity-match scoring factors (see scoreAutoTargets, + * gated by config.complexityAwareRouting). + */ +import { + analyzeSpecificity, + getSpecificityLevel, + getRecommendedMinTier, +} from "../specificityDetector"; +import type { RuleInput, SpecificityLevel } from "../specificityTypes"; +import { generateRoutingHints, type RoutingHint } from "../manifestAdapter"; + +export type ComplexityTier = "free" | "cheap" | "premium"; + +export interface ComplexityClassification { + /** 0..100 specificity / difficulty score. */ + score: number; + /** trivial | simple | moderate | complex | expert */ + level: SpecificityLevel; + /** Minimum provider tier recommended for this request. */ + recommendedTier: ComplexityTier; + /** True when the request carries tool/function schemas or agentic tool signals. */ + hasToolUse: boolean; + /** Names of the specificity rules that fired (for the inspector / dashboard). */ + signals: string[]; +} + +const TIER_ORDER: ComplexityTier[] = ["free", "cheap", "premium"]; + +/** Raise `tier` to at least `floor`; never lowers it. */ +export function escalateTier(tier: ComplexityTier, floor: ComplexityTier): ComplexityTier { + return TIER_ORDER.indexOf(tier) >= TIER_ORDER.indexOf(floor) ? tier : floor; +} + +/** + * Classify a request's complexity and recommend a minimum provider tier. + * Pure + dependency-light (no DB / network); safe on the hot path. + */ +export function classifyRequestComplexity(input: RuleInput): ComplexityClassification { + const result = analyzeSpecificity(input); + const level = getSpecificityLevel(result.score); + + const explicitTools = Array.isArray(input.tools) && input.tools.length > 0; + const hasToolUse = explicitTools || result.breakdown.toolCalling > 0; + + let recommendedTier = getRecommendedMinTier(level) as ComplexityTier; + // Tool-using / agentic requests need reliable function calling — floor at "cheap". + if (hasToolUse) recommendedTier = escalateTier(recommendedTier, "cheap"); + + return { + score: result.score, + level, + recommendedTier, + hasToolUse, + signals: result.rulesTriggered, + }; +} + +/** + * Build the opt-in complexity-aware routing hint for the auto-router. Returns a + * RoutingHint whose `recommendedMinTier` is escalated to the request's intrinsic + * complexity (and floored at "cheap" for tool-using requests), or `null` on any + * failure — fail-open, so scoring stays tier-neutral. Extracted from combo.ts to + * keep the complexity-routing logic in one module. + */ +export function buildComplexityRoutingHint( + modelTargets: Parameters[0], + body: { messages?: unknown; tools?: unknown; model?: unknown } | null | undefined, + log: { info: (tag: string, message: string) => void } +): RoutingHint | null { + try { + const ruleInput = { + messages: Array.isArray(body?.messages) + ? (body.messages as Array<{ role?: string; content?: string | unknown }>) + : [], + tools: Array.isArray(body?.tools) + ? (body.tools as Array<{ + function?: { name: string; description?: string; parameters?: unknown }; + }>) + : undefined, + model: typeof body?.model === "string" ? body.model : undefined, + }; + const hint = generateRoutingHints(modelTargets, ruleInput); + // Tool-use escalation: floor the recommended tier at "cheap" so scoring + // favors function-calling-reliable models for agentic requests. + const classification = classifyRequestComplexity(ruleInput); + hint.recommendedMinTier = escalateTier( + hint.recommendedMinTier as ComplexityTier, + classification.recommendedTier + ) as typeof hint.recommendedMinTier; + log.info( + "COMBO", + `Complexity-aware routing: level=${classification.level} score=${classification.score} minTier=${hint.recommendedMinTier} tools=${classification.hasToolUse}` + ); + return hint; + } catch { + return null; // fail-open: scoring stays tier-neutral + } +} diff --git a/open-sse/services/autoCombo/scoring.ts b/open-sse/services/autoCombo/scoring.ts index c03ac3926d..5a9d4eed76 100644 --- a/open-sse/services/autoCombo/scoring.ts +++ b/open-sse/services/autoCombo/scoring.ts @@ -5,6 +5,8 @@ */ import type { RoutingHint } from "../manifestAdapter"; +import { clamp01 } from "../../utils/number"; +import { classifyTier } from "../tierResolver"; export interface ScoringFactors { quota: number; @@ -86,19 +88,22 @@ export interface ScoredProvider { * Supports tierAffinity + specificityMatch weights when manifest routing is enabled. */ export function calculateScore(factors: ScoringFactors, weights: ScoringWeights): number { - return ( + // clamp01 bounds the result to [0,1] and maps a non-finite sum (a NaN factor) + // to 0, so a single bad input can't yield NaN (which sorts nondeterministically) + // or a score >1 from float drift in weights that nominally sum to 1. + return clamp01( weights.quota * factors.quota + - weights.health * factors.health + - weights.costInv * factors.costInv + - weights.latencyInv * factors.latencyInv + - weights.taskFit * factors.taskFit + - weights.stability * factors.stability + - weights.tierPriority * factors.tierPriority + - (weights.tierAffinity ?? 0) * factors.tierAffinity + - (weights.specificityMatch ?? 0) * factors.specificityMatch + - (weights.contextAffinity ?? 0) * factors.contextAffinity + - (weights.resetWindowAffinity ?? 0) * factors.resetWindowAffinity + - (weights.connectionDensity ?? 0) * factors.connectionDensity + weights.health * factors.health + + weights.costInv * factors.costInv + + weights.latencyInv * factors.latencyInv + + weights.taskFit * factors.taskFit + + weights.stability * factors.stability + + weights.tierPriority * factors.tierPriority + + (weights.tierAffinity ?? 0) * factors.tierAffinity + + (weights.specificityMatch ?? 0) * factors.specificityMatch + + (weights.contextAffinity ?? 0) * factors.contextAffinity + + (weights.resetWindowAffinity ?? 0) * factors.resetWindowAffinity + + (weights.connectionDensity ?? 0) * factors.connectionDensity ); } @@ -131,7 +136,6 @@ function calculateTierAffinity( ): number { if (!hint) return 0.5; try { - const { classifyTier } = require("../tierResolver"); const assignment = classifyTier(candidate.provider, candidate.model); const tierOrder = ["free", "cheap", "premium"]; const providerTierIdx = tierOrder.indexOf(assignment.tier); @@ -151,7 +155,6 @@ function calculateSpecificityMatch( ): number { if (!hint) return 0.5; try { - const { classifyTier } = require("../tierResolver"); const assignment = classifyTier(candidate.provider, candidate.model); const specificityScore = hint.specificity.score; @@ -176,24 +179,28 @@ export function calculateFactors( const maxLatency = Math.max(...pool.map((p) => p.p95LatencyMs), 1); const maxStdDev = Math.max(...pool.map((p) => p.latencyStdDev), 0.001); + // Every factor is contractually [0,1]. clamp01 guards against bad telemetry + // (negative quota / cost / latency, NaN, out-of-range candidate-supplied + // affinities) so a single bad input can't produce a negative or >1 factor + // that distorts the weighted score. return { - quota: Math.min(1, candidate.quotaRemaining / 100), + quota: clamp01(candidate.quotaRemaining / 100), health: candidate.circuitBreakerState === "CLOSED" ? 1.0 : candidate.circuitBreakerState === "HALF_OPEN" ? 0.5 : 0.0, - costInv: 1 - candidate.costPer1MTokens / maxCost, - latencyInv: 1 - candidate.p95LatencyMs / maxLatency, - taskFit: getTaskFitness(candidate.model, taskType), - stability: 1 - candidate.latencyStdDev / maxStdDev, + costInv: clamp01(1 - candidate.costPer1MTokens / maxCost), + latencyInv: clamp01(1 - candidate.p95LatencyMs / maxLatency), + taskFit: clamp01(getTaskFitness(candidate.model, taskType)), + stability: clamp01(1 - candidate.latencyStdDev / maxStdDev), tierPriority: calculateTierScore(candidate.accountTier, candidate.quotaResetIntervalSecs), tierAffinity: calculateTierAffinity(candidate, manifestHint), specificityMatch: calculateSpecificityMatch(candidate, manifestHint), - contextAffinity: candidate.contextAffinity ?? 0.5, - resetWindowAffinity: candidate.resetWindowAffinity ?? 0.5, - connectionDensity: Math.min(1, Math.max(0, ((candidate.connectionPoolSize ?? 1) - 1) / 10)), + contextAffinity: clamp01(candidate.contextAffinity ?? 0.5), + resetWindowAffinity: clamp01(candidate.resetWindowAffinity ?? 0.5), + connectionDensity: clamp01(((candidate.connectionPoolSize ?? 1) - 1) / 10), }; } diff --git a/open-sse/services/chatgptTlsClient.ts b/open-sse/services/chatgptTlsClient.ts index b5c3fdbf66..5b3276684f 100644 --- a/open-sse/services/chatgptTlsClient.ts +++ b/open-sse/services/chatgptTlsClient.ts @@ -212,23 +212,19 @@ export interface TlsFetchOptions { } import { resolveProxyForRequest } from "../utils/proxyFetch.ts"; +import { resolveTlsClientProxyUrl } from "./tlsClientProxy.ts"; /** * Resolve the proxy URL for a tls-client request. Per-call value wins; * otherwise we use the standard proxy fetch resolution which reads from * the dashboard AsyncLocalStorage context or falls back to env vars. + * + * Fail-closed: if resolution throws (e.g. a configured socks5 proxy with + * ENABLE_SOCKS5_PROXY=false), this rethrows rather than returning undefined — + * undefined would let the native binding connect directly and leak the real IP. */ function resolveProxyUrl(perCall: string | undefined): string | undefined { - if (perCall && perCall.length > 0) return perCall; - try { - const proxyInfo = resolveProxyForRequest("https://chatgpt.com"); - if (proxyInfo && proxyInfo.proxyUrl) { - return proxyInfo.proxyUrl; - } - } catch { - // Ignore resolution errors - } - return undefined; + return resolveTlsClientProxyUrl("https://chatgpt.com", perCall, resolveProxyForRequest); } export interface TlsFetchResult { diff --git a/open-sse/services/claudeCodeConstraints.ts b/open-sse/services/claudeCodeConstraints.ts index 42f5cc07e8..af17a3657c 100644 --- a/open-sse/services/claudeCodeConstraints.ts +++ b/open-sse/services/claudeCodeConstraints.ts @@ -2,16 +2,32 @@ * Claude Code API constraints. * * Enforces Anthropic API requirements that real Claude Code handles: - * 1. temperature=1 when thinking is enabled + * 1. Sampling params under extended thinking: temperature=1 and top_p>=0.95 + * (or unset) when thinking is enabled/adaptive * 2. Disable thinking when tool_choice forces a specific tool * 3. Enforce max 4 cache_control breakpoints * 4. Normalize cache_control TTL ordering */ +/** + * Anthropic's extended-thinking contract rejects non-default sampling params: + * with thinking enabled/adaptive, `temperature` may only be 1 and `top_p` must + * be >= 0.95 (or unset) — otherwise the Messages API returns HTTP 400 + * ("`temperature` may only be set to 1 ..." / "`top_p` must be greater than or + * equal to 0.95 ..."). Clients such as the VS Code Copilot "Ollama" provider + * routinely send other values (e.g. temperature 0.7, top_p 0.9), and thinking + * can be injected by per-model requestDefaults *after* the request is built, so + * normalize here: pin temperature to 1 and drop top_p (Anthropic's "unset" + * branch — which also preserves the "never send both temperature and top_p" + * invariant). + */ export function enforceThinkingTemperature(body: Record): void { const thinking = body.thinking as Record | undefined; if (thinking?.type === "enabled" || thinking?.type === "adaptive") { body.temperature = 1; + if (body.top_p !== undefined) { + delete body.top_p; + } } } diff --git a/open-sse/services/claudeTlsClient.ts b/open-sse/services/claudeTlsClient.ts index c061ec4c2d..8674b9f98b 100644 --- a/open-sse/services/claudeTlsClient.ts +++ b/open-sse/services/claudeTlsClient.ts @@ -212,23 +212,19 @@ export interface TlsFetchOptions { } import { resolveProxyForRequest } from "../utils/proxyFetch.ts"; +import { resolveTlsClientProxyUrl } from "./tlsClientProxy.ts"; /** * Resolve the proxy URL for a tls-client request. Per-call value wins; * otherwise we use the standard proxy fetch resolution which reads from * the dashboard AsyncLocalStorage context or falls back to env vars. + * + * Fail-closed: if resolution throws (e.g. a configured socks5 proxy with + * ENABLE_SOCKS5_PROXY=false), this rethrows rather than returning undefined — + * undefined would let the native binding connect directly and leak the real IP. */ function resolveProxyUrl(perCall: string | undefined): string | undefined { - if (perCall && perCall.length > 0) return perCall; - try { - const proxyInfo = resolveProxyForRequest("https://claude.ai"); - if (proxyInfo && proxyInfo.proxyUrl) { - return proxyInfo.proxyUrl; - } - } catch { - // Ignore resolution errors - } - return undefined; + return resolveTlsClientProxyUrl("https://claude.ai", perCall, resolveProxyForRequest); } export interface TlsFetchResult { diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts index 492abe2886..1dc770cd21 100644 --- a/open-sse/services/combo.ts +++ b/open-sse/services/combo.ts @@ -83,6 +83,7 @@ import { getSessionConnection } from "./sessionManager.ts"; import { orderTargetsByEvalScores } from "./evalRouting.ts"; import { generateRoutingHints } from "./manifestAdapter"; import type { RoutingHint } from "./manifestAdapter"; +import { buildComplexityRoutingHint } from "./autoCombo/complexityRouter"; import type { CompressionMode } from "./compression/types.ts"; import { getModelContextLimit } from "../../src/lib/modelCapabilities"; import { getProviderConnections } from "../../src/lib/db/providers"; @@ -143,9 +144,46 @@ function isProviderCircuitOpenResult( } const MAX_COMBO_DEPTH = 3; +// Absolute safety ceiling for operator-configured nesting depth. config.maxComboDepth +// can raise the default (3) up to this cap, or lower it, but never above — runaway +// nested-combo expansion is a real DoS/perf risk. +const MAX_COMBO_DEPTH_HARD_CAP = 10; const MAX_FALLBACK_WAIT_MS = 5000; const MAX_GLOBAL_ATTEMPTS = 30; +/** + * Clamp an operator-configured combo nesting depth (config.maxComboDepth) to a + * safe integer in [1, MAX_COMBO_DEPTH_HARD_CAP]. Anything non-numeric, < 1, or + * NaN falls back to the default MAX_COMBO_DEPTH so a bad config never disables + * nesting or blows past the safety ceiling. + */ +export function clampComboDepth(value: unknown): number { + const n = Math.floor(Number(value)); + if (!Number.isFinite(n) || n < 1) return MAX_COMBO_DEPTH; + return Math.min(n, MAX_COMBO_DEPTH_HARD_CAP); +} + +/** Minimum recorded requests before the predictive-TTFT breaker trusts the average. */ +const PREDICTIVE_TTFT_MIN_SAMPLES = 5; + +/** + * Predictive-TTFT circuit-breaker decision: skip a target whose recent average + * latency — measured over a statistically meaningful sample — exceeds the + * configured ceiling, so the combo fails over before paying a slow first byte. + * Returns false when disabled (ceiling <= 0), when there is no metric, or when + * the sample is too small to trust. + */ +export function shouldSkipForPredictedTtft( + metric: { requests?: number; avgLatencyMs?: number } | null | undefined, + predictiveTtftMs: number +): boolean { + if (!metric || !(predictiveTtftMs > 0)) return false; + return ( + (metric.requests ?? 0) >= PREDICTIVE_TTFT_MIN_SAMPLES && + (metric.avgLatencyMs ?? 0) > predictiveTtftMs + ); +} + function resolveDelayMs(value: unknown, fallback: number): number { const numericValue = Number(value); if (!Number.isFinite(numericValue) || numericValue < 0) return fallback; @@ -1128,19 +1166,24 @@ function expandRuntimeStep( allCombos: ComboCollectionLike, visited = new Set(), depth = 0, - path: string[] = [] + path: string[] = [], + maxDepth: number = MAX_COMBO_DEPTH ): ResolvedComboTarget[] { if (step.kind === "model") return [step]; - if (depth > MAX_COMBO_DEPTH) return []; + if (depth > maxDepth) return []; const combos = getCombosArray(allCombos); const nestedCombo = combos.find((combo) => combo.name === step.comboName); if (!nestedCombo || visited.has(step.comboName)) return []; - return resolveNestedComboTargets(nestedCombo, combos, new Set(visited), depth + 1, [ - ...path, - step.stepId, - ]); + return resolveNestedComboTargets( + nestedCombo, + combos, + new Set(visited), + depth + 1, + [...path, step.stepId], + maxDepth + ); } export function resolveNestedComboTargets( @@ -1148,13 +1191,14 @@ export function resolveNestedComboTargets( allCombos: ComboCollectionLike, visited = new Set(), depth = 0, - path: string[] = [] + path: string[] = [], + maxDepth: number = MAX_COMBO_DEPTH ): ResolvedComboTarget[] { const directTargets = (combo.models || []) .map((entry, index) => normalizeRuntimeStep(entry, combo.name, index, null, path)) .filter((entry): entry is ResolvedComboTarget => entry?.kind === "model"); - if (depth > MAX_COMBO_DEPTH) return directTargets; + if (depth > maxDepth) return directTargets; if (visited.has(combo.name)) return []; visited.add(combo.name); @@ -1163,7 +1207,9 @@ export function resolveNestedComboTargets( for (const step of runtimeSteps) { if (step.kind === "combo-ref") { - resolved.push(...expandRuntimeStep(step, allCombos, new Set(visited), depth, path)); + resolved.push( + ...expandRuntimeStep(step, allCombos, new Set(visited), depth, path, maxDepth) + ); continue; } resolved.push(step); @@ -1214,10 +1260,11 @@ export function validateComboDAG( comboName: string, allCombos: ComboCollectionLike, visited = new Set(), - depth = 0 + depth = 0, + maxDepth: number = MAX_COMBO_DEPTH ): void { - if (depth > MAX_COMBO_DEPTH) { - throw new Error(`Max combo nesting depth (${MAX_COMBO_DEPTH}) exceeded at "${comboName}"`); + if (depth > maxDepth) { + throw new Error(`Max combo nesting depth (${maxDepth}) exceeded at "${comboName}"`); } if (visited.has(comboName)) { throw new Error(`Circular combo reference detected: ${comboName}`); @@ -1233,7 +1280,7 @@ export function validateComboDAG( // Check if this model name is itself a combo (not a provider/model pattern) const nestedCombo = combos.find((c) => c.name === modelName); if (nestedCombo) { - validateComboDAG(modelName, combos, new Set(visited), depth + 1); + validateComboDAG(modelName, combos, new Set(visited), depth + 1, maxDepth); } } } @@ -1251,9 +1298,10 @@ export function resolveNestedComboModels( combo: ComboLike, allCombos: ComboCollectionLike, visited = new Set(), - depth = 0 + depth = 0, + maxDepth: number = MAX_COMBO_DEPTH ): string[] { - if (depth > MAX_COMBO_DEPTH) return combo.models.map((m) => normalizeModelEntry(m).model); + if (depth > maxDepth) return combo.models.map((m) => normalizeModelEntry(m).model); if (visited.has(combo.name)) return []; // cycle safety visited.add(combo.name); @@ -1266,7 +1314,13 @@ export function resolveNestedComboModels( if (nestedCombo) { // Recursively expand the nested combo - const nested = resolveNestedComboModels(nestedCombo, combos, new Set(visited), depth + 1); + const nested = resolveNestedComboModels( + nestedCombo, + combos, + new Set(visited), + depth + 1, + maxDepth + ); resolved.push(...nested); } else { resolved.push(modelName); @@ -2809,9 +2863,12 @@ async function applyRequestTagRouting( export function resolveComboTargets( combo: ComboLike, - allCombos: ComboCollectionLike + allCombos: ComboCollectionLike, + maxDepth: number = MAX_COMBO_DEPTH ): ResolvedComboTarget[] { - return allCombos ? resolveNestedComboTargets(combo, allCombos) : getDirectComboTargets(combo); + return allCombos + ? resolveNestedComboTargets(combo, allCombos, new Set(), 0, [], maxDepth) + : getDirectComboTargets(combo); } function resolveWeightedTargets( @@ -2850,11 +2907,12 @@ function resolveWeightedTargets( }; } -function scoreAutoTargets( +export function scoreAutoTargets( targets: ResolvedComboTarget[], candidates: AutoProviderCandidate[], taskType: string | null, - weights: ScoringWeights + weights: ScoringWeights, + manifestHint?: RoutingHint | null ) { const candidateByExecutionKey = new Map( candidates.map((candidate: ProviderCandidate & { executionKey: string }) => [ @@ -2870,7 +2928,8 @@ function scoreAutoTargets( candidate as ProviderCandidate, candidates, taskType ?? "general", - getTaskFitness + getTaskFitness, + manifestHint ?? undefined ); let score = calculateScore(factors, weights); // B17: Quota Share soft-policy deprioritization @@ -3129,7 +3188,7 @@ export async function handleComboChat({ let orderedTargets = strategy === "weighted" ? resolveWeightedTargets(combo, allCombos)?.orderedTargets || [] - : resolveComboTargets(combo, allCombos); + : resolveComboTargets(combo, allCombos, clampComboDepth(config.maxComboDepth)); orderedTargets = await applyRequestTagRouting(orderedTargets, body, log); @@ -3345,7 +3404,25 @@ export async function handleComboChat({ selectionReason = `score=${selection.score.toFixed(3)}${selection.isExploration ? " (exploration)" : ""}`; } - const scoredTargets = scoreAutoTargets(eligibleTargets, candidates, taskType, weights); + // Complexity-aware routing (2026, opt-in): classify the request's + // difficulty and feed a tier hint into scoring so tierAffinity / + // specificityMatch favor candidates whose tier matches the request. + const autoManifestHint: RoutingHint | null = + config.complexityAwareRouting === true + ? buildComplexityRoutingHint( + eligibleTargets.filter((t) => t.kind === "model"), + body, + log + ) + : null; + + const scoredTargets = scoreAutoTargets( + eligibleTargets, + candidates, + taskType, + weights, + autoManifestHint + ); const rankedTargets = scoredTargets.map((entry) => entry.target); const selectedTarget = scoredTargets.find((entry) => { @@ -3724,7 +3801,7 @@ export async function handleComboChat({ if (cMetrics) { const targetKey = orderedTargets[i].executionKey || modelStr; const m = cMetrics.byTarget[targetKey] || cMetrics.byModel[modelStr]; - if (m && m.requests >= 5 && m.avgLatencyMs > config.predictiveTtftMs) { + if (shouldSkipForPredictedTtft(m, config.predictiveTtftMs)) { log.warn( "COMBO", `Predictive TTFT Circuit Breaker: skipping ${modelStr} (avg ${m.avgLatencyMs}ms > max ${config.predictiveTtftMs}ms)` @@ -4531,7 +4608,7 @@ async function handleRoundRobinCombo({ ? resolveResilienceSettings(settings) : resolveResilienceSettings(null); - const orderedTargets = resolveComboTargets(combo, allCombos); + const orderedTargets = resolveComboTargets(combo, allCombos, clampComboDepth(config.maxComboDepth)); const tagFilteredTargets = await applyRequestTagRouting(orderedTargets, body, log); const evalRankedTargets = orderTargetsByEvalScores(tagFilteredTargets, config.evalRouting, log); const filteredTargets = filterTargetsByRequestCompatibility( diff --git a/open-sse/services/comboConfig.ts b/open-sse/services/comboConfig.ts index 5afe81f6aa..990dc11853 100644 --- a/open-sse/services/comboConfig.ts +++ b/open-sse/services/comboConfig.ts @@ -28,6 +28,10 @@ const DEFAULT_COMBO_CONFIG = { trackMetrics: true, reasoningTokenBufferEnabled: true, manifestRouting: false, + // Complexity-aware auto routing (2026): when on, the auto router scores + // candidates by how well their tier matches the request's classified + // difficulty (feeds tierAffinity/specificityMatch). Opt-in — off by default. + complexityAwareRouting: false, resetAwareSessionWeight: 0.35, resetAwareWeeklyWeight: 0.65, resetAwareTieBandPercent: 5, diff --git a/open-sse/services/comboMetrics.ts b/open-sse/services/comboMetrics.ts index 6c6e8e71da..698d567975 100644 --- a/open-sse/services/comboMetrics.ts +++ b/open-sse/services/comboMetrics.ts @@ -4,6 +4,8 @@ * Provides API for reading metrics from the dashboard. */ +import { recordProviderUsage } from "./autoCombo/providerDiversity"; + interface ModelMetrics { requests: number; successes: number; @@ -270,6 +272,12 @@ export function recordComboRequest( if (success) { combo.totalSuccesses++; + // Feed the provider-diversity report (/api/analytics/diversity): record the + // provider that actually served this request. recordComboRequest is the + // single chokepoint every combo strategy funnels through, so one call here + // covers priority / round-robin / weighted / auto / etc. + const usedProvider = toNonEmptyString(target?.provider); + if (usedProvider) recordProviderUsage(usedProvider); } else { combo.totalFailures++; } diff --git a/open-sse/services/emergencyFallback.ts b/open-sse/services/emergencyFallback.ts index 3819848291..60e3914172 100644 --- a/open-sse/services/emergencyFallback.ts +++ b/open-sse/services/emergencyFallback.ts @@ -12,6 +12,16 @@ * `OMNIROUTE_EMERGENCY_FALLBACK=false` (or `0`). Default remains enabled. */ +import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags"; + +const EMERGENCY_FALLBACK_FLAG_KEY = "OMNIROUTE_EMERGENCY_FALLBACK"; +const EMERGENCY_FALLBACK_FLAG_CACHE_MS = 500; + +type FeatureFlagResolver = (key: string) => boolean; + +let emergencyFallbackFlagCache: { value: boolean; expiresAt: number } | null = null; +let emergencyFallbackFeatureFlagResolver: FeatureFlagResolver = isFeatureFlagEnabled; + export interface EmergencyFallbackConfig { enabled: boolean; provider: string; @@ -66,11 +76,46 @@ export interface NoFallbackDecision { export type FallbackResult = FallbackDecision | NoFallbackDecision; -export function isEmergencyFallbackEnvEnabled(): boolean { +function isEmergencyFallbackRawEnvEnabled(): boolean { const raw = process.env.OMNIROUTE_EMERGENCY_FALLBACK; return raw !== "false" && raw !== "0"; } +export function resetEmergencyFallbackEnvCache(): void { + emergencyFallbackFlagCache = null; +} + +export function setEmergencyFallbackFeatureFlagResolverForTest( + resolver: FeatureFlagResolver | null +): void { + emergencyFallbackFeatureFlagResolver = resolver ?? isFeatureFlagEnabled; + resetEmergencyFallbackEnvCache(); +} + +export function isEmergencyFallbackEnvEnabled(): boolean { + const now = Date.now(); + if (emergencyFallbackFlagCache && emergencyFallbackFlagCache.expiresAt > now) { + return emergencyFallbackFlagCache.value; + } + + let value: boolean; + try { + value = emergencyFallbackFeatureFlagResolver(EMERGENCY_FALLBACK_FLAG_KEY); + } catch (error) { + console.warn( + "[emergencyFallback] Feature flag resolution failed; falling back to raw env:", + error instanceof Error ? error.message : error + ); + value = isEmergencyFallbackRawEnvEnabled(); + } + + emergencyFallbackFlagCache = { + value, + expiresAt: now + EMERGENCY_FALLBACK_FLAG_CACHE_MS, + }; + return value; +} + export function shouldUseFallback( status: number, errorBody: string, diff --git a/open-sse/services/grokTlsClient.ts b/open-sse/services/grokTlsClient.ts index d0e51f602d..198ed9e479 100644 --- a/open-sse/services/grokTlsClient.ts +++ b/open-sse/services/grokTlsClient.ts @@ -205,23 +205,19 @@ export interface TlsFetchOptions { } import { resolveProxyForRequest } from "../utils/proxyFetch.ts"; +import { resolveTlsClientProxyUrl } from "./tlsClientProxy.ts"; /** * Resolve the proxy URL for a tls-client request. Per-call value wins; * otherwise we use the standard proxy fetch resolution which reads from * the dashboard AsyncLocalStorage context or falls back to env vars. + * + * Fail-closed: if resolution throws (e.g. a configured socks5 proxy with + * ENABLE_SOCKS5_PROXY=false), this rethrows rather than returning undefined — + * undefined would let the native binding connect directly and leak the real IP. */ function resolveProxyUrl(perCall: string | undefined): string | undefined { - if (perCall && perCall.length > 0) return perCall; - try { - const proxyInfo = resolveProxyForRequest("https://grok.com"); - if (proxyInfo && proxyInfo.proxyUrl) { - return proxyInfo.proxyUrl; - } - } catch { - // Ignore resolution errors - } - return undefined; + return resolveTlsClientProxyUrl("https://grok.com", perCall, resolveProxyForRequest); } export interface TlsFetchResult { diff --git a/open-sse/services/perplexityTlsClient.ts b/open-sse/services/perplexityTlsClient.ts index debcd3e682..bdad1f1e87 100644 --- a/open-sse/services/perplexityTlsClient.ts +++ b/open-sse/services/perplexityTlsClient.ts @@ -203,23 +203,19 @@ export interface TlsFetchOptions { } import { resolveProxyForRequest } from "../utils/proxyFetch.ts"; +import { resolveTlsClientProxyUrl } from "./tlsClientProxy.ts"; /** * Resolve the proxy URL for a tls-client request. Per-call value wins; * otherwise we use the standard proxy fetch resolution which reads from * the dashboard AsyncLocalStorage context or falls back to env vars. + * + * Fail-closed: if resolution throws (e.g. a configured socks5 proxy with + * ENABLE_SOCKS5_PROXY=false), this rethrows rather than returning undefined — + * undefined would let the native binding connect directly and leak the real IP. */ function resolveProxyUrl(perCall: string | undefined): string | undefined { - if (perCall && perCall.length > 0) return perCall; - try { - const proxyInfo = resolveProxyForRequest("https://www.perplexity.ai"); - if (proxyInfo && proxyInfo.proxyUrl) { - return proxyInfo.proxyUrl; - } - } catch { - // Ignore resolution errors - } - return undefined; + return resolveTlsClientProxyUrl("https://www.perplexity.ai", perCall, resolveProxyForRequest); } export interface TlsFetchResult { diff --git a/open-sse/services/tlsClientProxy.ts b/open-sse/services/tlsClientProxy.ts new file mode 100644 index 0000000000..759df32909 --- /dev/null +++ b/open-sse/services/tlsClientProxy.ts @@ -0,0 +1,30 @@ +type ResolveProxyForRequest = ( + targetUrl: string +) => { source: string; proxyUrl: string | null } | null; + +/** + * Fail-closed proxy resolution for the impersonation TLS clients. + * - per-call override wins. + * - resolution returning a proxy → use it. + * - resolution returning direct/null → undefined (direct is legitimate: no proxy set). + * - resolution THROWING → rethrow. A configured-but-unusable proxy (e.g. socks5 with + * ENABLE_SOCKS5_PROXY=false) MUST NOT silently leak the real IP via a direct connection. + */ +export function resolveTlsClientProxyUrl( + targetUrl: string, + perCall: string | undefined, + resolveProxyForRequest: ResolveProxyForRequest +): string | undefined { + if (perCall && perCall.length > 0) return perCall; + let info: { source: string; proxyUrl: string | null } | null; + try { + info = resolveProxyForRequest(targetUrl); + } catch (err) { + throw new Error( + `[TlsClient] Proxy resolution failed for ${targetUrl}; refusing direct connection (fail-closed): ${ + err instanceof Error ? err.message : String(err) + }` + ); + } + return info && info.proxyUrl ? info.proxyUrl : undefined; +} diff --git a/open-sse/transformer/responsesTransformer.ts b/open-sse/transformer/responsesTransformer.ts index 43a0f19091..4bc554df3e 100644 --- a/open-sse/transformer/responsesTransformer.ts +++ b/open-sse/transformer/responsesTransformer.ts @@ -1,3 +1,4 @@ +import { appendToolCallArgumentDelta } from "../utils/toolCallArguments.ts"; import * as fs from "fs"; import * as path from "path"; /** @@ -527,15 +528,19 @@ export function createResponsesApiTransformStream(logger = null, keepaliveInterv .replace(/"[a-zA-Z0-9_]+":\s*\[\s*\],?/g, ""); } - if (refCallId) { + const existingArgs = state.funcArgsBuf[tcIdx] || ""; + const nextArgs = appendToolCallArgumentDelta(existingArgs, deltaStr); + const emittedDelta = nextArgs.slice(existingArgs.length); + state.funcArgsBuf[tcIdx] = nextArgs; + + if (refCallId && emittedDelta) { emit(controller, "response.function_call_arguments.delta", { type: "response.function_call_arguments.delta", item_id: `fc_${refCallId}`, output_index: tcIdx, - delta: deltaStr, + delta: emittedDelta, }); } - state.funcArgsBuf[tcIdx] += deltaStr; } } } diff --git a/open-sse/translator/response/openai-responses.ts b/open-sse/translator/response/openai-responses.ts index 0b50f80c0e..d0c6731d18 100644 --- a/open-sse/translator/response/openai-responses.ts +++ b/open-sse/translator/response/openai-responses.ts @@ -4,6 +4,7 @@ */ import { register } from "../registry.ts"; import { FORMATS } from "../formats.ts"; +import { appendToolCallArgumentDelta } from "../../utils/toolCallArguments.ts"; function normalizeToolName(value) { return typeof value === "string" ? value.trim() : ""; @@ -348,15 +349,19 @@ function emitToolCall(state, emit, tc) { if (tc.function?.arguments) { const refCallId = state.funcCallIds[tcIdx] || newCallId; - if (refCallId) { + const existingArgs = state.funcArgsBuf[tcIdx] || ""; + const nextArgs = appendToolCallArgumentDelta(existingArgs, tc.function.arguments); + const emittedDelta = nextArgs.slice(existingArgs.length); + state.funcArgsBuf[tcIdx] = nextArgs; + + if (refCallId && emittedDelta) { emit("response.function_call_arguments.delta", { type: "response.function_call_arguments.delta", item_id: `fc_${refCallId}`, output_index: tcIdx, - delta: tc.function.arguments, + delta: emittedDelta, }); } - state.funcArgsBuf[tcIdx] += tc.function.arguments; } } diff --git a/open-sse/translator/response/openai-to-claude.ts b/open-sse/translator/response/openai-to-claude.ts index 2dfa246d2d..c39020e0b5 100644 --- a/open-sse/translator/response/openai-to-claude.ts +++ b/open-sse/translator/response/openai-to-claude.ts @@ -2,6 +2,7 @@ import { register } from "../registry.ts"; import { FORMATS } from "../formats.ts"; import { CLAUDE_OAUTH_TOOL_PREFIX } from "../request/openai-to-claude.ts"; import { hasToolCallShim, applyToolCallShimToBuffer } from "../helpers/toolCallShim.ts"; +import { appendToolCallArgumentDelta } from "../../utils/toolCallArguments.ts"; // Helper: stop thinking block if started function stopThinkingBlock(state, results) { @@ -192,15 +193,16 @@ export function openaiToClaudeResponse(chunk, state) { if (toolInfo) { // Always buffer the raw stream so shimmed tools can re-emit a // corrected JSON at stop time. - toolInfo.argBuffer = (toolInfo.argBuffer || "") + tc.function.arguments; + const existingArgs = toolInfo.argBuffer || ""; + const nextArgs = appendToolCallArgumentDelta(existingArgs, tc.function.arguments); + let deltaStr = nextArgs.slice(existingArgs.length); + toolInfo.argBuffer = nextArgs; - if (toolInfo.shimmed) { - // Suppress passthrough; we emit one corrective delta at finish. + if (toolInfo.shimmed || !deltaStr) { + // Suppress passthrough for shimmed tools; emit one corrective delta at finish. continue; } - let deltaStr = tc.function.arguments; - // Fix #1852: Strip empty string and array placeholders from streaming tool arguments if (deltaStr.includes('""') || deltaStr.includes("[]") || deltaStr.includes("[ ]")) { deltaStr = deltaStr diff --git a/open-sse/utils/cursorAgentProtobuf.ts b/open-sse/utils/cursorAgentProtobuf.ts index 5e48afebe2..24cee2332b 100644 --- a/open-sse/utils/cursorAgentProtobuf.ts +++ b/open-sse/utils/cursorAgentProtobuf.ts @@ -25,6 +25,7 @@ const ACM_RUN_REQUEST = 1; // AgentClientMessage.run_request const ARR_CONVERSATION_STATE = 1; // AgentRunRequest.conversation_state const ARR_ACTION = 2; // AgentRunRequest.action +const ARR_MODEL_DETAILS = 3; // AgentRunRequest.model_details (ModelDetails, msg 88) const ARR_CONVERSATION_ID = 5; // AgentRunRequest.conversation_id const ARR_MCP_TOOLS = 4; // AgentRunRequest.mcp_tools (empty placeholder required) const ARR_REQUESTED_MODEL = 9; // AgentRunRequest.requested_model @@ -65,6 +66,15 @@ const DIM_HEIGHT = 2; // SelectedImage.Dimension.height (int32) const RM_MODEL_ID = 1; // RequestedModel.model_id const RM_PARAMETERS = 3; // RequestedModel.parameters [repeated] +// ModelDetails (msg 88) — the model envelope cursor-agent actually uses to resolve +// pinned model variants. Field numbers pinned from the cursor-agent descriptor (and +// CLIProxyAPIPlus's cursor proto). #3714: pinned Claude/GPT *thinking* variants returned +// an empty turn when sent only via RequestedModel (field 9) with a bare model_id; the +// working reference sends them as ModelDetails with all three string fields set. +const MD_MODEL_ID = 1; // ModelDetails.model_id +const MD_DISPLAY_MODEL_ID = 3; // ModelDetails.display_model_id +const MD_DISPLAY_NAME = 4; // ModelDetails.display_name + const RMP_ID = 1; // RequestedModel.ModelParameter.id const RMP_VALUE = 2; // RequestedModel.ModelParameter.value @@ -581,6 +591,17 @@ export function encodeAgentRunRequest(input: AgentRunInput): Buffer { } const requestedModel = encodeMessage(ARR_REQUESTED_MODEL, rmParts); + // ModelDetails { model_id, display_model_id, display_name } — all set to the resolved + // model id. #3714: RequestedModel (field 9) alone resolves server-routed ids + // (auto → default, composer-*) but pinned Claude/GPT *thinking* variants returned an + // empty turn without this envelope. cursor-agent's working wire format sends both, so + // we keep RequestedModel (preserves the -fast `parameters` it carries) and add this. + const modelDetails = encodeMessage(ARR_MODEL_DETAILS, [ + encodeString(MD_MODEL_ID, modelId), + encodeString(MD_DISPLAY_MODEL_ID, modelId), + encodeString(MD_DISPLAY_NAME, modelId), + ]); + // mcp_tools: McpTools envelope at field 4 of AgentRunRequest. Each tool // is packed inside the envelope at field 1 (repeated McpToolDefinition). // Empty placeholder for non-tool calls (the field is observably required @@ -596,6 +617,7 @@ export function encodeAgentRunRequest(input: AgentRunInput): Buffer { const agentRunRequest = [ conversationState, action, + modelDetails, mcpToolsBlock, encodeString(ARR_CONVERSATION_ID, conversationId), requestedModel, diff --git a/open-sse/utils/proxyDispatcher.ts b/open-sse/utils/proxyDispatcher.ts index 4b49eaf770..8f3c972f6b 100644 --- a/open-sse/utils/proxyDispatcher.ts +++ b/open-sse/utils/proxyDispatcher.ts @@ -2,6 +2,8 @@ import "./setupPolyfill.ts"; import { Agent, ProxyAgent, type Dispatcher } from "undici"; import { socksDispatcher } from "fetch-socks"; import { getUpstreamTimeoutConfig } from "@/shared/utils/runtimeTimeouts"; +import { stripIpv6Brackets, detectIpLiteralFamily, parseProxyFamily } from "./proxyFamily.ts"; +import { createSocksDispatcherWithFamily } from "./socksConnectorWithFamily.ts"; const DISPATCHER_CACHE_KEY = Symbol.for("omniroute.proxyDispatcher.cache"); const DEFAULT_DISPATCHER_KEY = Symbol.for("omniroute.proxyDispatcher.default"); @@ -25,6 +27,7 @@ type ProxyConfigObject = { port?: string | number | null; username?: string; password?: string; + family?: string; }; function getDispatcherCache(): DispatcherCache { @@ -161,14 +164,24 @@ export function normalizeProxyUrl( source = "proxy", { allowSocks5 = isSocks5ProxyEnabled() } = {} ): string { + // Strip a trailing synthetic `?family=ipv4|ipv6` marker BEFORE anything else. + // `extractExplicitPort` slices the authority off the raw string, so a marker + // turns the port substring into e.g. `80?family=ipv6`, which fails the digit + // test and silently falls back to the default port (8080 for http) — rewriting + // an http:80 proxy to :8080. We work on the marker-free string for both port + // extraction and URL parsing, then re-append the marker exactly once below. + const familyMatch = proxyUrl.match(/\?family=(ipv4|ipv6)$/); + const familySuffix = familyMatch ? familyMatch[0] : ""; + const baseUrl = familySuffix ? proxyUrl.slice(0, -familySuffix.length) : proxyUrl; + // Extract the explicit port from the raw URL string BEFORE parsing, // because `new URL()` silently strips default ports (80 for http, // 443 for https), which are valid and common for proxy servers. - const explicitPort = extractExplicitPort(proxyUrl); + const explicitPort = extractExplicitPort(baseUrl); let parsed; try { - parsed = new URL(proxyUrl); + parsed = new URL(baseUrl); } catch { throw new Error(`[ProxyDispatcher] Invalid ${source} URL`); } @@ -192,7 +205,15 @@ export function normalizeProxyUrl( // Build the URL string manually instead of using parsed.toString(), // which would strip default ports (80/443) and break the proxy connection. - return buildProxyUrlString(parsed, port); + // Preserve a synthetic `?family=` directive (the only query param we emit) + // so the connect-family pin survives normalization and reaches the dispatcher. + // The directive may arrive either as the stripped trailing marker (familySuffix) + // or as an inline query on `baseUrl`; resolve both, append the marker once. + const fam = parseProxyFamily( + (familyMatch ? familyMatch[1] : parsed.searchParams.get("family")) ?? undefined + ); + const base = buildProxyUrlString(parsed, port); + return fam === "auto" ? base : `${base}?family=${fam}`; } export function buildVercelRelayHeaders( @@ -253,7 +274,28 @@ export function proxyConfigToUrl( const proxyUrlStr = `${type}://${auth}${config.host}:${port}`; - return normalizeProxyUrl(proxyUrlStr, "context proxy", { allowSocks5 }); + const fam = parseProxyFamily(config.family); + const normalized = normalizeProxyUrl(proxyUrlStr, "context proxy", { allowSocks5 }); + return fam === "auto" ? normalized : `${normalized}?family=${fam}`; +} + +/** Resolve the concrete connect family for a proxy URL, fail-closed on contradictions. */ +function resolveDispatcherFamily(parsed: URL): 4 | 6 | null { + const directive = parseProxyFamily(parsed.searchParams.get("family") ?? undefined); + const literal = detectIpLiteralFamily(parsed.hostname); + if (directive === "auto") return literal; + const want = directive === "ipv6" ? 6 : 4; + if (literal !== null && literal !== want) { + throw new Error( + `[ProxyDispatcher] Proxy family directive ${directive} contradicts ${literal === 6 ? "IPv6" : "IPv4"} literal host` + ); + } + return want; +} + +/** Test-only accessor for the resolved family. */ +export function __resolveDispatcherFamilyForTest(proxyUrl: string): 4 | 6 | null { + return resolveDispatcherFamily(new URL(proxyUrl)); } export function createProxyDispatcher(proxyUrl: string): Dispatcher { @@ -265,28 +307,64 @@ export function createProxyDispatcher(proxyUrl: string): Dispatcher { if (dispatcher) return dispatcher; const parsed = new URL(normalizedUrl); - const explicitPort = extractExplicitPort(normalizedUrl); + const family = resolveDispatcherFamily(parsed); + parsed.searchParams.delete("family"); + const cleanUri = normalizedUrl.replace(/\?family=(ipv4|ipv6)$/, ""); + const explicitPort = extractExplicitPort(cleanUri); const port = explicitPort || normalizePort(parsed.port, parsed.protocol); if (parsed.protocol === "socks5:") { const socksOptions: SocksDispatcherOptions = { type: 5, - host: parsed.hostname, + host: stripIpv6Brackets(parsed.hostname), port: Number(port), }; if (parsed.username) socksOptions.userId = decodeURIComponent(parsed.username); if (parsed.password) socksOptions.password = decodeURIComponent(parsed.password); - dispatcher = socksDispatcher( - socksOptions as Parameters[0], - proxyDispatcherOptions - ) as Dispatcher; + dispatcher = + family === null + ? (socksDispatcher( + socksOptions as Parameters[0], + proxyDispatcherOptions + ) as Dispatcher) + : createSocksDispatcherWithFamily( + socksOptions as unknown as Parameters[0], + family, + proxyDispatcherOptions + ); } else { + // ProxyAgent omits `connect`; the client->proxy socket is built from `proxyTls`. + // undici 8.4.1 types `proxyTls?: buildConnector.BuildOptions`, a union whose + // `TcpNetConnectOpts` member nominally requires `port` — so TS rejects a bare + // `{ family, autoSelectFamily }` pin. At runtime undici merges these options into + // net.connect (the uri already carries the host:port), so the partial pin is + // valid; the cast suppresses the spurious missing-`port` error. dispatcher = new ProxyAgent({ - uri: normalizedUrl, + uri: cleanUri, ...proxyDispatcherOptions, + ...(family !== null + ? { proxyTls: { family, autoSelectFamily: false } as ProxyAgent.Options["proxyTls"] } + : {}), }); } dispatcherCache.set(normalizedUrl, dispatcher); return dispatcher; } + +/** Test-only: returns the SOCKS dispatcher options that would be built for a URL. */ +export function __getSocksOptionsForTest(proxyUrl: string): SocksDispatcherOptions { + const normalizedUrl = normalizeProxyUrl(proxyUrl, "proxy dispatcher"); + const parsed = new URL(normalizedUrl); + parsed.searchParams.delete("family"); + const explicitPort = extractExplicitPort(normalizedUrl); + const port = explicitPort || normalizePort(parsed.port, parsed.protocol); + const socksOptions: SocksDispatcherOptions = { + type: 5, + host: stripIpv6Brackets(parsed.hostname), + port: Number(port), + }; + if (parsed.username) socksOptions.userId = decodeURIComponent(parsed.username); + if (parsed.password) socksOptions.password = decodeURIComponent(parsed.password); + return socksOptions; +} diff --git a/open-sse/utils/proxyFamily.ts b/open-sse/utils/proxyFamily.ts new file mode 100644 index 0000000000..f6694f73c8 --- /dev/null +++ b/open-sse/utils/proxyFamily.ts @@ -0,0 +1,24 @@ +import { isIP } from "node:net"; + +export type ProxyFamily = "auto" | "ipv4" | "ipv6"; + +/** Remove the surrounding brackets from an IPv6 literal host (`[::1]` -> `::1`). */ +export function stripIpv6Brackets(host: string): string { + if (typeof host !== "string") return ""; + if (host.startsWith("[") && host.endsWith("]")) { + return host.slice(1, -1); + } + return host; +} + +/** 4 / 6 if the host is an IP literal (brackets tolerated), null if it is a hostname. */ +export function detectIpLiteralFamily(host: string): 4 | 6 | null { + const bare = stripIpv6Brackets(host); + const v = isIP(bare); + return v === 0 ? null : (v as 4 | 6); +} + +/** Normalize a stored family directive; anything unknown means "auto". */ +export function parseProxyFamily(value: unknown): ProxyFamily { + return value === "ipv4" || value === "ipv6" ? value : "auto"; +} diff --git a/open-sse/utils/proxyFamilyResolve.ts b/open-sse/utils/proxyFamilyResolve.ts new file mode 100644 index 0000000000..2b18e0849d --- /dev/null +++ b/open-sse/utils/proxyFamilyResolve.ts @@ -0,0 +1,39 @@ +import dns from "node:dns/promises"; +import { detectIpLiteralFamily, stripIpv6Brackets } from "./proxyFamily.ts"; + +export type FamilyLookupFn = ( + hostname: string +) => Promise>; + +const defaultLookup: FamilyLookupFn = (hostname) => dns.lookup(hostname, { all: true }); + +/** + * Fail-closed guarantee for an IPv6-only (or IPv4-only) proxy given as a hostname: + * refuse early if the hostname has no record in the required family. No-op for IP + * literals (their family is intrinsic). + */ +export async function assertHostnameSupportsFamily( + host: string, + family: 4 | 6, + lookupFn: FamilyLookupFn = defaultLookup +): Promise { + if (detectIpLiteralFamily(host) !== null) return; + let records: Array<{ address: string; family: number }>; + try { + records = await lookupFn(stripIpv6Brackets(host)); + } catch (err) { + throw new Error( + `[ProxyFamily] DNS resolution failed for ${host}; refusing to egress (fail-closed): ${ + err instanceof Error ? err.message : String(err) + }` + ); + } + const hasFamily = records.some((r) => r.family === family); + if (!hasFamily) { + throw new Error( + `[ProxyFamily] Proxy host ${host} has no ${family === 6 ? "IPv6 (AAAA)" : "IPv4 (A)"} record; refusing ${ + family === 6 ? "IPv6" : "IPv4" + }-only egress (fail-closed)` + ); + } +} diff --git a/open-sse/utils/proxyFetch.ts b/open-sse/utils/proxyFetch.ts index 5865ab676b..98b0ff6421 100644 --- a/open-sse/utils/proxyFetch.ts +++ b/open-sse/utils/proxyFetch.ts @@ -231,6 +231,26 @@ export async function runWithProxyContext(proxyConfig, fn) { } } + // Fail-closed family check: when the proxy URL carries a ?family=ipv6|ipv4 marker + // (set for HOSTNAME proxies by proxyConfigToUrl), verify the hostname actually has a + // record in that family before egressing. Refuse early rather than silently fall back + // to the other family. No-op for IP literals (their family is intrinsic). + if (resolvedProxyUrl && !isVercelRelay) { + try { + const u = new URL(resolvedProxyUrl); + const fam = u.searchParams.get("family"); + if (fam === "ipv6" || fam === "ipv4") { + const { assertHostnameSupportsFamily } = await import("./proxyFamilyResolve.ts"); + await assertHostnameSupportsFamily(u.hostname, fam === "ipv6" ? 6 : 4); + } + } catch (familyErr) { + const e = familyErr as Error & { code?: string; statusCode?: number }; + e.code = e.code || "PROXY_FAMILY_UNAVAILABLE"; + e.statusCode = e.statusCode || 503; + throw e; + } + } + return proxyContext.run(effectiveProxyConfig, async () => { if (resolvedProxyUrl && effectiveProxyConfig !== currentContext) { console.log( diff --git a/open-sse/utils/socksConnectorWithFamily.ts b/open-sse/utils/socksConnectorWithFamily.ts new file mode 100644 index 0000000000..8cf71acd3f --- /dev/null +++ b/open-sse/utils/socksConnectorWithFamily.ts @@ -0,0 +1,68 @@ +import { Agent, buildConnector, type Dispatcher } from "undici"; +import { SocksClient, type SocksProxy } from "socks"; + +/** The net.connect family options pinned for the SOCKS proxy hop. */ +export function buildSocksFamilySocketOptions(family: 4 | 6 | null): Record { + if (family === 6) return { family: 6, autoSelectFamily: false }; + if (family === 4) return { family: 4, autoSelectFamily: false }; + return {}; +} + +function resolvePort(protocol: string, port: string): number { + return port ? Number.parseInt(port, 10) : protocol === "http:" ? 80 : 443; +} + +/** + * Undici connector that tunnels through a single SOCKS5 proxy, pinning the family + * of the TCP connection to the proxy host when `family` is set. Mirrors fetch-socks' + * socksConnector but threads `socket_options` (which fetch-socks does not expose) + * into SocksClient so Happy Eyeballs cannot pick IPv4 for an IPv6-only egress policy. + */ +function socksConnectorWithFamily( + proxy: SocksProxy, + family: 4 | 6 | null, + tlsOpts: buildConnector.BuildOptions = {} +): buildConnector.connector { + const undiciConnect = buildConnector(tlsOpts); + const socketOptions = buildSocksFamilySocketOptions(family); + return async (options, callback) => { + const { protocol, hostname, port, httpSocket } = options as unknown as { + protocol: string; + hostname: string; + port: string; + httpSocket?: unknown; + }; + try { + const r = await SocksClient.createConnection({ + command: "connect", + proxy, + timeout: 10_000, + destination: { host: hostname, port: resolvePort(protocol, port) }, + existing_socket: httpSocket as never, + socket_options: socketOptions as never, + }); + const sock = r.socket; + if (protocol !== "https:") { + return callback(null, (sock as { setNoDelay: () => unknown }).setNoDelay() as never); + } + return undiciConnect({ ...options, httpSocket: sock } as never, callback); + } catch (error) { + return callback(error as Error, null); + } + }; +} + +/** Build an undici Agent dispatcher that SOCKS5-tunnels with a pinned proxy-hop family. */ +export function createSocksDispatcherWithFamily( + proxy: SocksProxy, + family: 4 | 6 | null, + agentOptions: Agent.Options = {} +): Dispatcher { + const { connect, ...rest } = agentOptions as Agent.Options & { + connect?: buildConnector.BuildOptions; + }; + return new Agent({ + ...rest, + connect: socksConnectorWithFamily(proxy, family, connect), + }); +} diff --git a/open-sse/utils/toolCallArguments.ts b/open-sse/utils/toolCallArguments.ts new file mode 100644 index 0000000000..b5554c681a --- /dev/null +++ b/open-sse/utils/toolCallArguments.ts @@ -0,0 +1,32 @@ +/** + * Accumulate streamed tool-call `arguments` fragments without corrupting them. + * + * Providers stream tool-call arguments in one of two shapes: + * - Incremental deltas: each chunk carries only the NEW fragment. These must + * be concatenated verbatim — even when a fragment's leading bytes repeat the + * tail of what we already have (e.g. the doubled `l` in `ls -ll`). + * - Full snapshots: each chunk re-sends the ENTIRE accumulated arguments so + * far. Concatenating those would duplicate the payload (issue #3701). + * + * We only dedup the snapshot case when it is UNAMBIGUOUS: an identical repeat, + * or a growing superset that still starts with everything seen so far. Every + * other fragment is treated as an incremental delta and appended as-is. + * + * A fuzzy suffix/prefix-overlap heuristic must NOT be used here: it silently + * drops bytes from legitimate incremental deltas (turning `ll` into `l`, `xx` + * into `x`), which trades a visible duplication bug for a silent truncation bug. + */ +export function appendToolCallArgumentDelta(current: unknown, incoming: unknown): string { + const existing = typeof current === "string" ? current : ""; + const next = typeof incoming === "string" ? incoming : ""; + + if (!existing) return next; + if (!next) return existing; + + // Unambiguous snapshot repeat / growth — replace instead of concatenating. + if (next === existing) return existing; + if (next.startsWith(existing)) return next; + + // Incremental delta fragment — append verbatim (preserves repeated chars). + return existing + next; +} diff --git a/package-lock.json b/package-lock.json index 3b293886a8..51a1bcdfd9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "omniroute", - "version": "3.8.23", + "version": "3.8.24", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "omniroute", - "version": "3.8.23", + "version": "3.8.24", "hasInstallScript": true, "license": "MIT", "workspaces": [ @@ -50,6 +50,7 @@ "lucide-react": "^1.16.0", "marked": "^18.0.4", "marked-terminal": "^7.3.0", + "material-symbols": "^0.45.1", "mermaid": "^11.15.0", "monaco-editor": "^0.55.1", "next": "^16.2.6", @@ -103,15 +104,23 @@ "c8": "^11.0.0", "concurrently": "^10.0.3", "cross-env": "^10.1.0", + "dpdm": "^4.2.0", "eslint": "^9.39.4", "eslint-config-next": "16.2.7", + "eslint-plugin-sonarjs": "^4.0.3", "glob": "^13.0.6", "husky": "^9.1.7", + "jscpd": "^4.2.5", "jsdom": "^29.1.1", + "knip": "^6.16.1", + "license-checker-rseidelsohn": "^5.0.1", "lint-staged": "^17.0.5", + "lockfile-lint": "^5.0.0", "node-loader": "^2.1.0", "prettier": "^3.8.3", + "size-limit": "^12.1.0", "tailwindcss": "^4.3.0", + "type-coverage": "^2.29.7", "typescript": "^6.0.3", "typescript-eslint": "^8.59.4", "vitest": "^4.1.7", @@ -2038,6 +2047,16 @@ } } }, + "node_modules/@gar/promise-retry": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@gar/promise-retry/-/promise-retry-1.0.3.tgz", + "integrity": "sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/@hapi/address": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/@hapi/address/-/address-5.1.1.tgz", @@ -2672,6 +2691,26 @@ "integrity": "sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==", "license": "MIT" }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@isaacs/string-locale-compare": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@isaacs/string-locale-compare/-/string-locale-compare-1.1.0.tgz", + "integrity": "sha512-SQ7Kzhh9+D+ZW9MA0zkYv3VXhIDNx+LzM6EJ+/65I3QY+enU6Itte7E5XX7EWrqLW2FN4n06GWzBnPoC3th2aQ==", + "dev": true, + "license": "ISC" + }, "node_modules/@istanbuljs/schema": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", @@ -2728,6 +2767,114 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@jscpd/badge-reporter": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@jscpd/badge-reporter/-/badge-reporter-4.2.5.tgz", + "integrity": "sha512-ktXrjPeRaRyUDktxTroSA2/w5sshXpQplWkUuq/e6XqEpKBSbGEnwZLIaegSijOrMwIcCXPQ9k4feXIz5eVJNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "badgen": "^3.2.3", + "colors": "^1.4.0", + "fs-extra": "^11.2.0" + } + }, + "node_modules/@jscpd/core": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@jscpd/core/-/core-4.2.5.tgz", + "integrity": "sha512-Esf2deHxaoNEjePwf2jqP6Urzj+BAOsJVPFLbnnSsV+q7rLNMcn0UEEoKBXIOOt4qMkrkhl9DfwpMyPPOr6GkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eventemitter3": "^5.0.1" + } + }, + "node_modules/@jscpd/finder": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@jscpd/finder/-/finder-4.2.5.tgz", + "integrity": "sha512-Rw0dtwp/EeLANbujOubuQeJIuXXXkAlT+f5geZhwkB9TxEYP0hqNrdOJUK/TDBKQjRGrOizEtdNy+S4UlbdzOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jscpd/core": "4.2.5", + "@jscpd/tokenizer": "4.2.5", + "blamer": "^1.0.6", + "bytes": "^3.1.2", + "cli-table3": "^0.6.5", + "colors": "^1.4.0", + "fast-glob": "^3.3.2", + "fs-extra": "^11.2.0", + "markdown-table": "^2.0.0", + "pug": "^3.0.4" + } + }, + "node_modules/@jscpd/finder/node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/@jscpd/finder/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/@jscpd/finder/node_modules/markdown-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-2.0.0.tgz", + "integrity": "sha512-Ezda85ToJUBhM6WGaG6veasyym+Tbs3cMAw/ZhOPqXiYsr0jgocBV3j3nx+4lk47plLlIqjwuTm/ywVI+zjJ/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "repeat-string": "^1.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/@jscpd/html-reporter": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@jscpd/html-reporter/-/html-reporter-4.2.5.tgz", + "integrity": "sha512-zMMIKbvi43dMgeNeHXlHQy1ovf+KJrzNlUubaBvCAVatqP23ksW8d3fmsevIQG9mMMTH0D1xOz+SxUn1FREOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "colors": "1.4.0", + "fs-extra": "^11.2.0", + "pug": "^3.0.4" + } + }, + "node_modules/@jscpd/tokenizer": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@jscpd/tokenizer/-/tokenizer-4.2.5.tgz", + "integrity": "sha512-UM8Wx/jwahmflqQExlcKMQTYOAy58N/fn7Pv6NYrkD3EZm/FTk7gW97wkXy5aDE1Ts9oBUpT9tLY2rz7ogCHAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jscpd/core": "4.2.5", + "spark-md5": "^3.0.2" + } + }, "node_modules/@lobehub/icons": { "version": "5.10.0", "resolved": "https://registry.npmjs.org/@lobehub/icons/-/icons-5.10.0.tgz", @@ -3319,6 +3466,552 @@ "node": ">=12.4.0" } }, + "node_modules/@npmcli/agent": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-4.0.2.tgz", + "integrity": "sha512-EUEuWAxnL07Sp5/iC/1X6Xj+XThUvnbei9zfRWZdEXa7lss9RTHMhAHBeg+MZ5To9s/gGaSI+UwZTPdYMvKSeg==", + "dev": true, + "license": "ISC", + "dependencies": { + "agent-base": "^7.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.1", + "lru-cache": "^11.2.1", + "socks-proxy-agent": "^8.0.3" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/agent/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/@npmcli/agent/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@npmcli/agent/node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@npmcli/arborist": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/@npmcli/arborist/-/arborist-9.6.0.tgz", + "integrity": "sha512-Dku9UWbrrX+UCu8rQ1obGKaQAL4kwdt3hHCNXrd0n0R/4B8oq3CzloUAShwFjfsAGM6KY27gPuNftOUEZ4nhOw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@gar/promise-retry": "^1.0.0", + "@isaacs/string-locale-compare": "^1.1.0", + "@npmcli/fs": "^5.0.0", + "@npmcli/installed-package-contents": "^4.0.0", + "@npmcli/map-workspaces": "^5.0.0", + "@npmcli/metavuln-calculator": "^9.0.2", + "@npmcli/name-from-folder": "^4.0.0", + "@npmcli/node-gyp": "^5.0.0", + "@npmcli/package-json": "^7.0.0", + "@npmcli/query": "^5.0.0", + "@npmcli/redact": "^4.0.0", + "@npmcli/run-script": "^10.0.0", + "bin-links": "^6.0.0", + "cacache": "^20.0.1", + "common-ancestor-path": "^2.0.0", + "hosted-git-info": "^9.0.0", + "json-stringify-nice": "^1.1.4", + "lru-cache": "^11.2.1", + "minimatch": "^10.0.3", + "nopt": "^9.0.0", + "npm-install-checks": "^8.0.0", + "npm-package-arg": "^13.0.0", + "npm-pick-manifest": "^11.0.1", + "npm-registry-fetch": "^19.0.0", + "pacote": "^21.0.2", + "parse-conflict-json": "^5.0.1", + "proc-log": "^6.0.0", + "proggy": "^4.0.0", + "promise-all-reject-late": "^1.0.0", + "promise-call-limit": "^3.0.1", + "semver": "^7.3.7", + "ssri": "^13.0.0", + "treeverse": "^3.0.0", + "walk-up-path": "^4.0.0" + }, + "bin": { + "arborist": "bin/index.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/arborist/node_modules/abbrev": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", + "integrity": "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/arborist/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@npmcli/arborist/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@npmcli/arborist/node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@npmcli/arborist/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@npmcli/arborist/node_modules/nopt": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz", + "integrity": "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^4.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/arborist/node_modules/semver": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@npmcli/fs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-5.0.0.tgz", + "integrity": "sha512-7OsC1gNORBEawOa5+j2pXN9vsicaIOH5cPXxoR6fJOmH6/EXpJB2CajXOu1fPRFun2m1lktEFX11+P89hqO/og==", + "dev": true, + "license": "ISC", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/fs/node_modules/semver": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@npmcli/git": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@npmcli/git/-/git-7.0.2.tgz", + "integrity": "sha512-oeolHDjExNAJAnlYP2qzNjMX/Xi9bmu78C9dIGr4xjobrSKbuMYCph8lTzn4vnW3NjIqVmw/f8BCfouqyJXlRg==", + "dev": true, + "license": "ISC", + "dependencies": { + "@gar/promise-retry": "^1.0.0", + "@npmcli/promise-spawn": "^9.0.0", + "ini": "^6.0.0", + "lru-cache": "^11.2.1", + "npm-pick-manifest": "^11.0.1", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "which": "^6.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/git/node_modules/ini": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz", + "integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/git/node_modules/isexe": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=20" + } + }, + "node_modules/@npmcli/git/node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@npmcli/git/node_modules/semver": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@npmcli/git/node_modules/which": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", + "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^4.0.0" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/installed-package-contents": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/installed-package-contents/-/installed-package-contents-4.0.0.tgz", + "integrity": "sha512-yNyAdkBxB72gtZ4GrwXCM0ZUedo9nIbOMKfGjt6Cu6DXf0p8y1PViZAKDC8q8kv/fufx0WTjRBdSlyrvnP7hmA==", + "dev": true, + "license": "ISC", + "dependencies": { + "npm-bundled": "^5.0.0", + "npm-normalize-package-bin": "^5.0.0" + }, + "bin": { + "installed-package-contents": "bin/index.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/map-workspaces": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@npmcli/map-workspaces/-/map-workspaces-5.0.3.tgz", + "integrity": "sha512-o2grssXo1e774E5OtEwwrgoszYRh0lqkJH+Pb9r78UcqdGJRDRfhpM8DvZPjzNLLNYeD/rNbjOKM3Ss5UABROw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/name-from-folder": "^4.0.0", + "@npmcli/package-json": "^7.0.0", + "glob": "^13.0.0", + "minimatch": "^10.0.3" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/map-workspaces/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@npmcli/map-workspaces/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@npmcli/map-workspaces/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@npmcli/metavuln-calculator": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/@npmcli/metavuln-calculator/-/metavuln-calculator-9.0.3.tgz", + "integrity": "sha512-94GLSYhLXF2t2LAC7pDwLaM4uCARzxShyAQKsirmlNcpidH89VA4/+K1LbJmRMgz5gy65E/QBBWQdUvGLe2Frg==", + "dev": true, + "license": "ISC", + "dependencies": { + "cacache": "^20.0.0", + "json-parse-even-better-errors": "^5.0.0", + "pacote": "^21.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/metavuln-calculator/node_modules/json-parse-even-better-errors": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-5.0.0.tgz", + "integrity": "sha512-ZF1nxZ28VhQouRWhUcVlUIN3qwSgPuswK05s/HIaoetAoE/9tngVmCHjSxmSQPav1nd+lPtTL0YZ/2AFdR/iYQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/metavuln-calculator/node_modules/semver": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@npmcli/name-from-folder": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/name-from-folder/-/name-from-folder-4.0.0.tgz", + "integrity": "sha512-qfrhVlOSqmKM8i6rkNdZzABj8MKEITGFAY+4teqBziksCQAOLutiAxM1wY2BKEd8KjUSpWmWCYxvXr0y4VTlPg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/node-gyp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/node-gyp/-/node-gyp-5.0.0.tgz", + "integrity": "sha512-uuG5HZFXLfyFKqg8QypsmgLQW7smiRjVc45bqD/ofZZcR/uxEjgQU8qDPv0s9TEeMUiAAU/GC5bR6++UdTirIQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/package-json": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/@npmcli/package-json/-/package-json-7.0.5.tgz", + "integrity": "sha512-iVuTlG3ORq2iaVa1IWUxAO/jIp77tUKBhoMjuzYW2kL4MLN1bi/ofqkZ7D7OOwh8coAx1/S2ge0rMdGv8sLSOQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/git": "^7.0.0", + "glob": "^13.0.0", + "hosted-git-info": "^9.0.0", + "json-parse-even-better-errors": "^5.0.0", + "proc-log": "^6.0.0", + "semver": "^7.5.3", + "spdx-expression-parse": "^4.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/package-json/node_modules/json-parse-even-better-errors": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-5.0.0.tgz", + "integrity": "sha512-ZF1nxZ28VhQouRWhUcVlUIN3qwSgPuswK05s/HIaoetAoE/9tngVmCHjSxmSQPav1nd+lPtTL0YZ/2AFdR/iYQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/package-json/node_modules/semver": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@npmcli/promise-spawn": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/@npmcli/promise-spawn/-/promise-spawn-9.0.1.tgz", + "integrity": "sha512-OLUaoqBuyxeTqUvjA3FZFiXUfYC1alp3Sa99gW3EUDz3tZ3CbXDdcZ7qWKBzicrJleIgucoWamWH1saAmH/l2Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "which": "^6.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/promise-spawn/node_modules/isexe": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=20" + } + }, + "node_modules/@npmcli/promise-spawn/node_modules/which": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", + "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^4.0.0" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/query": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/query/-/query-5.0.0.tgz", + "integrity": "sha512-8TZWfTQOsODpLqo9SVhVjHovmKXNpevHU0gO9e+y4V4fRIOneiXy0u0sMP9LmS71XivrEWfZWg50ReH4WRT4aQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "postcss-selector-parser": "^7.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/redact": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/redact/-/redact-4.0.0.tgz", + "integrity": "sha512-gOBg5YHMfZy+TfHArfVogwgfBeQnKbbGo3pSUyK/gSI0AVu+pEiDVcKlQb0D8Mg1LNRZILZ6XG8I5dJ4KuAd9Q==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/run-script": { + "version": "10.0.4", + "resolved": "https://registry.npmjs.org/@npmcli/run-script/-/run-script-10.0.4.tgz", + "integrity": "sha512-mGUWr1uMnf0le2TwfOZY4SFxZGXGfm4Jtay/nwAa2FLNAKXUoUwaGwBMNH36UHPtinWfTSJ3nqFQr0091CxVGg==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/node-gyp": "^5.0.0", + "@npmcli/package-json": "^7.0.0", + "@npmcli/promise-spawn": "^9.0.0", + "node-gyp": "^12.1.0", + "proc-log": "^6.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/@omniroute/open-sse": { "resolved": "open-sse", "link": true @@ -3332,6 +4025,372 @@ "node": ">= 20.0.0" } }, + "node_modules/@oxc-parser/binding-android-arm-eabi": { + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.133.0.tgz", + "integrity": "sha512-l/44caGse+VpnY9gx0yvvc5QnnG3yG1FO3KZgYvNL1GZrfK86zIwAOgGEVlxDyRymzrU/KHiblPFpevKOmJmUA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-android-arm64": { + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.133.0.tgz", + "integrity": "sha512-KUHmPMziLBp4u+zbrLdB7iWS7KshuZe+RAp7ELnY9SI9nNXBZ+dp8fiBqWOxhXqn+FQg3a4UcQhwmsJOKV8Jjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-darwin-arm64": { + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.133.0.tgz", + "integrity": "sha512-q8dWmnU/8ea2tga9w2f1PinQ5rcMPDUGkF64T189b65YMjUomET4oy5oRldOr4AwOQkneOG/Zttnz1Dvrc62wg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-darwin-x64": { + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.133.0.tgz", + "integrity": "sha512-cOKeIELIB2bJnCKwqx4Rdj+1Lss/U6uCbLxRySZrhyOOQa1flKhwZFjEHRHxk8fU1NKmhK5OnTdPQ4CpjuFuVw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-freebsd-x64": { + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.133.0.tgz", + "integrity": "sha512-OpaSv4pW3KgFrMYQxTaS0aOE4T1DQF3qZE/4B6uqqv1KgPWWd4UQhJALi8PJPX1RRV5K7ThKXRfF7qGg2+3l1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm-gnueabihf": { + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.133.0.tgz", + "integrity": "sha512-JGK1wlGrGwxBIlVSF7KWTX1/ru6BEtf28fRROztDRkLfiW+Kxa4onnriezMIiogfn9hVw2KzYcKiLjkLR2ns8A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm-musleabihf": { + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.133.0.tgz", + "integrity": "sha512-yuZO533Ftonxn/iyoqQzURzLQHMspvsIyfiCSNi1t/ER4eIQaR0SsmUOUm5b/lmSig7IWIUa5/BrbEkAPwcilQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm64-gnu": { + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.133.0.tgz", + "integrity": "sha512-hvpbqT5pN2rR+3+xtWeizwfR/aZ0vGceg6TqYMl+ToxMpk9/tmnX7kSvQnfEUkoua8mhogzvIKsAkn0wxgblBA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm64-musl": { + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.133.0.tgz", + "integrity": "sha512-wJQGamIosQBoJHW9+S5XxrtKRo3eyJxsnS1XCPrqN0LHi8uw1pTqqTfn3t/NVuvbBg7Pumn4ez9Eidgcn0xbEg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-ppc64-gnu": { + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.133.0.tgz", + "integrity": "sha512-Koaz32/O5+abIfrNGdyndgRvdOZ9jEf5/z3Ep9h3h2QWpdDiUQpVwgH0OcMXCs+l9aXxPLtkupqyVig9W6FDKw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-riscv64-gnu": { + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.133.0.tgz", + "integrity": "sha512-R4vOjWzxhnNWHnVLeiB6jNuIifdy9vcMXZGPc7StXcxBovI+U2zg1QhZ9o8OjV80oGivs1lX5NfPLzk4IPqlRA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-riscv64-musl": { + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.133.0.tgz", + "integrity": "sha512-iwgBNUTHiMdxARLYuM0SBlnYeb19iw1Ea5M+4ERZupCsBMLArti6FyZ6UfFjJxIiTDr2oW2DGQFxlQVQ/dW9rA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-s390x-gnu": { + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.133.0.tgz", + "integrity": "sha512-ZwZNo8FZmB/gVfboQl+wXilBigGl+6nQQs+nITOeAP/HcAOjiHl6XZJL9F/KXNEspODQcbjAiyjUbeCJd9a0fA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-x64-gnu": { + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.133.0.tgz", + "integrity": "sha512-govCvWx1dBlED3uu4qXctxpRcouu9I8Kn+DBktGCl760JtlGJzc9l/OmPJKlYWSbrRqKkMZehNeZ/4Wfma7uSA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-x64-musl": { + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.133.0.tgz", + "integrity": "sha512-ssTlpXD5Mq9uCssDJPzlRWqBt4Y7Zzd9i+XZhWmK/9Y6KUIuAxVYTYiI8lxcGWi0+3/Cz4A8q9UrD4NK9Y2j7g==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-openharmony-arm64": { + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.133.0.tgz", + "integrity": "sha512-51aByfXhPtLEdWG4a2Ihdw6cPWV1ei1AarALpFdDP8MLWDLE2NuUMgbo3DERR2Kt8fT/ok1GUvBiLxVGke9uUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-wasm32-wasi": { + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-wasm32-wasi/-/binding-wasm32-wasi-0.133.0.tgz", + "integrity": "sha512-2e16tkKp+wDO2GTAmXfxbBcCmGEaFPIJEIRBBmVKNVXSc8/fJsSIaBGyFTPHM9ST5GNWgJcYIt94rDTks+PLwA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-win32-arm64-msvc": { + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.133.0.tgz", + "integrity": "sha512-KPTNDKbxH1cglrqTyVeXHb4Pk4oksz8EcE1/v8zqU7N4UXbiHfA/IwtXZ2U77fnRAWBbgVkl/lZbL7o3hRdejg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-win32-ia32-msvc": { + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.133.0.tgz", + "integrity": "sha512-Una1bNYv9zCavQrfnDR9wuZVB3itLjCEH4Oz7i6CwAJN/Xq9b+zbbcxmvdkKvvJt4Ngc/MBmIYlbLo3zS4TQ0A==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-win32-x64-msvc": { + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.133.0.tgz", + "integrity": "sha512-kjBhCiOGSYTwDJQuuZa7a94JbP8htWu7J0X1KwH74kV2K5eYf6eyJRYmkpCDvr0XEL8tMxYI4WU1VekblFCLgg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, "node_modules/@oxc-project/types": { "version": "0.122.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz", @@ -3342,6 +4401,301 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@oxc-resolver/binding-android-arm-eabi": { + "version": "11.20.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm-eabi/-/binding-android-arm-eabi-11.20.0.tgz", + "integrity": "sha512-IjfWOXRgJFNdORDl+Uf1aibNgZY2guOD3zmOhx1BGVb/MIiqlFTdmjpQNplSN58lhWehnX4UNqC3QwpUo8pjJg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@oxc-resolver/binding-android-arm64": { + "version": "11.20.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm64/-/binding-android-arm64-11.20.0.tgz", + "integrity": "sha512-QqslZAuFQG8Q9xm7JuIn8JUbvywhSBMVhuQHtYW+auirZJloS41oxUUaBXk7uUhZJgp44c5zQLeVvmFaDQB+2Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@oxc-resolver/binding-darwin-arm64": { + "version": "11.20.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-arm64/-/binding-darwin-arm64-11.20.0.tgz", + "integrity": "sha512-MUcavykj2ewlR+kc5arpg4tC2RvzJkUxWtNv74pf7lcNk00GpIpN43vXMj+j6r4eMmfZhlb8hueKoIb8e9kAGQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oxc-resolver/binding-darwin-x64": { + "version": "11.20.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-x64/-/binding-darwin-x64-11.20.0.tgz", + "integrity": "sha512-BGB16nRUK5Etiv//ihPyzj8Lj1px0mhh4YIfe0FDf045ywknfSm0GEbiRESpr6Q4K82AvnyaRIhhluHByvS4bg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oxc-resolver/binding-freebsd-x64": { + "version": "11.20.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-freebsd-x64/-/binding-freebsd-x64-11.20.0.tgz", + "integrity": "sha512-JZgtePaqj3qmD5XFHJaSLWzHRxQu0LaPkdoM1KJXYADvAaa83ijXHclV3ej3CueeW0wxfIAbGCZVP45J0CA7uQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm-gnueabihf": { + "version": "11.20.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-11.20.0.tgz", + "integrity": "sha512-hOQ/p3ry3v3SchUBXicrrnszaI/UmYzM4wtS4RGfwgVUX7a+HbyQSzJ5aOzu+o6XZkFkS3ZXN4PZAzhOb77OSg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm-musleabihf": { + "version": "11.20.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-11.20.0.tgz", + "integrity": "sha512-2ArPksaw0AqeuGBfoS715VF+JvJQAhD2niWgjE5hVO+L+nAfikVQopvngCMX9x4BD8itWoQ3dnikrQyl5Ho5Jg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm64-gnu": { + "version": "11.20.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-11.20.0.tgz", + "integrity": "sha512-0bJnmYFp62JdZ4nVMDUZ/C58BCZOCcqgKtnUlp7L9Ojf/czIN+3j72YlLPeWLkzlr6SlYvIQA4SGV/HyO0d+qg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm64-musl": { + "version": "11.20.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-musl/-/binding-linux-arm64-musl-11.20.0.tgz", + "integrity": "sha512-wKHHzPKZo7Ufhv/Bt6yxT7FOgnIgW4gwXcJUipkShGp68W3wGVqvr1Sr0fY65lN0Oy6y41+g2kIDvkgZaMMUkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-ppc64-gnu": { + "version": "11.20.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-11.20.0.tgz", + "integrity": "sha512-RN8goF7Ie0B79L4i4G6OeBocTgSC56vJbQ65VJje+oXnldVpLnOU7j/AQ/dP94TcCS+Yh6WG8u3Qt4ETteXFNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-riscv64-gnu": { + "version": "11.20.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-11.20.0.tgz", + "integrity": "sha512-5l1yU6/xQEqLZRzxqmMxJfWPslpwCmBsdDGaBvABPehxquCXDC7dd7oraNdKSJUMDXSM7VvVj8H2D2FTjU7oWw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-riscv64-musl": { + "version": "11.20.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-11.20.0.tgz", + "integrity": "sha512-xHEvkbgz6UC+A3JOyDQy76LkUaxsNSfIr3/GV8slwZsnuooJiIB34gzJfsyvR4JdCYNUUPsRJc/w/oWkODu+hg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-s390x-gnu": { + "version": "11.20.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-11.20.0.tgz", + "integrity": "sha512-aWPDUUmSeyHvlW+SoEUd+JIJsQhVhu6a5tBpDRMu058naPAchTgAVGCFy35zjbnFlt0i8hLWziff6HX0D3LU4g==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-x64-gnu": { + "version": "11.20.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-gnu/-/binding-linux-x64-gnu-11.20.0.tgz", + "integrity": "sha512-x2YeSimvhJjKLVD8KSu8f/rqU1potcdEMkApIPJqjZWN7c2Fpt4g2X32WDg1p+XDAmyT7nuQGe0vnhvXeLbH+g==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-x64-musl": { + "version": "11.20.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-musl/-/binding-linux-x64-musl-11.20.0.tgz", + "integrity": "sha512-kcRLEIxpZefeYfLChjpgFf3ilBzRDZ+yobMrpRsQlSrxuFGtm3U6PMU7AaEpMqo3NfDGVyJJseAjnRLzMFHjwQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-openharmony-arm64": { + "version": "11.20.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-openharmony-arm64/-/binding-openharmony-arm64-11.20.0.tgz", + "integrity": "sha512-HHcfnApSZGtKhTiHqe8OZruOZe5XuFQH5/E0Yhj3u8fnFvzkM4/k6WjacUf4SvA0SPEAbfbgYmVPuo0VX/fIBQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@oxc-resolver/binding-wasm32-wasi": { + "version": "11.20.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-wasm32-wasi/-/binding-wasm32-wasi-11.20.0.tgz", + "integrity": "sha512-Tn0y1XOFYHNfK1wp1Z5QK8Rcld/bsOwRISQXfqAZ5IBpv8Gz1IvV39fUWNprqNdRizgcvFhOzWwFun2zkJsyBg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@oxc-resolver/binding-win32-arm64-msvc": { + "version": "11.20.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-11.20.0.tgz", + "integrity": "sha512-qPi25YNPe4YenS8MgsQU2+bIFHxxpLx1LVna2444cEHqNPhNjvWf9zqj4aWE43H9LpAsTmkkAlA3eL5ElBU3mA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@oxc-resolver/binding-win32-x64-msvc": { + "version": "11.20.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-x64-msvc/-/binding-win32-x64-msvc-11.20.0.tgz", + "integrity": "sha512-Wb14jWEW8huH6It9F6sXd9vrYmIS7pMrgkU6sxpLxkP+9z+wRgs71hUEhRpcn8FOXAFa27FVWfY2tRpbfTzfLw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@parcel/watcher": { "version": "2.5.6", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", @@ -5149,6 +6503,86 @@ "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", "license": "MIT" }, + "node_modules/@sigstore/bundle": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sigstore/bundle/-/bundle-4.0.0.tgz", + "integrity": "sha512-NwCl5Y0V6Di0NexvkTqdoVfmjTaQwoLM236r89KEojGmq/jMls8S+zb7yOwAPdXvbwfKDlP+lmXgAL4vKSQT+A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/protobuf-specs": "^0.5.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@sigstore/core": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@sigstore/core/-/core-3.2.1.tgz", + "integrity": "sha512-qRsxPnCrbC/puegGxKuynfnxgLiHqWStrSjxkoB4YKqq3Z3s4cyZyj42ZdWFAEblNP65C+rBH8EuREHIXoi83g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@sigstore/protobuf-specs": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@sigstore/protobuf-specs/-/protobuf-specs-0.5.1.tgz", + "integrity": "sha512-/ScWUhhoFasJsSRGTVBwId1loQjjnjAfE4djL6ZhrXRpNCmPTnUKF5Jokd58ILseOMjzET3UrMOtJPS9sYeI0g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@sigstore/sign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@sigstore/sign/-/sign-4.1.1.tgz", + "integrity": "sha512-Hf4xglukg0XXQ2RiD5vSoLjdPe8OBUPA8XeVjUObheuDcWdYWrnH/BNmxZCzkAy68MzmNCxXLeurJvs6hcP2OQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@gar/promise-retry": "^1.0.2", + "@sigstore/bundle": "^4.0.0", + "@sigstore/core": "^3.2.0", + "@sigstore/protobuf-specs": "^0.5.0", + "make-fetch-happen": "^15.0.4", + "proc-log": "^6.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@sigstore/tuf": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@sigstore/tuf/-/tuf-4.0.2.tgz", + "integrity": "sha512-TCAzTy0xzdP79EnxSjq9KQ3eaR7+FmudLC6eRKknVKZbV7ZNlGLClAAQb/HMNJ5n2OBNk2GT1tEmU0xuPr+SLQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/protobuf-specs": "^0.5.0", + "tuf-js": "^4.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@sigstore/verify": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@sigstore/verify/-/verify-3.1.1.tgz", + "integrity": "sha512-qv7+G3J2cc6wwFj3yKvXOamzqhMwSk1ogPGmhpS8iXllcPrJaIIBA+4HbttlHVu1pqWTdmaCH/WE7UOC51kdoA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/bundle": "^4.0.0", + "@sigstore/core": "^3.2.1", + "@sigstore/protobuf-specs": "^0.5.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/@sindresorhus/is": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", @@ -5875,6 +7309,69 @@ } } }, + "node_modules/@tufjs/canonical-json": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@tufjs/canonical-json/-/canonical-json-2.0.0.tgz", + "integrity": "sha512-yVtV8zsdo8qFHe+/3kw81dSLyF7D576A5cCFCi4X7B39tWT7SekaEFUnvnWJHz+9qO7qJTah1JbrDjWKqFtdWA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/@tufjs/models": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@tufjs/models/-/models-4.1.0.tgz", + "integrity": "sha512-Y8cK9aggNRsqJVaKUlEYs4s7CvQ1b1ta2DVPyAimb0I2qhzjNk+A+mxvll/klL0RlfuIUei8BF7YWiua4kQqww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tufjs/canonical-json": "2.0.0", + "minimatch": "^10.1.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@tufjs/models/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@tufjs/models/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@tufjs/models/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", @@ -6315,6 +7812,13 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/sarif": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@types/sarif/-/sarif-2.1.7.tgz", + "integrity": "sha512-kRz0VEkJqWLf1LLVN4pT1cg1Z9wAuvI6L97V3m2f5B76Tg8d413ddvLBPTEHAZJlnn4XSvu0FkZtViCQGVyrXQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -7140,6 +8644,54 @@ "d3-zoom": "^3.0.0" } }, + "node_modules/@yarnpkg/parsers": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@yarnpkg/parsers/-/parsers-3.0.3.tgz", + "integrity": "sha512-mQZgUSgFurUtA07ceMjxrWkYz8QtDuYkvPlu0ZqncgjopQ0t6CNEo/OSealkmnagSUx8ZD5ewvezUwUuMqutQg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "js-yaml": "^3.10.0", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=18.12.0" + } + }, + "node_modules/@yarnpkg/parsers/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/@yarnpkg/parsers/node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/abbrev": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", + "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -7385,6 +8937,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/array-find-index": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-find-index/-/array-find-index-1.0.2.tgz", + "integrity": "sha512-M1HQyIXcBGtVywBt8WVdim+lrNaK7VHp99Qt5pSNziXznKHViIBbXWtfRTpEFpF/c4FdfxNAsCCwPp5phBYJtw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/array-includes": { "version": "3.1.9", "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", @@ -7528,6 +9090,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, "node_modules/asn1js": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.7.tgz", @@ -7542,6 +9111,13 @@ "node": ">=12.0.0" } }, + "node_modules/assert-never": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/assert-never/-/assert-never-1.4.0.tgz", + "integrity": "sha512-5oJg84os6NMQNl27T9LnZkvvqzvAnHu03ShCnoj6bsJwS7L8AO4lf+C/XjK/nvzEqQB744moC6V128RucQd1jA==", + "dev": true, + "license": "MIT" + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -7703,6 +9279,26 @@ "npm": ">=6" } }, + "node_modules/babel-walk": { + "version": "3.0.0-canary-5", + "resolved": "https://registry.npmjs.org/babel-walk/-/babel-walk-3.0.0-canary-5.tgz", + "integrity": "sha512-GAwkz0AihzY5bkwIY5QDR+LvsRQgB/B+1foMPvi0FZPMl5fjD7ICiznUiBdLYMH1QYe6vqu4gWYytZOccLouFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.9.6" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/badgen": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/badgen/-/badgen-3.3.2.tgz", + "integrity": "sha512-fbQwK9norfdzbdsoPwbLIAmgBXDGEme3jeIyqPAH7o6vp9lmuLHS7uXULvOiQ6XnMLkYNG4gDjILf74hgtTAug==", + "dev": true, + "license": "MIT" + }, "node_modules/bail": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", @@ -7797,6 +9393,23 @@ "node": "*" } }, + "node_modules/bin-links": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/bin-links/-/bin-links-6.0.2.tgz", + "integrity": "sha512-frE1t78WOwJ45PKV2cF2tNPjTcs9L1J9s6VkrV59wanRP4GlaomuxYPVma7BwthMg8WnfSory4w5PTE6FZZ81w==", + "dev": true, + "license": "ISC", + "dependencies": { + "cmd-shim": "^8.0.0", + "npm-normalize-package-bin": "^5.0.0", + "proc-log": "^6.0.0", + "read-cmd-shim": "^6.0.0", + "write-file-atomic": "^7.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/bindings": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", @@ -7819,6 +9432,20 @@ "readable-stream": "^3.4.0" } }, + "node_modules/blamer": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/blamer/-/blamer-1.0.7.tgz", + "integrity": "sha512-GbBStl/EVlSWkiJQBZps3H1iARBrC7vt++Jb/TTmCNu/jZ04VW7tSN1nScbFXBUy1AN+jzeL7Zep9sbQxLhXKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "^4.0.0", + "which": "^2.0.2" + }, + "engines": { + "node": ">=8.9" + } + }, "node_modules/body-parser": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", @@ -8033,6 +9660,19 @@ "node": ">=8.0.0" } }, + "node_modules/builtin-modules": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz", + "integrity": "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/bun-types": { "version": "1.3.14", "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.3.14.tgz", @@ -8067,6 +9707,16 @@ "node": ">= 0.8" } }, + "node_modules/bytes-iec": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/bytes-iec/-/bytes-iec-3.1.1.tgz", + "integrity": "sha512-fey6+4jDK7TFtFg/klGSvNKJctyU7n2aQdnM+CO0ruLPbqqMOM8Tio0Pc+deqUeVKX1tL5DQep1zQ7+37aTAsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/bytestreamjs": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/bytestreamjs/-/bytestreamjs-2.0.1.tgz", @@ -8110,6 +9760,38 @@ } } }, + "node_modules/cacache": { + "version": "20.0.4", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-20.0.4.tgz", + "integrity": "sha512-M3Lab8NPYlZU2exsL3bMVvMrMqgwCnMWfdZbK28bn3pK6APT/Te/I8hjRPNu1uwORY9a1eEQoifXbKPQMfMTOA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/fs": "^5.0.0", + "fs-minipass": "^3.0.0", + "glob": "^13.0.0", + "lru-cache": "^11.1.0", + "minipass": "^7.0.3", + "minipass-collect": "^2.0.1", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "p-map": "^7.0.2", + "ssri": "^13.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/cacache/node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -8286,6 +9968,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/character-parser": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/character-parser/-/character-parser-2.2.0.tgz", + "integrity": "sha512-+UqJQjFEFaTAs3bNsF2j2kEN1baG/zghZbdqoYEDxGZtJo9LBzl1A+m0D4n3qKx8N2FNv8/Xp6yV9mQmBuptaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-regex": "^1.0.3" + } + }, "node_modules/character-reference-invalid": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", @@ -8673,6 +10365,16 @@ "node": ">=0.10.0" } }, + "node_modules/cmd-shim": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/cmd-shim/-/cmd-shim-8.0.0.tgz", + "integrity": "sha512-Jk/BK6NCapZ58BKUxlSI+ouKRbjH1NLZCgJkYoab+vEHUY3f6OzpNBN9u7HFSv9J6TRDGs4PLOHezoKGaFRSCA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/code-excerpt": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/code-excerpt/-/code-excerpt-4.0.0.tgz", @@ -8719,6 +10421,16 @@ "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", "license": "MIT" }, + "node_modules/colors": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz", + "integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.1.90" + } + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -8750,6 +10462,16 @@ "node": ">=22.12.0" } }, + "node_modules/common-ancestor-path": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/common-ancestor-path/-/common-ancestor-path-2.0.0.tgz", + "integrity": "sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">= 18" + } + }, "node_modules/compute-scroll-into-view": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz", @@ -8927,6 +10649,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/constantinople": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/constantinople/-/constantinople-4.0.1.tgz", + "integrity": "sha512-vCrqcSIq4//Gx74TXXCGnHpulY1dskqLTFGDmhrGxzeXL8lF8kvXv6mpNWlJj1uD4DW23D4ljAqbY4RRaaUZIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.6.0", + "@babel/types": "^7.6.1" + } + }, "node_modules/content-disposition": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", @@ -9086,6 +10819,19 @@ "dev": true, "license": "MIT" }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -9949,6 +11695,13 @@ "node": ">=0.10.0" } }, + "node_modules/doctypes": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/doctypes/-/doctypes-1.1.0.tgz", + "integrity": "sha512-LLBi6pEqS6Do3EKQ3J0NqHWV5hhb78Pi8vvESYwyOy2c31ZEZVdtitdzsQsKb7878PEERhzUk0ftqGhG6Mz+pQ==", + "dev": true, + "license": "MIT" + }, "node_modules/dompurify": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.3.tgz", @@ -9973,6 +11726,144 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/dpdm": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/dpdm/-/dpdm-4.2.0.tgz", + "integrity": "sha512-Vq862fZ9UE66rlr2VcMhU8ZstTH3ItqmniLSCtAeg6T2AYeB2oD3Z6lGjiFDjyUvxLbfLyBBNWagCLMehpmo5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.6.2", + "fs-extra": "^11.3.3", + "glob": "^13.0.0", + "ora": "^9.1.0", + "tslib": "^2.8.1", + "typescript": "^5.9.3", + "yargs": "^18.0.0" + }, + "bin": { + "dpdm": "lib/bin/dpdm.js" + } + }, + "node_modules/dpdm/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/dpdm/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/dpdm/node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/dpdm/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/dpdm/node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/dpdm/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/dpdm/node_modules/yargs": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^7.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/dpdm/node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -10066,6 +11957,16 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/environment": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", @@ -10749,6 +12650,95 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/eslint-plugin-sonarjs": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-sonarjs/-/eslint-plugin-sonarjs-4.0.3.tgz", + "integrity": "sha512-5drkJKLC9qQddIiaATV0e8+ygbUc7b0Ti6VB7M2d3jmKNh3X0RaiIJYTs3dr9xnlhlrxo+/s1FoO3Jgv6O/c7g==", + "dev": true, + "license": "LGPL-3.0-only", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "builtin-modules": "^3.3.0", + "bytes": "^3.1.2", + "functional-red-black-tree": "^1.0.1", + "globals": "^17.4.0", + "jsx-ast-utils-x": "^0.1.0", + "lodash.merge": "^4.6.2", + "minimatch": "^10.2.5", + "scslre": "^0.3.0", + "semver": "^7.7.4", + "ts-api-utils": "^2.5.0", + "typescript": ">=5" + }, + "peerDependencies": { + "eslint": "^8.0.0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-plugin-sonarjs/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/eslint-plugin-sonarjs/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/eslint-plugin-sonarjs/node_modules/globals": { + "version": "17.6.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz", + "integrity": "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-plugin-sonarjs/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/eslint-plugin-sonarjs/node_modules/semver": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/eslint-scope": { "version": "8.4.0", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", @@ -11045,6 +13035,53 @@ "node": ">=18.0.0" } }, + "node_modules/execa": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-4.1.0.tgz", + "integrity": "sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.0", + "get-stream": "^5.0.0", + "human-signals": "^1.1.1", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.0", + "onetime": "^5.1.0", + "signal-exit": "^3.0.2", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/execa/node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/execa/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, "node_modules/expand-template": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", @@ -11065,6 +13102,13 @@ "node": ">=12.0.0" } }, + "node_modules/exponential-backoff": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/express": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", @@ -11269,6 +13313,16 @@ "reusify": "^1.0.4" } }, + "node_modules/fd-package-json": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fd-package-json/-/fd-package-json-2.0.0.tgz", + "integrity": "sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "walk-up-path": "^4.0.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -11495,6 +13549,22 @@ "node": ">= 0.6" } }, + "node_modules/formatly": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/formatly/-/formatly-0.3.0.tgz", + "integrity": "sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "fd-package-json": "^2.0.0" + }, + "bin": { + "formatly": "bin/index.mjs" + }, + "engines": { + "node": ">=18.3.0" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -11547,6 +13617,34 @@ "devOptional": true, "license": "MIT" }, + "node_modules/fs-extra": { + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.5.tgz", + "integrity": "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/fs-minipass": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-3.0.3.tgz", + "integrity": "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "node_modules/fsevents": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", @@ -11808,6 +13906,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/functional-red-black-tree": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", + "integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==", + "dev": true, + "license": "MIT" + }, "node_modules/functions-have-names": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", @@ -11905,6 +14010,22 @@ "node": ">= 0.4" } }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-symbol-description": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", @@ -11924,9 +14045,9 @@ } }, "node_modules/get-tsconfig": { - "version": "4.13.7", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", - "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==", + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", "dev": true, "license": "MIT", "dependencies": { @@ -12522,6 +14643,29 @@ "node": ">=16.9.0" } }, + "node_modules/hosted-git-info": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz", + "integrity": "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^11.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/hosted-git-info/node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/html-encoding-sniffer": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", @@ -12562,6 +14706,13 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "dev": true, + "license": "BSD-2-Clause" + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -12582,6 +14733,30 @@ "url": "https://opencollective.com/express" } }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/http-proxy-agent/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/http-proxy-middleware": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-4.1.0.tgz", @@ -12618,6 +14793,16 @@ "integrity": "sha512-SMS9V6Sn7VWaS11lYhoAr0ceoaiolTWf4jYdJn0NJhCdKMu9R2H9Fh0LBDWBHQF6HRLI1PmaePYsjanSpE5PEw==", "license": "MIT" }, + "node_modules/human-signals": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-1.1.1.tgz", + "integrity": "sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8.12.0" + } + }, "node_modules/husky": { "version": "9.1.7", "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", @@ -12696,6 +14881,58 @@ "node": ">= 4" } }, + "node_modules/ignore-walk": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-8.0.0.tgz", + "integrity": "sha512-FCeMZT4NiRQGh+YkeKMtWrOmBgWjHjMJ26WQWrRQyoyzqevdaGSakUaJW5xQYmjLlUVk2qUnCjYVBax9EKKg8A==", + "dev": true, + "license": "ISC", + "dependencies": { + "minimatch": "^10.0.3" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/ignore-walk/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/ignore-walk/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/ignore-walk/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/immer": { "version": "11.1.4", "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", @@ -13335,6 +15572,30 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-expression": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-expression/-/is-expression-4.0.0.tgz", + "integrity": "sha512-zMIXX63sxzG3XrkHkrAPvm/OVZVSCPNkwMHU8oTX7/U3AL78I0QXCEICXUM13BIa8TYGZ68PiTKfQz3yaTNr4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^7.1.1", + "object-assign": "^4.1.1" + } + }, + "node_modules/is-expression/node_modules/acorn": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", + "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/is-extendable": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", @@ -13654,6 +15915,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-string": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", @@ -13908,6 +16182,13 @@ "node": ">=10" } }, + "node_modules/js-stringify": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/js-stringify/-/js-stringify-1.0.2.tgz", + "integrity": "sha512-rtS5ATOo2Q5k1G+DADISilDA6lv79zIiwFd6CcjuIxGKLFm5C+RLImRscVap9k55i+MOZwgliw+NejvkLuGD5g==", + "dev": true, + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -13936,6 +16217,39 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jscpd": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/jscpd/-/jscpd-4.2.5.tgz", + "integrity": "sha512-KDpApYw1ChGelfHb7MwYTEx694OnW52pv3McAasidUV4ILcGDQMiVJzB+vI8ox+ZPVfOSvdXQCk8uRa9B0LXnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jscpd/badge-reporter": "4.2.5", + "@jscpd/core": "4.2.5", + "@jscpd/finder": "4.2.5", + "@jscpd/html-reporter": "4.2.5", + "@jscpd/tokenizer": "4.2.5", + "colors": "^1.4.0", + "commander": "^15.0.0", + "fs-extra": "^11.2.0", + "jscpd-sarif-reporter": "4.2.5" + }, + "bin": { + "jscpd": "bin/jscpd" + } + }, + "node_modules/jscpd-sarif-reporter": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/jscpd-sarif-reporter/-/jscpd-sarif-reporter-4.2.5.tgz", + "integrity": "sha512-O8LcM9grAS5yO5x1Q0yegYaYcUX//IEBEyvzGFSYCeo1YzHbMnAI6EK7oTrwD+7Csjvfg9m8B8G7OOxzcSlr9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "colors": "^1.4.0", + "fs-extra": "^11.2.0", + "node-sarif-builder": "^4.1.0" + } + }, "node_modules/jsdom": { "version": "29.1.1", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", @@ -14041,6 +16355,16 @@ "dev": true, "license": "MIT" }, + "node_modules/json-stringify-nice": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/json-stringify-nice/-/json-stringify-nice-1.1.4.tgz", + "integrity": "sha512-5Z5RFW63yxReJ7vANgW6eZFGWaQvnPE3WNmZoOJrSkGju2etKA2L5rrOa1sm877TVTFt57A80BH1bArcmlLfPw==", + "dev": true, + "license": "ISC", + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/json-stringify-safe": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", @@ -14066,6 +16390,47 @@ "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", "license": "MIT" }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonparse": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", + "integrity": "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==", + "dev": true, + "engines": [ + "node >= 0.2.0" + ], + "license": "MIT" + }, + "node_modules/jstransformer": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/jstransformer/-/jstransformer-1.0.0.tgz", + "integrity": "sha512-C9YK3Rf8q6VAPDCCU9fnqo3mAfOH6vUGnMcP4AQAYIEpWtfGLpwOTmZ+igtdK5y+VvI2n3CyYSzy4Qh34eq24A==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-promise": "^2.0.0", + "promise": "^7.0.1" + } + }, + "node_modules/jstransformer/node_modules/is-promise": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz", + "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==", + "dev": true, + "license": "MIT" + }, "node_modules/jsx-ast-utils": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", @@ -14082,6 +16447,30 @@ "node": ">=4.0" } }, + "node_modules/jsx-ast-utils-x": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/jsx-ast-utils-x/-/jsx-ast-utils-x-0.1.0.tgz", + "integrity": "sha512-eQQBjBnsVtGacsG9uJNB8qOr3yA8rga4wAaGG1qRcBzSIvfhERLrWxMAM1hp5fcS6Abo8M4+bUBTekYR0qTPQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/just-diff": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/just-diff/-/just-diff-6.0.2.tgz", + "integrity": "sha512-S59eriX5u3/QhMNq3v/gm8Kd0w8OS6Tz2FS1NG4blv+z0MuQcBRJyFWjdovM0Rad4/P4aUPFtnkNjMjyMlMSYA==", + "dev": true, + "license": "MIT" + }, + "node_modules/just-diff-apply": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/just-diff-apply/-/just-diff-apply-5.5.0.tgz", + "integrity": "sha512-OYTthRfSh55WOItVqwpefPtNt2VdKsq5AnAK6apdtR6yCH8pr0CmSr710J0Mf+WdQy7K/OzMy7K2MgAfdQURDw==", + "dev": true, + "license": "MIT" + }, "node_modules/katex": { "version": "0.16.45", "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.45.tgz", @@ -14143,6 +16532,58 @@ "node": ">=0.10.0" } }, + "node_modules/knip": { + "version": "6.16.1", + "resolved": "https://registry.npmjs.org/knip/-/knip-6.16.1.tgz", + "integrity": "sha512-TKMn1rxgH6h9vXR9Y0B+Cq7AdPTr9EI02IwoT65NzqYUkvoDQAaJ/aPybiFpAhZ1px6cNYYwXf86iHkBgzCo9w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/webpro" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/knip" + } + ], + "license": "ISC", + "dependencies": { + "fdir": "^6.5.0", + "formatly": "^0.3.0", + "get-tsconfig": "4.14.0", + "jiti": "^2.7.0", + "oxc-parser": "^0.133.0", + "oxc-resolver": "^11.20.0", + "picomatch": "^4.0.4", + "smol-toml": "^1.6.1", + "strip-json-comments": "5.0.3", + "tinyglobby": "^0.2.16", + "unbash": "^3.0.0", + "yaml": "^2.9.0", + "zod": "^4.1.11" + }, + "bin": { + "knip": "bin/knip.js", + "knip-bun": "bin/knip-bun.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/knip/node_modules/strip-json-comments": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz", + "integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/koffi": { "version": "2.16.1", "resolved": "https://registry.npmjs.org/koffi/-/koffi-2.16.1.tgz", @@ -14221,6 +16662,47 @@ "node": ">= 0.8.0" } }, + "node_modules/license-checker-rseidelsohn": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/license-checker-rseidelsohn/-/license-checker-rseidelsohn-5.0.1.tgz", + "integrity": "sha512-9X+ikKxt9Hy3zOrOZzW1dXL4St5akoYjLt63Am9JZVzU6aTdN+xfDvqySpnJT+gF/h5RmtMk2waW6TDNNCKbqQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@npmcli/arborist": "9.6.0", + "@npmcli/package-json": "7.0.5", + "chalk": "4.1.2", + "debug": "^4.3.4", + "lodash.clonedeep": "^4.5.0", + "mkdirp": "^1.0.4", + "nopt": "^7.2.0", + "semver": "^7.3.5", + "spdx-correct": "^3.2.0", + "spdx-expression-parse": "^4.0.0", + "spdx-satisfies": "^6.0.0", + "treeify": "^1.1.0" + }, + "bin": { + "license-checker-rseidelsohn": "bin/license-checker-rseidelsohn.js" + }, + "engines": { + "node": ">=24", + "npm": ">=11" + } + }, + "node_modules/license-checker-rseidelsohn/node_modules/semver": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -14482,6 +16964,19 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -14561,6 +17056,98 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lockfile-lint": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/lockfile-lint/-/lockfile-lint-5.0.0.tgz", + "integrity": "sha512-QcVIVITLZAhWYHU2wbNSOMgwc6EN4Y2sy6mjgS5aikYyRzgDIfotXUsCrm38En+3fZpc58Yu7DF9dNeT/goi1A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "cosmiconfig": "^9.0.0", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "lockfile-lint-api": "^5.9.2", + "yargs": "^17.7.2" + }, + "bin": { + "lockfile-lint": "bin/lockfile-lint.js" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/lockfile-lint-api": { + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/lockfile-lint-api/-/lockfile-lint-api-5.9.2.tgz", + "integrity": "sha512-3QhxWxl3jT9GcMxuCnTsU8Tz5U6U1lKBlKBu2zOYOz/x3ONUoojEtky3uzoaaDgExcLqIX0Aqv2I7TZXE383CQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@yarnpkg/parsers": "^3.0.0-rc.48.1", + "debug": "^4.3.4", + "object-hash": "^3.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/lockfile-lint/node_modules/cosmiconfig": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.2.tgz", + "integrity": "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/lockfile-lint/node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/lockfile-lint/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/lodash": { "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", @@ -14574,6 +17161,13 @@ "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", "license": "MIT" }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -14785,6 +17379,30 @@ "node": ">=10" } }, + "node_modules/make-fetch-happen": { + "version": "15.0.6", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-15.0.6.tgz", + "integrity": "sha512-Je0fLJ0F5atA7F+eIlLzk+Wkcl57JDf4kf+EW8xiP5E31xOQxkIxTbgf1Oi1Lw9tRI9UEMRdI5Vz2xTzoNU1Jw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@gar/promise-retry": "^1.0.0", + "@npmcli/agent": "^4.0.0", + "@npmcli/redact": "^4.0.0", + "cacache": "^20.0.1", + "http-cache-semantics": "^4.1.1", + "minipass": "^7.0.2", + "minipass-fetch": "^5.0.0", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^1.0.0", + "proc-log": "^6.0.0", + "ssri": "^13.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/markdown-extensions": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/markdown-extensions/-/markdown-extensions-2.0.0.tgz", @@ -14876,6 +17494,12 @@ "node": ">=10" } }, + "node_modules/material-symbols": { + "version": "0.45.1", + "resolved": "https://registry.npmjs.org/material-symbols/-/material-symbols-0.45.1.tgz", + "integrity": "sha512-iQibeoKymxHthNqTjYg/jCN2pRySFQ+DAD0sYK9FS7rUpf38i1qR3N4I1jfi/bLS5qdEKvkkJAvf2mE89amSZw==", + "license": "Apache-2.0" + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -15212,6 +17836,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -16106,6 +18737,142 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/minipass-collect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-2.0.1.tgz", + "integrity": "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minipass-fetch": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-5.0.2.tgz", + "integrity": "sha512-2d0q2a8eCi2IRg/IGubCNRJoYbA1+YPXAzQVRFmB45gdGZafyivnZ5YSEfo3JikbjGxOdntGFvBQGqaSMXlAFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.0.3", + "minipass-sized": "^2.0.0", + "minizlib": "^3.0.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + }, + "optionalDependencies": { + "iconv-lite": "^0.7.2" + } + }, + "node_modules/minipass-flush": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.7.tgz", + "integrity": "sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-flush/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-flush/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/minipass-pipeline": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", + "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-pipeline/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-pipeline/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/minipass-sized": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-2.0.0.tgz", + "integrity": "sha512-zSsHhto5BcUVM2m1LurnXY6M//cGhVaegT71OfOXoprxT6o780GZd792ea6FfrQkuU4usHZIUczAQMRUE2plzA==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/mkdirp-classic": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", @@ -16223,6 +18990,16 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/nanospinner": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/nanospinner/-/nanospinner-1.2.2.tgz", + "integrity": "sha512-Zt/AmG6qRU3e+WnzGGLuMCEAO/dAu45stNbHY223tUxldaDAeE+FxSPsd9Q+j+paejmm0ZbrNVs5Sraqy3dRxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picocolors": "^1.1.1" + } + }, "node_modules/napi-build-utils": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", @@ -16438,6 +19215,106 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/node-gyp": { + "version": "12.4.0", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.4.0.tgz", + "integrity": "sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "graceful-fs": "^4.2.6", + "nopt": "^9.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "tar": "^7.5.4", + "tinyglobby": "^0.2.12", + "undici": "^6.25.0", + "which": "^6.0.0" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/node-gyp/node_modules/abbrev": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", + "integrity": "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/node-gyp/node_modules/isexe": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=20" + } + }, + "node_modules/node-gyp/node_modules/nopt": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz", + "integrity": "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^4.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/node-gyp/node_modules/semver": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-gyp/node_modules/undici": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.26.0.tgz", + "integrity": "sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/node-gyp/node_modules/which": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", + "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^4.0.0" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/node-loader": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/node-loader/-/node-loader-2.1.0.tgz", @@ -16471,6 +19348,200 @@ "dev": true, "license": "MIT" }, + "node_modules/node-sarif-builder": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/node-sarif-builder/-/node-sarif-builder-4.1.0.tgz", + "integrity": "sha512-IWqZF6u0EI/07HTBm+zZ+MgXgWl09dnSJRGaDCPBSlOqilDcx6pj3Mpb3HvPN8V2Gr+ISw7ZrMsL7STWs1F++w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/sarif": "^2.1.7", + "fs-extra": "^11.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/nopt": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz", + "integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^2.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-bundled": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-5.0.0.tgz", + "integrity": "sha512-JLSpbzh6UUXIEoqPsYBvVNVmyrjVZ1fzEFbqxKkTJQkWBO3xFzFT+KDnSKQWwOQNbuWRwt5LSD6HOTLGIWzfrw==", + "dev": true, + "license": "ISC", + "dependencies": { + "npm-normalize-package-bin": "^5.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm-install-checks": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/npm-install-checks/-/npm-install-checks-8.0.0.tgz", + "integrity": "sha512-ScAUdMpyzkbpxoNekQ3tNRdFI8SJ86wgKZSQZdUxT+bj0wVFpsEMWnkXP0twVe1gJyNF5apBWDJhhIbgrIViRA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "semver": "^7.1.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm-install-checks/node_modules/semver": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/npm-normalize-package-bin": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-5.0.0.tgz", + "integrity": "sha512-CJi3OS4JLsNMmr2u07OJlhcrPxCeOeP/4xq67aWNai6TNWWbTrlNDgl8NcFKVlcBKp18GPj+EzbNIgrBfZhsag==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm-package-arg": { + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-13.0.2.tgz", + "integrity": "sha512-IciCE3SY3uE84Ld8WZU23gAPPV9rIYod4F+rc+vJ7h7cwAJt9Vk6TVsK60ry7Uj3SRS3bqRRIGuTp9YVlk6WNA==", + "dev": true, + "license": "ISC", + "dependencies": { + "hosted-git-info": "^9.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "validate-npm-package-name": "^7.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm-package-arg/node_modules/semver": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/npm-packlist": { + "version": "10.0.4", + "resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-10.0.4.tgz", + "integrity": "sha512-uMW73iajD8hiH4ZBxEV3HC+eTnppIqwakjOYuvgddnalIw2lJguKviK1pcUJDlIWm1wSJkchpDZDSVVsZEYRng==", + "dev": true, + "license": "ISC", + "dependencies": { + "ignore-walk": "^8.0.0", + "proc-log": "^6.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm-pick-manifest": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/npm-pick-manifest/-/npm-pick-manifest-11.0.3.tgz", + "integrity": "sha512-buzyCfeoGY/PxKqmBqn1IUJrZnUi1VVJTdSSRPGI60tJdUhUoSQFhs0zycJokDdOznQentgrpf8LayEHyyYlqQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "npm-install-checks": "^8.0.0", + "npm-normalize-package-bin": "^5.0.0", + "npm-package-arg": "^13.0.0", + "semver": "^7.3.5" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm-pick-manifest/node_modules/semver": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/npm-registry-fetch": { + "version": "19.1.1", + "resolved": "https://registry.npmjs.org/npm-registry-fetch/-/npm-registry-fetch-19.1.1.tgz", + "integrity": "sha512-TakBap6OM1w0H73VZVDf44iFXsOS3h+L4wVMXmbWOQroZgFhMch0juN6XSzBNlD965yIKvWg2dfu7NSiaYLxtw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/redact": "^4.0.0", + "jsonparse": "^1.3.1", + "make-fetch-happen": "^15.0.0", + "minipass": "^7.0.2", + "minipass-fetch": "^5.0.0", + "minizlib": "^3.0.1", + "npm-package-arg": "^13.0.0", + "proc-log": "^6.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -16480,6 +19551,16 @@ "node": ">=0.10.0" } }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -16797,6 +19878,85 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/oxc-parser": { + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.133.0.tgz", + "integrity": "sha512-661RSx+ZcjBmjBYid+Fpp/2F5EbtildpeoZh5HdgnGs+jZ03nqQEQW8yGkt4BGyOC3OMPDQQRl8M5kqD2/g6jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "^0.133.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxc-parser/binding-android-arm-eabi": "0.133.0", + "@oxc-parser/binding-android-arm64": "0.133.0", + "@oxc-parser/binding-darwin-arm64": "0.133.0", + "@oxc-parser/binding-darwin-x64": "0.133.0", + "@oxc-parser/binding-freebsd-x64": "0.133.0", + "@oxc-parser/binding-linux-arm-gnueabihf": "0.133.0", + "@oxc-parser/binding-linux-arm-musleabihf": "0.133.0", + "@oxc-parser/binding-linux-arm64-gnu": "0.133.0", + "@oxc-parser/binding-linux-arm64-musl": "0.133.0", + "@oxc-parser/binding-linux-ppc64-gnu": "0.133.0", + "@oxc-parser/binding-linux-riscv64-gnu": "0.133.0", + "@oxc-parser/binding-linux-riscv64-musl": "0.133.0", + "@oxc-parser/binding-linux-s390x-gnu": "0.133.0", + "@oxc-parser/binding-linux-x64-gnu": "0.133.0", + "@oxc-parser/binding-linux-x64-musl": "0.133.0", + "@oxc-parser/binding-openharmony-arm64": "0.133.0", + "@oxc-parser/binding-wasm32-wasi": "0.133.0", + "@oxc-parser/binding-win32-arm64-msvc": "0.133.0", + "@oxc-parser/binding-win32-ia32-msvc": "0.133.0", + "@oxc-parser/binding-win32-x64-msvc": "0.133.0" + } + }, + "node_modules/oxc-parser/node_modules/@oxc-project/types": { + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", + "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/oxc-resolver": { + "version": "11.20.0", + "resolved": "https://registry.npmjs.org/oxc-resolver/-/oxc-resolver-11.20.0.tgz", + "integrity": "sha512-CblytBiV/a/ZXY34dsVU2NxhIOxMXst8CvDCtyBelVITgd7PLrKzbEbA6oKLdPjvDKDzCiW48qzmzZ+mYaqn+g==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxc-resolver/binding-android-arm-eabi": "11.20.0", + "@oxc-resolver/binding-android-arm64": "11.20.0", + "@oxc-resolver/binding-darwin-arm64": "11.20.0", + "@oxc-resolver/binding-darwin-x64": "11.20.0", + "@oxc-resolver/binding-freebsd-x64": "11.20.0", + "@oxc-resolver/binding-linux-arm-gnueabihf": "11.20.0", + "@oxc-resolver/binding-linux-arm-musleabihf": "11.20.0", + "@oxc-resolver/binding-linux-arm64-gnu": "11.20.0", + "@oxc-resolver/binding-linux-arm64-musl": "11.20.0", + "@oxc-resolver/binding-linux-ppc64-gnu": "11.20.0", + "@oxc-resolver/binding-linux-riscv64-gnu": "11.20.0", + "@oxc-resolver/binding-linux-riscv64-musl": "11.20.0", + "@oxc-resolver/binding-linux-s390x-gnu": "11.20.0", + "@oxc-resolver/binding-linux-x64-gnu": "11.20.0", + "@oxc-resolver/binding-linux-x64-musl": "11.20.0", + "@oxc-resolver/binding-openharmony-arm64": "11.20.0", + "@oxc-resolver/binding-wasm32-wasi": "11.20.0", + "@oxc-resolver/binding-win32-arm64-msvc": "11.20.0", + "@oxc-resolver/binding-win32-x64-msvc": "11.20.0" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -16829,6 +19989,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-map": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", + "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/package-json": { "version": "10.0.1", "resolved": "https://registry.npmjs.org/package-json/-/package-json-10.0.1.tgz", @@ -16865,6 +20038,38 @@ "integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==", "license": "MIT" }, + "node_modules/pacote": { + "version": "21.5.1", + "resolved": "https://registry.npmjs.org/pacote/-/pacote-21.5.1.tgz", + "integrity": "sha512-KvcJ9iy3crysCsgqc4+PknH/w6jkrp8JN36mpZBPwNaDRwTfMZD37YzRazNstiZUOhuF5pno9f78n9mEJBavwg==", + "dev": true, + "license": "ISC", + "dependencies": { + "@gar/promise-retry": "^1.0.0", + "@npmcli/git": "^7.0.0", + "@npmcli/installed-package-contents": "^4.0.0", + "@npmcli/package-json": "^7.0.0", + "@npmcli/promise-spawn": "^9.0.0", + "@npmcli/run-script": "^10.0.0", + "cacache": "^20.0.0", + "fs-minipass": "^3.0.0", + "minipass": "^7.0.2", + "npm-package-arg": "^13.0.0", + "npm-packlist": "^10.0.1", + "npm-pick-manifest": "^11.0.1", + "npm-registry-fetch": "^19.0.0", + "proc-log": "^6.0.0", + "sigstore": "^4.0.0", + "ssri": "^13.0.0", + "tar": "^7.4.3" + }, + "bin": { + "pacote": "bin/index.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -16877,6 +20082,31 @@ "node": ">=6" } }, + "node_modules/parse-conflict-json": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/parse-conflict-json/-/parse-conflict-json-5.0.1.tgz", + "integrity": "sha512-ZHEmNKMq1wyJXNwLxyHnluPfRAFSIliBvbK/UiOceROt4Xh9Pz0fq49NytIaeaCUf5VR86hwQ/34FCcNU5/LKQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "json-parse-even-better-errors": "^5.0.0", + "just-diff": "^6.0.0", + "just-diff-apply": "^5.2.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/parse-conflict-json/node_modules/json-parse-even-better-errors": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-5.0.0.tgz", + "integrity": "sha512-ZF1nxZ28VhQouRWhUcVlUIN3qwSgPuswK05s/HIaoetAoE/9tngVmCHjSxmSQPav1nd+lPtTL0YZ/2AFdR/iYQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/parse-entities": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", @@ -17311,6 +20541,20 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss-selector-parser": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.4.tgz", + "integrity": "sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/powershell-utils": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz", @@ -17377,6 +20621,16 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/proc-log": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", + "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/process-warning": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", @@ -17393,6 +20647,46 @@ ], "license": "MIT" }, + "node_modules/proggy": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/proggy/-/proggy-4.0.0.tgz", + "integrity": "sha512-MbA4R+WQT76ZBm/5JUpV9yqcJt92175+Y0Bodg3HgiXzrmKu7Ggq+bpn6y6wHH+gN9NcyKn3yg1+d47VaKwNAQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/promise": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz", + "integrity": "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "asap": "~2.0.3" + } + }, + "node_modules/promise-all-reject-late": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/promise-all-reject-late/-/promise-all-reject-late-1.0.1.tgz", + "integrity": "sha512-vuf0Lf0lOxyQREH7GDIOUMLS7kz+gs8i6B+Yi8dC68a2sychGrHTJYghMBD6k7eUcH0H5P73EckCA48xijWqXw==", + "dev": true, + "license": "ISC", + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/promise-call-limit": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/promise-call-limit/-/promise-call-limit-3.0.2.tgz", + "integrity": "sha512-mRPQO2T1QQVw11E7+UdCJu7S61eJVWknzml9sC1heAdj1jxl0fWMBypIt9ZOcLFf8FkG995ZD7RnVk7HH72fZw==", + "dev": true, + "license": "ISC", + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -17491,6 +20785,142 @@ "node": ">=10" } }, + "node_modules/pug": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pug/-/pug-3.0.4.tgz", + "integrity": "sha512-kFfq5mMzrS7+wrl5pLJzZEzemx34OQ0w4SARfhy/3yxTlhbstsudDwJzhf1hP02yHzbjoVMSXUj/Sz6RNfMyXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "pug-code-gen": "^3.0.4", + "pug-filters": "^4.0.0", + "pug-lexer": "^5.0.1", + "pug-linker": "^4.0.0", + "pug-load": "^3.0.0", + "pug-parser": "^6.0.0", + "pug-runtime": "^3.0.1", + "pug-strip-comments": "^2.0.0" + } + }, + "node_modules/pug-attrs": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pug-attrs/-/pug-attrs-3.0.0.tgz", + "integrity": "sha512-azINV9dUtzPMFQktvTXciNAfAuVh/L/JCl0vtPCwvOA21uZrC08K/UnmrL+SXGEVc1FwzjW62+xw5S/uaLj6cA==", + "dev": true, + "license": "MIT", + "dependencies": { + "constantinople": "^4.0.1", + "js-stringify": "^1.0.2", + "pug-runtime": "^3.0.0" + } + }, + "node_modules/pug-code-gen": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pug-code-gen/-/pug-code-gen-3.0.4.tgz", + "integrity": "sha512-6okWYIKdasTyXICyEtvobmTZAVX57JkzgzIi4iRJlin8kmhG+Xry2dsus+Mun/nGCn6F2U49haHI5mkELXB14g==", + "dev": true, + "license": "MIT", + "dependencies": { + "constantinople": "^4.0.1", + "doctypes": "^1.1.0", + "js-stringify": "^1.0.2", + "pug-attrs": "^3.0.0", + "pug-error": "^2.1.0", + "pug-runtime": "^3.0.1", + "void-elements": "^3.1.0", + "with": "^7.0.0" + } + }, + "node_modules/pug-error": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pug-error/-/pug-error-2.1.0.tgz", + "integrity": "sha512-lv7sU9e5Jk8IeUheHata6/UThZ7RK2jnaaNztxfPYUY+VxZyk/ePVaNZ/vwmH8WqGvDz3LrNYt/+gA55NDg6Pg==", + "dev": true, + "license": "MIT" + }, + "node_modules/pug-filters": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/pug-filters/-/pug-filters-4.0.0.tgz", + "integrity": "sha512-yeNFtq5Yxmfz0f9z2rMXGw/8/4i1cCFecw/Q7+D0V2DdtII5UvqE12VaZ2AY7ri6o5RNXiweGH79OCq+2RQU4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "constantinople": "^4.0.1", + "jstransformer": "1.0.0", + "pug-error": "^2.0.0", + "pug-walk": "^2.0.0", + "resolve": "^1.15.1" + } + }, + "node_modules/pug-lexer": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pug-lexer/-/pug-lexer-5.0.1.tgz", + "integrity": "sha512-0I6C62+keXlZPZkOJeVam9aBLVP2EnbeDw3An+k0/QlqdwH6rv8284nko14Na7c0TtqtogfWXcRoFE4O4Ff20w==", + "dev": true, + "license": "MIT", + "dependencies": { + "character-parser": "^2.2.0", + "is-expression": "^4.0.0", + "pug-error": "^2.0.0" + } + }, + "node_modules/pug-linker": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/pug-linker/-/pug-linker-4.0.0.tgz", + "integrity": "sha512-gjD1yzp0yxbQqnzBAdlhbgoJL5qIFJw78juN1NpTLt/mfPJ5VgC4BvkoD3G23qKzJtIIXBbcCt6FioLSFLOHdw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pug-error": "^2.0.0", + "pug-walk": "^2.0.0" + } + }, + "node_modules/pug-load": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pug-load/-/pug-load-3.0.0.tgz", + "integrity": "sha512-OCjTEnhLWZBvS4zni/WUMjH2YSUosnsmjGBB1An7CsKQarYSWQ0GCVyd4eQPMFJqZ8w9xgs01QdiZXKVjk92EQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "object-assign": "^4.1.1", + "pug-walk": "^2.0.0" + } + }, + "node_modules/pug-parser": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/pug-parser/-/pug-parser-6.0.0.tgz", + "integrity": "sha512-ukiYM/9cH6Cml+AOl5kETtM9NR3WulyVP2y4HOU45DyMim1IeP/OOiyEWRr6qk5I5klpsBnbuHpwKmTx6WURnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pug-error": "^2.0.0", + "token-stream": "1.0.0" + } + }, + "node_modules/pug-runtime": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/pug-runtime/-/pug-runtime-3.0.1.tgz", + "integrity": "sha512-L50zbvrQ35TkpHwv0G6aLSuueDRwc/97XdY8kL3tOT0FmhgG7UypU3VztfV/LATAvmUfYi4wNxSajhSAeNN+Kg==", + "dev": true, + "license": "MIT" + }, + "node_modules/pug-strip-comments": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pug-strip-comments/-/pug-strip-comments-2.0.0.tgz", + "integrity": "sha512-zo8DsDpH7eTkPHCXFeAk1xZXJbyoTfdPlNR0bK7rpOMuhBYb0f5qUVCO1xlsitYd3w5FQTK7zpNVKb3rZoUrrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "pug-error": "^2.0.0" + } + }, + "node_modules/pug-walk": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pug-walk/-/pug-walk-2.0.0.tgz", + "integrity": "sha512-yYELe9Q5q9IQhuvqsZNwA5hfPkMJ8u92bQLIMcsMxf/VADjNtEYptU+inlufAFYcWdHlwNfZOEnOOQrZrcyJCQ==", + "dev": true, + "license": "MIT" + }, "node_modules/pump": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", @@ -17795,6 +21225,16 @@ } } }, + "node_modules/read-cmd-shim": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/read-cmd-shim/-/read-cmd-shim-6.0.0.tgz", + "integrity": "sha512-1zM5HuOfagXCBWMN83fuFI/x+T/UhZ7k+KIzhrHXcQoeX5+7gmaDYjELQHmmzIodumBHeByBJT4QYS7ufAgs7A==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -17989,6 +21429,19 @@ "redux": "^5.0.0" } }, + "node_modules/refa": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/refa/-/refa-0.12.1.tgz", + "integrity": "sha512-J8rn6v4DBb2nnFqkqwy6/NnTYMcgLA+sLr0iIO41qpv0n+ngb7ksag2tMRl0inb1bbO/esUwzW1vbJi7K0sI0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.8.0" + }, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, "node_modules/reflect-metadata": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", @@ -18042,6 +21495,20 @@ "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", "license": "MIT" }, + "node_modules/regexp-ast-analysis": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/regexp-ast-analysis/-/regexp-ast-analysis-0.7.1.tgz", + "integrity": "sha512-sZuz1dYW/ZsfG17WSAG7eS85r5a0dDsvg+7BiiYR5o6lKCAtUrEwdmRmaGF6rwVj3LcmAeYkOWKEPlbPzN3Y3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.8.0", + "refa": "^0.12.1" + }, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, "node_modules/regexp.prototype.flags": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", @@ -18216,6 +21683,16 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/repeat-string": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", + "integrity": "sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -18582,6 +22059,21 @@ "compute-scroll-into-view": "^3.0.2" } }, + "node_modules/scslre": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/scslre/-/scslre-0.3.0.tgz", + "integrity": "sha512-3A6sD0WYP7+QrjbfNA2FN3FsOaGGFoekCVgTyypy53gPxhbkCIjtO6YWgdrfM+n/8sI8JeXZOIxsHjMTNxQ4nQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.8.0", + "refa": "^0.12.0", + "regexp-ast-analysis": "^0.7.0" + }, + "engines": { + "node": "^14.0.0 || >=16.0.0" + } + }, "node_modules/section-matter": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz", @@ -18967,6 +22459,24 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/sigstore": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/sigstore/-/sigstore-4.1.1.tgz", + "integrity": "sha512-endqECJkfhozrXMK5ngu/UAA0xVcVEFdnHJCElGaExypjW+HK5i6zu3NteLoaX/iFbRUbC3+DjttQs0GARr+5w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/bundle": "^4.0.0", + "@sigstore/core": "^3.2.1", + "@sigstore/protobuf-specs": "^0.5.0", + "@sigstore/sign": "^4.1.1", + "@sigstore/tuf": "^4.0.2", + "@sigstore/verify": "^3.1.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/simple-concat": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", @@ -19014,6 +22524,34 @@ "simple-concat": "^1.0.0" } }, + "node_modules/size-limit": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/size-limit/-/size-limit-12.1.0.tgz", + "integrity": "sha512-VnDS2fycANrJFVPQwjaD+h+hkISY7EB3LsPsYWje4lBCjQwwsZLxjwwRwVJKHrcj2ZqyG+DdXykWm9mbZklZrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes-iec": "^3.1.1", + "lilconfig": "^3.1.3", + "nanospinner": "^1.2.2", + "picocolors": "^1.1.1", + "tinyglobby": "^0.2.16" + }, + "bin": { + "size-limit": "bin.js" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "jiti": "^2.0.0" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, "node_modules/skin-tone": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/skin-tone/-/skin-tone-2.0.0.tgz", @@ -19066,6 +22604,19 @@ "npm": ">= 3.0.0" } }, + "node_modules/smol-toml": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.1.tgz", + "integrity": "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, "node_modules/socks": { "version": "2.8.7", "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", @@ -19080,6 +22631,31 @@ "npm": ">= 3.0.0" } }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/socks-proxy-agent/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/sonic-boom": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", @@ -19117,6 +22693,113 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/spark-md5": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/spark-md5/-/spark-md5-3.0.2.tgz", + "integrity": "sha512-wcFzz9cDfbuqe0FZzfi2or1sgyIrsDwmPwfZC4hiNidPdPINjeUwNfv5kldczoEAcjl9Y1L3SM7Uz2PUEQzxQw==", + "dev": true, + "license": "(WTFPL OR MIT)" + }, + "node_modules/spdx-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/spdx-compare/-/spdx-compare-1.0.0.tgz", + "integrity": "sha512-C1mDZOX0hnu0ep9dfmuoi03+eOdDoz2yvK79RxbcrVEG1NO1Ph35yW102DHWKN4pk80nwCgeMmSY5L25VE4D9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-find-index": "^1.0.2", + "spdx-expression-parse": "^3.0.0", + "spdx-ranges": "^2.0.0" + } + }, + "node_modules/spdx-compare/node_modules/spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-correct": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-correct/node_modules/spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "dev": true, + "license": "CC-BY-3.0" + }, + "node_modules/spdx-expression-parse": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-4.0.0.tgz", + "integrity": "sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz", + "integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/spdx-ranges": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/spdx-ranges/-/spdx-ranges-2.1.1.tgz", + "integrity": "sha512-mcdpQFV7UDAgLpXEE/jOMqvK4LBoO0uTQg0uvXUewmEFhpiZx5yJSZITHB8w1ZahKdhfZqP5GPEOKLyEq5p8XA==", + "dev": true, + "license": "(MIT AND CC-BY-3.0)" + }, + "node_modules/spdx-satisfies": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/spdx-satisfies/-/spdx-satisfies-6.0.0.tgz", + "integrity": "sha512-oOWQocnRbFVtBnBITfFgzjhnOklHossTvI+6C1hB2slvp3HgTsfru5wuo8HY2rQpwSm5JuIhNzIuqOfR5IuojQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "spdx-compare": "^1.0.0", + "spdx-expression-parse": "^3.0.0", + "spdx-ranges": "^2.0.0" + } + }, + "node_modules/spdx-satisfies/node_modules/spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, "node_modules/split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", @@ -19216,6 +22899,19 @@ "win32" ] }, + "node_modules/ssri": { + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-13.0.1.tgz", + "integrity": "sha512-QUiRf1+u9wPTL/76GTYlKttDEBWV1ga9ZXW8BG6kfdeyyM8LGPix9gROyg9V2+P0xNyF3X2Go526xKFdMZrHSQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/stable-hash": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", @@ -19526,6 +23222,16 @@ "node": ">=0.10.0" } }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/strip-indent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", @@ -19729,6 +23435,23 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tar": { + "version": "7.5.16", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.16.tgz", + "integrity": "sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/tar-fs": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", @@ -19759,6 +23482,26 @@ "node": ">=6" } }, + "node_modules/tar/node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/terminal-size": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/terminal-size/-/terminal-size-4.0.1.tgz", @@ -19966,6 +23709,13 @@ "node": ">=0.6" } }, + "node_modules/token-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/token-stream/-/token-stream-1.0.0.tgz", + "integrity": "sha512-VSsyNPPW74RpHwR8Fc21uubwHY7wMDeJLys2IX5zJNih+OnAnaifKHo+1LHT7DAdloQ7apeaaWg8l7qnf/TnEg==", + "dev": true, + "license": "MIT" + }, "node_modules/tough-cookie": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", @@ -20002,6 +23752,26 @@ "tree-kill": "cli.js" } }, + "node_modules/treeify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/treeify/-/treeify-1.1.0.tgz", + "integrity": "sha512-1m4RA7xVAJrSGrrXGs0L3YTwyvBs2S8PbRHaLZAkFw7JR8oIFwYtysxlBZhYIa7xSyiYJKZ3iGrrk55cGA3i9A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/treeverse": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/treeverse/-/treeverse-3.0.0.tgz", + "integrity": "sha512-gcANaAnd2QDZFmHFEOF4k7uc1J/6a6z3DJMd/QwEyxLoKGiptJRwid582r7QIsFlFMIZ3SnxfS52S4hm2DHkuQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "node_modules/trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", @@ -20076,6 +23846,29 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tsutils": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz", + "integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^1.8.1" + }, + "engines": { + "node": ">= 6" + }, + "peerDependencies": { + "typescript": ">=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta" + } + }, + "node_modules/tsutils/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true, + "license": "0BSD" + }, "node_modules/tsx": { "version": "4.22.4", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", @@ -20126,6 +23919,21 @@ "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", "license": "0BSD" }, + "node_modules/tuf-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/tuf-js/-/tuf-js-4.1.0.tgz", + "integrity": "sha512-50QV99kCKH5P/Vs4E2Gzp7BopNV+KzTXqWeaxrfu5IQJBOULRsTIS9seSsOVT8ZnGXzCyx55nYWAi4qJzpZKEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tufjs/models": "4.1.0", + "debug": "^4.4.3", + "make-fetch-happen": "^15.0.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/tunnel-agent": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", @@ -20152,6 +23960,77 @@ "node": ">= 0.8.0" } }, + "node_modules/type-coverage": { + "version": "2.29.7", + "resolved": "https://registry.npmjs.org/type-coverage/-/type-coverage-2.29.7.tgz", + "integrity": "sha512-E67Chw7SxFe++uotisxt/xzB1UxxvLztzzQqVyUZ/jKujsejVqvoO5vn25oMvqJydqYrASBVBCQCy082E2qQYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "4.1.2", + "minimist": "1", + "type-coverage-core": "^2.29.7" + }, + "bin": { + "type-coverage": "bin/type-coverage" + } + }, + "node_modules/type-coverage-core": { + "version": "2.29.7", + "resolved": "https://registry.npmjs.org/type-coverage-core/-/type-coverage-core-2.29.7.tgz", + "integrity": "sha512-bt+bnXekw3p5NnqiZpNupOOxfUKGw2Z/YJedfGHkxpeyGLK7DZ59a6Wds8eq1oKjJc5Wulp2xL207z8FjFO14Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-glob": "3", + "minimatch": "6 || 7 || 8 || 9 || 10", + "normalize-path": "3", + "tslib": "1 || 2", + "tsutils": "3" + }, + "peerDependencies": { + "typescript": "2 || 3 || 4 || 5" + } + }, + "node_modules/type-coverage-core/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/type-coverage-core/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/type-coverage-core/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/type-fest": { "version": "4.41.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", @@ -20300,6 +24179,16 @@ "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", "license": "MIT" }, + "node_modules/unbash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/unbash/-/unbash-3.0.0.tgz", + "integrity": "sha512-FeFPZ/WFT0mbRCuydiZzpPFlrYN8ZUpphQKoq4EeElVIYjYyGzPMxQR/simUwCOJIyVhpFk4RbtyO7RuMpMnHA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + } + }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", @@ -20457,6 +24346,16 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -20714,6 +24613,16 @@ "dev": true, "license": "MIT" }, + "node_modules/validate-npm-package-name": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-7.0.2.tgz", + "integrity": "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -20970,6 +24879,16 @@ } } }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", @@ -21003,6 +24922,16 @@ "node": ">=20.0.0" } }, + "node_modules/walk-up-path": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/walk-up-path/-/walk-up-path-4.0.0.tgz", + "integrity": "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==", + "dev": true, + "license": "ISC", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/web-namespaces": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", @@ -21207,6 +25136,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/with": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/with/-/with-7.0.2.tgz", + "integrity": "sha512-RNGKj82nUPg3g5ygxkQl0R937xLyho1J24ItRCBTr/m1YnZkzJy1hUiHUJrc/VlsDQzsCnInEGSg3bci0Lmd4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.9.6", + "@babel/types": "^7.9.6", + "assert-never": "^1.2.1", + "babel-walk": "3.0.0-canary-5" + }, + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -21268,6 +25213,19 @@ "win32" ] }, + "node_modules/write-file-atomic": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-7.0.1.tgz", + "integrity": "sha512-OTIk8iR8/aCRWBqvxrzxR0hgxWpnYBblY1S5hDWBQfk/VFmJwzmJgQFN3WsoUKHISv2eAwe+PpbUzyL1CKTLXg==", + "dev": true, + "license": "ISC", + "dependencies": { + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/ws": { "version": "8.21.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", @@ -21390,7 +25348,6 @@ "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", "dev": true, "license": "ISC", - "optional": true, "bin": { "yaml": "bin.mjs" }, @@ -21587,7 +25544,7 @@ }, "open-sse": { "name": "@omniroute/open-sse", - "version": "3.8.23" + "version": "3.8.24" } } } diff --git a/package.json b/package.json index e41b18509c..81973cc165 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "omniroute", - "version": "3.8.23", + "version": "3.8.24", "description": "Unified AI router with 160+ providers, RTK+Caveman compression, auto fallback, MCP/A2A, desktop, PWA, and OpenAI-compatible APIs.", "type": "module", "bin": { @@ -121,6 +121,7 @@ "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:tracked-artifacts": "node scripts/check/check-tracked-artifacts.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", @@ -131,9 +132,23 @@ "check:route-guard-membership": "node --import tsx scripts/check/check-route-guard-membership.ts", "check:test-discovery": "node scripts/check/check-test-discovery.mjs", "check:complexity": "node scripts/check/check-complexity.mjs", + "check:dead-code": "node scripts/check/check-dead-code.mjs", + "check:cognitive-complexity": "node scripts/check/check-cognitive-complexity.mjs", + "check:type-coverage": "node scripts/check/check-type-coverage.mjs", + "check:lockfile": "node scripts/check/check-lockfile.mjs", + "check:bundle-size": "node scripts/check/check-bundle-size.mjs", + "check:circular-deps": "node scripts/check/check-circular-deps.mjs", + "check:licenses": "node scripts/check/check-licenses.mjs", + "check:pr-evidence": "node scripts/check/check-pr-evidence.mjs", + "check:vuln-ratchet": "node scripts/check/check-vuln-ratchet.mjs", + "check:codeql-ratchet": "node scripts/check/check-codeql-ratchet.mjs", + "check:secrets": "node scripts/check/check-secrets.mjs", + "check:workflows": "node scripts/check/check-workflows.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", + "quality:scan": "node scripts/quality/run-all-gates.mjs", + "quality:scan:fast": "node scripts/quality/run-all-gates.mjs --fast", "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", @@ -201,6 +216,7 @@ "lucide-react": "^1.16.0", "marked": "^18.0.4", "marked-terminal": "^7.3.0", + "material-symbols": "^0.45.1", "mermaid": "^11.15.0", "monaco-editor": "^0.55.1", "next": "^16.2.6", @@ -256,15 +272,23 @@ "c8": "^11.0.0", "concurrently": "^10.0.3", "cross-env": "^10.1.0", + "dpdm": "^4.2.0", "eslint": "^9.39.4", "eslint-config-next": "16.2.7", + "eslint-plugin-sonarjs": "^4.0.3", "glob": "^13.0.6", "husky": "^9.1.7", + "jscpd": "^4.2.5", "jsdom": "^29.1.1", + "knip": "^6.16.1", + "license-checker-rseidelsohn": "^5.0.1", "lint-staged": "^17.0.5", + "lockfile-lint": "^5.0.0", "node-loader": "^2.1.0", "prettier": "^3.8.3", + "size-limit": "^12.1.0", "tailwindcss": "^4.3.0", + "type-coverage": "^2.29.7", "typescript": "^6.0.3", "typescript-eslint": "^8.59.4", "vitest": "^4.1.7", diff --git a/quality-baseline.json b/quality-baseline.json index 67242ed671..2488a1c320 100644 --- a/quality-baseline.json +++ b/quality-baseline.json @@ -2,9 +2,14 @@ "_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": 3501, + "value": 3658, "direction": "down" }, + "eslintErrors": { + "value": 0, + "direction": "down", + "eps": 0 + }, "coverage.statements": { "value": 76.5, "direction": "up" @@ -19,9 +24,23 @@ }, "coverage.branches": { "value": 73, - "direction": "up" + "direction": "up", + "eps": 1.5 + }, + "openapiCoverage.pct": { + "value": 38.3, + "direction": "up", + "eps": 0.5 + }, + "i18nUiCoverage.pct": { + "value": 80.1, + "direction": "up", + "eps": 0.5 } }, "_coverage_note": "Pisos anti-flake ~2pt abaixo do real do CI mergeado MEDIDO COM os 135 testes religados (run 27247237268: statements 78.4 / lines 78.4 / functions 83.84 / branches 75.73). O religamento da 6A.1 HONESTIFICOU a regua: os ~82.5 anteriores eram inflados porque modulos nunca importados ficavam fora do denominador do c8. Apertar via --require-tighten na Fase 6A (2026-06-16).", - "_eslint_rebaseline_2026_06_09": "Baseline 3482 foi congelado na branch das Fases 0-6 (base ~v3.8.17) ANTES da v3.8.18 sair; a tag v3.8.18 publicada ja media 3501 (delta nasceu no fim daquele ciclo, antes do job quality-gate existir no ci.yml). O ciclo v3.8.19 esta NEUTRO (tag 3501 == HEAD 3501). Re-baseline para o estado real da main publicada; reduzir os ~19 (any em codigo do ciclo v3.8.18) e ligar --require-tighten ficam para a Fase 6A (2026-06-16)." + "_eslint_rebaseline_2026_06_09": "Baseline 3482 foi congelado na branch das Fases 0-6 (base ~v3.8.17) ANTES da v3.8.18 sair; a tag v3.8.18 publicada ja media 3501 (delta nasceu no fim daquele ciclo, antes do job quality-gate existir no ci.yml). O ciclo v3.8.19 esta NEUTRO (tag 3501 == HEAD 3501). Re-baseline para o estado real da main publicada; reduzir os ~19 (any em codigo do ciclo v3.8.18) e ligar --require-tighten ficam para a Fase 6A (2026-06-16).", + "_eslint_rebaseline_2026_06_13_int6a": "3501 -> 3653. Medido: dos 152 de drift, ~132 sao pre-existentes dos ciclos v3.8.20-v3.8.24 (base 33667fcf3 mede ~3633) e ~20 vem dos scripts .mjs de tooling novos das Fases 6A.3-6A.12 (gates de qualidade; warnings de estilo aceitaveis em tooling). Apertar via --require-tighten fica para o fim do ciclo.", + "_eslint_rebaseline_2026_06_13_phase7": "3653 -> 3658: +5 warnings dos scripts .mjs de tooling novos da Fase 7 (check-vuln-ratchet/codeql/secrets/workflows/dead-code/etc). Tooling de qualidade; apertar via --require-tighten no fim do ciclo.", + "_metrics_added_2026_06_13_6a11": "openapiCoverage.pct (38.3) e i18nUiCoverage.pct (80.1) promovidas de pisos manuais THRESHOLD para metricas de catraca (direction up) na Task 6A.11. eslintErrors fixado em 0 (era metrica orfa detectada pelo motor v2 da 6A.5)." } diff --git a/scripts/build/assembleStandalone.mjs b/scripts/build/assembleStandalone.mjs index e65cfe86a8..a7afff2d7a 100644 --- a/scripts/build/assembleStandalone.mjs +++ b/scripts/build/assembleStandalone.mjs @@ -32,7 +32,7 @@ * Turbopack hashed-chunk patch (.next/server/ *.js) - Y - SHARED (opt-in: patchTurbopackChunks) * --- npm-UNIQUE --- * MITM tsc compile -> app/src/mitm/ - Y - UNIQUE (prepublish) - * MCP server esbuild -> app/open-sse/mcp-server/server.js - Y - UNIQUE (prepublish) + * MCP server esbuild -> dist/open-sse/mcp-server/server.js - Y - UNIQUE (prepublish) * CLI esbuild -> bin/omniroute.mjs - Y - UNIQUE (prepublish) * sidecar/doc copies (.env.example, docs/, sync-env, etc.) - Y - UNIQUE (prepublish) * prune + validate (pack-artifact-policy) - Y - UNIQUE (prepublish) diff --git a/scripts/check/check-bundle-size.mjs b/scripts/check/check-bundle-size.mjs new file mode 100644 index 0000000000..994b0bf3d6 --- /dev/null +++ b/scripts/check/check-bundle-size.mjs @@ -0,0 +1,173 @@ +#!/usr/bin/env node +// scripts/check/check-bundle-size.mjs +// Catraca de bundle size (Task 12 — Fase 7). +// +// MODO PREFERENCIAL — size-limit + @size-limit/file (ou outro plugin): +// Rodar `size-limit --json` via .size-limit.json; extrair o campo `size` de cada +// entry e somar. Emite `bundleSize=`. +// +// MODO FALLBACK — raw fs.statSync() (sem plugins instalados): +// Quando size-limit retorna "no plugins" (isEmpty — só o core está instalado), o +// script lê os `path` declarados em .size-limit.json diretamente via fs.statSync() +// e soma os bytes. Mesmas entradas, mesma métrica. Emite `bundleSize=`. +// +// MODO SKIP — entradas inexistentes: +// Se nenhuma das entradas do .size-limit.json existir (ex: build não rodou e os +// arquivos apontados são artefatos gerados), emite `bundleSize=SKIP reason=no-build` +// e sai 0. +// +// Esta versão é ADVISORY: sempre sai 0 independente do resultado. +// O ratchet (direction:down) é gerenciado pelo motor de quality-baseline.json na +// integração com o CI (Task 12 INT). +// +// Uso: +// node scripts/check/check-bundle-size.mjs +// node scripts/check/check-bundle-size.mjs --json (força saída JSON de size-limit se possível) +import fs from "node:fs"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; +import { pathToFileURL, fileURLToPath } from "node:url"; + +const ROOT = process.cwd(); +const SIZE_LIMIT_CONFIG = path.join(ROOT, ".size-limit.json"); +const SIZE_LIMIT_BIN = path.join(ROOT, "node_modules", ".bin", "size-limit"); + +/** + * Tenta rodar size-limit --json e retorna o array de resultados. + * Lança se size-limit não tiver plugins instalados (plugins.isEmpty). + * + * @returns {Array<{name: string, size: number, sizeLimit?: number, passed?: boolean}>} + * @throws {SizeLimitNoPluginsError} + */ +export function runSizeLimit(cwd = ROOT, binPath = SIZE_LIMIT_BIN) { + if (!fs.existsSync(binPath)) { + throw Object.assign(new Error("size-limit binary not found"), { code: "SL_NO_BIN" }); + } + let stdout; + try { + stdout = execFileSync("node", [binPath, "--json"], { + encoding: "utf8", + cwd, + maxBuffer: 8 * 1024 * 1024, + }); + } catch (err) { + const combined = (err.stdout || "") + (err.stderr || ""); + if ( + combined.includes("Install Size Limit preset") || + combined.includes("plugins.isEmpty") || + combined.includes("@size-limit/preset") + ) { + throw Object.assign(new Error("size-limit: no plugins installed"), { + code: "SL_NO_PLUGINS", + }); + } + throw err; + } + return JSON.parse(stdout.trim()); +} + +/** + * Parseia o JSON de saída do size-limit e retorna o total em bytes. + * Lança se o JSON não tiver o campo `size` em pelo menos uma entrada. + * + * @param {Array<{name: string, size?: number}>} results + * @returns {number} total em bytes + */ +export function parseSizeLimitResults(results) { + if (!Array.isArray(results)) { + throw new TypeError("parseSizeLimitResults: esperado array de resultados"); + } + let total = 0; + let hasMeasured = false; + for (const entry of results) { + if (typeof entry.size === "number") { + total += entry.size; + hasMeasured = true; + } + } + if (!hasMeasured) { + throw new Error("parseSizeLimitResults: nenhuma entrada com campo `size` numérico"); + } + return total; +} + +/** + * Fallback: lê os `path` do .size-limit.json via fs.statSync(). + * Retorna {total, entries, allMissing} onde: + * - total: soma dos bytes dos arquivos encontrados + * - entries: [{name, path, size}] + * - allMissing: true se NENHUM arquivo existia (skip) + * + * @param {string} configPath + * @param {string} cwd + */ +export function measureViaFileStat(configPath = SIZE_LIMIT_CONFIG, cwd = ROOT) { + if (!fs.existsSync(configPath)) { + return { total: 0, entries: [], allMissing: true }; + } + const config = JSON.parse(fs.readFileSync(configPath, "utf8")); + let total = 0; + let found = 0; + const entries = []; + for (const entry of config) { + const entryPath = path.isAbsolute(entry.path) + ? entry.path + : path.join(cwd, entry.path); + if (!fs.existsSync(entryPath)) { + entries.push({ name: entry.name, path: entry.path, size: null }); + continue; + } + const size = fs.statSync(entryPath).size; + total += size; + found++; + entries.push({ name: entry.name, path: entry.path, size }); + } + return { total, entries, allMissing: found === 0 }; +} + +function main() { + // Step 1: tenta com size-limit + plugin instalado + let totalBytes = null; + let mode = "size-limit"; + + try { + const results = runSizeLimit(ROOT, SIZE_LIMIT_BIN); + totalBytes = parseSizeLimitResults(results); + } catch (err) { + if (err.code === "SL_NO_PLUGINS" || err.code === "SL_NO_BIN") { + // Step 2: fallback para leitura direta de arquivo + mode = "fallback-stat"; + const { total, entries, allMissing } = measureViaFileStat(SIZE_LIMIT_CONFIG, ROOT); + + if (allMissing) { + // Step 3: skip gracioso — entradas não existem (build necessário) + console.log("bundleSize=SKIP reason=no-build"); + if (process.env.CI) { + console.log("::notice::check-bundle-size skipped — entradas do .size-limit.json não encontradas (build necessário)"); + } + return; + } + + totalBytes = total; + for (const e of entries) { + if (e.size !== null) { + const kb = (e.size / 1024).toFixed(2); + console.log(` ${e.name}: ${kb} KB (${e.size} bytes)`); + } else { + console.log(` ${e.name}: ausente (não contabilizado)`); + } + } + } else { + // Erro inesperado — reporta mas não falha (advisory) + console.error(`[bundle-size] Aviso: size-limit retornou erro inesperado: ${err.message}`); + console.log("bundleSize=SKIP reason=size-limit-error"); + return; + } + } + + const kb = (totalBytes / 1024).toFixed(2); + console.log(`bundleSize=${totalBytes}`); + console.log(`[bundle-size] ${mode}: total ${kb} KB (${totalBytes} bytes) — advisory, saindo 0`); +} + +if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main(); diff --git a/scripts/check/check-circular-deps.mjs b/scripts/check/check-circular-deps.mjs new file mode 100644 index 0000000000..3d922e1b2d --- /dev/null +++ b/scripts/check/check-circular-deps.mjs @@ -0,0 +1,143 @@ +#!/usr/bin/env node +// scripts/check/check-circular-deps.mjs +// Gate: dpdm circular-deps cross-check (segunda opinião complementar ao check-cycles.mjs). +// +// check-cycles.mjs usa AST-TS próprio mas cobre apenas 5 sub-árvores + somente +// imports relativos. Este script usa dpdm (v4) que rastreia path-aliases via +// tsconfig.json e cobre entrypoints de alto risco. +// +// Advisory nesta versão: exit 0 sempre; imprime `circularDeps=N` para baseline. +// Direção da catraca: down (não pode subir). Adicionar ao quality-baseline.json +// como `{ value: N, direction: "down" }` após a primeira run verde no CI. +// +// Escopo limitado a 4 entrypoints principais para manter o tempo de análise +// abaixo de 60s. dpdm rastreia transitivamente todas as deps de cada entry. +// Cobrir mais entries aumenta o tempo sem proporcional ganho (as deps core se +// repetem via transitividade). +// +// Nota: dpdm pode reportar mais ciclos que check-cycles.mjs porque conta +// permutações de paths que passam pelo mesmo SCC, não apenas SCCs únicos. +// Isso é esperado — ferramentas diferentes, métricas complementares. + +import { execFileSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { readFileSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import os from "node:os"; +import path from "node:path"; +import fs from "node:fs"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const projectRoot = resolve(__dirname, "../.."); + +// Entrypoints: cobrem o pipeline principal (chat, combo, MCP, DB). +// Mantido enxuto para que `dpdm -T` (transform) termine em < 60s. +const ENTRYPOINTS = [ + "open-sse/handlers/chatCore.ts", + "open-sse/services/combo.ts", + "open-sse/mcp-server/index.ts", + "src/lib/db/core.ts", +]; + +const DPDM_BIN = resolve(projectRoot, "node_modules/.bin/dpdm"); +const TSCONFIG = resolve(projectRoot, "tsconfig.json"); + +/** + * Parseia a saída JSON do dpdm e retorna a contagem de ciclos. + * Função exportada para ser testada isoladamente sem executar o dpdm. + * + * @param {string} jsonStr - string com o JSON de saída do dpdm (campo "circulars"). + * @returns {{ count: number, circulars: string[][] }} + */ +export function parseDpdmOutput(jsonStr) { + let parsed; + try { + parsed = JSON.parse(jsonStr); + } catch { + throw new Error(`dpdm JSON parse failed: ${jsonStr.slice(0, 200)}`); + } + const circulars = Array.isArray(parsed.circulars) ? parsed.circulars : []; + return { count: circulars.length, circulars }; +} + +/** + * Executa o dpdm e retorna a string JSON bruta do arquivo de saída. + * + * @returns {string} conteúdo JSON do arquivo temporário. + */ +function runDpdm() { + if (!existsSync(DPDM_BIN)) { + throw new Error(`dpdm binary not found at ${DPDM_BIN}. Run: npm install`); + } + + const tmpFile = path.join(os.tmpdir(), `dpdm-output-${process.pid}.json`); + + try { + execFileSync( + "node", + [ + DPDM_BIN, + "--circular", + "--no-warning", + "--no-tree", + "-T", + "--tsconfig", + TSCONFIG, + "-o", + tmpFile, + ...ENTRYPOINTS, + ], + { + cwd: projectRoot, + stdio: "inherit", + timeout: 120_000, + } + ); + + if (!existsSync(tmpFile)) { + throw new Error(`dpdm did not produce output file at ${tmpFile}`); + } + + const raw = readFileSync(tmpFile, "utf8"); + return raw; + } finally { + try { + fs.unlinkSync(tmpFile); + } catch { + // best-effort cleanup + } + } +} + +function main() { + console.log("[circular-deps] Running dpdm cross-check..."); + console.log(`[circular-deps] Entrypoints: ${ENTRYPOINTS.join(", ")}`); + + let raw; + try { + raw = runDpdm(); + } catch (err) { + console.error(`[circular-deps] ERROR running dpdm: ${err.message}`); + process.exit(1); + } + + let result; + try { + result = parseDpdmOutput(raw); + } catch (err) { + console.error(`[circular-deps] ERROR parsing dpdm output: ${err.message}`); + process.exit(1); + } + + // Advisory mode: always exit 0. Catraca pode ser adicionada no quality-baseline.json + // após baseline ser estabelecida. + console.log(`[circular-deps] circularDeps=${result.count}`); + console.log( + `[circular-deps] Advisory — add to quality-baseline.json: { "value": ${result.count}, "direction": "down" }` + ); + process.exit(0); +} + +// Permite que o módulo seja importado em testes sem executar main(). +if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main(); diff --git a/scripts/check/check-codeql-ratchet.mjs b/scripts/check/check-codeql-ratchet.mjs new file mode 100644 index 0000000000..37e62c7ead --- /dev/null +++ b/scripts/check/check-codeql-ratchet.mjs @@ -0,0 +1,307 @@ +#!/usr/bin/env node +// scripts/check/check-codeql-ratchet.mjs +// Catraca de alertas CodeQL (Task 7.3 — Fase 7). +// +// Usa a GitHub API via `gh` CLI para buscar alertas de code-scanning abertos e +// não-dismissed (respeita Hard Rule #14: alertas dismissed não contam). +// +// Saída (stdout): +// codeqlAlerts=N — contagem de alertas CodeQL abertos, não-dismissed +// codeqlAlerts=SKIP reason=binary-absent — `gh` não está no PATH +// codeqlAlerts=SKIP reason=no-auth — `gh` presente mas sem autenticação +// codeqlAlerts=SKIP reason=api-error: — erro da API GitHub +// +// Esta versão é ADVISORY (sai 0 sempre). O ratchet (direction:down) é gerenciado +// pelo motor quality-baseline.json no CI (Task 7.3 INT). +// +// Uso: +// node scripts/check/check-codeql-ratchet.mjs +// node scripts/check/check-codeql-ratchet.mjs --json # imprime array de alertas +// node scripts/check/check-codeql-ratchet.mjs --quiet # suprime logs de diagnóstico + +import { execFileSync, spawnSync } from "node:child_process"; +import { pathToFileURL } from "node:url"; + +const QUIET = process.argv.includes("--quiet"); +const PRINT_JSON = process.argv.includes("--json"); + +// --------------------------------------------------------------------------- +// Pure parsing function (exported for tests) +// --------------------------------------------------------------------------- + +/** + * Conta alertas CodeQL abertos e não-dismissed a partir do JSON da GitHub API. + * + * A GitHub API /code-scanning/alerts retorna um array de: + * { + * number: number, + * state: "open" | "dismissed" | "fixed", + * dismissed_reason: string | null, + * dismissed_at: string | null, + * tool: { name: string, ... }, + * rule: { id: string, severity: string, security_severity_level?: string, ... }, + * ... + * } + * + * Hard Rule #14: alertas com `state="dismissed"` NÃO contam, independente da razão. + * Filtramos por state="open" E tool.name contendo "CodeQL" (case-insensitive). + * Alertas de outras ferramentas (ex: Semgrep) são ignorados. + * + * @param {Array|null} alerts - Array de alertas da API GitHub + * @returns {{ alertCount: number, bySeverity: Record, byRule: Record }} + */ +export function parseCodeQLAlerts(alerts) { + if (!Array.isArray(alerts)) { + return { alertCount: 0, bySeverity: {}, byRule: {} }; + } + + let alertCount = 0; + const bySeverity = {}; + const byRule = {}; + + for (const alert of alerts) { + // Ignorar alertas não-CodeQL (outras ferramentas de code scanning) + const toolName = alert?.tool?.name ?? ""; + if (!toolName.toLowerCase().includes("codeql")) continue; + + // Hard Rule #14: alertas dismissed não contam + if (alert.state === "dismissed") continue; + + // Só alertas abertos + if (alert.state !== "open") continue; + + alertCount++; + + // Coletar por severidade (security_severity_level ou severity da rule) + const severity = ( + alert?.rule?.security_severity_level ?? + alert?.rule?.severity ?? + "unknown" + ).toLowerCase(); + bySeverity[severity] = (bySeverity[severity] ?? 0) + 1; + + // Coletar por rule ID + const ruleId = alert?.rule?.id ?? "unknown"; + byRule[ruleId] = (byRule[ruleId] ?? 0) + 1; + } + + return { alertCount, bySeverity, byRule }; +} + +// --------------------------------------------------------------------------- +// Repository detection +// --------------------------------------------------------------------------- + +/** + * Detecta o owner/repo do repositório atual usando `gh repo view`. + * Retorna null se `gh` não estiver disponível ou não autenticado. + * + * @param {string} ghBin - Caminho para o binário gh + * @returns {string|null} "owner/repo" ou null + */ +export function detectRepo(ghBin) { + try { + const stdout = execFileSync(ghBin, ["repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"], { + encoding: "utf8", + timeout: 15_000, + }); + return stdout.trim() || null; + } catch { + return null; + } +} + +// --------------------------------------------------------------------------- +// Binary detection +// --------------------------------------------------------------------------- + +/** + * Detecta se o binário `gh` está disponível no PATH. + * Usa `which` (Unix) sem interpolação de shell — Hard Rule #13. + * + * @returns {string|null} Caminho absoluto para o binário, ou null se ausente. + */ +export function findGhCli() { + try { + const result = spawnSync("which", ["gh"], { + encoding: "utf8", + timeout: 5_000, + }); + if (result.status === 0) { + return result.stdout.trim(); + } + } catch { + // which não disponível + } + + // Fallback: tentar executar diretamente para verificar ENOENT + try { + const result = spawnSync("gh", ["--version"], { + encoding: "utf8", + timeout: 5_000, + }); + if (result.error?.code === "ENOENT") return null; + if (result.status !== null) return "gh"; // found in PATH + } catch { + // noop + } + + return null; +} + +// --------------------------------------------------------------------------- +// API caller +// --------------------------------------------------------------------------- + +/** + * Busca alertas CodeQL abertos via `gh api`. + * Pagina automaticamente (GitHub retorna max 100 por página). + * + * @param {string} ghBin - Caminho para o binário gh + * @param {string} repo - "owner/repo" + * @returns {Array} Array de alertas + */ +function fetchCodeQLAlerts(ghBin, repo) { + const allAlerts = []; + let page = 1; + const perPage = 100; + + while (true) { + const endpoint = `/repos/${repo}/code-scanning/alerts?state=open&tool_name=CodeQL&per_page=${perPage}&page=${page}`; + + if (!QUIET) { + process.stderr.write(`[codeql-ratchet] Buscando alertas: página ${page} ...\n`); + } + + let stdout; + try { + stdout = execFileSync(ghBin, ["api", endpoint], { + encoding: "utf8", + timeout: 30_000, + maxBuffer: 16 * 1024 * 1024, + }); + } catch (err) { + const errMsg = String(err.stderr ?? err.message ?? ""); + + // Sem autenticação + if (errMsg.includes("authentication") || errMsg.includes("401") || errMsg.includes("not logged")) { + return { error: "no-auth", message: errMsg }; + } + + // Rate limit ou outro erro HTTP + const codeMatch = /HTTP (\d{3})/.exec(errMsg); + const code = codeMatch ? codeMatch[1] : "unknown"; + return { error: `api-error:${code}`, message: errMsg }; + } + + let page_alerts; + try { + page_alerts = JSON.parse(stdout); + } catch (parseErr) { + process.stderr.write(`[codeql-ratchet] ERRO ao parsear resposta da API: ${parseErr.message}\n`); + process.exit(2); + } + + // A API retorna null quando não há mais páginas (ou array vazio) + if (!Array.isArray(page_alerts) || page_alerts.length === 0) break; + + allAlerts.push(...page_alerts); + + // Se retornou menos que perPage, chegamos à última página + if (page_alerts.length < perPage) break; + + page++; + } + + return allAlerts; +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +function main() { + const ghBin = findGhCli(); + + if (!ghBin) { + console.log("codeqlAlerts=SKIP reason=binary-absent"); + if (!QUIET) { + process.stderr.write( + "[codeql-ratchet] SKIP — `gh` CLI não encontrado no PATH.\n" + + "[codeql-ratchet] Instale via: https://cli.github.com/\n" + + "[codeql-ratchet] ADVISORY — este gate sai 0 (ratchet entra no CI da Fase 7 INT).\n" + ); + } + process.exitCode = 0; + return; + } + + // Detectar repositório + const repo = detectRepo(ghBin); + if (!repo) { + console.log("codeqlAlerts=SKIP reason=no-repo"); + if (!QUIET) { + process.stderr.write( + "[codeql-ratchet] SKIP — não foi possível detectar o repositório GitHub.\n" + + "[codeql-ratchet] Execute dentro de um repositório GitHub com `gh` autenticado.\n" + ); + } + process.exitCode = 0; + return; + } + + if (!QUIET) { + process.stderr.write(`[codeql-ratchet] Repositório detectado: ${repo}\n`); + } + + // Buscar alertas + const result = fetchCodeQLAlerts(ghBin, repo); + + // Tratar erros da API com skip gracioso + if (!Array.isArray(result)) { + const { error, message } = result; + console.log(`codeqlAlerts=SKIP reason=${error}`); + if (!QUIET) { + process.stderr.write(`[codeql-ratchet] SKIP — erro ao consultar API GitHub: ${message.slice(0, 200)}\n`); + } + process.exitCode = 0; + return; + } + + if (PRINT_JSON) { + process.stdout.write(JSON.stringify(result, null, 2) + "\n"); + return; + } + + const { alertCount, bySeverity, byRule } = parseCodeQLAlerts(result); + + // Emitir em formato KEY=VALUE para o coletor de métricas (collect-metrics.mjs) + console.log(`codeqlAlerts=${alertCount}`); + + if (!QUIET) { + const severitySummary = Object.entries(bySeverity) + .map(([k, v]) => `${k}=${v}`) + .join(", ") || "nenhum"; + const topRules = Object.entries(byRule) + .sort(([, a], [, b]) => b - a) + .slice(0, 5) + .map(([r, n]) => `${r}(${n})`) + .join(", ") || "nenhum"; + + process.stderr.write( + `[codeql-ratchet] Alertas CodeQL abertos (não-dismissed): ${alertCount}\n` + ); + if (alertCount > 0) { + process.stderr.write(`[codeql-ratchet] Por severidade: ${severitySummary}\n`); + process.stderr.write(`[codeql-ratchet] Top regras: ${topRules}\n`); + } + process.stderr.write( + "[codeql-ratchet] ADVISORY — esta versão não falha pela contagem (ratchet entra no CI).\n" + ); + } + + // Sai 0 sempre nesta versão (advisory) + process.exitCode = 0; +} + +if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main(); diff --git a/scripts/check/check-cognitive-complexity.mjs b/scripts/check/check-cognitive-complexity.mjs new file mode 100644 index 0000000000..014e5f8e2f --- /dev/null +++ b/scripts/check/check-cognitive-complexity.mjs @@ -0,0 +1,95 @@ +#!/usr/bin/env node +// scripts/check/check-cognitive-complexity.mjs +// Advisory gate para complexidade cognitiva (sonarjs/cognitive-complexity). +// +// Roda o ESLint sobre src+open-sse usando um config flat STANDALONE +// (eslint.sonarjs.config.mjs) que liga APENAS `sonarjs/cognitive-complexity` — +// mantendo a contagem ISOLADA do orçamento de warnings do lint principal (3653). +// +// Modo advisory: sai com código 0 independente da contagem. Imprime o valor +// para anotação do baseline conceitual. O ratchet INT virá quando o baseline +// for congelado em quality-baseline.json. +// +// Saída canônica: cognitiveComplexity=N (parseable por collect-metrics.mjs) +// +// Uso: +// node scripts/check/check-cognitive-complexity.mjs +// node scripts/check/check-cognitive-complexity.mjs --quiet # só a linha canônica +import { execFileSync } from "node:child_process"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +const ROOT = process.cwd(); +const QUIET = process.argv.includes("--quiet"); +const CONFIG_PATH = path.join(ROOT, "eslint.sonarjs.config.mjs"); + +const ESLINT_BIN = path.join(ROOT, "node_modules", ".bin", "eslint"); + +const ESLINT_ARGS = [ + "--no-config-lookup", + "--config", + CONFIG_PATH, + "--format", + "json", + "src", + "open-sse", +]; + +/** + * Parses the ESLint JSON output (array of file results) and counts total + * `sonarjs/cognitive-complexity` violations. + * + * Exported so unit tests can call it directly with synthetic data. + * + * @param {Array<{messages: Array<{ruleId: string}>}>} report + * @returns {number} + */ +export function countCognitiveViolations(report) { + let count = 0; + for (const file of report) { + for (const msg of file.messages) { + if (msg.ruleId === "sonarjs/cognitive-complexity") { + count++; + } + } + } + return count; +} + +function runEslint() { + let stdout; + try { + stdout = execFileSync(ESLINT_BIN, ESLINT_ARGS, { + encoding: "utf8", + maxBuffer: 64 * 1024 * 1024, + }); + } catch (err) { + // ESLint exits non-zero when there are lint errors; the JSON report is still + // in stdout. Re-throw only if there is no parseable output. + stdout = err.stdout ? String(err.stdout) : ""; + if (!stdout.trim()) throw err; + } + return JSON.parse(stdout); +} + +function main() { + const report = runEslint(); + const count = countCognitiveViolations(report); + + if (!QUIET) { + console.log( + `[cognitive-complexity] advisory — ${count} function(s) exceed the cognitive-complexity threshold (15).` + ); + console.log( + `[cognitive-complexity] Annotate this value as the baseline in quality-baseline.json when the INT ratchet is wired.` + ); + } + + // Canonical machine-readable output consumed by collect-metrics.mjs + console.log(`cognitiveComplexity=${count}`); + + // Advisory: always exit 0 + process.exit(0); +} + +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 index b5c83990ca..43b89c41e0 100644 --- a/scripts/check/check-db-rules.mjs +++ b/scripts/check/check-db-rules.mjs @@ -11,9 +11,12 @@ // (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. +// Stale-enforcement (6A.3): entradas em INTENTIONALLY_INTERNAL / EXTERNAL_DB_ALLOWED +// que não suprimem nenhuma violação real → gate falha com instrução de remoção. import fs from "node:fs"; import path from "node:path"; import { pathToFileURL } from "node:url"; +import { assertNoStale } from "./lib/allowlist.mjs"; const cwd = process.cwd(); const DB_DIR = path.join(cwd, "src/lib/db"); @@ -215,10 +218,16 @@ export function collectSqlScanFiles(apiDir = API_DIR, handlersDir = HANDLERS_DIR function main() { const failures = []; + const localDbSource = fs.readFileSync(LOCAL_DB, "utf8"); // (a) re-export completeness const dbModules = collectDbModules(); - const reexported = extractReexportedModules(fs.readFileSync(LOCAL_DB, "utf8")); + const reexported = extractReexportedModules(localDbSource); + + // Live unexported modules BEFORE allowlist filtering (needed for stale-enforcement). + const liveUnexported = dbModules.filter((mod) => !reexported.has(mod)); + assertNoStale(INTENTIONALLY_INTERNAL, liveUnexported, "check-db-rules:unexported"); + const missing = findMissingReexports(dbModules, reexported); if (missing.length) { failures.push( @@ -230,7 +239,7 @@ function main() { } // (b) localDb sem lógica - if (hasLogic(fs.readFileSync(LOCAL_DB, "utf8"))) { + if (hasLogic(localDbSource)) { 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/.` @@ -238,7 +247,12 @@ function main() { } // (c) SQL cru fora de db/ - const rawSql = findRawSql(collectSqlScanFiles()); + // Live raw-SQL offenders BEFORE allowlist filtering (needed for stale-enforcement). + const scanFiles = collectSqlScanFiles(); + const liveRawSql = findRawSql(scanFiles, new Set()); + assertNoStale(EXTERNAL_DB_ALLOWED, liveRawSql, "check-db-rules:raw-sql"); + + const rawSql = findRawSql(scanFiles); if (rawSql.length) { failures.push( `[#5 sql-cru] ${rawSql.length} arquivo(s) com SQL cru fora de src/lib/db/:\n` + @@ -250,12 +264,14 @@ function main() { if (failures.length) { console.error(`[check-db-rules] FALHOU:\n\n` + failures.join("\n\n")); - process.exit(1); + process.exitCode = 1; + } + if (!process.exitCode) { + console.log( + `[check-db-rules] OK (${dbModules.length} módulos db/, ${reexported.size} re-exportados, ` + + `${INTENTIONALLY_INTERNAL.size} intencionalmente-internos (Rule #2); ${EXTERNAL_DB_ALLOWED.size} leituras de DB externo permitidas (#3500))` + ); } - console.log( - `[check-db-rules] OK (${dbModules.length} módulos db/, ${reexported.size} re-exportados, ` + - `${INTENTIONALLY_INTERNAL.size} intencionalmente-internos (Rule #2); ${EXTERNAL_DB_ALLOWED.size} leituras de DB externo permitidas (#3500))` - ); } if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main(); diff --git a/scripts/check/check-dead-code.mjs b/scripts/check/check-dead-code.mjs new file mode 100644 index 0000000000..c6e7028bb1 --- /dev/null +++ b/scripts/check/check-dead-code.mjs @@ -0,0 +1,139 @@ +#!/usr/bin/env node +// scripts/check/check-dead-code.mjs +// Gate de dead-code via knip — unused exports, unused files. +// Esta versão é ADVISORY (sai 0 sempre, exceto erro de execução). +// O ratchet no quality-baseline.json entra no bloco INT da Fase 7. +// +// Saída (stdout): +// DEAD_EXPORTS= — exports/re-exports/tipos não utilizados +// DEAD_FILES= — arquivos sem nenhum consumidor +// DEAD_TOTAL= — soma de ambos (métrica primária para o ratchet) +// +// Use --json para imprimir o relatório completo do knip em JSON. +// Use --quiet para suprimir logs de diagnóstico. + +import { execFileSync } from "node:child_process"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +const ROOT = process.cwd(); +const KNIP_BIN = path.join(ROOT, "node_modules", ".bin", "knip"); +const QUIET = process.argv.includes("--quiet"); +const PRINT_JSON = process.argv.includes("--json"); + +/** + * Conta dead exports e dead files a partir do output JSON do knip. + * + * O reporter JSON do knip emite: + * { issues: Array<{ file, exports?, files?, types?, nsExports?, nsTypes?, ... }> } + * + * Cada entrada em `exports`, `types`, `nsExports`, `nsTypes` é um símbolo morto naquele + * arquivo. A presença do arquivo em si na lista (campo `files: []` não-vazio ou arquivo + * sem outros campos relevantes com `files: true` no include) indica arquivo morto. + * + * @param {object} knipJson - Objeto JSON parseado do output do knip + * @returns {{ deadExports: number, deadFiles: number, deadTotal: number }} + */ +export function parseKnipMetrics(knipJson) { + if (!knipJson || !Array.isArray(knipJson.issues)) { + return { deadExports: 0, deadFiles: 0, deadTotal: 0 }; + } + + let deadExports = 0; + let deadFiles = 0; + + for (const fileEntry of knipJson.issues) { + // Dead file: o arquivo aparece na lista com campo `files` populado + // (knip emite um entry com files:[] indicando "este arquivo é morto") + if (Array.isArray(fileEntry.files) && fileEntry.files.length > 0) { + deadFiles += fileEntry.files.length; + } + // Alguns reporters indicam arquivo morto sem campo files — o entry existe + // sem exports/types = o arquivo inteiro não tem consumidor + // (conservador: só contar quando files[] está presente e populado) + + // Dead exports: somar todos os símbolos mortos por tipo de export + const exportFields = ["exports", "types", "nsExports", "nsTypes", "enumMembers", "namespaceMembers", "duplicates"]; + for (const field of exportFields) { + if (Array.isArray(fileEntry[field])) { + deadExports += fileEntry[field].length; + } + } + } + + return { + deadExports, + deadFiles, + deadTotal: deadExports + deadFiles, + }; +} + +function runKnip() { + const args = [ + "--reporter", "json", + "--no-progress", + "--no-exit-code", // não falha por contagem — só coletamos métricas + ]; + + if (!QUIET) { + process.stderr.write("[dead-code] Rodando knip --reporter json ...\n"); + } + + let stdout; + try { + stdout = execFileSync(KNIP_BIN, args, { + cwd: ROOT, + encoding: "utf8", + maxBuffer: 128 * 1024 * 1024, + timeout: 300_000, // 5 min (knip pode ser lento em monorepos grandes) + }); + } catch (err) { + // knip sai com código != 0 quando encontra issues; o JSON ainda vai no stdout. + stdout = err.stdout ? String(err.stdout) : ""; + if (!stdout.trim()) { + process.stderr.write(`[dead-code] ERRO ao executar knip: ${err.message}\n`); + process.exit(2); + } + } + + let knipJson; + try { + knipJson = JSON.parse(stdout); + } catch (parseErr) { + process.stderr.write(`[dead-code] ERRO ao parsear JSON do knip: ${parseErr.message}\n`); + process.stderr.write(`[dead-code] stdout (primeiros 500 chars): ${stdout.slice(0, 500)}\n`); + process.exit(2); + } + + return knipJson; +} + +function main() { + const knipJson = runKnip(); + + if (PRINT_JSON) { + process.stdout.write(JSON.stringify(knipJson, null, 2) + "\n"); + return; + } + + const { deadExports, deadFiles, deadTotal } = parseKnipMetrics(knipJson); + + // Emitir em formato KEY=VALUE para o coletor de métricas (collect-metrics.mjs) + console.log(`DEAD_EXPORTS=${deadExports}`); + console.log(`DEAD_FILES=${deadFiles}`); + console.log(`DEAD_TOTAL=${deadTotal}`); + + if (!QUIET) { + process.stderr.write( + `[dead-code] exports mortos: ${deadExports} | arquivos mortos: ${deadFiles} | total: ${deadTotal}\n` + ); + process.stderr.write( + `[dead-code] ADVISORY — esta versão não falha pela contagem (ratchet entra no INT da Fase 7).\n` + ); + } + + // Sai 0 sempre nesta versão (advisory) + process.exitCode = 0; +} + +if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main(); diff --git a/scripts/check/check-deps.mjs b/scripts/check/check-deps.mjs index f39954d51a..1cc88f29c8 100644 --- a/scripts/check/check-deps.mjs +++ b/scripts/check/check-deps.mjs @@ -1,18 +1,78 @@ #!/usr/bin/env node // scripts/check/check-deps.mjs -// Gate anti-slopsquatting: toda dependência em package.json (raiz + electron) deve +// Gate anti-slopsquatting: toda dependência em QUALQUER package.json do repo 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. +// +// 6A.8: Expandido de 2 manifests hardcoded (package.json + electron/package.json) +// para descoberta automática de TODOS os package.json do repo, excluindo: +// - node_modules/ (dep tree) +// - .next/, .build/, dist/, dist-electron/ (build artefatos) +// - .claude/ (worktrees de agentes) +// - _references/, _mono_repo/ (código de referência não pertencente ao repo) +// Isso garante que workspaces novos (opencode-plugin, opencode-provider, open-sse, etc.) +// sejam automaticamente cobertos sem edição do script. +// +// Task 7.8: Anti-slopsquatting completo — para deps NOVAS (fora da allowlist), +// dois sub-checks adicionais ANTES de falhar: +// (a) a dep EXISTE no npm registry (npm view version) +// (b) foi publicada há ≥72h (age-cooldown contra typosquatting de nomes alucinados) +// Ambas as chamadas são tolerantes a falha de rede: se o registry estiver inacessível, +// emite aviso mas não bloqueia — o gate principal (allowlist) ainda captura a dep nova. import fs from "node:fs"; import path from "node:path"; import { pathToFileURL } from "node:url"; +import { execFileSync } from "node:child_process"; +import { assertNoStale } from "./lib/allowlist.mjs"; const ROOT = process.cwd(); const ALLOWLIST_PATH = path.join(ROOT, "dependency-allowlist.json"); -const MANIFESTS = ["package.json", path.join("electron", "package.json")]; + +// Directories to exclude when discovering package.json files. +// Using a set of path segment prefixes (relative to ROOT, forward slashes). +const EXCLUDED_SEGMENTS = new Set([ + "node_modules", + ".next", + ".build", + "dist", + "dist-electron", + ".claude", + "_references", + "_mono_repo", +]); + +/** + * 6A.8: Discover all package.json files in the repo, excluding build artefacts, + * reference code, and agent worktrees. Returns relative paths (forward slashes). + */ +export function discoverManifests(root) { + const out = []; + + function walk(dir, depth) { + if (depth > 5) return; // guard against very deep nesting + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const e of entries) { + if (EXCLUDED_SEGMENTS.has(e.name)) continue; + const full = path.join(dir, e.name); + if (e.isDirectory()) { + walk(full, depth + 1); + } else if (e.name === "package.json") { + out.push(path.relative(root, full).replace(/\\/g, "/")); + } + } + } + + walk(root, 0); + return out.sort(); +} /** Nomes de deps no manifesto que não estão na allowlist (de-dup, ordem preservada). */ export function findUnapprovedDeps(depNames, allowlist) { @@ -26,19 +86,133 @@ export function findUnapprovedDeps(depNames, allowlist) { return out; } -function depNamesFromManifest(file) { - const full = path.join(ROOT, file); +function depNamesFromManifest(root, rel) { + const full = path.join(root, rel); if (!fs.existsSync(full)) return []; - const pkg = JSON.parse(fs.readFileSync(full, "utf8")); + let pkg; + try { + pkg = JSON.parse(fs.readFileSync(full, "utf8")); + } catch { + return []; // skip malformed manifests (e.g. reference code) + } return [ ...Object.keys(pkg.dependencies || {}), ...Object.keys(pkg.devDependencies || {}), ...Object.keys(pkg.optionalDependencies || {}), + ...Object.keys(pkg.peerDependencies || {}), ]; } -function collectDepNames() { - return MANIFESTS.flatMap(depNamesFromManifest); +function collectDepNames(root) { + return discoverManifests(root).flatMap((rel) => depNamesFromManifest(root, rel)); +} + +// ─── Task 7.8: registry-existence + age-cooldown ────────────────────────────── + +/** + * Pure function — determines whether a package is old enough to be trusted. + * + * A dep that was just registered (within the last 72h) is a red flag for + * slopsquatting: an attacker can register the name an AI hallucinated within + * minutes of the hallucination becoming public. The 72h window gives the npm + * security team time to act and gives maintainers a chance to notice. + * + * @param {number} timeCreatedMs - Unix timestamp (ms) of when the package was + * first published to the registry (npm `time.created` field). + * @param {number} nowMs - Unix timestamp (ms) for "now" (injectable for tests). + * @param {number} minAgeHours - Minimum acceptable age in hours (default 72). + * @returns {{ ok: boolean; ageHours: number }} ok=true if old enough. + */ +export function evaluateDepAge(timeCreatedMs, nowMs, minAgeHours = 72) { + const ageHours = (nowMs - timeCreatedMs) / (1000 * 60 * 60); + return { ok: ageHours >= minAgeHours, ageHours }; +} + +/** + * Queries the npm registry for a package. + * Returns { exists: boolean, createdMs: number | null } or null on network error. + * Network failures are treated as "offline" — the caller decides what to do. + * + * @param {string} pkgName + * @param {number} timeoutMs - How long to wait for the registry (default 8 000). + * @returns {{ exists: boolean; createdMs: number | null } | null} + */ +export function queryNpmRegistry(pkgName, timeoutMs = 8000) { + // Scope packages need URL-encoding for the `npm view` command. + // `npm view` accepts scoped packages natively — no encoding needed. + try { + const raw = execFileSync( + "npm", + ["view", pkgName, "time.created", "--json"], + { + encoding: "utf8", + timeout: timeoutMs, + // Suppress npm progress/warn output on stderr + stdio: ["ignore", "pipe", "pipe"], + } + ); + // npm view --json emits a quoted string or null/empty for missing fields + const trimmed = raw.trim(); + if (!trimmed) { + // Package exists but has no time.created (very unusual; treat as exists, age unknown) + return { exists: true, createdMs: null }; + } + const parsed = JSON.parse(trimmed); + if (!parsed) return { exists: true, createdMs: null }; + const ms = new Date(parsed).getTime(); + return { exists: true, createdMs: Number.isFinite(ms) ? ms : null }; + } catch (err) { + // npm exits with code 1 when the package is NOT found ("E404") + const stderr = err.stderr?.toString() || ""; + const stdout = err.stdout?.toString() || ""; + if (stderr.includes("E404") || stdout.includes("E404") || stderr.includes("npm ERR! code E404")) { + return { exists: false, createdMs: null }; + } + // Any other error (ETIMEDOUT, ENOTFOUND, etc.) = network/offline — return null + return null; + } +} + +/** + * For a list of new (unapproved) deps, performs registry existence + age checks. + * Returns an object with three lists: + * - notFound: packages that do NOT exist in the registry (likely hallucinated) + * - tooNew: packages that exist but were published within the last 72h + * - offline: packages we could not verify (registry unreachable) + * + * Designed to run AFTER findUnapprovedDeps — only called when there are new deps. + * + * @param {string[]} newDeps + * @param {number} minAgeHours + * @param {number} nowMs + * @returns {{ notFound: string[]; tooNew: Array<{name:string,ageHours:number}>; offline: string[] }} + */ +export function auditNewDepsRegistry(newDeps, minAgeHours = 72, nowMs = Date.now()) { + const notFound = []; + const tooNew = []; + const offline = []; + + for (const dep of newDeps) { + const result = queryNpmRegistry(dep); + if (result === null) { + // Network error — skip gracefully + offline.push(dep); + continue; + } + if (!result.exists) { + notFound.push(dep); + continue; + } + if (result.createdMs !== null) { + const { ok, ageHours } = evaluateDepAge(result.createdMs, nowMs, minAgeHours); + if (!ok) { + tooNew.push({ name: dep, ageHours: Math.round(ageHours * 10) / 10 }); + } + } + // exists + old enough (or age unknown) → pass silently + } + + return { notFound, tooNew, offline }; } function main() { @@ -50,17 +224,66 @@ function main() { process.exit(1); } const allowlist = new Set(JSON.parse(fs.readFileSync(ALLOWLIST_PATH, "utf8")).allowed || []); - const unapproved = findUnapprovedDeps(collectDepNames(), allowlist); + const allDepNames = collectDepNames(ROOT); + + // 6A.8: stale-allowlist enforcement. + // A dep in the allowlist that is no longer used in ANY manifest is stale — the dep + // was removed, but the allowlist entry was not. Stale entries let the dep silently + // re-appear without triggering the review gate (regression risk). + // Note: only flag entries that appear in NO manifest; a dep may be in the allowlist + // but only transitively installed, so we check against what manifests declare. + const liveDepSet = new Set(allDepNames); + assertNoStale(allowlist, liveDepSet, "check-deps"); + + const unapproved = findUnapprovedDeps(allDepNames, allowlist); if (unapproved.length) { + // Task 7.8: For each new dep, run registry-existence + age-cooldown checks. + // This enriches the error message — tells the reviewer whether the package + // even exists and how recently it was published, before they allowlist it. + // Failures here do NOT add extra exit(1) calls — the allowlist gate already + // fails; these are purely informational addenda to the error output. 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.` ); + + // Registry audit (Task 7.8) — runs only when there are new deps. + // Failures are non-fatal on network errors; registry check is advisory enrichment + // (the allowlist gate above is the hard block). + console.error(`[check-deps] Verificando deps novas no registry npm (Task 7.8)…`); + const { notFound, tooNew, offline } = auditNewDepsRegistry(unapproved); + + if (offline.length) { + console.warn( + `[check-deps] WARN — registry npm inacessível (offline?); ` + + `não foi possível verificar: ${offline.join(", ")}` + ); + } + if (notFound.length) { + console.error( + `[check-deps] BLOQUEIO EXTRA — ${notFound.length} dep(s) NÃO encontrada(s) no registry npm ` + + `(provável nome alucinado — NÃO adicionar à allowlist!):\n` + + notFound.map((d) => ` ✗✗ ${d} (não existe no registry)`).join("\n") + ); + } + if (tooNew.length) { + console.error( + `[check-deps] BLOQUEIO EXTRA — ${tooNew.length} dep(s) publicada(s) há <72h ` + + `(age-cooldown anti-slopsquatting — aguarde 72h após publicação):\n` + + tooNew.map((d) => ` ✗✗ ${d.name} (publicada há ~${d.ageHours}h)`).join("\n") + ); + } + process.exit(1); } - console.log(`[check-deps] OK — ${allowlist.size} dependências na allowlist, nenhuma nova`); + if (process.exitCode === 1) return; // stale entries already logged + const manifests = discoverManifests(ROOT); + console.log( + `[check-deps] OK — ${allowlist.size} dependências na allowlist, ` + + `${manifests.length} manifests escaneados, nenhuma nova dep` + ); } 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 index 626ebd53f1..f05299fe4e 100644 --- a/scripts/check/check-docs-symbols.mjs +++ b/scripts/check/check-docs-symbols.mjs @@ -14,9 +14,12 @@ // 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. +// Stale-enforcement (6A.3): entrada em KNOWN_STALE_DOC_REFS que não suprime nenhum miss +// real → gate falha com instrução de remoção (evita furo de regressão silencioso). import fs from "node:fs"; import path from "node:path"; import { pathToFileURL } from "node:url"; +import { assertNoStale } from "./lib/allowlist.mjs"; const ROOT = process.cwd(); const DOCS = path.join(ROOT, "docs"); @@ -201,6 +204,13 @@ function main() { file: path.relative(ROOT, f).replace(/\\/g, "/"), paths: extractDocApiPaths(fs.readFileSync(f, "utf8")), })); + + // Live misses BEFORE allowlist filtering — used for stale-enforcement. + // The paths (not "file → path" strings) are the unit that the allowlist keys on. + const allMisses = findStaleDocApiRefs(docPathsByFile, routeFiles, new Set()); + const liveMissPaths = allMisses.map((m) => m.split(" → ")[1]); + assertNoStale(KNOWN_STALE_DOC_REFS, liveMissPaths, "check-docs-symbols"); + const misses = findStaleDocApiRefs(docPathsByFile, routeFiles, KNOWN_STALE_DOC_REFS); if (misses.length) { console.error( @@ -210,12 +220,14 @@ function main() { ` adicione um padrão a IGNORE com justificativa. NÃO adicione à allowlist sem` + ` confirmar que é drift pré-existente real.` ); - process.exit(1); + process.exitCode = 1; + } + if (!process.exitCode) { + console.log( + `[check-docs-symbols] OK — ${docFiles.length} docs canônicas, ` + + `${routeFiles.size} rotas conhecidas, ${KNOWN_STALE_DOC_REFS.size} stale congeladas` + ); } - 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 index 7843f16e88..1dc283a59c 100644 --- a/scripts/check/check-duplication.mjs +++ b/scripts/check/check-duplication.mjs @@ -19,7 +19,9 @@ const BASELINE_PATH = path.resolve( ); 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__/**"]; +// Use local binary (pinned in package.json devDependencies — no registry download at CI time) +const JSCPD_BIN = path.join(ROOT, "node_modules", ".bin", "jscpd"); +const JSCPD_FIXED_ARGS = ["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) { @@ -31,7 +33,7 @@ export function evaluateDuplication(current, baseline, eps = EPS) { function measureDuplicationPct() { const out = fs.mkdtempSync(path.join(os.tmpdir(), "jscpd-")); - execFileSync("npx", ["--yes", ...JSCPD_ARGS, "--output", out], { stdio: "ignore" }); + execFileSync(JSCPD_BIN, [...JSCPD_FIXED_ARGS, "--output", out], { stdio: "ignore" }); const report = JSON.parse(fs.readFileSync(path.join(out, "jscpd-report.json"), "utf8")); return report.statistics.total.percentage; } diff --git a/scripts/check/check-env-doc-sync.mjs b/scripts/check/check-env-doc-sync.mjs index cc5de4f713..ac62a048b6 100644 --- a/scripts/check/check-env-doc-sync.mjs +++ b/scripts/check/check-env-doc-sync.mjs @@ -76,6 +76,9 @@ const IGNORE_FROM_CODE = new Set([ // CI providers (set by the runner). "GITHUB_BASE_REF", "GITHUB_BASE_SHA", + // PR body injected by GitHub Actions into the pr-evidence gate (github.event.pull_request.body); + // a CI-only signal, never an OmniRoute runtime config (Phase 7.10). + "PR_BODY", // CLI machine-id token opt-out (server-side flag; not user-configurable via .env). "OMNIROUTE_DISABLE_CLI_TOKEN", // update-notifier opt-out for the CLI binary. diff --git a/scripts/check/check-error-helper.mjs b/scripts/check/check-error-helper.mjs index 05a577e9d0..0e49fc2eba 100644 --- a/scripts/check/check-error-helper.mjs +++ b/scripts/check/check-error-helper.mjs @@ -19,19 +19,38 @@ import fs from "node:fs"; import path from "node:path"; import { pathToFileURL } from "node:url"; +import { assertNoStale } from "./lib/allowlist.mjs"; const cwd = process.cwd(); + +// Directories to scan (Hard Rule #12 applies to ALL error-response-building surfaces). +// 6A.8: expanded from executors+handlers to include MCP server tools and API route files. const SCAN_DIRS = [ path.join(cwd, "open-sse/executors"), path.join(cwd, "open-sse/handlers"), + path.join(cwd, "open-sse/mcp-server"), ]; +// Glob-style pattern for API route files under src/app/api/ (matched by path test below). +const IS_API_ROUTE = /^src\/app\/api\/.+\/route\.tsx?$/; + // 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([]); +export const KNOWN_MISSING_ERROR_HELPER = new Set([ + // --- original open-sse/executors + handlers scope (pre-6A.8) --- + // --- 6A.8 expanded scope: src/app/api/**/route.ts pre-existing violations --- + // TODO(6A.8): pre-existing, triage — route through buildErrorBody()/sanitizeErrorMessage() + "src/app/api/cli-tools/backups/route.ts", + "src/app/api/cli-tools/guide-settings/[toolId]/route.ts", + "src/app/api/logs/export/route.ts", + "src/app/api/models/catalog/route.ts", + "src/app/api/providers/test-batch/route.ts", + "src/app/api/settings/import-json/route.ts", + "src/app/api/usage/proxy-logs/route.ts", +]); // Import specifiers that count as "uses the error helper" (path ends in utils/error). const ERROR_HELPER_IMPORT = @@ -213,6 +232,7 @@ export function findErrorHelperViolations(files, allowlist) { function collectFiles() { const files = []; + // Standard scan dirs (open-sse/executors, handlers, mcp-server). for (const dir of SCAN_DIRS) { for (const p of walk(dir)) { files.push({ @@ -221,26 +241,28 @@ function collectFiles() { }); } } + // 6A.8: also scan all src/app/api/**/route.ts files. + const apiRoot = path.join(cwd, "src/app/api"); + for (const p of walk(apiRoot)) { + const rel = path.relative(cwd, p).replace(/\\/g, "/"); + if (IS_API_ROUTE.test(rel)) { + files.push({ path: rel, source: fs.readFileSync(p, "utf8") }); + } + } return files; } function main() { const files = collectFiles(); + + // 6A.8: stale-allowlist enforcement. + // Compute live violations WITHOUT the allowlist so we can detect entries that are + // now stale (the violation was fixed, but the freeze entry was not removed). + const liveViolations = findErrorHelperViolations(files, new Set()); + assertNoStale(KNOWN_MISSING_ERROR_HELPER, liveViolations, "check-error-helper"); + + // Suppress known pre-existing violations so only NEW leaks fail the gate. 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 ` + @@ -252,6 +274,7 @@ function main() { ); process.exit(1); } + if (process.exitCode === 1) return; // stale entries already logged console.log( `[check-error-helper] OK (${files.length} files scanned, ${KNOWN_MISSING_ERROR_HELPER.size} known-missing frozen)` ); diff --git a/scripts/check/check-fabricated-docs.mjs b/scripts/check/check-fabricated-docs.mjs index 36f1a9d64b..6bac4aa4d9 100644 --- a/scripts/check/check-fabricated-docs.mjs +++ b/scripts/check/check-fabricated-docs.mjs @@ -270,6 +270,11 @@ const ENV_VAR_DENYLIST = new Set([ "PROVIDER_HEALTH_AUTOPILOT_RECOVERY_RETRY_BACKOFF_FLOOR", "PROVIDER_HEALTH_AUTOPILOT_RECOVERY_RETRY_BACKOFF_CEILING", "PROVIDER_HEALTH_AUTOPILOT_RECOVERY_RETRY_BACKOFF_CAP", + // Gate allowlist constant names (JS identifiers, not env vars) — documented in + // docs/architecture/QUALITY_GATES.md and docs/research/DISCOVERY_TOOL_DESIGN.md + "KNOWN_STALE_DOC_REFS", // export const in check-docs-symbols.mjs + "KNOWN_MISSING", // export const in check-fetch-targets.mjs + "KNOWN_RAW_SQL", // export const in check-db-rules.mjs ]); /** Endpoints that don't follow the standard route.ts pattern. */ diff --git a/scripts/check/check-fetch-targets.mjs b/scripts/check/check-fetch-targets.mjs index b51517e208..a3832993ca 100644 --- a/scripts/check/check-fetch-targets.mjs +++ b/scripts/check/check-fetch-targets.mjs @@ -1,32 +1,49 @@ #!/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. +// scripts/check/check-fetch-targets.mjs v2 +// Gate anti-alucinação: todo fetch("/api/...") em src/ (client-side) deve +// resolver para um route.ts real em src/app/api/. Mata rotas inventadas. +// +// Três subchecks (6A.7): +// 1. Paths estáticos literais: fetch("/api/foo") → rota deve existir +// 2. Prefixo de template literal: fetch(`/api/x/${id}`) → prefixo estático deve ter +// ao menos uma rota filha/irmã +// 3. Método HTTP literal: fetch("/api/foo", { method: "POST" }) → route.ts +// deve exportar POST (inclui re-exports) +// +// Escopo (v2): todo src/**/*.{ts,tsx} client-side +// Excluídos: src/app/api/**, src/lib/db/**, *.test.ts, *.spec.ts, node_modules import fs from "node:fs"; import path from "node:path"; import { pathToFileURL } from "node:url"; +import { assertNoStale } from "./lib/allowlist.mjs"; const cwd = process.cwd(); -const DASH = path.join(cwd, "src/app/(dashboard)"); +const SRC = path.join(cwd, "src"); 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. +// Mismatches src/**→rota PRÉ-EXISTENTES ou não-resolvíveis estaticamente. +// Congelados para a catraca ficar verde e bloquear qualquer nova rota inventada. +// NÃO adicione novos sem justificativa — esse é o ponto do gate. +// +// Format for stale-enforcement: entries must match the string produced by +// the checkers below (i.e. the raw apiPath or prefix string, not the file+arrow). const KNOWN_MISSING = new Set([ - // All previously known-missing routes have been resolved. + // src/lib/evals/evalRunner.ts → /api/data (server-side eval runner calling a + // local data endpoint that is not a Next.js route; needs a real route or fix) + "/api/data", + // src/app/(dashboard)/…/AgentBridgePageClient.tsx calls bypass with PUT but + // the route only exports GET/POST/DELETE — real method miss, tracked for fix. + "/api/tools/agent-bridge/bypass::PUT", ]); +// ─── filesystem helpers ─────────────────────────────────────────────────────── + function walk(dir, acc = []) { if (!fs.existsSync(dir)) return acc; for (const e of fs.readdirSync(dir, { withFileTypes: true })) { @@ -37,6 +54,19 @@ function walk(dir, acc = []) { return acc; } +/** Is this file excluded from client-side scanning? */ +function isExcluded(filePath) { + const rel = path.relative(cwd, filePath).replace(/\\/g, "/"); + return ( + rel.startsWith("src/app/api/") || + rel.includes("node_modules") || + rel.includes(".next") || + rel.startsWith("src/lib/db/") || + /\.test\.(ts|tsx)$/.test(rel) || + /\.spec\.(ts|tsx)$/.test(rel) + ); +} + function collectRouteFiles() { return new Set( walk(API) @@ -45,55 +75,226 @@ function collectRouteFiles() { ); } -// /api/providers/abc/models → src/app/api/providers/[id]/models/route.ts -export function resolveApiPathToRoute(apiPath, routeFiles) { +// ─── route resolution helpers (exported for tests) ─────────────────────────── + +/** + * Resolves an API path to the most specific matching route file. + * Prefers static routes over dynamic [param] routes when both match. + * + * @param {string} apiPath - e.g. "/api/combos/test" + * @param {Set} routeFiles - relative paths like "src/app/api/…/route.ts" + * @returns {string | null} matched route file path, or null + */ +export function resolveApiPathToRouteFile(apiPath, routeFiles) { const segs = apiPath .replace(/^\//, "") .replace(/[?#].*$/, "") .split("/"); + let staticMatch = null; + let dynamicMatch = null; for (const rf of routeFiles) { const rsegs = rf .replace(/^src\/app\//, "") .replace(/\/route\.tsx?$/, "") .split("/"); if (rsegs.length !== segs.length) continue; + const isDynamic = rsegs.some((rs) => /^\[.*\]$/.test(rs)); const ok = rsegs.every((rs, i) => rs === segs[i] || /^\[.*\]$/.test(rs)); + if (ok) { + if (!isDynamic) staticMatch = rf; + else if (!dynamicMatch) dynamicMatch = rf; + } + } + return staticMatch || dynamicMatch; +} + +/** + * Returns true if the API path resolves to any known route file. + * Exported for backward compatibility with existing tests. + */ +export function resolveApiPathToRoute(apiPath, routeFiles) { + return resolveApiPathToRouteFile(apiPath, routeFiles) !== null; +} + +/** + * Prefix-match for template literals: checks whether any route exists whose + * path starts with the static prefix (same depth or deeper). + * + * @param {string} prefix - static prefix extracted from a template literal, + * e.g. "/api/providers/" or "/api/usage/analytics?since=" + * @param {Set} routeFiles + * @returns {boolean} + */ +export function resolveApiPrefixToRoute(prefix, routeFiles) { + // Strip query params and trailing slash from the prefix + const cleanPrefix = prefix.replace(/[?#].*$/, "").replace(/\/$/, ""); + const prefixSegs = cleanPrefix.replace(/^\//, "").split("/"); + for (const rf of routeFiles) { + const rsegs = rf + .replace(/^src\/app\//, "") + .replace(/\/route\.tsx?$/, "") + .split("/"); + // Route must be at least as deep as the prefix + if (rsegs.length < prefixSegs.length) continue; + const ok = prefixSegs.every((ps, i) => ps === rsegs[i] || /^\[.*\]$/.test(rsegs[i])); 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 ${...}). +/** + * Checks whether a route file's source exports the given HTTP method. + * Handles: + * - `export async function POST(…)` + * - `export const DELETE = …` + * - `export { GET, PUT } from "…"` (re-exports) + * + * @param {string} routeSource - the CONTENT of the route file (string) + * @param {string} method - uppercase HTTP method, e.g. "POST" + * @returns {boolean} + */ +export function routeExportsMethod(routeSource, method) { + // Direct export: `export [async] function METHOD` or `export const METHOD` + const directRe = new RegExp( + `export\\s+(?:async\\s+)?(?:function|const)\\s+${method}\\b` + ); + if (directRe.test(routeSource)) return true; + // Re-export: `export { GET, PUT } from "…"` + const reExportRe = /export\s*\{([^}]+)\}\s*from/g; + let m; + while ((m = reExportRe.exec(routeSource))) { + const names = m[1] + .split(",") + .map((s) => s.trim().split(/\s+as\s+/)[0].trim()); + if (names.includes(method)) return true; + } + return false; +} + +// ─── extraction helpers ─────────────────────────────────────────────────────── + +/** + * Extracts static /api/ paths from fetch/fetchJson/apiFetch calls. + * Returns only full static literals (no template expressions). + */ +function extractStaticFetchPaths(content) { + // Matches: fetch("/api/foo"), fetch('/api/foo'), fetch(`/api/foo`) (no ${ }) + // The negative lookahead (?!.*\$\{) is applied on the matched string itself 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]); + while ((m = re.exec(content))) out.push(m[1]); return out; } +/** + * Extracts static prefixes from template-literal fetch calls that contain + * at least one dynamic expression (${…}). + */ +function extractTemplateFetchPrefixes(content) { + const re = /(?:fetch|fetchJson|apiFetch)\(\s*`(\/api\/[^`]*)`/g; + const out = []; + let m; + while ((m = re.exec(content))) { + const full = m[1]; + const dynIdx = full.indexOf("${"); + if (dynIdx !== -1) { + out.push(full.substring(0, dynIdx)); + } + } + return out; +} + +/** + * Extracts static fetch paths together with the HTTP method literal, when + * present in the options object (2nd argument of fetch). + * Returns { apiPath, method } pairs where method defaults to "GET". + */ +function extractStaticFetchPathsWithMethod(content) { + // Match static path + optional second argument block (up to 500 chars) + const re = + /(?:fetch|fetchJson|apiFetch)\(\s*["'](\/api\/[A-Za-z0-9_\-/[\]]+)["']\s*(?:,\s*(\{[^)]{0,500}))?/g; + const out = []; + let m; + while ((m = re.exec(content))) { + const apiPath = m[1]; + const optStr = m[2] || ""; + const methodMatch = /method\s*:\s*["']([A-Z]+)["']/.exec(optStr); + const method = methodMatch ? methodMatch[1] : "GET"; + out.push({ apiPath, method }); + } + return out; +} + +// ─── main ───────────────────────────────────────────────────────────────────── + function main() { const routeFiles = collectRouteFiles(); - const misses = []; - for (const f of walk(DASH)) { - for (const apiPath of extractFetchPaths(f)) { + const liveMissesStatic = new Set(); + const liveMissesMethod = new Set(); + + for (const f of walk(SRC)) { + if (isExcluded(f)) continue; + const content = fs.readFileSync(f, "utf8"); + + // Subcheck 1: static paths + for (const apiPath of extractStaticFetchPaths(content)) { if (IGNORE.some((rx) => rx.test(apiPath))) continue; - if (KNOWN_MISSING.has(apiPath)) continue; + if (KNOWN_MISSING.has(apiPath)) { + liveMissesStatic.add(apiPath); // record as live so stale-check works + continue; + } if (!resolveApiPathToRoute(apiPath, routeFiles)) { - misses.push(`${path.relative(cwd, f)} → ${apiPath}`); + console.error( + `[check-fetch-targets] ✗ rota inexistente: ${path.relative(cwd, f)} → ${apiPath}` + ); + process.exitCode = 1; + liveMissesStatic.add(apiPath); + } + } + + // Subcheck 2: template literal prefixes + for (const prefix of extractTemplateFetchPrefixes(content)) { + if (IGNORE.some((rx) => rx.test(prefix))) continue; + if (!resolveApiPrefixToRoute(prefix, routeFiles)) { + console.error( + `[check-fetch-targets] ✗ prefixo de template inexistente: ${path.relative(cwd, f)} → "${prefix}"` + ); + process.exitCode = 1; + } + } + + // Subcheck 3: HTTP method on static paths + for (const { apiPath, method } of extractStaticFetchPathsWithMethod(content)) { + if (method === "GET") continue; + if (IGNORE.some((rx) => rx.test(apiPath))) continue; + const routeFile = resolveApiPathToRouteFile(apiPath, routeFiles); + if (!routeFile) continue; // Already caught by subcheck 1 + const key = `${apiPath}::${method}`; + if (KNOWN_MISSING.has(key)) { + liveMissesMethod.add(key); // record as live for stale-check + continue; + } + const routeSource = fs.readFileSync(path.join(cwd, routeFile), "utf8"); + if (!routeExportsMethod(routeSource, method)) { + console.error( + `[check-fetch-targets] ✗ método ${method} não exportado: ${path.relative(cwd, f)} → ${apiPath} (em ${routeFile})` + ); + process.exitCode = 1; + liveMissesMethod.add(key); } } } - 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); + + // Stale-enforcement: any entry in KNOWN_MISSING that was NOT seen as a live + // violation means the problem was fixed — the entry must be removed to lock + // in the improvement (6A.3 pattern). + const allLive = new Set([...liveMissesStatic, ...liveMissesMethod]); + assertNoStale([...KNOWN_MISSING], allLive, "fetch-targets"); + + if (!process.exitCode) { + console.log(`[check-fetch-targets] OK (${routeFiles.size} rotas conhecidas)`); } - 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 index 220c4001b1..eabc4c6807 100644 --- a/scripts/check/check-file-size.mjs +++ b/scripts/check/check-file-size.mjs @@ -17,7 +17,9 @@ function getArg(name, 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"]; +const SCAN_DIRS = ["src", "open-sse", "electron", "bin"]; +// Directories to skip when walking — build artifacts and installed packages. +const SKIP_DIRS = new Set(["node_modules", "dist-electron", ".next", ".build", "dist", "coverage"]); /** * Avalia LOC atuais contra o baseline congelado. @@ -45,8 +47,11 @@ 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); + if (e.isDirectory()) { + if (!SKIP_DIRS.has(e.name)) 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; } diff --git a/scripts/check/check-known-symbols.ts b/scripts/check/check-known-symbols.ts index 9596f8b745..e9132e5632 100644 --- a/scripts/check/check-known-symbols.ts +++ b/scripts/check/check-known-symbols.ts @@ -1,7 +1,7 @@ #!/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: +// que silenciosamente vira no-op" em seis 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 @@ -21,13 +21,27 @@ // 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. // +// (4) MCP TOOLS — todos os tools registrados em createMcpServer() (base MCP_TOOLS + +// memoryTools + skillTools + gamificationTools + pluginTools + notionTools + +// obsidianTools) DEVEM ter ao menos um escopo atribuído (scope-enforcement). Os +// nomes são congelados em KNOWN_MCP_TOOL_NAMES. Catraca: tool removido = fail. +// Tool novo = report (não bloqueia adições legítimas). +// +// (5) A2A SKILLS — chaves de A2A_SKILL_HANDLERS (src/lib/a2a/taskExecution.ts) DEVEM +// bater bidirecionalmente com skills[].id expostos no Agent Card +// (src/app/.well-known/agent.json/route.ts). Divergência em qualquer direção = fail. +// +// (6) CLOUD AGENTS — entradas de AGENTS em src/lib/cloudAgent/registry.ts DEVEM bater +// bidirecionalmente com os arquivos de classe em src/lib/cloudAgent/agents/ +// (basename sem extensão). Divergência = fail. +// // 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 { readFileSync, readdirSync } from "node:fs"; import { fileURLToPath, pathToFileURL } from "node:url"; -import { dirname, resolve as resolvePath } from "node:path"; +import { dirname, resolve as resolvePath, basename, extname } from "node:path"; const HERE = dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = resolvePath(HERE, "..", ".."); @@ -182,7 +196,236 @@ export function findNewTranslatorPairs(frozen: readonly string[], live: Set !t.scopes || t.scopes.length === 0).map((t) => t.name); +} + +/** + * Returns tools in the frozen snapshot that are no longer in the live + * registry (removals are regressions). + */ +export function findMissingMcpTools(frozen: readonly string[], live: Set): string[] { + return frozen.filter((name) => !live.has(name)); +} + +/** + * Returns live tools not present in the frozen snapshot (additions are + * informative, not failures). + */ +export function findNewMcpTools(frozen: readonly string[], live: Set): string[] { + const frozenSet = new Set(frozen); + return [...live].filter((name) => !frozenSet.has(name)).sort(); +} + +/** + * Snapshot of all MCP tool names registered by createMcpServer() as of + * 2026-06-13. Catraca: tool removed = fail; tool added = informative report. + * To update after an intentional removal/rename: edit this list and document + * the reason in the commit message. + * + * Sources: + * - MCP_TOOLS (33 base tools: omniroute_* + compression + agent_skills) + * - memoryTools (3): omniroute_memory_* + * - skillTools (4): omniroute_skills_* + * - gamificationTools (8): gamification_* + * - pluginTools (8): plugin_* + * - notionTools (6): notion_* + * - obsidianTools (22): obsidian_* + * agentSkillTools and compressionTools are included in MCP_TOOLS (deduped by RESERVED_MCP_NAMES). + */ +export const KNOWN_MCP_TOOL_NAMES: readonly string[] = [ + // MCP_TOOLS base (33) + "omniroute_get_health", + "omniroute_list_combos", + "omniroute_get_combo_metrics", + "omniroute_switch_combo", + "omniroute_check_quota", + "omniroute_route_request", + "omniroute_cost_report", + "omniroute_list_models_catalog", + "omniroute_web_search", + "omniroute_simulate_route", + "omniroute_set_budget_guard", + "omniroute_set_routing_strategy", + "omniroute_set_resilience_profile", + "omniroute_test_combo", + "omniroute_get_provider_metrics", + "omniroute_best_combo_for_task", + "omniroute_explain_route", + "omniroute_get_session_snapshot", + "omniroute_db_health_check", + "omniroute_sync_pricing", + "omniroute_cache_stats", + "omniroute_cache_flush", + "omniroute_compression_status", + "omniroute_compression_configure", + "omniroute_set_compression_engine", + "omniroute_list_compression_combos", + "omniroute_compression_combo_stats", + "omniroute_oneproxy_fetch", + "omniroute_oneproxy_rotate", + "omniroute_oneproxy_stats", + "omniroute_agent_skills_list", + "omniroute_agent_skills_get", + "omniroute_agent_skills_coverage", + // memoryTools (3) + "omniroute_memory_search", + "omniroute_memory_add", + "omniroute_memory_clear", + // skillTools (4) + "omniroute_skills_list", + "omniroute_skills_enable", + "omniroute_skills_execute", + "omniroute_skills_executions", + // gamificationTools (8) + "gamification_leaderboard", + "gamification_rank", + "gamification_profile", + "gamification_badges", + "gamification_transfer", + "gamification_invite", + "gamification_servers", + "gamification_anomalies", + // pluginTools (8) + "plugin_list", + "plugin_install", + "plugin_activate", + "plugin_deactivate", + "plugin_uninstall", + "plugin_configure", + "plugin_executions", + "plugin_scan", + // notionTools (6) + "notion_search", + "notion_get_page", + "notion_list_block_children", + "notion_query_database", + "notion_get_database", + "notion_append_blocks", + // obsidianTools (22) + "obsidian_check_status", + "obsidian_search_simple", + "obsidian_search_structured", + "obsidian_read_note", + "obsidian_list_vault", + "obsidian_get_document_map", + "obsidian_get_note_metadata", + "obsidian_get_active_file", + "obsidian_get_periodic_note", + "obsidian_get_tags", + "obsidian_list_commands", + "obsidian_write_note", + "obsidian_append_note", + "obsidian_patch_note", + "obsidian_delete_note", + "obsidian_move_note", + "obsidian_execute_command", + "obsidian_open_file", + "obsidian_sync_status", + "obsidian_sync_trigger", + "obsidian_sync_conflicts", + "obsidian_sync_resolve_conflict", +]; + +// ─────────────────────────────────────────────────────────────────────────── +// (5) A2A SKILLS — bidirectional diff between handlers and agent card +// ─────────────────────────────────────────────────────────────────────────── + +export type A2ASkillDiff = { + /** Skills registered in A2A_SKILL_HANDLERS but not exposed in the Agent Card */ + inHandlersNotCard: string[]; + /** Skills exposed in the Agent Card but not registered in A2A_SKILL_HANDLERS */ + inCardNotHandlers: string[]; +}; + +/** + * Bidirectionally diffs A2A skill handler keys against Agent Card skill IDs. + * Both directions matter: + * - inHandlersNotCard: skill is routable but agents can't discover it + * - inCardNotHandlers: skill is advertised but calling it fails silently + */ +export function diffA2ASkills( + handlers: Set, + agentCard: Set +): A2ASkillDiff { + const inHandlersNotCard = [...handlers].filter((s) => !agentCard.has(s)).sort(); + const inCardNotHandlers = [...agentCard].filter((s) => !handlers.has(s)).sort(); + return { inHandlersNotCard, inCardNotHandlers }; +} + +// ─────────────────────────────────────────────────────────────────────────── +// (6) CLOUD AGENTS — registry keys vs agent class files +// ─────────────────────────────────────────────────────────────────────────── + +export type CloudAgentDiff = { + /** Registry keys with no corresponding agent file in agents/ */ + inRegistryNotFiles: string[]; + /** Agent files with no corresponding registry key */ + inFilesNotRegistry: string[]; +}; + +/** + * Bidirectionally diffs cloud agent registry keys against agent file basenames + * (filename without extension, e.g. "codex.ts" → "codex"). Note: registry key + * "codex-cloud" maps to file "codex.ts" — this mapping is handled by the + * caller (main) which reads the actual class-name-to-key binding from registry.ts. + * The pure function here just diffs two already-normalised sets. + */ +export function diffCloudAgents( + registryKeys: Set, + agentFiles: Set +): CloudAgentDiff { + const inRegistryNotFiles = [...registryKeys].filter((k) => !agentFiles.has(k)).sort(); + const inFilesNotRegistry = [...agentFiles].filter((f) => !registryKeys.has(f)).sort(); + return { inRegistryNotFiles, inFilesNotRegistry }; +} + +/** + * Reads the registry.ts source file and returns the set of provider IDs + * (keys in the AGENTS object literal). + */ +export function extractCloudAgentRegistryKeys(registrySource: string): Set { + // Match the AGENTS object: find start, extract keys + const start = registrySource.indexOf("const AGENTS: Record = {"); + if (start < 0) throw new Error("could not find `const AGENTS:` in cloudAgent/registry.ts"); + const end = registrySource.indexOf("\n};", start); + if (end < 0) throw new Error("could not find end of AGENTS map"); + const block = registrySource.slice(start, end); + // Match quoted or bare keys: "codex-cloud": or jules: + const keyRe = /^\s*(?:"([^"]+)"|([A-Za-z0-9_$-]+))\s*:/gm; + const keys = new Set(); + let match: RegExpExecArray | null; + while ((match = keyRe.exec(block)) !== null) { + const key = match[1] ?? match[2]; + // Skip the TypeScript type annotation line + if (key && key !== "Record") keys.add(key); + } + return keys; +} + +/** + * Lists agent file basenames (without extension) from the agents/ directory. + * Maps known filename→registryKey aliases (e.g. "codex" → "codex-cloud"). + */ +export const AGENT_FILE_TO_REGISTRY_KEY: Record = { + codex: "codex-cloud", +}; + +// ─────────────────────────────────────────────────────────────────────────── +// main() — importa módulos reais, lê fontes, roda as seis sub-checagens // ─────────────────────────────────────────────────────────────────────────── async function main(): Promise { @@ -270,6 +513,123 @@ async function main(): Promise { } const newPairs = findNewTranslatorPairs(KNOWN_TRANSLATOR_PAIRS, livePairs); + // ── (4) MCP tools scope + snapshot ─────────────────────────────────────── + const { MCP_TOOLS } = await import("@omniroute/open-sse/mcp-server/schemas/tools.ts"); + const { memoryTools } = await import("@omniroute/open-sse/mcp-server/tools/memoryTools.ts"); + const { skillTools } = await import("@omniroute/open-sse/mcp-server/tools/skillTools.ts"); + const { gamificationTools } = await import( + "@omniroute/open-sse/mcp-server/tools/gamificationTools.ts" + ); + const { pluginTools } = await import("@omniroute/open-sse/mcp-server/tools/pluginTools.ts"); + const { notionTools } = await import("@omniroute/open-sse/mcp-server/tools/notionTools.ts"); + const { obsidianTools } = await import("@omniroute/open-sse/mcp-server/tools/obsidianTools.ts"); + + // Build the full live set of registered tools (deduped by RESERVED_MCP_NAMES logic: + // agentSkillTools + compressionTools are already in MCP_TOOLS). + const liveMcpTools: McpToolLike[] = [ + ...(MCP_TOOLS as unknown as McpToolLike[]), + ...Object.values(memoryTools as Record), + ...Object.values(skillTools as Record), + ...(gamificationTools as unknown as McpToolLike[]), + ...(pluginTools as unknown as McpToolLike[]), + ...(notionTools as unknown as McpToolLike[]), + ...(obsidianTools as unknown as McpToolLike[]), + ]; + const liveMcpToolNames = new Set(liveMcpTools.map((t) => t.name)); + + // 4a. Every registered tool must have at least one scope. + const toolsWithoutScopes = checkMcpToolsHaveScopes(liveMcpTools); + if (toolsWithoutScopes.length) { + failures.push( + `[mcp-tools] ${toolsWithoutScopes.length} tool(s) sem scope(s) atribuído(s) — todo tool registrado deve ter ao menos 1 scope para scope-enforcement:\n` + + toolsWithoutScopes.map((n) => ` ✗ ${n}`).join("\n") + + `\n → adicione o campo scopes: [...] na definição do tool.` + ); + } + + // 4b. Snapshot catraca: tools removed are regressions. + const missingMcpTools = findMissingMcpTools(KNOWN_MCP_TOOL_NAMES, liveMcpToolNames); + if (missingMcpTools.length) { + failures.push( + `[mcp-tools] ${missingMcpTools.length} tool(s) congelado(s) sumiram do registry vivo (regressão):\n` + + missingMcpTools.map((n) => ` ✗ ${n}`).join("\n") + + `\n → restaure o tool ou, se a remoção foi intencional, atualize KNOWN_MCP_TOOL_NAMES.` + ); + } + const newMcpTools = findNewMcpTools(KNOWN_MCP_TOOL_NAMES, liveMcpToolNames); + + // ── (5) A2A skills ─────────────────────────────────────────────────────── + const { A2A_SKILL_HANDLERS } = await import("@/lib/a2a/taskExecution.ts"); + const handlerKeys = new Set(Object.keys(A2A_SKILL_HANDLERS as Record)); + + // Parse the Agent Card route statically (the skills array is a literal in the source). + const agentCardSource = readFileSync( + resolvePath(REPO_ROOT, "src/app/.well-known/agent.json/route.ts"), + "utf8" + ); + // Extract skill IDs: `id: "..."` lines inside the skills array. + const skillIdRe = /\bid:\s*"([^"]+)"/g; + const agentCardSkills = new Set(); + let skillMatch: RegExpExecArray | null; + while ((skillMatch = skillIdRe.exec(agentCardSource)) !== null) { + agentCardSkills.add(skillMatch[1]); + } + if (agentCardSkills.size === 0) { + failures.push( + `[a2a-skills] parse do Agent Card não encontrou nenhum skill id (regex quebrada ou arquivo movido?)` + ); + } + + const { inHandlersNotCard, inCardNotHandlers } = diffA2ASkills(handlerKeys, agentCardSkills); + if (inHandlersNotCard.length) { + failures.push( + `[a2a-skills] ${inHandlersNotCard.length} skill(s) em A2A_SKILL_HANDLERS mas ausente(s) do Agent Card (agentes não conseguem descobrir):\n` + + inHandlersNotCard.map((s) => ` ✗ ${s}`).join("\n") + + `\n → adicione o skill em src/app/.well-known/agent.json/route.ts (skills array).` + ); + } + if (inCardNotHandlers.length) { + failures.push( + `[a2a-skills] ${inCardNotHandlers.length} skill(s) expostos no Agent Card mas ausente(s) de A2A_SKILL_HANDLERS (chamada silenciosamente falha):\n` + + inCardNotHandlers.map((s) => ` ✗ ${s}`).join("\n") + + `\n → registre o handler em src/lib/a2a/taskExecution.ts (A2A_SKILL_HANDLERS).` + ); + } + + // ── (6) Cloud agents ───────────────────────────────────────────────────── + const registrySource = readFileSync( + resolvePath(REPO_ROOT, "src/lib/cloudAgent/registry.ts"), + "utf8" + ); + const registryKeys = extractCloudAgentRegistryKeys(registrySource); + + // Read agent file basenames from agents/ directory, applying the alias map. + const agentsDir = resolvePath(REPO_ROOT, "src/lib/cloudAgent/agents"); + const agentFileBases = new Set( + readdirSync(agentsDir) + .filter((f) => /\.(ts|js)$/.test(f) && !f.endsWith(".d.ts") && !f.endsWith(".test.ts")) + .map((f) => { + const base = basename(f, extname(f)); + return AGENT_FILE_TO_REGISTRY_KEY[base] ?? base; + }) + ); + + const { inRegistryNotFiles, inFilesNotRegistry } = diffCloudAgents(registryKeys, agentFileBases); + if (inRegistryNotFiles.length) { + failures.push( + `[cloud-agents] ${inRegistryNotFiles.length} chave(s) no registry sem arquivo de classe em agents/:\n` + + inRegistryNotFiles.map((k) => ` ✗ ${k}`).join("\n") + + `\n → crie o arquivo src/lib/cloudAgent/agents/.ts ou atualize AGENT_FILE_TO_REGISTRY_KEY.` + ); + } + if (inFilesNotRegistry.length) { + failures.push( + `[cloud-agents] ${inFilesNotRegistry.length} arquivo(s) em agents/ sem entrada no registry:\n` + + inFilesNotRegistry.map((f) => ` ✗ ${f}`).join("\n") + + `\n → registre o agente em src/lib/cloudAgent/registry.ts ou adicione o alias em AGENT_FILE_TO_REGISTRY_KEY.` + ); + } + // ── Resultado ───────────────────────────────────────────────────────────── if (failures.length) { console.error(`[known-symbols] ${failures.length} sub-checagem(ns) falharam:\n\n${failures.join("\n\n")}`); @@ -279,8 +639,17 @@ async function main(): Promise { const newPairsNote = newPairs.length ? ` (${newPairs.length} par(es) novo(s) não-congelado(s): ${newPairs.join(", ")} — atualize KNOWN_TRANSLATOR_PAIRS se intencional)` : ""; + const newMcpNote = newMcpTools.length + ? ` (${newMcpTools.length} tool(s) novo(s) não-congelado(s): ${newMcpTools.join(", ")} — atualize KNOWN_MCP_TOOL_NAMES 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}` + `[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}; ` + + `${liveMcpToolNames.size} tools MCP (${toolsWithoutScopes.length === 0 ? "todos com scope" : `${toolsWithoutScopes.length} sem scope`}) vs ${KNOWN_MCP_TOOL_NAMES.length} congelados${newMcpNote}; ` + + `${handlerKeys.size} A2A skills (handlers↔card OK); ` + + `${registryKeys.size} cloud agents (registry↔files OK)` ); } diff --git a/scripts/check/check-licenses.mjs b/scripts/check/check-licenses.mjs new file mode 100644 index 0000000000..3061314db6 --- /dev/null +++ b/scripts/check/check-licenses.mjs @@ -0,0 +1,243 @@ +#!/usr/bin/env node +// scripts/check/check-licenses.mjs +// Gate de license compliance — PLANO-QUALITY-GATES-FASE7.md, Task 20. +// +// Política: OmniRoute é MIT. Dependências de PRODUÇÃO com licença fora da allowlist SPDX +// e sem exceção registrada em .license-allowlist.json => FALHA (policy violation). +// devDependencies com licença não-padrão => advisory (impressas, não falham). +// +// Ferramenta: license-checker-rseidelsohn v4+ (node_modules/.bin/license-checker-rseidelsohn). +// +// Uso: +// node scripts/check/check-licenses.mjs # modo normal +// node scripts/check/check-licenses.mjs --verbose # lista todos os pacotes classificados +// node scripts/check/check-licenses.mjs --json # emite o raw JSON do license-checker +// +// Sair com código 0 = tudo OK (ou apenas advisory). +// Sair com código 1 = violação de política em dep de produção. + +import { execFileSync } from "node:child_process"; +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, ".license-allowlist.json"); +const CHECKER_BIN = path.join(ROOT, "node_modules", ".bin", "license-checker-rseidelsohn"); + +const VERBOSE = process.argv.includes("--verbose"); +const PRINT_JSON = process.argv.includes("--json"); + +// --------------------------------------------------------------------------- +// Allowlist loading +// --------------------------------------------------------------------------- + +/** + * Loads and returns the license allowlist from .license-allowlist.json. + * + * @returns {{ allowed: string[], allowedExpressions: string[], exceptions: Record }} + */ +export function loadAllowlist() { + if (!fs.existsSync(ALLOWLIST_PATH)) { + throw new Error(`Allowlist not found: ${ALLOWLIST_PATH}. Create .license-allowlist.json first.`); + } + const raw = fs.readFileSync(ALLOWLIST_PATH, "utf-8"); + const parsed = JSON.parse(raw); + return { + allowed: parsed.allowed ?? [], + allowedExpressions: parsed.allowedExpressions ?? [], + exceptions: parsed.exceptions ?? {}, + }; +} + +// --------------------------------------------------------------------------- +// Classification logic (pure, testable) +// --------------------------------------------------------------------------- + +/** + * Classifies a package+license against the allowlist. + * + * @param {string} packageName - Package name without version, e.g. "lightningcss" + * @param {string} license - License string from license-checker, e.g. "MPL-2.0" + * @param {{ allowed: string[], allowedExpressions: string[], exceptions: Record }} allowlist + * @returns {{ status: "allowed" | "exception" | "denied", reason: string }} + */ +export function classifyLicense(packageName, license, allowlist) { + const { allowed, allowedExpressions, exceptions } = allowlist; + + // 1. Direct SPDX match + if (allowed.includes(license)) { + return { status: "allowed", reason: `SPDX match: ${license}` }; + } + + // 2. Expression match (e.g. "(MIT OR Apache-2.0)") + if (allowedExpressions.includes(license)) { + return { status: "allowed", reason: `allowed expression: ${license}` }; + } + + // 3. Per-package exception (strip version suffix for lookup) + const baseName = stripVersion(packageName); + if (exceptions[baseName]) { + const exc = exceptions[baseName]; + return { + status: "exception", + reason: `exception: ${exc.justification} [risk=${exc.risk}]`, + }; + } + + // 4. Denied + return { status: "denied", reason: `license '${license}' not in allowlist and no exception registered for '${baseName}'` }; +} + +/** + * Strips the @version suffix from a package key returned by license-checker. + * e.g. "lightningcss@1.32.0" => "lightningcss" + * "@img/sharp-libvips-linux-x64@1.2.4" => "@img/sharp-libvips-linux-x64" + * + * @param {string} pkgKey - Package key as returned by license-checker + * @returns {string} + */ +export function stripVersion(pkgKey) { + // Handle scoped packages: @scope/name@version + const scopedMatch = pkgKey.match(/^(@[^/]+\/[^@]+)(?:@.*)?$/); + if (scopedMatch) return scopedMatch[1]; + // Regular: name@version + const regularMatch = pkgKey.match(/^([^@]+)(?:@.*)?$/); + if (regularMatch) return regularMatch[1]; + return pkgKey; +} + +// --------------------------------------------------------------------------- +// Runner +// --------------------------------------------------------------------------- + +/** + * Runs license-checker-rseidelsohn --production and returns parsed JSON. + * + * @returns {Record} + */ +function runLicenseChecker() { + if (!fs.existsSync(CHECKER_BIN)) { + throw new Error( + `license-checker-rseidelsohn not found at ${CHECKER_BIN}.\n` + + `Install it: npm install --save-dev license-checker-rseidelsohn` + ); + } + + const output = execFileSync(CHECKER_BIN, ["--production", "--json"], { + cwd: ROOT, + encoding: "utf-8", + maxBuffer: 32 * 1024 * 1024, // 32 MB + }); + + return JSON.parse(output); +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +function main() { + /** @type {Record} */ + let licenseData; + try { + licenseData = runLicenseChecker(); + } catch (err) { + console.error("[check-licenses] Falha ao rodar license-checker-rseidelsohn:"); + console.error(err.message ?? err); + process.exitCode = 1; + return; + } + + if (PRINT_JSON) { + console.log(JSON.stringify(licenseData, null, 2)); + return; + } + + /** @type {{ allowed: string[], allowedExpressions: string[], exceptions: Record }} */ + let allowlist; + try { + allowlist = loadAllowlist(); + } catch (err) { + console.error("[check-licenses]", err.message); + process.exitCode = 1; + return; + } + + const violations = []; + const exceptions = []; + const advisory = []; + let allowedCount = 0; + + for (const [pkgKey, info] of Object.entries(licenseData)) { + const license = info.licenses ?? "UNKNOWN"; + const result = classifyLicense(pkgKey, license, allowlist); + + if (result.status === "allowed") { + allowedCount++; + if (VERBOSE) { + console.log(` ✓ ${pkgKey}: ${license}`); + } + } else if (result.status === "exception") { + exceptions.push({ pkgKey, license, reason: result.reason }); + } else { + violations.push({ pkgKey, license, reason: result.reason }); + } + } + + const total = Object.keys(licenseData).length; + + // Print summary + console.log(`[check-licenses] Escaneados ${total} pacotes de produção.`); + console.log(` Permitidos: ${allowedCount}`); + console.log(` Exceções registradas: ${exceptions.length}`); + console.log(` Violações de política: ${violations.length}`); + + // Print exceptions (informational) + if (exceptions.length > 0) { + console.log("\n[check-licenses] Exceções registradas (não bloqueantes, revisar periodicamente):"); + for (const { pkgKey, license } of exceptions) { + const baseName = stripVersion(pkgKey); + const exc = allowlist.exceptions[baseName]; + const riskTag = exc?.risk === "medium" ? " ⚠️ RISK=medium" : ""; + console.log(` ⚑ ${pkgKey}: ${license}${riskTag}`); + if (exc?.risk === "medium") { + console.log(` → ${exc.justification}`); + } + } + } + + // Print advisory (devDep non-standard — empty here since we run --production) + if (advisory.length > 0) { + console.log("\n[check-licenses] Advisory (devDeps com licença não-padrão):"); + for (const { pkgKey, license } of advisory) { + console.log(` ℹ ${pkgKey}: ${license}`); + } + } + + // Print violations and fail + if (violations.length > 0) { + console.error("\n[check-licenses] ❌ VIOLAÇÕES DE POLÍTICA — deps de produção com licença não permitida:"); + for (const { pkgKey, license, reason } of violations) { + console.error(` ✗ ${pkgKey}: ${license}`); + console.error(` → ${reason}`); + } + console.error( + "\nAdicione a licença à allowlist 'allowed' em .license-allowlist.json (se SPDX-permissiva)\n" + + "ou registre uma exceção por-pacote em 'exceptions' com justificativa e 'reviewAt'.\n" + + "NÃO mascare copyleft forte sem registrar a justificativa. Ver PLANO-QUALITY-GATES-FASE7.md § Task 20." + ); + process.exitCode = 1; + return; + } + + console.log("\n[check-licenses] ✅ Todos os pacotes de produção estão em conformidade com a política de licenças."); +} + +// Run only when invoked directly (not when imported by tests) +const isMain = process.argv[1] === pathToFileURL(import.meta.url).pathname || + process.argv[1]?.endsWith("check-licenses.mjs"); + +if (isMain) { + main(); +} diff --git a/scripts/check/check-lockfile.mjs b/scripts/check/check-lockfile.mjs new file mode 100644 index 0000000000..61a7b35a8b --- /dev/null +++ b/scripts/check/check-lockfile.mjs @@ -0,0 +1,116 @@ +#!/usr/bin/env node +// scripts/check/check-lockfile.mjs +// Gate de política de lockfile (CLAUDE.md — extensão Hard Rule #1). +// +// Objetivo: detectar supply-chain poisoning no package-lock.json antes que código +// malicioso entre no repo. Verifica: +// --validate-https → toda URL "resolved" deve usar HTTPS (bloqueia http://) +// --validate-integrity → todo pacote deve ter hash de integridade sha512 +// --allowed-hosts npm → apenas registry.npmjs.org é host permitido +// +// Complementa check-deps (Fase 2 / allowlist de nomes): aquele garante que só +// nomes aprovados entram; este garante que os pacotes instalados vieram do registry +// legítimo com integridade verificável. +// +// Referência: PLANO-QUALITY-GATES-FASE7.md, Task 7.7. +// Tool: lockfile-lint v5 (node_modules/.bin/lockfile-lint). + +import { execFileSync } from "node:child_process"; +import path from "node:path"; +import fs from "node:fs"; +import { pathToFileURL } from "node:url"; + +const ROOT = process.cwd(); + +/** + * Returns the canonical lockfile-lint configuration used by this gate. + * Exporting this object makes the policy auditable and unit-testable without + * spawning a child process. + * + * @returns {{ + * lockfilePath: string, + * type: string, + * validateHttps: boolean, + * validateIntegrity: boolean, + * allowedHosts: string[], + * }} + */ +export function getLockfileLintConfig() { + return { + lockfilePath: path.join(ROOT, "package-lock.json"), + type: "npm", + validateHttps: true, + validateIntegrity: true, + // Only the official npm registry is permitted. + // registry.npmjs.org resolves to the "npm" shorthand in lockfile-lint. + // If the project ever adopts a scoped/private registry, add its hostname here + // and document the justification. + allowedHosts: ["npm"], + }; +} + +/** + * Builds the argv array to pass to the lockfile-lint binary, derived from + * the config returned by getLockfileLintConfig(). + * + * @param {ReturnType} cfg + * @returns {string[]} + */ +export function buildLockfileLintArgs(cfg) { + const args = [ + "--path", cfg.lockfilePath, + "--type", cfg.type, + ]; + if (cfg.validateHttps) args.push("--validate-https"); + if (cfg.validateIntegrity) args.push("--validate-integrity"); + if (cfg.allowedHosts.length) { + args.push("--allowed-hosts", ...cfg.allowedHosts); + } + return args; +} + +function main() { + const cfg = getLockfileLintConfig(); + + if (!fs.existsSync(cfg.lockfilePath)) { + console.error( + `[check-lockfile] FAIL — lockfile not found: ${cfg.lockfilePath}\n` + + " → Run `npm install` to generate package-lock.json" + ); + process.exit(1); + } + + const bin = path.join(ROOT, "node_modules", ".bin", "lockfile-lint"); + if (!fs.existsSync(bin)) { + console.error( + `[check-lockfile] FAIL — lockfile-lint binary not found at:\n ${bin}\n` + + " → Run `npm install` to install dev dependencies" + ); + process.exit(1); + } + + const args = buildLockfileLintArgs(cfg); + + try { + const output = execFileSync(bin, args, { encoding: "utf8" }); + // lockfile-lint outputs a green ✔ message on success + console.log("[check-lockfile] OK —", output.trim()); + } catch (err) { + const stdout = err.stdout ?? ""; + const stderr = err.stderr ?? ""; + console.error("[check-lockfile] FAIL — lockfile-lint found policy violations:"); + if (stdout) console.error(stdout); + if (stderr) console.error(stderr); + console.error( + "\n Possible causes:\n" + + " • A package was resolved from a non-HTTPS URL (http:// poisoning attempt)\n" + + " • A package is missing its integrity hash (tampered or legacy entry)\n" + + " • A package was resolved from a host other than registry.npmjs.org\n" + + " If a scoped/private registry is intentionally used, add its hostname\n" + + " to getLockfileLintConfig().allowedHosts in scripts/check/check-lockfile.mjs" + ); + process.exit(1); + } +} + +if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main(); diff --git a/scripts/check/check-migration-numbering.mjs b/scripts/check/check-migration-numbering.mjs index 3e157c9a70..8b81de86fd 100644 --- a/scripts/check/check-migration-numbering.mjs +++ b/scripts/check/check-migration-numbering.mjs @@ -11,9 +11,12 @@ // 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. +// Stale-enforcement (6A.3): entrada em KNOWN_GAPS / KNOWN_DUPLICATE_VERSIONS que não +// suprime nenhuma anomalia real → gate falha com instrução de remoção. import fs from "node:fs"; import path from "node:path"; import { pathToFileURL } from "node:url"; +import { assertNoStale } from "./lib/allowlist.mjs"; const cwd = process.cwd(); const MIGRATIONS_DIR = path.join(cwd, "src/lib/db/migrations"); @@ -26,12 +29,15 @@ 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. +// foi promovida para um número novo, e o slot antigo é tolerado). Adicionar aqui +// SOMENTE se houver um arquivo físico duplicado no disco (stale-enforcement 6A.3 +// detecta entradas sem duplicata física viva e força remoção). // --------------------------------------------------------------------------- export const KNOWN_DUPLICATE_VERSIONS = new Set([ - "041", // session_account_affinity → promovida para 050 (SUPERSEDED_DUPLICATE_MIGRATIONS) + // "041" was removed: 041_session_account_affinity.sql no longer exists on disk + // (only 041_compression_receipts.sql remains), so no physical duplicate is present. + // The SUPERSEDED_DUPLICATE_MIGRATIONS entry in migrationRunner.ts handles the runner + // compatibility at runtime without needing an allowlist here. (#6A.3 stale cleanup) ]); // --------------------------------------------------------------------------- @@ -108,6 +114,14 @@ function listMigrationFilenames() { function main() { const filenames = listMigrationFilenames(); + + // Compute raw anomalies WITHOUT allowlists — needed for stale-enforcement (6A.3). + const raw = findMigrationAnomalies(filenames, new Set(), new Set()); + const liveGaps = raw.gaps; + const liveDupVersions = raw.duplicates.map((d) => d.version); + assertNoStale(KNOWN_GAPS, liveGaps, "check-migration-numbering:gaps"); + assertNoStale(KNOWN_DUPLICATE_VERSIONS, liveDupVersions, "check-migration-numbering:duplicates"); + const { duplicates, gaps, badNames } = findMigrationAnomalies( filenames, KNOWN_DUPLICATE_VERSIONS, @@ -133,13 +147,15 @@ function main() { `adicione o número às allowlists KNOWN_DUPLICATE_VERSIONS / KNOWN_GAPS com ` + `justificativa rastreável a src/lib/db/migrationRunner.ts.` ); - process.exit(1); + process.exitCode = 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 (!process.exitCode) { + 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 index d91673602b..bb19af19b4 100644 --- a/scripts/check/check-openapi-routes.mjs +++ b/scripts/check/check-openapi-routes.mjs @@ -4,17 +4,20 @@ // 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). +// Stale-enforcement (6A.3): entrada em KNOWN_STALE_SPEC que não suprime nenhum path +// órfão real → gate falha com instrução de remoção (evita furo de regressão silencioso). import fs from "node:fs"; import path from "node:path"; import { pathToFileURL } from "node:url"; import yaml from "js-yaml"; +import { assertNoStale } from "./lib/allowlist.mjs"; 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([ +export 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. ]); @@ -56,20 +59,25 @@ function main() { 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) - ); + + // Live orphans BEFORE allowlist filtering (needed for stale-enforcement). + const liveOrphans = findSpecPathsWithoutRoute(specPaths, implPaths); + assertNoStale(KNOWN_STALE_SPEC, liveOrphans, "openapi-routes"); + + const orphans = liveOrphans.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); + process.exitCode = 1; + } + if (!process.exitCode) { + console.log( + `[openapi-routes] OK — ${specPaths.length} paths na spec, todos com rota real (${implPaths.length} rotas)` + ); } - 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-pr-evidence.mjs b/scripts/check/check-pr-evidence.mjs new file mode 100644 index 0000000000..f5cbbf8624 --- /dev/null +++ b/scripts/check/check-pr-evidence.mjs @@ -0,0 +1,249 @@ +#!/usr/bin/env node +// scripts/check/check-pr-evidence.mjs +// Gate: Hard Rule #18 — "evidence before assertions". +// +// When a PR body makes claims about test/validation success (e.g. "tests pass", +// "all green", "fixed", "added endpoint") it MUST include a block of command +// output as proof. A PR body with claims but no attached evidence => FAIL. +// +// Skip policy: when not running in a PR context (no PR_BODY env var and `gh pr +// view` is unavailable), exits 0 silently so the gate never blocks local dev. +// +// Conservative by design (two-signal requirement): +// - A PR body with NO claim-trigger terms always passes. +// - A PR body with a claim but also an evidence block always passes. +// - Only the combination of claim(s) + NO evidence block => FAIL. +// +// What counts as a TRIGGER (claim term)? +// See CLAIM_TRIGGERS below. We require at least ONE strong trigger OR two +// weak triggers to fire (reduces false positives from casual phrases). +// +// What counts as EVIDENCE? +// - A fenced code block (``` ... ```) that contains ≥1 line of output +// characters (not just whitespace/backticks). The block must look like +// terminal/command output: numbers, colons, path separators, status words. +// - OR an explicit "Evidence" / "Validation" / "Test output" / "Output" +// section header (##/### prefix) followed by non-empty content. +// - OR an inline `code span` that matches common test-runner patterns +// (e.g. "passing", "failed 0", "✓", "PASS", "ok"). + +import { execFileSync, spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +// --------------------------------------------------------------------------- +// Claim triggers +// --------------------------------------------------------------------------- +// STRONG triggers: single occurrence is sufficient to fire. +// These are explicit outcome/validation assertions. +const STRONG_TRIGGERS = [ + /\ball\s+(?:tests?\s+)?(?:pass(?:ed|ing)?|green)\b/i, + /\btests?\s+(?:pass(?:ed|ing)?|are\s+(?:green|passing))\b/i, + /\b(?:typecheck|lint|build)\s+(?:pass(?:ed|ing)?|(?:is\s+)?clean|(?:is\s+)?green|(?:is\s+)?ok)\b/i, + /\bvalidated\s+(?:on|in|via|with|against|locally|on\s+vps)\b/i, + /\b(?:zero|0)\s+(?:errors?|failures?|regressions?)\b/i, + /\btdd\b.*\b(?:pass(?:ed|ing)?|green)\b/i, + /\bfixed\b.*\band\s+(?:verified|confirmed|tested)\b/i, + /\bproof\s*:/i, +]; + +// WEAK triggers: need at least 2 present to fire. +// These are common "seems fine" phrases that alone don't warrant evidence. +const WEAK_TRIGGERS = [ + /\b(?:added|implemented|created)\s+(?:a\s+)?(?:new\s+)?(?:endpoint|route|test|handler|check)\b/i, + /\bfixed\b/i, + /\bresolves?\s+#\d+/i, + /\bshould\s+(?:work|pass|be\s+(?:fine|ok|green))\b/i, + /\b(?:verified|confirmed|validated)\b/i, + /\b(?:all|no)\s+(?:regressions?|breaking\s+changes?)\b/i, + /\blooks?\s+(?:good|fine|ok)\b/i, + /\bworks?\s+(?:correctly|fine|as\s+expected)\b/i, +]; + +// --------------------------------------------------------------------------- +// Evidence patterns +// --------------------------------------------------------------------------- +// 1. A fenced code block with "command-output-like" content. +// The block content must have at least one line that looks like output +// (contains digit sequences, colons, path separators, or known status tokens). +const FENCED_BLOCK_RE = /```[^\n]*\n([\s\S]*?)```/g; +const OUTPUT_LINE_RE = + /(?:\d+\s+(?:pass(?:ing)?|fail(?:ing)?|pending)|[✓✗ו]\s|\bPASS\b|\bFAIL\b|\bok\b|\berror\b.*\d|\bwarning\b.*\d|\d+\s+(?:test|spec|suite)|exit\s+code\s*[0-9]|at\s+\S+:\d+|[A-Za-z]+:\s*\d+|^\s*\d+\s+\w)/im; + +// 2. An explicit evidence/validation section header followed by non-blank content. +const EVIDENCE_SECTION_RE = + /^#{1,4}\s+(?:evidence|validation|test\s+(?:output|results?|run)|output|verified|proof)\b[^\n]*/im; + +// 3. An inline code span matching common test-runner result tokens. +const INLINE_RESULT_RE = + /`[^`]*(?:\d+\s+(?:pass(?:ing)?|fail(?:ing)?|test)|✓|✗|PASS(?:ED)?|FAIL(?:ED)?|all\s+\d+)[^`]*`/i; + +// --------------------------------------------------------------------------- +// Pure evaluation function (exported for tests) +// --------------------------------------------------------------------------- +/** + * Evaluate a PR body string. + * + * @param {string} body + * @returns {{ result: "pass" | "fail" | "skip"; reason: string }} + */ +export function evaluatePrBody(body) { + if (!body || body.trim().length === 0) { + return { result: "skip", reason: "PR body is empty — nothing to evaluate." }; + } + + // --- 1. Detect claims --- + const strongMatches = STRONG_TRIGGERS.filter((re) => re.test(body)); + const weakMatches = WEAK_TRIGGERS.filter((re) => re.test(body)); + const hasClaim = strongMatches.length >= 1 || weakMatches.length >= 2; + + if (!hasClaim) { + return { result: "pass", reason: "No outcome-claim terms detected — no evidence required." }; + } + + // --- 2. Detect evidence --- + const hasEvidence = hasEvidenceBlock(body); + + if (hasEvidence) { + return { + result: "pass", + reason: "Outcome claim detected and evidence block found.", + }; + } + + // Build a helpful message listing which triggers fired. + const firedStrong = strongMatches.map((re) => re.source); + const firedWeak = weakMatches.map((re) => re.source); + return { + result: "fail", + reason: + "PR body contains outcome claims but no evidence block (command output, Evidence section, or inline result span).\n" + + (firedStrong.length > 0 ? ` Strong triggers matched: ${firedStrong.join(", ")}\n` : "") + + (firedWeak.length >= 2 ? ` Weak triggers matched (≥2): ${firedWeak.join(", ")}\n` : "") + + "\n" + + "Hard Rule #18 requires proof that the fix works:\n" + + " a) Add a fenced code block (```) containing test-runner or command output, OR\n" + + " b) Add a section headed '## Evidence', '## Validation', '## Test output', etc., OR\n" + + " c) Add an inline code span with a result token (e.g. `42 passing`, `PASSED`).\n" + + "See CLAUDE.md → Hard Rule #18.", + }; +} + +/** + * Returns true if the body contains at least one recognised evidence block. + * @param {string} body + * @returns {boolean} + */ +function hasEvidenceBlock(body) { + // Check fenced code blocks for output-like content. + FENCED_BLOCK_RE.lastIndex = 0; + let match; + while ((match = FENCED_BLOCK_RE.exec(body)) !== null) { + const blockContent = match[1] ?? ""; + if (blockContent.trim().length > 0 && OUTPUT_LINE_RE.test(blockContent)) { + return true; + } + } + + // Check for explicit evidence/validation section header with non-empty body. + if (EVIDENCE_SECTION_RE.test(body)) { + // Ensure there's actual content after the header. + const afterHeader = body.replace(EVIDENCE_SECTION_RE, "").trim(); + if (afterHeader.length > 20) { + return true; + } + } + + // Check for inline code span containing result tokens. + if (INLINE_RESULT_RE.test(body)) { + return true; + } + + return false; +} + +// --------------------------------------------------------------------------- +// CLI entry point +// --------------------------------------------------------------------------- +function getArg(name, fallbackValue = "") { + const index = process.argv.indexOf(name); + if (index === -1 || index === process.argv.length - 1) return fallbackValue; + return process.argv[index + 1]; +} + +function buildReport(lines) { + return `${lines.join("\n")}\n`; +} + +// Only run as CLI entry point. +const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href; + +if (isMain) { + const summaryFile = getArg("--summary-file", ""); + + // --- Resolve PR body --- + let prBody = null; + + // Priority 1: PR_BODY env var (set by CI / manual invocation). + if (typeof process.env.PR_BODY === "string") { + prBody = process.env.PR_BODY; + } + + // Priority 2: --body-file argument. + const bodyFile = getArg("--body-file", ""); + if (prBody === null && bodyFile && existsSync(bodyFile)) { + prBody = readFileSync(bodyFile, "utf8"); + } + + // Priority 3: `gh pr view` (only available inside a PR context). + if (prBody === null) { + const ghResult = spawnSync("gh", ["pr", "view", "--json", "body", "--jq", ".body"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + if (ghResult.status === 0 && ghResult.stdout.trim()) { + prBody = ghResult.stdout.trim(); + } + } + + // No PR context available — skip silently. + if (prBody === null) { + const report = buildReport([ + "## PR Evidence Gate", + "", + "Skipped: no PR body available (PR_BODY not set, --body-file not provided, gh pr view unavailable).", + ]); + if (summaryFile) { + mkdirSync(path.dirname(summaryFile), { recursive: true }); + writeFileSync(summaryFile, report); + } + process.stdout.write(report); + process.exit(0); + } + + const { result, reason } = evaluatePrBody(prBody); + + const reportLines = ["## PR Evidence Gate", ""]; + + if (result === "skip") { + reportLines.push("Result: SKIP", "", reason); + } else if (result === "pass") { + reportLines.push("Result: PASS", "", reason); + } else { + reportLines.push("Result: FAIL", "", reason); + } + + const report = buildReport(reportLines); + + if (summaryFile) { + mkdirSync(path.dirname(summaryFile), { recursive: true }); + writeFileSync(summaryFile, report); + } + + process.stdout.write(report); + + if (result === "fail") { + process.exit(1); + } +} diff --git a/scripts/check/check-provider-consistency.ts b/scripts/check/check-provider-consistency.ts index e44a2ea33c..88254eb459 100644 --- a/scripts/check/check-provider-consistency.ts +++ b/scripts/check/check-provider-consistency.ts @@ -5,9 +5,12 @@ // 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. +// Stale-enforcement (6A.3): entrada em KNOWN_REGISTRY_ONLY que não suprime nenhum órfão +// real → gate falha com instrução de remoção (evita furo de regressão silencioso). import { pathToFileURL } from "node:url"; import { AI_PROVIDERS, getProviderById } from "@/shared/constants/providers.ts"; import { REGISTRY } from "@omniroute/open-sse/config/providerRegistry.ts"; +import { assertNoStale } from "./lib/allowlist.mjs"; // Entradas registry-only conhecidas (meia-registro pré-existente). Cada uma com // justificativa. Remover daqui ao registrar o provider em providers.ts. @@ -25,18 +28,25 @@ export function findOrphanRegistryIds( 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); + + // Live orphans BEFORE allowlist filtering (needed for stale-enforcement). + const liveOrphans = Object.keys(REGISTRY).filter((id) => !isKnown(id)); + assertNoStale(Object.keys(KNOWN_REGISTRY_ONLY), liveOrphans, "provider-consistency"); + + const orphans = liveOrphans.filter((id) => !(id in 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); + process.exitCode = 1; + } + if (!process.exitCode) { + 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)` + ); } - 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 index 6432405012..685ac3d282 100644 --- a/scripts/check/check-public-creds.mjs +++ b/scripts/check/check-public-creds.mjs @@ -19,17 +19,43 @@ import fs from "node:fs"; import path from "node:path"; import { pathToFileURL } from "node:url"; +import { assertNoStale } from "./lib/allowlist.mjs"; 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", +// 6A.8: Instead of a static hardcoded list, scan the two credential-bearing subtrees +// dynamically so new files (new executor, new OAuth provider) are caught automatically. +// Anchor files (providerRegistry.ts, oauth.ts) are the canonical credential config; +// the broader scan covers new additions in open-sse/ and src/lib/oauth/. +// Exclusions: test files, node_modules, .next. +const SCAN_ROOTS = [ + path.join(cwd, "open-sse"), + path.join(cwd, "src", "lib", "oauth"), ]; +function walkTs(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()) { + if (e.name !== "node_modules" && e.name !== ".next") walkTs(p, acc); + } else if (/\.tsx?$/.test(e.name) && !/\.test\.tsx?$/.test(e.name)) { + acc.push(p); + } + } + return acc; +} + +function collectScannedFiles() { + const files = []; + for (const root of SCAN_ROOTS) { + for (const abs of walkTs(root)) { + files.push(path.relative(cwd, abs).replace(/\\/g, "/")); + } + } + return files; +} + // 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) @@ -54,10 +80,20 @@ const ENV_KEY_RE = /(clientId|clientSecret|apiKey)Env\s*:/; // // All five public client_ids (9 call-sites) were migrated to resolvePublicCred() in // #3493 (embedded as claude_id/codex_id/qwen_id/kimi_id/github_copilot_id in -// open-sse/utils/publicCreds.ts), matching the Gemini/Antigravity pattern. The -// allowlist is now empty — any new literal public client_id must be embedded via -// resolvePublicCred(), not frozen here. -export const KNOWN_LITERAL_CREDS = new Set([]); +// open-sse/utils/publicCreds.ts), matching the Gemini/Antigravity pattern. +// +// 6A.8: Expanded scope to open-sse/** + src/lib/oauth/**. Newly discovered FPs: +// +// open-sse/services/usage.ts L543: `getMiniMaxUsage(apiKey: string, provider: "minimax" | "minimax-cn")` +// The CRED_KEY_RE matches `apiKey:` in the TypeScript function-parameter type annotation. +// "minimax" and "minimax-cn" are provider-name strings in the type annotation, NOT credentials. +// This is a false positive (the gate was designed for object-literal assignments, not fn params). +// TODO(6A.8): Consider tightening CRED_KEY_RE to exclude function-signature contexts — but +// that adds complexity; the FP rate is low (1 file). Frozen by file:line:value key. +export const KNOWN_LITERAL_CREDS = new Set([ + "open-sse/services/usage.ts:543:minimax", // TODO(6A.8): pre-existing FP — TS fn-param type, not a credential + "open-sse/services/usage.ts:543:minimax-cn", // TODO(6A.8): pre-existing FP — TS fn-param type, not a credential +]); /** * Encontra atribuições de uma chave de credencial a uma string literal não-vazia. @@ -115,14 +151,29 @@ export function findLiteralCreds(source, allowlist, relFile = "") { } 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 scannedFiles = collectScannedFiles(); + + // 6A.8: stale-allowlist enforcement. + // Compute all live violations WITHOUT the allowlist, then check for stale entries. + const liveViolationKeys = new Set(); + for (const rel of scannedFiles) { + const src = fs.readFileSync(path.join(cwd, rel), "utf8"); + for (const v of findLiteralCreds(src, new Set(), rel)) { + // v is like "L543: apiKey = \"minimax\"" — generate the same file:line:value key + // that the allowlist uses so stale detection matches by canonical key form. + const lineMatch = v.match(/^L(\d+):/); + const lineNo = lineMatch ? lineMatch[1] : "?"; + const valMatch = v.match(/"([^"]+)"$/); + const val = valMatch ? valMatch[1] : v; + liveViolationKeys.add(`${rel}:${lineNo}:${val}`); + liveViolationKeys.add(val); // also track plain value for backward compat } - const src = fs.readFileSync(abs, "utf8"); + } + assertNoStale(KNOWN_LITERAL_CREDS, liveViolationKeys, "check-public-creds"); + + const allMisses = []; + for (const rel of scannedFiles) { + const src = fs.readFileSync(path.join(cwd, rel), "utf8"); for (const v of findLiteralCreds(src, KNOWN_LITERAL_CREDS, rel)) { allMisses.push(`${rel} ${v}`); } @@ -139,8 +190,9 @@ function main() { ); process.exit(1); } + if (process.exitCode === 1) return; // stale entries already logged console.log( - `[check-public-creds] OK (${SCANNED_FILES.length} arquivo(s), ` + + `[check-public-creds] OK (${scannedFiles.length} arquivo(s) em ${SCAN_ROOTS.length} raiz(es), ` + `${KNOWN_LITERAL_CREDS.size} literal(is) congelado(s))` ); } diff --git a/scripts/check/check-route-guard-membership.ts b/scripts/check/check-route-guard-membership.ts index 9b09414d59..8fa8a39b7c 100644 --- a/scripts/check/check-route-guard-membership.ts +++ b/scripts/check/check-route-guard-membership.ts @@ -16,11 +16,31 @@ // 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 { readFileSync, readdirSync, statSync } from "node:fs"; +import { join, relative } from "node:path"; import { pathToFileURL } from "node:url"; import { isLocalOnlyPath } from "@/server/authz/routeGuard.ts"; +// Inline stale-allowlist helper (mirrors scripts/check/lib/allowlist.mjs). +// The TypeScript gate cannot import the .mjs helper directly; keep this in sync. +function assertNoStaleEntries( + allowlist: string[] | Record, + liveItems: string[], + gateName: string +): void { + const liveSet = new Set(liveItems); + const keys = Array.isArray(allowlist) ? allowlist : Object.keys(allowlist); + const stale = keys.filter((k) => !liveSet.has(k)); + if (stale.length > 0) { + console.error( + `[${gateName}] ${stale.length} entrada(s) obsoleta(s) na allowlist ` + + `— a violação foi corrigida; REMOVA a entrada para travar a correção:\n` + + stale.map((e) => ` ✗ ${e}`).join("\n") + ); + process.exitCode = 1; + } +} + // 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/. @@ -65,6 +85,84 @@ export function findUnclassifiedSpawnRoutes( return apiPaths.filter((p) => !isLocalOnly(p) && !(p in allowlist)); } +// --- 6A.8: source-based spawn detection --- + +// Patterns that indicate a route.ts spawns child processes. +// Matches: import from "child_process" / "node:child_process" / "worker_threads" / +// "node:worker_threads" or a spawn( / execFile( / exec( call. +const SPAWN_SOURCE_RE = + /\b(?:from\s+["'](?:node:)?(?:child_process|worker_threads)["']|require\s*\(\s*["'](?:node:)?(?:child_process|worker_threads)["']\s*\)|spawn\s*\(|execFile\s*\(|execFileSync\s*\(|exec\s*\()/; + +/** + * Returns true if the given source text of a route.ts file directly imports + * from child_process / worker_threads or calls spawn()/execFile()/exec(). + * Used by the 6A.8 source-scan subcheck to find spawn-capable routes outside + * the static SPAWN_CAPABLE_ROUTE_ROOTS list. + */ +export function isSpawnCapableSource(source: string): boolean { + return SPAWN_SOURCE_RE.test(source); +} + +/** + * Walk all route.ts files under src/app/api/ from repoRoot and return those whose + * source matches isSpawnCapableSource. Returns relative paths (forward slashes). + */ +export function findSpawnCapableRoutes(repoRoot: string): string[] { + const apiDir = join(repoRoot, "src", "app", "api"); + const out: string[] = []; + + function walk(dir: string): void { + let entries: string[]; + try { + entries = readdirSync(dir); + } catch { + return; + } + for (const entry of entries) { + const full = join(dir, entry); + try { + if (statSync(full).isDirectory()) { + walk(full); + } else if (entry === "route.ts") { + const src = readFileSync(full, "utf8"); + if (isSpawnCapableSource(src)) { + out.push(relative(repoRoot, full).replace(/\\/g, "/")); + } + } + } catch { + // skip unreadable + } + } + } + + walk(apiDir); + return out.sort(); +} + +/** + * 6A.8: pre-existing spawn-capable route.ts files that live OUTSIDE + * SPAWN_CAPABLE_ROUTE_ROOTS but are NOT yet classified local-only. + * Each entry is documented security debt (Hard Rules #15/#17): + * the route can spawn child processes and is reachable past the loopback gate. + * + * TODO(6A.8): classify these in LOCAL_ONLY_API_PREFIXES / LOCAL_ONLY_API_PATTERNS + * or add specific auth-only enforcement (no loopback, but require-auth before spawn). + * Adding an entry here requires a justification + follow-up issue. + */ +export const KNOWN_UNCLASSIFIED_SOURCE_SPAWN: Record = { + // RESOLVED (6A.8 P1, 2026-06-13): /api/system/version and /api/db-backups/exportAll + // are now classified in LOCAL_ONLY_API_PREFIXES (loopback-enforced before auth). + // The stale-enforcement guard requires this set to stay empty until a NEW + // unclassified spawn-capable route appears. + // NOTE: cli-tools/antigravity-mitm/route.ts triggers child_process INDIRECTLY via + // dynamic import to @/mitm/manager.runtime, but does NOT directly import child_process. + // The source-scan gate covers DIRECT imports/calls only; this route is NOT in the + // spawn-capable set by source analysis. Kept as a comment for documentation but + // NOT in the allowlist (stale-enforcement would flag it). The route has requireCliToolsAuth() + // for auth gating; the underlying spawn happens in mitm/manager.runtime. + // If /api/cli-tools/ is ever added to LOCAL_ONLY_API_PREFIXES, revisit this note. +}; + /** Recursively collect every `route.ts` under `dir` (returns [] if dir absent). */ function collectRouteFiles(dir: string): string[] { let entries: string[]; @@ -86,23 +184,71 @@ function collectRouteFiles(dir: string): string[] { } function main(): void { + const cwd = process.cwd(); + + // --- Subcheck 1 (original): SPAWN_CAPABLE_ROUTE_ROOTS --- const apiPaths = SPAWN_CAPABLE_ROUTE_ROOTS.flatMap(collectRouteFiles) .map(routeFileToApiPath) .sort(); const unclassified = findUnclassifiedSpawnRoutes(apiPaths, isLocalOnlyPath, KNOWN_UNCLASSIFIED); + // --- Subcheck 2 (6A.8): source-based scan — ALL route.ts files --- + // Find every route.ts that imports child_process / worker_threads and verify it is + // either classified local-only or frozen in KNOWN_UNCLASSIFIED_SOURCE_SPAWN. + const spawnCapableFiles = findSpawnCapableRoutes(cwd); + + // Stale-enforcement: if a route was fixed (no longer spawn-capable, or was classified), + // the KNOWN_UNCLASSIFIED_SOURCE_SPAWN entry must be removed. + assertNoStaleEntries( + KNOWN_UNCLASSIFIED_SOURCE_SPAWN, + spawnCapableFiles, + "route-guard-membership/source-spawn" + ); + + // Find spawn-capable routes outside SPAWN_CAPABLE_ROUTE_ROOTS that are not classified + // local-only and not in the source-spawn allowlist. + const unclassifiedSourceSpawn = spawnCapableFiles.filter((rel) => { + const apiPath = routeFileToApiPath(rel); + // Already covered by subcheck 1 (in a SPAWN_CAPABLE_ROUTE_ROOT)? Skip. + if (SPAWN_CAPABLE_ROUTE_ROOTS.some((root) => rel.startsWith(root + "/"))) return false; + // In the source-spawn allowlist? Skip. + if (rel in KNOWN_UNCLASSIFIED_SOURCE_SPAWN) return false; + // Classified local-only? Skip. + if (isLocalOnlyPath(apiPath)) return false; + return true; + }); + + // Report + let failed = false; + 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` + + `[route-guard-membership] CRITICAL — ${unclassified.length} spawn-capable route(s) in SPAWN_CAPABLE_ROUTE_ROOTS NOT classified local-only (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).` + `\n → add a matching prefix to LOCAL_ONLY_API_PREFIXES or a pattern to LOCAL_ONLY_API_PATTERNS in src/server/authz/routeGuard.ts, or freeze in KNOWN_UNCLASSIFIED with justification.` ); - process.exit(1); + failed = true; } + if (unclassifiedSourceSpawn.length) { + console.error( + `[route-guard-membership] CRITICAL — ${unclassifiedSourceSpawn.length} route.ts file(s) contain child_process/worker_threads but are NOT classified local-only (Hard Rules #15/#17):\n` + + unclassifiedSourceSpawn.map((p) => ` ✗ ${p} (${routeFileToApiPath(p)})`).join("\n") + + `\n → classify in LOCAL_ONLY_API_PREFIXES / LOCAL_ONLY_API_PATTERNS, or freeze in KNOWN_UNCLASSIFIED_SOURCE_SPAWN with justification.` + ); + failed = true; + } + + if (failed) process.exit(1); + if (process.exitCode === 1) return; // stale entries already logged + 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)` + `[route-guard-membership] OK — ` + + `${apiPaths.length} route(s) in ${SPAWN_CAPABLE_ROUTE_ROOTS.length} root(s) all local-only; ` + + `${spawnCapableFiles.length} source-spawn route(s) scanned, ` + + `${Object.keys(KNOWN_UNCLASSIFIED_SOURCE_SPAWN).length} frozen as security debt, ` + + `0 new gaps` ); // Explicit exit: importing routeGuard.ts pulls in runtime settings, which opens // the SQLite DB and starts a background health-check timer that would otherwise diff --git a/scripts/check/check-secrets.mjs b/scripts/check/check-secrets.mjs new file mode 100644 index 0000000000..fe0110a324 --- /dev/null +++ b/scripts/check/check-secrets.mjs @@ -0,0 +1,240 @@ +#!/usr/bin/env node +// scripts/check/check-secrets.mjs +// Catraca de secret scanning via gitleaks (Task 7.18 — Fase 7). +// +// Complementa `check-public-creds.mjs` (Fase 6, cobre credenciais OAuth públicas +// conhecidas em 2 arquivos específicos): este gate pega a classe geral de secrets — +// `const API_KEY = "sk-…"`, tokens em config/teste/docs, secrets em histórico. +// +// Saída (stdout): +// secretFindings=N — número de findings do gitleaks +// secretFindings=SKIP reason=binary-absent — gitleaks não está no PATH +// +// Esta versão é ADVISORY (sai 0 sempre). O ratchet (direction:down) é gerenciado +// pelo motor quality-baseline.json no CI (Task 7.18 INT). +// +// Uso: +// node scripts/check/check-secrets.mjs +// node scripts/check/check-secrets.mjs --json # imprime JSON bruto do gitleaks +// node scripts/check/check-secrets.mjs --quiet # suprime logs de diagnóstico + +import fs from "node:fs"; +import { execFileSync, spawnSync } from "node:child_process"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +const ROOT = process.cwd(); +const QUIET = process.argv.includes("--quiet"); +const PRINT_JSON = process.argv.includes("--json"); +const GITLEAKS_CONFIG = path.join(ROOT, ".gitleaks.toml"); + +// --------------------------------------------------------------------------- +// Pure parsing function (exported for tests) +// --------------------------------------------------------------------------- + +/** + * Conta findings no JSON emitido por `gitleaks detect --report-format json`. + * + * O gitleaks emite um array de findings (ou array vazio / null quando limpo): + * [ + * { + * Description: string, + * StartLine: number, + * EndLine: number, + * Match: string, // valor mascarado ou trecho + * Secret: string, // valor mascarado + * File: string, // caminho relativo + * Commit: string, + * Entropy: number, + * Author: string, + * Email: string, + * Date: string, + * Tags: string[], + * RuleID: string, + * Fingerprint: string + * }, + * ... + * ] + * + * @param {Array|null} gitleaksJson - Array de findings do gitleaks (ou null) + * @returns {{ findingCount: number, byRule: Record, byFile: Record }} + */ +export function parseGitleaksJson(gitleaksJson) { + // null ou array vazio = nenhum finding + if (gitleaksJson === null || (Array.isArray(gitleaksJson) && gitleaksJson.length === 0)) { + return { findingCount: 0, byRule: {}, byFile: {} }; + } + + if (!Array.isArray(gitleaksJson)) { + return { findingCount: 0, byRule: {}, byFile: {} }; + } + + let findingCount = 0; + const byRule = {}; + const byFile = {}; + + for (const finding of gitleaksJson) { + if (!finding || typeof finding !== "object") continue; + + findingCount++; + + // Agrupar por RuleID (gitleaks usa PascalCase) + const ruleId = finding.RuleID ?? finding.ruleId ?? "unknown"; + byRule[ruleId] = (byRule[ruleId] ?? 0) + 1; + + // Agrupar por arquivo + const file = finding.File ?? finding.file ?? "unknown"; + byFile[file] = (byFile[file] ?? 0) + 1; + } + + return { findingCount, byRule, byFile }; +} + +// --------------------------------------------------------------------------- +// Binary detection +// --------------------------------------------------------------------------- + +/** + * Detecta se o binário `gitleaks` está disponível no PATH. + * Usa `which` (Unix) sem interpolação de shell — Hard Rule #13. + * + * @returns {string|null} Caminho para o binário, ou null se ausente. + */ +export function findGitleaks() { + try { + const result = spawnSync("which", ["gitleaks"], { + encoding: "utf8", + timeout: 5_000, + }); + if (result.status === 0) { + return result.stdout.trim(); + } + } catch { + // which não disponível + } + + // Fallback: tentar executar diretamente para verificar ENOENT + try { + const result = spawnSync("gitleaks", ["version"], { + encoding: "utf8", + timeout: 5_000, + }); + if (result.error?.code === "ENOENT") return null; + if (result.status !== null) return "gitleaks"; // encontrado no PATH + } catch { + // noop + } + + return null; +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +function main() { + const gitleaksBin = findGitleaks(); + + if (!gitleaksBin) { + console.log("secretFindings=SKIP reason=binary-absent"); + if (!QUIET) { + process.stderr.write( + "[check-secrets] SKIP — gitleaks não encontrado no PATH.\n" + + "[check-secrets] Instale via: https://github.com/gitleaks/gitleaks\n" + + "[check-secrets] ADVISORY — este gate sai 0 (ratchet entra no CI da Fase 7 INT).\n" + ); + } + process.exitCode = 0; + return; + } + + // Construir args sem interpolação de variáveis no script (Hard Rule #13) + const args = [ + "detect", + "--no-git", // escanear diretório em vez de histórico git (mais rápido em CI) + "--report-format", "json", + "--report-path", "-", // output para stdout + "--source", ROOT, + "--no-banner", + ]; + + // Adicionar config personalizada se existir + if (fs.existsSync(GITLEAKS_CONFIG)) { + args.push("--config", GITLEAKS_CONFIG); + } + + if (!QUIET) { + process.stderr.write("[check-secrets] Rodando gitleaks detect --no-git --report-format json ...\n"); + } + + let stdout = ""; + try { + stdout = execFileSync(gitleaksBin, args, { + cwd: ROOT, + encoding: "utf8", + maxBuffer: 32 * 1024 * 1024, + timeout: 120_000, // 2 min + }); + } catch (err) { + // exit 1 com stdout = findings encontrados (comportamento esperado do gitleaks) + stdout = err.stdout ? String(err.stdout) : ""; + const stderr = err.stderr ? String(err.stderr) : ""; + + if (err.status === 1 && stdout.trim()) { + // Normal: gitleaks achou findings e saiu com exit 1 + } else if (!stdout.trim()) { + process.stderr.write(`[check-secrets] ERRO ao executar gitleaks: ${err.message}\n`); + if (stderr) process.stderr.write(`[check-secrets] stderr: ${stderr.slice(0, 500)}\n`); + process.exit(2); + } + } + + let gitleaksJson; + if (!stdout.trim() || stdout.trim() === "null") { + gitleaksJson = []; + } else { + try { + const parsed = JSON.parse(stdout.trim()); + gitleaksJson = parsed === null ? [] : parsed; + } catch (parseErr) { + process.stderr.write(`[check-secrets] ERRO ao parsear JSON do gitleaks: ${parseErr.message}\n`); + process.stderr.write(`[check-secrets] stdout (primeiros 500 chars): ${stdout.slice(0, 500)}\n`); + process.exit(2); + } + } + + if (PRINT_JSON) { + process.stdout.write(JSON.stringify(gitleaksJson, null, 2) + "\n"); + return; + } + + const { findingCount, byRule, byFile } = parseGitleaksJson(gitleaksJson); + + // Emitir em formato KEY=VALUE para o coletor de métricas (collect-metrics.mjs) + console.log(`secretFindings=${findingCount}`); + + if (!QUIET) { + if (findingCount > 0) { + const topRules = Object.entries(byRule) + .sort(([, a], [, b]) => b - a) + .slice(0, 5) + .map(([r, n]) => `${r}(${n})`) + .join(", "); + process.stderr.write(`[check-secrets] Findings: ${findingCount} (top rules: ${topRules})\n`); + process.stderr.write( + "[check-secrets] Para allowlistar findings legítimos (fixtures de teste, creds públicas),\n" + + "[check-secrets] adicione entradas em .gitleaks.toml [[allowlist]] com comentário.\n" + ); + } else { + process.stderr.write("[check-secrets] Nenhum finding detectado.\n"); + } + process.stderr.write( + "[check-secrets] ADVISORY — esta versão não falha pela contagem (ratchet entra no CI).\n" + ); + } + + // Sai 0 sempre nesta versão (advisory) + process.exitCode = 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 index c1db1e1139..f334003aaa 100644 --- a/scripts/check/check-test-masking.mjs +++ b/scripts/check/check-test-masking.mjs @@ -5,8 +5,12 @@ // 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. +// +// v2 (6A.10): acrescenta 3 novos subchecks: +// 1. Arquivos de teste DELETADOS: --diff-filter=MDR com detecção de rename. +// 2. Aumento líquido de .skip/.todo/.only/{skip:true}: esconde asserts sem remover. +// 3. Tautologias extras: expect(true).toBe(true), assert.equal(1,1), assert.ok(true). import fs from "node:fs"; -import path from "node:path"; import { execFileSync } from "node:child_process"; import { pathToFileURL } from "node:url"; @@ -24,14 +28,77 @@ 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. */ +/** + * (6A.10 subcheck 2) Conta marcadores de skip/todo/only que silenciam testes: + * - .skip(, .todo(, .only( — em qualquer runner (node:test, jest, vitest) + * - { skip: true } — opção de objeto node:test + */ +export function countSkips(src) { + const modifiers = (src.match(/\.\s*(?:skip|todo|only)\s*\(/g) || []).length; + const skipOpt = (src.match(/\{\s*skip\s*:\s*true\s*\}/g) || []).length; + return modifiers + skipOpt; +} + +/** + * (6A.10 subcheck 3) Conta tautologias que mantêm os asserts no texto mas nunca + * verificam nada real: + * - expect(true).toBe(true) + * - assert.equal(1, 1) / assert.strictEqual(1, 1) + * - assert.ok(true) (já coberto por countTautologies; incluído aqui para completude) + */ +export function countExtendedTautologies(src) { + let count = 0; + // expect(true).toBe(true) + count += (src.match(/\bexpect\s*\(\s*true\s*\)\s*\.\s*toBe\s*\(\s*true\s*\)/g) || []).length; + // assert.equal(1, 1) / assert.strictEqual(1, 1) — literal numeric identity + count += (src.match(/\bassert\s*\.\s*(?:strict)?[Ee]qual\s*\(\s*1\s*,\s*1\s*\)/g) || []).length; + // assert.ok(true) + count += (src.match(/\bassert\s*\.\s*ok\s*\(\s*true\s*\)/g) || []).length; + return count; +} + +/** + * (6A.10 subcheck 1) Sinaliza arquivos de teste DELETADOS ou renomeados-e-não- + * substituídos. Recebe lista de paths de arquivos de teste que foram deletados + * (filtro D do git diff --diff-filter=MDR). + */ +export function evaluateDeletedFiles(deletedPaths) { + const flags = []; + for (const f of deletedPaths) { + if (TEST_RE.test(f)) { + flags.push(`${f}: arquivo de teste deletado — revisão humana obrigatória (mascaramento alto-sinal)`); + } + } + return flags; +} + +/** + * Avalia por-arquivo: flag em remoção líquida de asserts, nova tautologia, + * aumento líquido de skips, ou nova tautologia extendida. + * + * Cada entrada de perFile deve ter: + * { file, baseAsserts, headAsserts, baseTaut, headTaut, + * baseSkips, headSkips, baseExtTaut, headExtTaut } + * + * Os campos de skip e extTaut são opcionais (default 0) para compatibilidade + * com chamadas legadas que só passam baseAsserts/headAsserts/baseTaut/headTaut. + */ export function evaluateMasking(perFile) { const flags = []; for (const f of perFile) { + const baseSkips = f.baseSkips ?? 0; + const headSkips = f.headSkips ?? 0; + const baseExtTaut = f.baseExtTaut ?? 0; + const headExtTaut = f.headExtTaut ?? 0; + 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)`); + if (headSkips > baseSkips) + flags.push(`${f.file}: ${headSkips - baseSkips} novo(s) .skip/.todo/.only (asserts silenciados sem remoção)`); + if (headExtTaut > baseExtTaut) + flags.push(`${f.file}: nova(s) ${headExtTaut - baseExtTaut} tautologia(s) estendida(s) (expect(true).toBe(true) / assert.equal(1,1))`); } return flags; } @@ -56,6 +123,19 @@ function main() { console.log("[test-masking] sem base ref (não é PR) — pulando."); return; } + + // (6A.10 subcheck 1) Arquivos de teste deletados/renomeados via MDR filter + const deletedAndRenamed = git([ + "diff", "--name-only", "--diff-filter=DR", "-M", + `${base}...HEAD`, + ]) + .split("\n") + .map((s) => s.trim()) + .filter(Boolean); + + const deletedFlags = evaluateDeletedFiles(deletedAndRenamed); + + // Arquivos de teste modificados (subcheck original + skips + extTaut) const changed = git(["diff", "--name-only", "--diff-filter=M", `${base}...HEAD`]) .split("\n") .map((s) => s.trim()) @@ -71,19 +151,28 @@ function main() { headAsserts: countAssertions(headSrc), baseTaut: countTautologies(baseSrc), headTaut: countTautologies(headSrc), + baseSkips: countSkips(baseSrc), + headSkips: countSkips(headSrc), + baseExtTaut: countExtendedTautologies(baseSrc), + headExtTaut: countExtendedTautologies(headSrc), }); } - const flags = evaluateMasking(perFile); - if (flags.length) { + const maskingFlags = evaluateMasking(perFile); + const allFlags = [...deletedFlags, ...maskingFlags]; + + if (allFlags.length) { console.error( - `[test-masking] ${flags.length} sinal(is) de enfraquecimento de teste:\n` + - flags.map((f) => " ✗ " + f).join("\n") + + `[test-masking] ${allFlags.length} sinal(is) de enfraquecimento de teste:\n` + + allFlags.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`); + console.log( + `[test-masking] OK — ${changed.length} arquivo(s) de teste modificado(s), ` + + `${deletedAndRenamed.length > 0 ? deletedAndRenamed.length + " deletado(s)/renomeado(s) OK" : "nenhum deletado"} — sem enfraquecimento` + ); } if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main(); diff --git a/scripts/check/check-tracked-artifacts.mjs b/scripts/check/check-tracked-artifacts.mjs new file mode 100644 index 0000000000..833ed8dc95 --- /dev/null +++ b/scripts/check/check-tracked-artifacts.mjs @@ -0,0 +1,94 @@ +#!/usr/bin/env node +// scripts/check/check-tracked-artifacts.mjs +// Gate: falha se o git rastreia artefatos de build/artefatos gerados proibidos. +// Guarda contra `git add -A` acidental em worktrees que include node_modules symlinks. +// Incidente registrado 2× neste repo (v3.8.12 / v3.8.13) — Hard Rule #7 extensão. +// +// Artefatos proibidos: +// - node_modules/ — deps de build nunca devem entrar no repo +// - .next/ — output do build Next.js +// - coverage/ — relatórios de cobertura gerados pelo c8 +// - quality-metrics.json — saída do collect-metrics.mjs (gerado, não-versionado) +// - symlinks rastreados (mode 120000) — indício de `git add -A` em worktree + +import { execFileSync } from "node:child_process"; +import { pathToFileURL } from "node:url"; + +const FORBIDDEN_PREFIXES = ["node_modules/", ".next/", "coverage/"]; +const FORBIDDEN_EXACT = new Set(["quality-metrics.json"]); + +/** + * Verifica se algum caminho na lista de arquivos rastreados corresponde a um + * artefato proibido. Também aceita uma lista separada de symlinks rastreados. + * + * @param {string[]} trackedFiles - saída de `git ls-files` (caminhos relativos) + * @param {string[]} trackedSymlinks - caminhos com mode 120000 (saída de git ls-files -s) + * @returns {string[]} lista de violações (strings descritivas) + */ +export function checkTrackedArtifacts(trackedFiles, trackedSymlinks = []) { + const violations = []; + + for (const file of trackedFiles) { + if (FORBIDDEN_EXACT.has(file)) { + violations.push(`forbidden tracked artifact: ${file}`); + continue; + } + for (const prefix of FORBIDDEN_PREFIXES) { + if (file.startsWith(prefix)) { + violations.push(`forbidden tracked artifact (${prefix}*): ${file}`); + break; + } + } + } + + for (const sym of trackedSymlinks) { + violations.push(`forbidden tracked symlink (mode 120000): ${sym}`); + } + + return violations; +} + +function getTrackedFiles() { + const output = execFileSync("git", ["ls-files"], { encoding: "utf8" }); + return output + .split("\n") + .map((l) => l.trim()) + .filter(Boolean); +} + +function getTrackedSymlinks() { + // git ls-files -s prints: \t + // mode 120000 = symlink + const output = execFileSync("git", ["ls-files", "-s"], { encoding: "utf8" }); + const symlinks = []; + for (const line of output.split("\n")) { + if (line.startsWith("120000")) { + const parts = line.split("\t"); + if (parts[1]) symlinks.push(parts[1].trim()); + } + } + return symlinks; +} + +function main() { + const trackedFiles = getTrackedFiles(); + const trackedSymlinks = getTrackedSymlinks(); + const violations = checkTrackedArtifacts(trackedFiles, trackedSymlinks); + + if (violations.length === 0) { + console.log("[tracked-artifacts] OK — no forbidden artifacts tracked by git"); + process.exit(0); + } + + console.error(`[tracked-artifacts] FAIL — ${violations.length} forbidden artifact(s) tracked by git:`); + for (const v of violations) { + console.error(` ✗ ${v}`); + } + console.error( + "\n → Run: git rm --cached to untrack the artifact." + + "\n → Add the path to .gitignore to prevent re-tracking." + ); + process.exit(1); +} + +if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main(); diff --git a/scripts/check/check-type-coverage.mjs b/scripts/check/check-type-coverage.mjs new file mode 100644 index 0000000000..6e536f2b77 --- /dev/null +++ b/scripts/check/check-type-coverage.mjs @@ -0,0 +1,108 @@ +#!/usr/bin/env node +// scripts/check/check-type-coverage.mjs +// Type-coverage ratchet (Task 6 of Fase 7). +// +// Measures the % of typed symbols across the codebase using the `type-coverage` +// tool and prints `typeCoveragePct=`. This is advisory in Phase-INT (exits 0) +// — it complements the per-file explicit-any budget in check-t11-any-budget.mjs +// with a project-wide %-typed view. +// +// tsconfig used: open-sse/tsconfig.json +// - Rationale: the only tsconfig that covers the full open-sse workspace +// (src+open-sse together). `tsconfig.json` excludes open-sse; the +// `tsconfig.typecheck-core.json` only lists 26 explicit files (partial). +// open-sse/tsconfig.json sets `baseUrl: ".."` and path aliases so it +// resolves both workspaces correctly and yields a representative global %. +// +// Direction: up (% can only improve; ratchet blocks drops once wired into INT). +// +// Run: +// node scripts/check/check-type-coverage.mjs +// node scripts/check/check-type-coverage.mjs --update # ratchet baseline up +// +// Exit codes: 0 = advisory pass (current version), 1 = ratchet regression. + +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +const ROOT = process.cwd(); +const TSCONFIG = path.join(ROOT, "open-sse", "tsconfig.json"); + +/** + * Parse the JSON output produced by `type-coverage --json-output`. + * Returns the coverage percentage as a number (e.g. 91.66). + * Throws if the output cannot be parsed or has unexpected shape. + * + * Exported for unit-testing against synthetic output. + */ +export function parseTypeCoverageOutput(jsonText) { + let parsed; + try { + parsed = JSON.parse(jsonText); + } catch (err) { + throw new Error(`[type-coverage] Failed to parse JSON output: ${err.message}`); + } + + if (typeof parsed.percent !== "number") { + throw new Error( + `[type-coverage] Unexpected output shape — missing numeric 'percent' field. Got: ${JSON.stringify(parsed)}` + ); + } + + return parsed.percent; +} + +function runTypeCoverage() { + const typeCoverageBin = path.join(ROOT, "node_modules", ".bin", "type-coverage"); + + if (!fs.existsSync(typeCoverageBin)) { + throw new Error(`[type-coverage] Binary not found at ${typeCoverageBin}`); + } + if (!fs.existsSync(TSCONFIG)) { + throw new Error(`[type-coverage] tsconfig not found at ${TSCONFIG}`); + } + + let stdout; + try { + stdout = execFileSync(typeCoverageBin, ["--json-output", "-p", TSCONFIG], { + encoding: "utf8", + maxBuffer: 32 * 1024 * 1024, + cwd: ROOT, + }); + } catch (err) { + // type-coverage exits non-zero when --at-least check fails, but we don't use that. + // If there is stdout, try to parse it anyway. + stdout = err.stdout ? String(err.stdout) : ""; + if (!stdout.trim()) throw err; + } + + return parseTypeCoverageOutput(stdout.trim()); +} + +function main() { + console.log("[type-coverage] Running type-coverage (this may take ~30-60 s)…"); + console.log(`[type-coverage] tsconfig: ${path.relative(ROOT, TSCONFIG)}`); + + let pct; + try { + pct = runTypeCoverage(); + } catch (err) { + console.error(`[type-coverage] FAIL — ${err.message}`); + // Advisory: exit 0 so CI is not blocked until INT wiring. + process.exit(0); + } + + // Canonical output line consumed by collect-metrics.mjs and shell scripts. + console.log(`typeCoveragePct=${pct}`); + console.log(`[type-coverage] Advisory OK — ${pct}% symbols typed (direction: up)`); + + // Advisory: always exit 0 in this version. + // Once wired into quality-baseline.json (INT), exit 1 on regression here. + process.exit(0); +} + +if (import.meta.url === pathToFileURL(process.argv[1] || "").href) { + main(); +} diff --git a/scripts/check/check-vuln-ratchet.mjs b/scripts/check/check-vuln-ratchet.mjs new file mode 100644 index 0000000000..1f8b574c31 --- /dev/null +++ b/scripts/check/check-vuln-ratchet.mjs @@ -0,0 +1,251 @@ +#!/usr/bin/env node +// scripts/check/check-vuln-ratchet.mjs +// Catraca de vulnerabilidades de dependências via osv-scanner (Task 7.2 — Fase 7). +// +// Saída (stdout): +// vulnCount=N — total de vulnerabilidades encontradas (todos os severities) +// vulnCount=SKIP reason=binary-absent — osv-scanner não está no PATH +// +// Esta versão é ADVISORY (sai 0 sempre). O ratchet (direction:down) é gerenciado +// pelo motor quality-baseline.json no CI (Task 7.2 INT). +// +// Uso: +// node scripts/check/check-vuln-ratchet.mjs +// node scripts/check/check-vuln-ratchet.mjs --json # imprime JSON bruto do osv-scanner +// node scripts/check/check-vuln-ratchet.mjs --quiet # suprime logs de diagnóstico + +import { execFileSync, spawnSync } from "node:child_process"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +const ROOT = process.cwd(); +const QUIET = process.argv.includes("--quiet"); +const PRINT_JSON = process.argv.includes("--json"); + +// --------------------------------------------------------------------------- +// Pure parsing function (exported for tests) +// --------------------------------------------------------------------------- + +/** + * Conta vulnerabilidades no JSON emitido por `osv-scanner --format json`. + * + * Formato do osv-scanner v1+: + * { + * results: [ + * { + * packages: [ + * { + * package: { name, version, ecosystem }, + * vulnerabilities: [ { id, aliases, affected, ... }, ... ], + * groups: [ { ids: [...] }, ... ] + * }, + * ... + * ] + * }, + * ... + * ] + * } + * + * Contagem: cada entrada em `vulnerabilities[]` de cada package conta como 1 vuln. + * Se `groups` estiver presente e tiver menos entradas que `vulnerabilities`, usamos + * `groups.length` para deduplificar (mesma vuln em múltiplos pacotes conta 1x por + * grupo). Caso contrário, contamos `vulnerabilities.length`. + * + * @param {object|null} osvJson - Objeto JSON parseado do osv-scanner + * @returns {{ vulnCount: number, bySeverity: Record }} + */ +export function parseOsvJson(osvJson) { + if (!osvJson || !Array.isArray(osvJson.results)) { + return { vulnCount: 0, bySeverity: {} }; + } + + let vulnCount = 0; + const bySeverity = {}; + + for (const result of osvJson.results) { + if (!Array.isArray(result.packages)) continue; + + for (const pkg of result.packages) { + if (!Array.isArray(pkg.vulnerabilities)) continue; + + // Use groups for deduplication when available (same vuln in multiple paths) + const pkgCount = Array.isArray(pkg.groups) && pkg.groups.length > 0 + ? pkg.groups.length + : pkg.vulnerabilities.length; + + vulnCount += pkgCount; + + // Collect severity info from the vulnerability entries + for (const vuln of pkg.vulnerabilities) { + const severity = extractSeverity(vuln); + bySeverity[severity] = (bySeverity[severity] ?? 0) + 1; + } + } + } + + return { vulnCount, bySeverity }; +} + +/** + * Extrai a severidade de uma entrada de vulnerabilidade do osv-scanner. + * Tenta database_specific.severity, depois severity[0].type, depois "UNKNOWN". + * + * @param {object} vuln - Entrada de vulnerabilidade do osv-scanner + * @returns {string} + */ +export function extractSeverity(vuln) { + if (!vuln) return "UNKNOWN"; + + // osv-scanner v2 field: database_specific.severity (common in OSV schema) + const dbSeverity = vuln.database_specific?.severity; + if (typeof dbSeverity === "string" && dbSeverity.length > 0) { + return dbSeverity.toUpperCase(); + } + + // CVSS severity array: [{ type: "CVSS_V3", score: "CVSS:3.1/..." }, ...] + if (Array.isArray(vuln.severity) && vuln.severity.length > 0) { + const first = vuln.severity[0]; + if (typeof first?.type === "string") { + return first.type; + } + } + + return "UNKNOWN"; +} + +// --------------------------------------------------------------------------- +// Binary detection +// --------------------------------------------------------------------------- + +/** + * Detecta se o binário `osv-scanner` está disponível no PATH. + * Usa `which` (Unix) sem interpolação de shell — Hard Rule #13. + * + * @returns {string|null} Caminho absoluto para o binário, ou null se ausente. + */ +export function findOsvScanner() { + try { + const result = spawnSync("which", ["osv-scanner"], { + encoding: "utf8", + timeout: 5_000, + }); + if (result.status === 0) { + return result.stdout.trim(); + } + } catch { + // which não disponível — tentar command -v via sh + } + + // Fallback: tentar executar diretamente para verificar ENOENT + try { + const result = spawnSync("osv-scanner", ["--version"], { + encoding: "utf8", + timeout: 5_000, + }); + if (result.error?.code === "ENOENT") return null; + if (result.status !== null) return "osv-scanner"; // found in PATH + } catch { + // noop + } + + return null; +} + +// --------------------------------------------------------------------------- +// Runner +// --------------------------------------------------------------------------- + +/** + * Executa o osv-scanner sobre o lockfile/diretório. + * Usa execFileSync sem shell interpolation (Hard Rule #13). + * + * @param {string} osvBin - Caminho para o binário osv-scanner + * @returns {object} JSON parseado do output + */ +function runOsvScanner(osvBin) { + const args = [ + "--format", "json", + "--lockfile", path.join(ROOT, "package-lock.json"), + ]; + + if (!QUIET) { + process.stderr.write("[vuln-ratchet] Rodando osv-scanner --format json ...\n"); + } + + let stdout; + try { + stdout = execFileSync(osvBin, args, { + cwd: ROOT, + encoding: "utf8", + maxBuffer: 32 * 1024 * 1024, + timeout: 120_000, // 2 min + }); + } catch (err) { + // osv-scanner sai com código != 0 quando encontra vulnerabilidades; + // o JSON ainda vai no stdout. + stdout = err.stdout ? String(err.stdout) : ""; + if (!stdout.trim()) { + process.stderr.write(`[vuln-ratchet] ERRO ao executar osv-scanner: ${err.message}\n`); + process.exit(2); + } + } + + try { + return JSON.parse(stdout); + } catch (parseErr) { + process.stderr.write(`[vuln-ratchet] ERRO ao parsear JSON do osv-scanner: ${parseErr.message}\n`); + process.stderr.write(`[vuln-ratchet] stdout (primeiros 500 chars): ${stdout.slice(0, 500)}\n`); + process.exit(2); + } +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +function main() { + const osvBin = findOsvScanner(); + + if (!osvBin) { + // Skip gracioso: binário ausente — esperado em ambientes sem osv-scanner instalado. + console.log("vulnCount=SKIP reason=binary-absent"); + if (!QUIET) { + process.stderr.write( + "[vuln-ratchet] SKIP — osv-scanner não encontrado no PATH.\n" + + "[vuln-ratchet] Instale via: https://google.github.io/osv-scanner/\n" + + "[vuln-ratchet] ADVISORY — este gate sai 0 (ratchet entra no CI da Fase 7 INT).\n" + ); + } + process.exitCode = 0; + return; + } + + const osvJson = runOsvScanner(osvBin); + + if (PRINT_JSON) { + process.stdout.write(JSON.stringify(osvJson, null, 2) + "\n"); + return; + } + + const { vulnCount, bySeverity } = parseOsvJson(osvJson); + + // Emitir em formato KEY=VALUE para o coletor de métricas (collect-metrics.mjs) + console.log(`vulnCount=${vulnCount}`); + + if (!QUIET) { + const severitySummary = Object.entries(bySeverity) + .map(([k, v]) => `${k}=${v}`) + .join(", ") || "nenhuma"; + process.stderr.write( + `[vuln-ratchet] Total de vulnerabilidades: ${vulnCount} (${severitySummary})\n` + ); + process.stderr.write( + "[vuln-ratchet] ADVISORY — esta versão não falha pela contagem (ratchet entra no CI).\n" + ); + } + + // Sai 0 sempre nesta versão (advisory) + process.exitCode = 0; +} + +if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main(); diff --git a/scripts/check/check-workflows.mjs b/scripts/check/check-workflows.mjs new file mode 100644 index 0000000000..194427eeed --- /dev/null +++ b/scripts/check/check-workflows.mjs @@ -0,0 +1,304 @@ +#!/usr/bin/env node +// scripts/check/check-workflows.mjs +// Lint + security audit of GitHub Actions workflow files. +// PLANO-QUALITY-GATES-FASE7.md, Task 19. +// +// Tools: +// actionlint — syntax / correctness / shellcheck of workflow YAML +// zizmor — 24+ security audits (unpinned actions, script injection, +// pull_request_target misuse, cache poisoning, …) +// +// Graceful-SKIP contract: +// If EITHER binary is absent from PATH, the script prints a SKIP notice and +// exits 0. This allows the gate to run in environments that have the tools +// installed (CI with setup steps, developer machines with actionlint/zizmor) +// while being inert elsewhere. +// +// Output (stdout, one line each): +// workflowFindings= — total findings from both tools combined +// actionlintFindings= — findings from actionlint alone +// zizmorFindings= — findings from zizmor alone +// +// Exit codes: +// 0 — SKIP (binary absent) or all tools passed (0 findings each) +// 1 — one or more findings (gate failure; advisory mode = always 0; see --advisory) +// +// Usage: +// node scripts/check/check-workflows.mjs # advisory (exit 0 always) +// node scripts/check/check-workflows.mjs --strict # fail on any finding +// node scripts/check/check-workflows.mjs --quiet # suppress progress logs + +import { execFileSync, spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +const ROOT = process.cwd(); +const WORKFLOWS_DIR = path.join(ROOT, ".github", "workflows"); +const ZIZMOR_CONFIG = path.join(ROOT, ".zizmor.yml"); + +const STRICT = process.argv.includes("--strict"); +const QUIET = process.argv.includes("--quiet"); + +// --------------------------------------------------------------------------- +// Utility: resolve binary from PATH (cross-platform) +// --------------------------------------------------------------------------- + +/** + * Checks whether a binary exists in PATH by running `which`/`where`. + * Returns true if found, false otherwise. + * + * @param {string} name - Binary name (e.g. "actionlint") + * @returns {boolean} + */ +export function isBinaryAvailable(name) { + // Use `command -v` on Unix; `where` on Windows (via cmd). + // We shell through `sh -c` because execFileSync needs the actual path + // and we want cross-platform behaviour. + const result = spawnSync("sh", ["-c", `command -v ${name}`], { + encoding: "utf8", + timeout: 5_000, + windowsHide: true, + }); + return result.status === 0 && result.stdout.trim().length > 0; +} + +// --------------------------------------------------------------------------- +// actionlint result parsing +// --------------------------------------------------------------------------- + +/** + * Parses actionlint output (line-based, one finding per line) and counts + * findings. Each non-empty line = one finding. + * + * actionlint emits lines in the format: + * ::: [] + * or a summary line when all is well (zero findings = empty stdout). + * + * @param {string} stdout - Raw stdout from actionlint + * @returns {{ count: number, lines: string[] }} + */ +export function parseActionlintOutput(stdout) { + const lines = stdout + .split("\n") + .map((l) => l.trim()) + .filter(Boolean); + return { count: lines.length, lines }; +} + +// --------------------------------------------------------------------------- +// zizmor result parsing +// --------------------------------------------------------------------------- + +/** + * Parses zizmor JSON output and counts findings. + * + * zizmor --format json emits a JSON object: + * { diagnostics: Array<{ ...finding fields }> } + * or an array directly in older versions. + * + * If JSON parsing fails, falls back to line counting (graceful degradation). + * + * @param {string} stdout - Raw stdout from zizmor --format json (or text) + * @returns {{ count: number, diagnostics: unknown[] }} + */ +export function parseZizmorOutput(stdout) { + const trimmed = stdout.trim(); + if (!trimmed) { + return { count: 0, diagnostics: [] }; + } + + try { + const parsed = JSON.parse(trimmed); + // zizmor ≥0.8 emits { diagnostics: [...] } + if (parsed && typeof parsed === "object" && Array.isArray(parsed.diagnostics)) { + return { count: parsed.diagnostics.length, diagnostics: parsed.diagnostics }; + } + // Older versions may emit a bare array + if (Array.isArray(parsed)) { + return { count: parsed.length, diagnostics: parsed }; + } + // Unexpected JSON shape — treat whole object as 0 findings if it has no + // obvious error marker; this is a best-effort parse. + return { count: 0, diagnostics: [] }; + } catch { + // Not JSON (e.g. text output or error message) — count non-empty lines as + // a conservative fallback. + const lines = trimmed.split("\n").filter(Boolean); + return { count: lines.length, diagnostics: [] }; + } +} + +// --------------------------------------------------------------------------- +// Runner helpers +// --------------------------------------------------------------------------- + +/** + * Collects all *.yml files from the workflows directory. + * + * @param {string} workflowsDir + * @returns {string[]} Absolute paths + */ +export function collectWorkflowFiles(workflowsDir) { + if (!fs.existsSync(workflowsDir)) { + return []; + } + return fs + .readdirSync(workflowsDir) + .filter((f) => f.endsWith(".yml") || f.endsWith(".yaml")) + .map((f) => path.join(workflowsDir, f)); +} + +/** + * Runs actionlint over the given workflow files. + * Returns parsed result. Never throws — returns count=0 on any exec error so + * that one broken binary does not abort the whole check. + * + * @param {string[]} files - Absolute paths to workflow YAMLs + * @returns {{ count: number, lines: string[], skipped: boolean }} + */ +export function runActionlint(files) { + if (files.length === 0) { + return { count: 0, lines: [], skipped: false }; + } + try { + const stdout = execFileSync("actionlint", files, { + encoding: "utf8", + // actionlint exits non-zero when it finds issues; capture output anyway + // by catching the thrown error. + }); + return { ...parseActionlintOutput(stdout), skipped: false }; + } catch (err) { + // execFileSync throws when exit code != 0. + // stdout still contains the finding lines. + const stdout = (err && typeof err === "object" && "stdout" in err ? err.stdout : "") || ""; + return { ...parseActionlintOutput(String(stdout)), skipped: false }; + } +} + +/** + * Runs zizmor over the workflows directory. + * Returns parsed result. Never throws. + * + * @param {string} workflowsDir - Path to .github/workflows + * @returns {{ count: number, diagnostics: unknown[], skipped: boolean }} + */ +export function runZizmor(workflowsDir) { + const args = ["--format", "json"]; + if (fs.existsSync(ZIZMOR_CONFIG)) { + args.push("--config", ZIZMOR_CONFIG); + } + args.push(workflowsDir); + + try { + const stdout = execFileSync("zizmor", args, { + encoding: "utf8", + maxBuffer: 4 * 1024 * 1024, + }); + return { ...parseZizmorOutput(stdout), skipped: false }; + } catch (err) { + const stdout = (err && typeof err === "object" && "stdout" in err ? err.stdout : "") || ""; + return { ...parseZizmorOutput(String(stdout)), skipped: false }; + } +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +function main() { + const hasActionlint = isBinaryAvailable("actionlint"); + const hasZizmor = isBinaryAvailable("zizmor"); + + if (!hasActionlint && !hasZizmor) { + console.log( + "[check-workflows] SKIP — actionlint and zizmor not found in PATH.\n" + + " Install them to enable workflow linting and security audit:\n" + + " • actionlint: https://github.com/rhysd/actionlint\n" + + " • zizmor: https://github.com/woodruffw/zizmor\n" + + " This gate is advisory — the build is not blocked." + ); + process.stdout.write("workflowFindings=SKIP\n"); + process.exit(0); + } + + const workflowFiles = collectWorkflowFiles(WORKFLOWS_DIR); + + if (workflowFiles.length === 0) { + if (!QUIET) { + console.log(`[check-workflows] No workflow files found in ${WORKFLOWS_DIR} — nothing to check.`); + } + process.stdout.write("workflowFindings=0\nactionlintFindings=0\nzizmorFindings=0\n"); + process.exit(0); + } + + if (!QUIET) { + console.log(`[check-workflows] Found ${workflowFiles.length} workflow file(s) to check.`); + } + + let actionlintCount = 0; + let zizmorCount = 0; + + // ── actionlint ──────────────────────────────────────────────────────────── + if (hasActionlint) { + if (!QUIET) { + process.stderr.write("[check-workflows] Running actionlint …\n"); + } + const result = runActionlint(workflowFiles); + actionlintCount = result.count; + + if (result.count > 0 && !QUIET) { + console.error(`[check-workflows] actionlint: ${result.count} finding(s):`); + result.lines.forEach((l) => console.error(` ${l}`)); + } else if (!QUIET) { + console.log(`[check-workflows] actionlint: OK (0 findings)`); + } + } else { + if (!QUIET) { + console.log("[check-workflows] actionlint: SKIP (not in PATH)"); + } + } + + // ── zizmor ──────────────────────────────────────────────────────────────── + if (hasZizmor) { + if (!QUIET) { + process.stderr.write("[check-workflows] Running zizmor …\n"); + } + const result = runZizmor(WORKFLOWS_DIR); + zizmorCount = result.count; + + if (result.count > 0 && !QUIET) { + console.error(`[check-workflows] zizmor: ${result.count} finding(s).`); + console.error(" Run: zizmor --format text .github/workflows/ for human-readable details."); + } else if (!QUIET) { + console.log(`[check-workflows] zizmor: OK (0 findings)`); + } + } else { + if (!QUIET) { + console.log("[check-workflows] zizmor: SKIP (not in PATH)"); + } + } + + const total = actionlintCount + zizmorCount; + process.stdout.write(`workflowFindings=${total}\n`); + process.stdout.write(`actionlintFindings=${actionlintCount}\n`); + process.stdout.write(`zizmorFindings=${zizmorCount}\n`); + + if (STRICT && total > 0) { + console.error( + `\n[check-workflows] FAIL — ${total} workflow finding(s) total (--strict mode).` + ); + process.exit(1); + } + + if (total > 0 && !QUIET) { + console.log( + `[check-workflows] ADVISORY — ${total} finding(s) detected. ` + + "Pass --strict to block the gate." + ); + } + + process.exit(0); +} + +if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main(); diff --git a/scripts/check/lib/allowlist.mjs b/scripts/check/lib/allowlist.mjs new file mode 100644 index 0000000000..5c5e639415 --- /dev/null +++ b/scripts/check/lib/allowlist.mjs @@ -0,0 +1,57 @@ +#!/usr/bin/env node +// scripts/check/lib/allowlist.mjs +// Shared helper for stale-allowlist enforcement (6A.3). +// +// Purpose: detect allowlist entries that no longer correspond to any live +// violation. When a developer fixes a violation, the entry in KNOWN_* must +// also be removed — otherwise the gate silently allows the violation to +// regress. This pattern is validated practice (ESLint --report-unused-disable- +// directives; Notion suppression hygiene). + +/** + * Returns the subset of `allowlist` entries that do NOT appear in + * `liveViolations`. These are "stale" entries — the violation they once + * suppressed has been corrected, so the entry should be removed to prevent + * silent regression. + * + * @param {string[] | Set} allowlist - The known-violations list/set. + * @param {string[] | Set} liveViolations - Violations detected in the + * current run (strings as they appear in the allowlist). + * @param {string} gateName - Gate name used only in future error messages; not + * used internally by this function but kept for API consistency with + * assertNoStale. + * @returns {string[]} Stale entries (present in allowlist, absent in live). + */ +export function reportStaleEntries(allowlist, liveViolations, gateName) { + const liveSet = liveViolations instanceof Set ? liveViolations : new Set(liveViolations); + const stale = []; + for (const entry of allowlist) { + if (!liveSet.has(entry)) { + stale.push(entry); + } + } + return stale; +} + +/** + * Calls reportStaleEntries; if any stale entries are found, logs them to + * stderr and sets process.exitCode = 1 so the gate fails without throwing + * (allowing multiple gates to report before the process exits). + * + * @param {string[] | Set} allowlist + * @param {string[] | Set} liveViolations + * @param {string} gateName - Shown in the error message to identify the gate. + * @returns {string[]} The same stale array returned by reportStaleEntries. + */ +export function assertNoStale(allowlist, liveViolations, gateName) { + const stale = reportStaleEntries(allowlist, liveViolations, gateName); + if (stale.length > 0) { + console.error( + `[${gateName}] ${stale.length} entrada(s) obsoleta(s) na allowlist ` + + `— a violação foi corrigida; REMOVA a entrada para travar a correção:\n` + + stale.map((e) => ` ✗ ${e}`).join("\n") + ); + process.exitCode = 1; + } + return stale; +} diff --git a/scripts/quality/check-quality-ratchet.mjs b/scripts/quality/check-quality-ratchet.mjs index 791c194817..2e88519907 100644 --- a/scripts/quality/check-quality-ratchet.mjs +++ b/scripts/quality/check-quality-ratchet.mjs @@ -2,6 +2,8 @@ // 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. +// +// v2 (6A.5): --require-tighten, eps por métrica, warning de métricas órfãs. import fs from "node:fs"; import path from "node:path"; @@ -18,7 +20,13 @@ const UPDATE = process.argv.includes("--update"); // 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; +// --require-tighten: falha quando uma métrica melhorou além de tightenSlack sem que o +// baseline tenha sido apertado. Garante que melhorias permanentes sejam capturadas. +// Sem esta flag, melhorias são apenas registradas (comportamento v1 — retrocompat). +const REQUIRE_TIGHTEN = process.argv.includes("--require-tighten"); + +// Global fallback eps. Cada métrica pode sobrepor via `eps` no baseline. +const GLOBAL_EPS = 0.01; function load(p) { if (!fs.existsSync(p)) { @@ -31,6 +39,7 @@ function load(p) { const baseline = load(BASELINE); const metrics = load(METRICS); const failures = []; +const tightenFailures = []; const improvements = []; const rows = []; @@ -38,6 +47,13 @@ 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 + + // Per-metric eps; falls back to global EPS if not specified in the spec. + const eps = spec.eps !== undefined ? spec.eps : GLOBAL_EPS; + + // Per-metric tightenSlack; falls back to eps (same tolerance as regression check). + const tightenSlack = spec.tightenSlack !== undefined ? spec.tightenSlack : eps; + if (current === undefined) { if (ALLOW_MISSING) { rows.push([key, base, "—", "SKIP (ausente)"]); @@ -49,25 +65,47 @@ for (const [key, spec] of Object.entries(baseline.metrics)) { } let status = "ok"; if (dir === "down") { - if (current > base + EPS) { + if (current > base + eps) { failures.push(`${key}: ${current} > baseline ${base} (não pode aumentar)`); status = "REGRESSÃO"; - } else if (current < base - EPS) { + } else if (current < base - eps) { improvements.push([key, current]); status = "↑ melhorou"; + if (REQUIRE_TIGHTEN && base - current > tightenSlack) { + tightenFailures.push( + `${key}: melhorou de ${base} para ${current} (delta ${(base - current).toFixed(4)} > slack ${tightenSlack}) — rode 'npm run quality:ratchet -- --update' e commite o baseline apertado neste PR`, + ); + } } } else { - if (current < base - EPS) { + if (current < base - eps) { failures.push(`${key}: ${current} < baseline ${base} (não pode cair)`); status = "REGRESSÃO"; - } else if (current > base + EPS) { + } else if (current > base + eps) { improvements.push([key, current]); status = "↑ melhorou"; + if (REQUIRE_TIGHTEN && current - base > tightenSlack) { + tightenFailures.push( + `${key}: melhorou de ${base} para ${current} (delta ${(current - base).toFixed(4)} > slack ${tightenSlack}) — rode 'npm run quality:ratchet -- --update' e commite o baseline apertado neste PR`, + ); + } } } rows.push([key, base, current, status]); } +// Behavior 3: warn about orphan metrics (present in collected metrics but absent in baseline). +const baselineKeys = new Set(Object.keys(baseline.metrics)); +const orphans = Object.keys(metrics).filter((k) => !baselineKeys.has(k)); +if (orphans.length > 0) { + console.warn( + `[quality-ratchet] WARN: ${orphans.length} métrica(s) órfã(s) — presente(s) em ${path.basename(METRICS)} mas sem entrada no baseline: ${orphans.join(", ")}`, + ); + console.warn( + `[quality-ratchet] WARN: adicione ${orphans.length === 1 ? "essa métrica" : "essas métricas"} ao baseline (com value/direction) para que sejam catraceadas.`, + ); +} + if (SUMMARY) { const md = [ "# Quality Ratchet", @@ -84,6 +122,9 @@ if (SUMMARY) { fs.writeFileSync(SUMMARY, md + "\n"); } +// Tighten check runs only when there are no regressions (regressions take priority). +// With --update, improvements are captured into the baseline, so tighten check +// is bypassed (the update itself is the required action). 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"); @@ -94,4 +135,14 @@ if (failures.length) { console.error("[quality-ratchet] FALHOU:\n" + failures.map((f) => " ✗ " + f).join("\n")); process.exit(1); } + +// Behavior 1: --require-tighten gate (only triggers when there are no regressions and no --update). +if (REQUIRE_TIGHTEN && !UPDATE && tightenFailures.length > 0) { + console.error( + "[quality-ratchet] FALHOU (--require-tighten): métrica(s) melhoraram mas o baseline não foi apertado:\n" + + tightenFailures.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 index 471094e99b..bf24f96b29 100644 --- a/scripts/quality/collect-metrics.mjs +++ b/scripts/quality/collect-metrics.mjs @@ -2,9 +2,15 @@ // 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. +// Fase 6A.11: openapiCoverage.pct + i18nUiCoverage.pct (mínimo entre locales). +// Task 7.9: coverage..lines para ~8 módulos críticos, lidos do +// coverage/coverage-summary.json se existir (sem erro se ausente). import fs from "node:fs"; +import { promises as fsAsync } from "node:fs"; import path from "node:path"; +import { pathToFileURL } from "node:url"; import { execFileSync } from "node:child_process"; +import yaml from "js-yaml"; const cwd = process.cwd(); const out = {}; @@ -37,7 +43,213 @@ function coverage() { 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)); +// 3) Coverage per critical module (Task 7.9) +// Reads coverage/coverage-summary.json (produced by `npm run test:coverage` via c8). +// If the file is absent → silently skips (no error). This allows the gate to +// function normally in environments where coverage was not run (e.g. lint-only CI). +// +// The summary JSON produced by c8 looks like: +// { "total": {...}, "/abs/path/to/file.ts": { lines: { pct: 78 }, ... }, ... } +// +// modulePaths is a record of { metricSuffix: relPathFromRoot[] } — the first +// matching key in the summary wins. + +/** + * Pure function — extracts per-module line-coverage percentages from a + * coverage-summary.json object. + * + * @param {Record} summaryJson + * The parsed coverage-summary.json (keys are absolute file paths or "total"). + * @param {Record} modulePaths + * Map of { metricKey: [relPath, ...fallbacks] } where relPath is relative to + * the repo root (forward slashes). Returns the lines.pct of the first match. + * @param {string} repoRoot Absolute path to the repo root (used to build keys). + * @returns {Record} Map of metricKey → lines.pct (0-100). + */ +export function extractModuleCoverage(summaryJson, modulePaths, repoRoot) { + const result = {}; + // Build a normalised lookup: absolute path (forward slashes) → pct + const lookup = new Map(); + for (const [rawKey, data] of Object.entries(summaryJson)) { + if (rawKey === "total") continue; + const norm = rawKey.replace(/\\/g, "/"); + const pct = data?.lines?.pct; + if (typeof pct === "number") lookup.set(norm, pct); + } + + const normRoot = repoRoot.replace(/\\/g, "/").replace(/\/$/, ""); + + for (const [metricKey, candidates] of Object.entries(modulePaths)) { + for (const rel of candidates) { + const abs = `${normRoot}/${rel.replace(/\\/g, "/").replace(/^\//, "")}`; + if (lookup.has(abs)) { + result[metricKey] = lookup.get(abs); + break; + } + } + } + return result; +} + +/** The 8 critical modules tracked by Task 7.9 (relative paths from repo root). */ +export const CRITICAL_MODULE_PATHS = { + "coverage.chatCore.lines": ["open-sse/handlers/chatCore.ts"], + "coverage.combo.lines": ["open-sse/services/combo.ts"], + "coverage.accountFallback.lines": ["open-sse/services/accountFallback.ts"], + "coverage.auth.lines": ["src/sse/services/auth.ts"], + "coverage.routeGuard.lines": ["src/server/authz/routeGuard.ts"], + "coverage.error.lines": ["open-sse/utils/error.ts"], + "coverage.publicCreds.lines": ["open-sse/utils/publicCreds.ts"], + "coverage.circuitBreaker.lines": ["src/shared/utils/circuitBreaker.ts"], +}; + +function coverageByModule() { + const p = path.join(cwd, "coverage", "coverage-summary.json"); + if (!fs.existsSync(p)) return; // absent → skip silently (Task 7.9 spec) + let summaryJson; + try { + summaryJson = JSON.parse(fs.readFileSync(p, "utf8")); + } catch { + return; // malformed file → skip + } + const moduleMetrics = extractModuleCoverage(summaryJson, CRITICAL_MODULE_PATHS, cwd); + Object.assign(out, moduleMetrics); +} + +// 4) OpenAPI coverage: percentage of implemented routes documented in openapi.yaml +function openapiCoverage() { + const API_ROOT = path.join(cwd, "src", "app", "api"); + const OPENAPI_PATH = path.join(cwd, "docs", "reference", "openapi.yaml"); + if (!fs.existsSync(API_ROOT) || !fs.existsSync(OPENAPI_PATH)) return; + + function collectRoutePaths(dir) { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + const paths = []; + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + paths.push(...collectRoutePaths(fullPath)); + continue; + } + if (entry.isFile() && entry.name === "route.ts") { + const apiPath = path + .dirname(fullPath) + .replace(API_ROOT, "") + .replace(/\[([^\]]+)\]/g, "{$1}"); + paths.push(`/api${apiPath}`); + } + } + return paths; + } + + function normalizePath(p) { + return p.replace(/\/\[\.\.\.([^\]]+)\]/g, "/{$1}").replace(/\[([^\]]+)\]/g, "{$1}"); + } + + const implementedPaths = collectRoutePaths(API_ROOT).map(normalizePath); + const raw = yaml.load(fs.readFileSync(OPENAPI_PATH, "utf-8")); + const documentedPaths = new Set(Object.keys(raw.paths || {})); + const covered = implementedPaths.filter((p) => documentedPaths.has(p)).length; + const total = implementedPaths.length; + if (total > 0) out["openapiCoverage.pct"] = parseFloat(((covered / total) * 100).toFixed(1)); +} + +// 4) i18n UI coverage: minimum real coverage across all non-en locales +async function i18nUiCoverage() { + const MESSAGES_DIR = path.join(cwd, "src", "i18n", "messages"); + const CONFIG_PATH = path.join(cwd, "config", "i18n.json"); + const SOURCE_LOCALE = "en"; + const PLACEHOLDER_PREFIX = "__MISSING__:"; + + if (!fs.existsSync(MESSAGES_DIR)) return; + const sourcePath = path.join(MESSAGES_DIR, `${SOURCE_LOCALE}.json`); + if (!fs.existsSync(sourcePath)) return; + + function isPlainObject(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); + } + + function collectLeafPaths(obj, prefix = []) { + const paths = []; + for (const [key, value] of Object.entries(obj)) { + const next = [...prefix, key]; + if (isPlainObject(value)) { + paths.push(...collectLeafPaths(value, next)); + } else { + paths.push(next); + } + } + return paths; + } + + const FORBIDDEN_KEY_SEGMENTS = new Set(["__proto__", "prototype", "constructor"]); + + function lookupPath(obj, parts) { + let cur = obj; + for (const part of parts) { + if (!isPlainObject(cur)) return undefined; + if (FORBIDDEN_KEY_SEGMENTS.has(part)) return undefined; + if (!Object.prototype.hasOwnProperty.call(cur, part)) return undefined; + const entry = Object.entries(cur).find(([k]) => k === part); + cur = entry ? entry[1] : undefined; + } + return cur; + } + + const source = JSON.parse(fs.readFileSync(sourcePath, "utf8")); + const enPaths = collectLeafPaths(source); + const totalEn = enPaths.length; + if (totalEn === 0) return; + + let configCodes = null; + if (fs.existsSync(CONFIG_PATH)) { + try { + const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8")); + if (Array.isArray(cfg.locales)) configCodes = new Set(cfg.locales.map((l) => l.code)); + } catch { + /* ignore */ + } + } + + const onDisk = (await fsAsync.readdir(MESSAGES_DIR)) + .filter((f) => f.endsWith(".json") && f !== `${SOURCE_LOCALE}.json`) + .map((f) => f.slice(0, -5)) + .filter((code) => (configCodes ? configCodes.has(code) : true)); + + let minCoverage = 100; + for (const locale of onDisk) { + const localePath = path.join(MESSAGES_DIR, `${locale}.json`); + let target; + try { + target = JSON.parse(fs.readFileSync(localePath, "utf8")); + } catch { + minCoverage = 0; + continue; + } + let present = 0; + let placeholder = 0; + for (const pathParts of enPaths) { + const value = lookupPath(target, pathParts); + if (value === undefined || isPlainObject(value)) continue; + present++; + if (typeof value === "string" && value.startsWith(PLACEHOLDER_PREFIX)) placeholder++; + } + const coverage = ((present - placeholder) / totalEn) * 100; + if (coverage < minCoverage) minCoverage = coverage; + } + + if (onDisk.length > 0) out["i18nUiCoverage.pct"] = parseFloat(minCoverage.toFixed(1)); +} + +// Only run the collection pipeline when this file is executed directly. +// When imported (e.g. in tests), only the exported pure functions are available +// without triggering the expensive ESLint + i18n filesystem walks. +if (import.meta.url === pathToFileURL(process.argv[1] || "").href) { + eslintCounts(); + coverage(); + coverageByModule(); + openapiCoverage(); + await i18nUiCoverage(); + fs.writeFileSync(path.join(cwd, "quality-metrics.json"), JSON.stringify(out, null, 2) + "\n"); + console.log("[collect-metrics]", JSON.stringify(out)); +} diff --git a/scripts/quality/run-all-gates.mjs b/scripts/quality/run-all-gates.mjs new file mode 100644 index 0000000000..2b20a7221d --- /dev/null +++ b/scripts/quality/run-all-gates.mjs @@ -0,0 +1,169 @@ +#!/usr/bin/env node +// scripts/quality/run-all-gates.mjs +// Agregador paralelo de quality gates determinísticos. +// Roda os gates filesystem-only em paralelo (pool ~4), agrega resultados, e exibe +// uma tabela consolidada com {gate, status, durationMs}. Sai com código 1 se +// qualquer gate falhar. Alvo: < 3 min no total. +// +// Usage: +// node scripts/quality/run-all-gates.mjs # run all gates +// node scripts/quality/run-all-gates.mjs --fast # skip slow gates (duplication) +// +// Via npm: npm run quality:scan + +import { spawn } from "node:child_process"; +import { pathToFileURL } from "node:url"; + +const FAST_ONLY = process.argv.includes("--fast"); + +// Gates determinísticos filesystem-only, agrupados por tempo estimado. +// Cada entrada: { name: string, cmd: string[], label?: string } +// "slow" gates são omitidos com --fast. +const GATES = [ + // Group A — instant (<1s) + { name: "check:tracked-artifacts", cmd: ["node", "scripts/check/check-tracked-artifacts.mjs"] }, + { name: "check:any-budget:t11", cmd: ["node", "scripts/check/check-t11-any-budget.mjs"] }, + { name: "check:migration-numbering", cmd: ["node", "scripts/check/check-migration-numbering.mjs"] }, + { name: "check:node-runtime", cmd: ["node", "--import", "tsx", "scripts/check/check-supported-node-runtime.ts"] }, + + // Group B — fast (<5s) + { name: "check:provider-consistency", cmd: ["node", "--import", "tsx", "scripts/check/check-provider-consistency.ts"] }, + { name: "check:public-creds", cmd: ["node", "scripts/check/check-public-creds.mjs"] }, + { name: "check:error-helper", cmd: ["node", "scripts/check/check-error-helper.mjs"] }, + { name: "check:fetch-targets", cmd: ["node", "scripts/check/check-fetch-targets.mjs"] }, + { name: "check:openapi-routes", cmd: ["node", "scripts/check/check-openapi-routes.mjs"] }, + { name: "check:deps", cmd: ["node", "scripts/check/check-deps.mjs"] }, + + // Group C — moderate (<15s) + { name: "check:db-rules", cmd: ["node", "scripts/check/check-db-rules.mjs"] }, + { name: "check:file-size", cmd: ["node", "scripts/check/check-file-size.mjs"] }, + { name: "check:complexity", cmd: ["node", "scripts/check/check-complexity.mjs"] }, + { name: "check:docs-symbols", cmd: ["node", "scripts/check/check-docs-symbols.mjs"] }, + { name: "check:known-symbols", cmd: ["node", "--import", "tsx", "scripts/check/check-known-symbols.ts"] }, + { name: "check:route-guard-membership", cmd: ["node", "--import", "tsx", "scripts/check/check-route-guard-membership.ts"] }, + { name: "check:test-discovery", cmd: ["node", "scripts/check/check-test-discovery.mjs"] }, + { name: "check:test-masking", cmd: ["node", "scripts/check/check-test-masking.mjs"] }, + + // Group D — slow (>15s); skipped with --fast + { name: "check:duplication", cmd: ["node", "scripts/check/check-duplication.mjs"], slow: true }, + { name: "check:cycles", cmd: ["node", "scripts/check/check-cycles.mjs"], slow: true }, +]; + +const CONCURRENCY = 4; + +/** + * Run a single gate command, capturing last line of stdout/stderr. + * @param {{ name: string, cmd: string[] }} gate + * @returns {Promise<{ name: string, exitCode: number, durationMs: number, lastLine: string }>} + */ +function runGate(gate) { + return new Promise((resolve) => { + const start = Date.now(); + const [bin, ...args] = gate.cmd; + const proc = spawn(bin, args, { stdio: ["ignore", "pipe", "pipe"], cwd: process.cwd() }); + + const lines = []; + const collectLine = (chunk) => { + const text = chunk.toString(); + for (const line of text.split("\n")) { + const t = line.trim(); + if (t) lines.push(t); + } + }; + + proc.stdout.on("data", collectLine); + proc.stderr.on("data", collectLine); + + proc.on("close", (code) => { + const durationMs = Date.now() - start; + const lastLine = lines[lines.length - 1] ?? ""; + resolve({ name: gate.name, exitCode: code ?? 1, durationMs, lastLine }); + }); + + proc.on("error", (err) => { + const durationMs = Date.now() - start; + resolve({ name: gate.name, exitCode: 1, durationMs, lastLine: err.message }); + }); + }); +} + +/** + * Run gates with a concurrency pool. + * @param {typeof GATES} gates + * @param {number} concurrency + * @returns {Promise[]>} + */ +async function runWithPool(gates, concurrency) { + const results = []; + let idx = 0; + + async function worker() { + while (idx < gates.length) { + const gate = gates[idx++]; + const result = await runGate(gate); + results.push(result); + } + } + + const workers = Array.from({ length: Math.min(concurrency, gates.length) }, () => worker()); + await Promise.all(workers); + return results; +} + +function formatTable(results) { + const COL_NAME = 35; + const COL_STATUS = 8; + const COL_MS = 10; + const COL_MSG = 60; + + const header = + " " + + "GATE".padEnd(COL_NAME) + + "STATUS".padEnd(COL_STATUS) + + "TIME(ms)".padEnd(COL_MS) + + "LAST LINE"; + + const separator = " " + "─".repeat(COL_NAME + COL_STATUS + COL_MS + COL_MSG); + + const rows = results.map((r) => { + const status = r.exitCode === 0 ? "PASS" : "FAIL"; + const name = r.name.padEnd(COL_NAME); + const statusCol = (r.exitCode === 0 ? "✔ " + status : "✗ " + status).padEnd(COL_STATUS + 2); + const ms = String(r.durationMs).padStart(6) + "ms "; + const msg = r.lastLine.slice(0, COL_MSG); + return ` ${name}${statusCol}${ms}${msg}`; + }); + + return [separator, header, separator, ...rows, separator].join("\n"); +} + +async function main() { + const gates = FAST_ONLY ? GATES.filter((g) => !g.slow) : GATES; + + console.log(`\n[quality:scan] Running ${gates.length} gate(s) with concurrency=${CONCURRENCY}...\n`); + const wallStart = Date.now(); + + const results = await runWithPool(gates, CONCURRENCY); + + const wallMs = Date.now() - wallStart; + const failed = results.filter((r) => r.exitCode !== 0); + const passed = results.filter((r) => r.exitCode === 0); + + console.log(formatTable(results)); + console.log( + `\n Summary: ${passed.length} passed, ${failed.length} failed` + + ` | Total: ${(wallMs / 1000).toFixed(1)}s (wall clock)\n` + ); + + if (failed.length > 0) { + console.error(`[quality:scan] FAIL — ${failed.length} gate(s) failed:`); + for (const r of failed) { + console.error(` ✗ ${r.name}`); + } + process.exit(1); + } + + console.log("[quality:scan] All gates passed."); +} + +if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main(); diff --git a/semcheck.yaml b/semcheck.yaml new file mode 100644 index 0000000000..f86a9bb94c --- /dev/null +++ b/semcheck.yaml @@ -0,0 +1,160 @@ +# semcheck.yaml — docs↔code semantic consistency (Task 14 — Fase 7) +# +# PURPOSE +# Fuzzy (LLM-assisted) layer that catches documentation describing behaviour +# that the code no longer implements. Complements the deterministic +# check-docs-symbols.mjs (Fase 6) which catches renamed/deleted exports. +# semcheck catches semantic drift: a doc that says "returns 200 on success" +# when the code now returns 204, or that describes a feature that was removed. +# +# OPERATING MODE — ADVISORY / NON-BLOCKING +# - This gate is LLM-backed → runs in the NIGHTLY job only (not per-PR). +# - It NEVER blocks a PR merge automatically. +# - Findings appear as annotations in the CI summary and as a comment on the +# PR if triggered by a label (see ci.yml `semcheck` job). +# - To trigger on a specific PR: add the label `semcheck` to the PR. +# - To promote to blocking: set `fail-on-issues: true` and wire the job +# under `required-checks` in the branch protection rule. +# +# FAIL MODE +# fail-on-issues: false ← advisory (current) +# fail-on-issues: true ← blocking (future, opt-in) +# +# TOOL +# semcheck OSS (https://github.com/semcheck/semcheck — MIT). +# Install: npm install --save-dev semcheck (not bundled; nightly-only) +# Run: npx semcheck --config semcheck.yaml +# +# ADDING NEW RULES +# 1. Pick a doc file and the matching source file(s). +# 2. Write a `claim` that the doc makes about the code behaviour. +# 3. Optionally add `context-file` paths for the LLM to read. +# 4. Set `severity: warning` for informational, `error` for blocking candidates. +# Rules are evaluated independently; a failure in one does not block others. + +version: "1" + +fail-on-issues: false # ADVISORY — change to true to make blocking + +model: "gpt-4o-mini" # cheap model for advisory pass; upgrade to gpt-4o for precision + +rules: + + # ── Routing & Combo ────────────────────────────────────────────────────── + + - id: combo-strategies-count + doc: docs/routing/AUTO-COMBO.md + claim: > + The document states that OmniRoute supports 14 combo routing strategies. + Verify that open-sse/services/combo.ts exports or references exactly 14 + strategy identifiers (or that any discrepancy has a comment explaining why). + context-files: + - open-sse/services/combo.ts + severity: warning + + - id: auto-combo-9-factors + doc: docs/routing/AUTO-COMBO.md + claim: > + The document describes exactly 9 scoring factors for the Auto-Combo + strategy. Verify that the implementation references 9 distinct factors + (e.g. in the autoCombo scoring function or its constants/comments). + context-files: + - open-sse/services/autoCombo/ + severity: warning + + - id: resilience-3-layers + doc: docs/architecture/RESILIENCE_GUIDE.md + claim: > + The document describes 3 and only 3 resilience mechanisms: Provider + Circuit Breaker, Connection Cooldown, and Model Lockout. Verify that no + 4th mechanism is implemented without being documented. + context-files: + - src/shared/utils/circuitBreaker.ts + - open-sse/services/accountFallback.ts + severity: warning + + # ── Security ───────────────────────────────────────────────────────────── + + - id: error-sanitization-flow + doc: docs/security/ERROR_SANITIZATION.md + claim: > + The document states that all error responses MUST route through + buildErrorBody() or sanitizeErrorMessage() from open-sse/utils/error.ts. + Verify that the described API (function names, module path) matches what + is actually exported in that file. + context-files: + - open-sse/utils/error.ts + severity: error + + - id: public-creds-pattern + doc: docs/security/PUBLIC_CREDS.md + claim: > + The document describes a mandatory resolvePublicCred() function in + open-sse/utils/publicCreds.ts. Verify that function exists with roughly + the API signature the doc describes (accepts a key, returns a string). + context-files: + - open-sse/utils/publicCreds.ts + severity: error + + # ── MCP Server ─────────────────────────────────────────────────────────── + + - id: mcp-tool-count + doc: docs/frameworks/MCP-SERVER.md + claim: > + The document states the MCP server exposes 43 tools in total (30 base + + 3 memory + 4 skills + 6 notion). Verify this count is consistent with + the number of tool definitions found in open-sse/mcp-server/tools/. + context-files: + - open-sse/mcp-server/tools/ + severity: warning + + # ── A2A Server ─────────────────────────────────────────────────────────── + + - id: a2a-skills-count + doc: docs/frameworks/A2A-SERVER.md + claim: > + The document lists exactly 5 built-in A2A skills: smart-routing, + quota-management, provider-discovery, cost-analysis, health-report. + Verify that src/lib/a2a/skills/ contains entries matching these names + and that no additional undocumented skills exist. + context-files: + - src/lib/a2a/skills/ + - src/lib/a2a/taskExecution.ts + severity: warning + + # ── API Reference ───────────────────────────────────────────────────────── + + - id: api-route-v1-prefix + doc: docs/reference/API_REFERENCE.md + claim: > + The document states all API routes are under /v1/ (e.g. /v1/chat/completions). + Verify that src/app/api/v1/ exists and that the key routes mentioned in + the doc (chat/completions, embeddings, models) are present. + context-files: + - src/app/api/v1/ + severity: warning + + # ── Embedded Services ───────────────────────────────────────────────────── + + - id: embedded-services-api-pattern + doc: docs/frameworks/EMBEDDED-SERVICES.md + claim: > + The document says each embedded service exposes 7 API endpoints: + install, start, stop, restart, update, status, auto-start (plus a shared + logs endpoint). Verify that at least one service under src/app/api/services/ + follows this pattern. + context-files: + - src/app/api/services/ + severity: warning + + # ── Route Guard ─────────────────────────────────────────────────────────── + + - id: route-guard-local-only + doc: docs/security/ROUTE_GUARD_TIERS.md + claim: > + The document describes LOCAL_ONLY_API_PREFIXES enforced by + src/server/authz/routeGuard.ts via isLocalOnlyPath(). Verify these + identifiers exist in the source file with roughly the described semantics. + context-files: + - src/server/authz/routeGuard.ts + severity: error diff --git a/sonar-project.properties b/sonar-project.properties index f22e77b98f..78a3470d41 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -6,8 +6,32 @@ sonar.tests=tests sonar.test.inclusions=tests/**/*.test.ts,tests/**/*.spec.ts sonar.exclusions=tests/**,src/i18n/messages/**,docs/**,coverage/**,.next/**,dist/**,build/**,node_modules/** sonar.javascript.lcov.reportPaths=coverage/lcov.info -sonar.coverage.exclusions=**/* -sonar.cpd.exclusions=**/* + +# ── "Clean as You Code" — new-code quality gate ─────────────────────────── +# New code must not introduce any bug, vulnerability, hotspot or code smell. +# Legacy code is grandfathered (sonar.newCode.referenceBranch baseline). +# +# OPERATIONAL PRE-REQUISITES (owner action required before the gate is live): +# 1. Set repository secret SONAR_TOKEN (user/project token from SonarQube UI). +# 2. Set repository secret SONAR_HOST_URL (e.g. https://sonar.example.com for +# a self-hosted Community Build instance, or https://sonarcloud.io for the +# cloud plan). +# 3. Ensure the `sonarqube` job in `.github/workflows/ci.yml` passes both +# secrets to the SonarScanner step as environment variables; without them +# the step is a no-op (secrets-gated). +# 4. Configure the "Clean as You Code" Quality Gate in the SonarQube project +# settings (Conditions → New Code: Bugs=0, Vulnerabilities=0, +# Security Hotspots Reviewed=100%, Code Smells=0 or bounded). +# +# sonar.qualitygate.wait=true causes the scanner to poll until the gate +# result is available and fail the CI step when the gate is RED. +# Without it the step always exits 0 even when the gate fails. +sonar.qualitygate.wait=true + +# New-code baseline: compare against the tip of the release branch so that +# every PR against release/* is judged as "new code vs release baseline". +# Override with sonar.newCode.referenceBranch on a per-branch basis if needed. +sonar.newCode.referenceBranch=main # ── Hotspot suppressions ─────────────────────────────────────────────────── # The following rules surface "review this" hotspots that are bounded / diff --git a/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx b/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx index 961eb75a92..615c7d58d8 100644 --- a/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx +++ b/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx @@ -23,6 +23,26 @@ import { SELF_ACCOUNT_QUOTA_SCOPE, SELF_USAGE_SCOPE } from "@/shared/constants/s // Constants for validation const MAX_KEY_NAME_LENGTH = 200; const MAX_SELECTED_MODELS = 500; +const CLAUDE_CODE_DEFAULT_MODEL_ID = "cc/*"; +const CLAUDE_CODE_DEFAULT_MODEL_NAME = "Claude Code default"; +const CLAUDE_CODE_DEFAULT_FAMILIES = [ + { id: "other", label: "other" }, + { id: "fable", label: "fable" }, + { id: "opus", label: "opus" }, + { id: "sonnet", label: "sonnet" }, + { id: "haiku", label: "haiku" }, +] as const; +type ClaudeCodeFamilyId = (typeof CLAUDE_CODE_DEFAULT_FAMILIES)[number]["id"]; +type ClaudeCodeBlockableFamilyId = Exclude; +const CLAUDE_CODE_FAMILY_BLOCK_PATTERNS: Record = { + fable: ["claude-fable*", "fable"], + opus: ["claude-opus*", "opus"], + sonnet: ["claude-sonnet*", "sonnet"], + haiku: ["claude-haiku*", "haiku"], +}; +const CLAUDE_CODE_BLOCK_PATTERN_SET = new Set( + Object.values(CLAUDE_CODE_FAMILY_BLOCK_PATTERNS).flat() +); function toLocalDateTimeInputValue(value: string | null | undefined): string { if (!value) return ""; @@ -92,6 +112,7 @@ interface ApiKey { name: string; key: string; allowedModels: string[] | null; + blockedModels?: string[] | null; allowedCombos: string[] | null; allowedConnections: string[] | null; noLog?: boolean; @@ -137,6 +158,7 @@ function formatUsdCost(value: number, locale: string): string { interface Model { id: string; owned_by: string; + name?: string; } interface ComboOption { @@ -148,6 +170,41 @@ interface ComboOption { /** Tuple type for models grouped by provider: [providerName, models[]] */ type ProviderGroup = [provider: string, models: Model[]]; +function isClaudeCodeModel(model: Model): boolean { + return ( + model.owned_by === "claude" || model.id.startsWith("cc/") || model.id.startsWith("claude/") + ); +} + +function withClaudeCodeDefaultModel(models: Model[]): Model[] { + if (!models.some(isClaudeCodeModel)) return models; + if (models.some((model) => model.id === CLAUDE_CODE_DEFAULT_MODEL_ID)) return models; + return [ + { + id: CLAUDE_CODE_DEFAULT_MODEL_ID, + name: CLAUDE_CODE_DEFAULT_MODEL_NAME, + owned_by: "claude", + }, + ...models, + ]; +} + +function getBlockedClaudeCodeFamilies(blockedModels: string[]): ClaudeCodeBlockableFamilyId[] { + return (Object.keys(CLAUDE_CODE_FAMILY_BLOCK_PATTERNS) as ClaudeCodeBlockableFamilyId[]).filter( + (familyId) => + CLAUDE_CODE_FAMILY_BLOCK_PATTERNS[familyId].some((pattern) => blockedModels.includes(pattern)) + ); +} + +function isClaudeCodeFamilyModel(modelId: string, familyId: ClaudeCodeBlockableFamilyId): boolean { + const normalized = modelId.toLowerCase(); + return ( + normalized === familyId || + normalized.includes(`/${familyId}`) || + normalized.includes(`-${familyId}`) + ); +} + export default function ApiManagerPageClient() { const t = useTranslations("apiManager"); const tc = useTranslations("common"); @@ -437,6 +494,7 @@ export default function ApiManagerPageClient() { const quotaKeys = filteredKeys.filter(isQuotaKey); const normalKeys = filteredKeys.filter((k) => !isQuotaKey(k)); + const permissionModels = useMemo(() => withClaudeCodeDefaultModel(allModels), [allModels]); const quotaGroupsForKey = (k: ApiKey): string[] => { if (!Array.isArray(k.allowedQuotas)) return []; @@ -600,7 +658,8 @@ export default function ApiManagerPageClient() { scopes: string[], allowedEndpoints: string[], streamDefaultMode: StreamDefaultMode, - disableNonPublicModels: boolean + disableNonPublicModels: boolean, + blockedModels: string[] ) => { if (!editingKey || !editingKey.id) return; @@ -620,6 +679,9 @@ export default function ApiManagerPageClient() { const validModels = allowedModels.filter( (id) => typeof id === "string" && id.length > 0 && id.length < 200 ); + const validBlockedModels = blockedModels.filter( + (id) => typeof id === "string" && id.length > 0 && id.length < 200 + ); const validCombos = allowedCombos.filter( (name) => typeof name === "string" && name.trim().length > 0 && name.length < 200 @@ -648,6 +710,7 @@ export default function ApiManagerPageClient() { body: JSON.stringify({ name: sanitizedName, allowedModels: validModels, + blockedModels: validBlockedModels, allowedCombos: validCombos, allowedConnections: validConnections, noLog, @@ -690,14 +753,14 @@ export default function ApiManagerPageClient() { // ids like "openai-compatible-chat-" into the grouping label) const modelsByProvider = useMemo((): ProviderGroup[] => { const grouped: Record = {}; - for (const model of allModels) { + for (const model of permissionModels) { const provider = getProviderDisplayName(model.owned_by) || model.owned_by || t("unknownProvider"); if (!grouped[provider]) grouped[provider] = []; grouped[provider].push(model); } return Object.entries(grouped).sort((a, b) => compareTr(a[0], b[0])); - }, [allModels, t]); + }, [permissionModels, t]); // Filter models based on debounced search const filteredModelsByProvider = useMemo((): ProviderGroup[] => { @@ -710,6 +773,7 @@ export default function ApiManagerPageClient() { models.filter( (m) => matchesSearch(m.id, debouncedSearchModel) || + matchesSearch(m.name || "", debouncedSearchModel) || matchesSearch(provider, debouncedSearchModel) ), ] @@ -1100,8 +1164,8 @@ export default function ApiManagerPageClient() { payments @@ -1406,7 +1470,7 @@ export default function ApiManagerPageClient() { }} apiKey={editingKey} modelsByProvider={filteredModelsByProvider} - allModels={allModels} + allModels={permissionModels} allCombos={allCombos} allConnections={allConnections} searchModel={searchModel} @@ -1458,7 +1522,8 @@ const PermissionsModal = memo(function PermissionsModal({ scopes: string[], allowedEndpoints: string[], streamDefaultMode: StreamDefaultMode, - disableNonPublicModels: boolean + disableNonPublicModels: boolean, + blockedModels: string[] ) => void; }) { const t = useTranslations("apiManager"); @@ -1466,12 +1531,20 @@ const PermissionsModal = memo(function PermissionsModal({ // Initialize state from props - component remounts when key prop changes const initialModels = Array.isArray(apiKey?.allowedModels) ? apiKey.allowedModels : []; + const initialBlockedModels = useMemo( + () => (Array.isArray(apiKey?.blockedModels) ? apiKey.blockedModels : []), + [apiKey?.blockedModels] + ); const initialCombos = Array.isArray(apiKey?.allowedCombos) ? apiKey.allowedCombos : []; const initialConnections = Array.isArray(apiKey?.allowedConnections) ? apiKey.allowedConnections : []; const [keyName, setKeyName] = useState(apiKey?.name ?? ""); const [selectedModels, setSelectedModels] = useState(initialModels); + const [blockedClaudeCodeFamilies, setBlockedClaudeCodeFamilies] = useState< + ClaudeCodeBlockableFamilyId[] + >(() => getBlockedClaudeCodeFamilies(initialBlockedModels)); + const [claudeCodeFamiliesExpanded, setClaudeCodeFamiliesExpanded] = useState(false); const [selectedCombos, setSelectedCombos] = useState(initialCombos); const [allowAll, setAllowAll] = useState(initialModels.length === 0); const [allowAllCombos, setAllowAllCombos] = useState(initialCombos.length === 0); @@ -1530,6 +1603,11 @@ const PermissionsModal = memo(function PermissionsModal({ const [disableNonPublicModels, setDisableNonPublicModels] = useState( apiKey?.disableNonPublicModels === true ); + const getModelDisplayName = useCallback( + (modelId: string) => + modelId === CLAUDE_CODE_DEFAULT_MODEL_ID ? CLAUDE_CODE_DEFAULT_MODEL_NAME : modelId, + [] + ); // Memoize callbacks to prevent child re-renders const handleToggleModel = useCallback( @@ -1538,6 +1616,9 @@ const PermissionsModal = memo(function PermissionsModal({ setSelectedModels((prev) => { if (prev.includes(modelId)) { + if (modelId === CLAUDE_CODE_DEFAULT_MODEL_ID) { + setClaudeCodeFamiliesExpanded(false); + } return prev.filter((m) => m !== modelId); } return [...prev, modelId]; @@ -1565,6 +1646,8 @@ const PermissionsModal = memo(function PermissionsModal({ const handleSelectAll = useCallback(() => { setAllowAll(true); setSelectedModels([]); + setBlockedClaudeCodeFamilies([]); + setClaudeCodeFamiliesExpanded(false); }, []); const handleRestrictMode = useCallback(() => { @@ -1589,10 +1672,21 @@ const PermissionsModal = memo(function PermissionsModal({ const handleSelectAllModels = useCallback(() => { const allModelIds = allModels.map((m) => m.id); setSelectedModels(allModelIds); + setBlockedClaudeCodeFamilies([]); + setClaudeCodeFamiliesExpanded(false); }, [allModels]); const handleDeselectAllModels = useCallback(() => { setSelectedModels([]); + setBlockedClaudeCodeFamilies([]); + setClaudeCodeFamiliesExpanded(false); + }, []); + + const handleBlockClaudeCodeFamily = useCallback((familyId: ClaudeCodeBlockableFamilyId) => { + setBlockedClaudeCodeFamilies((prev) => (prev.includes(familyId) ? prev : [...prev, familyId])); + setSelectedModels((prev) => + prev.filter((modelId) => !isClaudeCodeFamilyModel(modelId, familyId)) + ); }, []); const handleToggleCombo = useCallback( @@ -1660,6 +1754,16 @@ const PermissionsModal = memo(function PermissionsModal({ tz: scheduleTz, } : null; + const hasClaudeCodeDefaultSelected = + !allowAll && selectedModels.includes(CLAUDE_CODE_DEFAULT_MODEL_ID); + const blockedModels = initialBlockedModels.filter( + (pattern) => !CLAUDE_CODE_BLOCK_PATTERN_SET.has(pattern) + ); + if (hasClaudeCodeDefaultSelected) { + for (const familyId of blockedClaudeCodeFamilies) { + blockedModels.push(...CLAUDE_CODE_FAMILY_BLOCK_PATTERNS[familyId]); + } + } onSave( keyName, allowAll ? [] : selectedModels, @@ -1681,7 +1785,8 @@ const PermissionsModal = memo(function PermissionsModal({ }), allowAllEndpoints ? [] : selectedEndpoints, streamDefaultMode, - disableNonPublicModels + disableNonPublicModels, + blockedModels ); }, [ onSave, @@ -1712,12 +1817,32 @@ const PermissionsModal = memo(function PermissionsModal({ selectedEndpoints, streamDefaultMode, disableNonPublicModels, + blockedClaudeCodeFamilies, + initialBlockedModels, apiKey?.scopes, t, ]); const selectedCount = selectedModels.length; const totalModels = allModels.length; + const hasClaudeCodeDefaultSelected = + !allowAll && selectedModels.includes(CLAUDE_CODE_DEFAULT_MODEL_ID); + const orderedSelectedModels = useMemo(() => { + if (!hasClaudeCodeDefaultSelected) return selectedModels; + return [ + CLAUDE_CODE_DEFAULT_MODEL_ID, + ...selectedModels.filter((modelId) => modelId !== CLAUDE_CODE_DEFAULT_MODEL_ID), + ]; + }, [hasClaudeCodeDefaultSelected, selectedModels]); + const visibleClaudeCodeFamilies = useMemo( + () => + CLAUDE_CODE_DEFAULT_FAMILIES.filter( + (family) => + family.id === "other" || + !blockedClaudeCodeFamilies.includes(family.id as ClaudeCodeBlockableFamilyId) + ), + [blockedClaudeCodeFamilies] + ); return ( -
- {selectedModels.map((modelId) => ( - - - {modelId} - - + + + + {claudeCodeFamiliesExpanded && ( +
+
+ )} +
+ ); + } + + return ( + - close - - - ))} + + {getModelDisplayName(modelId)} + + + + ); + })} )} @@ -2426,7 +2634,7 @@ const PermissionsModal = memo(function PermissionsModal({ }`} title={model.id} > - {model.id} + {getModelDisplayName(model.id)} ); })} diff --git a/src/app/(dashboard)/dashboard/logs/page.tsx b/src/app/(dashboard)/dashboard/logs/page.tsx index da13c5a770..c99aafd955 100644 --- a/src/app/(dashboard)/dashboard/logs/page.tsx +++ b/src/app/(dashboard)/dashboard/logs/page.tsx @@ -12,6 +12,20 @@ const TIME_RANGES = [ { label: "24h", hours: 24 }, ]; +type LogsTranslator = ((key: string, values?: Record) => string) & { + has?: (key: string) => boolean; +}; + +function logsText( + t: LogsTranslator, + key: string, + fallback: string, + values?: Record +) { + if (typeof t.has !== "function" || !t.has(key)) return fallback; + return values ? t(key, values) : t(key); +} + export default function LogsPage() { const [showExport, setShowExport] = useState(false); const [exporting, setExporting] = useState(false); @@ -21,7 +35,7 @@ export default function LogsPage() { const [requestLogKey, setRequestLogKey] = useState(0); const dropdownRef = useRef(null); const requestLoggerRef = useRef(null); - const t = useTranslations("logs"); + const t = useTranslations("logs") as LogsTranslator; useEffect(() => { function handleClickOutside(e: MouseEvent) { @@ -34,7 +48,8 @@ export default function LogsPage() { }, []); // initial id from URL (synchronously on client) so child can open on mount - const initialId = typeof window !== "undefined" ? new URL(window.location.href).searchParams.get("id") : null; + const initialId = + typeof window !== "undefined" ? new URL(window.location.href).searchParams.get("id") : null; async function handleExport(hours: number) { setExporting(true); @@ -64,26 +79,40 @@ export default function LogsPage() { setShowCleanHistory(false); setCleanHistoryStatus(null); try { - const res = await fetch("/api/settings/purge-logs", { method: "POST" }); + const res = await fetch("/api/settings/purge-request-history", { method: "POST" }); const data = await res.json().catch(() => null); if (!res.ok) { - throw new Error(data?.error || "Failed to clean log history."); + throw new Error( + data?.error || logsText(t, "cleanHistoryFailed", "Failed to clean log history.") + ); } const deleted = typeof data?.deleted === "number" ? data.deleted : 0; - const deletedArtifacts = typeof data?.deletedArtifacts === "number" ? data.deletedArtifacts : 0; + const deletedArtifacts = + typeof data?.deletedArtifacts === "number" ? data.deletedArtifacts : 0; + const deletedDetailedLogs = + typeof data?.deletedDetailedLogs === "number" ? data.deletedDetailedLogs : 0; + const successFallback = `Cleaned ${deleted} log entr${deleted === 1 ? "y" : "ies"}, ${deletedArtifacts} artifact${ + deletedArtifacts === 1 ? "" : "s" + }, and ${deletedDetailedLogs} legacy detail row${deletedDetailedLogs === 1 ? "" : "s"}.`; setRequestLogKey((key) => key + 1); setCleanHistoryStatus( - deleted || deletedArtifacts - ? `Cleaned ${deleted} log entr${deleted === 1 ? "y" : "ies"} and ${deletedArtifacts} artifact${ - deletedArtifacts === 1 ? "" : "s" - }.` - : "No expired log history needed cleanup." + deleted || deletedArtifacts || deletedDetailedLogs + ? logsText(t, "cleanHistorySuccess", successFallback, { + deleted, + deletedArtifacts, + deletedDetailedLogs, + }) + : logsText(t, "cleanHistoryEmpty", "No request log history was found.") ); } catch (err) { - console.error("Failed to clean log history", err); - setCleanHistoryStatus(err instanceof Error ? err.message : "Failed to clean log history."); + console.error(logsText(t, "cleanHistoryFailed", "Failed to clean log history."), err); + setCleanHistoryStatus( + err instanceof Error + ? err.message + : logsText(t, "cleanHistoryFailed", "Failed to clean log history.") + ); } finally { setCleaningHistory(false); } @@ -102,8 +131,8 @@ export default function LogsPage() { onClick={() => setShowCleanHistory(true)} disabled={cleaningHistory} className="flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg - border border-red-500/30 bg-red-500/10 text-red-200 hover:bg-red-500/20 - hover:border-red-400/50 transition-all duration-200 + border border-red-500/30 bg-red-500/10 text-red-700 hover:bg-red-500/15 + hover:border-red-500/50 dark:text-red-300 dark:hover:bg-red-500/20 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed" > - Clean history + {logsText(t, "cleanHistoryButton", "Clean history")}
@@ -195,10 +224,14 @@ export default function LogsPage() { isOpen={showCleanHistory} onClose={() => setShowCleanHistory(false)} onConfirm={handleCleanHistory} - title="Clean log history?" - message="This clears expired log history and prunes related artifacts using the current retention policy. The live page will refresh after cleanup." - confirmText="Clean history" - cancelText="Cancel" + title={logsText(t, "cleanHistoryTitle", "Clean log history?")} + message={logsText( + t, + "cleanHistoryMessage", + "This permanently clears all request log rows, legacy detail rows, and local artifact files under DATA_DIR/call_logs. The live page will refresh after cleanup." + )} + confirmText={logsText(t, "cleanHistoryConfirm", "Clean history")} + cancelText={logsText(t, "cleanHistoryCancel", "Cancel")} loading={cleaningHistory} />
diff --git a/src/app/(dashboard)/dashboard/playground/components/StudioConfigPane.tsx b/src/app/(dashboard)/dashboard/playground/components/StudioConfigPane.tsx index 379b6c0256..21ca3082c2 100644 --- a/src/app/(dashboard)/dashboard/playground/components/StudioConfigPane.tsx +++ b/src/app/(dashboard)/dashboard/playground/components/StudioConfigPane.tsx @@ -2,7 +2,7 @@ // src/app/(dashboard)/dashboard/playground/components/StudioConfigPane.tsx -import { useState } from "react"; +import { useEffect, useState } from "react"; import ParamSliders, { type PlaygroundParams } from "./ParamSliders"; import type { PlaygroundEndpoint } from "@/lib/playground/codeExport"; import { endpointToPath } from "@/lib/playground/codeExport"; @@ -10,6 +10,12 @@ import PresetPicker from "./PresetPicker"; import ImprovePromptButton from "./ImprovePromptButton"; import { useProviderOptions } from "@/app/(dashboard)/dashboard/translator/hooks/useProviderOptions"; import { useAvailableModels } from "@/app/(dashboard)/dashboard/translator/hooks/useAvailableModels"; +import { + ANTHROPIC_COMPATIBLE_PREFIX, + CLAUDE_CODE_COMPATIBLE_PREFIX, + OPENAI_COMPATIBLE_PREFIX, +} from "@/shared/constants/providers"; +import { pickDefaultModel, resolveModelFilterKey } from "./modelSelection"; export interface ConfigState { endpoint: PlaygroundEndpoint; @@ -49,18 +55,41 @@ const ENDPOINT_OPTIONS: Array<{ value: PlaygroundEndpoint; label: string }> = [ */ export default function StudioConfigPane({ configState, setConfigState }: StudioConfigPaneProps) { const [collapsed, setCollapsed] = useState(false); - const { provider, setProvider, providerOptions, loading: loadingProviders } = useProviderOptions( - configState.provider ?? "" - ); + const { + provider, + setProvider, + providerOptions, + loading: loadingProviders, + } = useProviderOptions(configState.provider ?? ""); // #3505: filter models by the selected provider's catalog namespace. Compatible providers // emit models under their node prefix (e.g. "myprefix/gpt-4o"), not under the connection id, // so use the option's modelPrefix when present; fall back to the id for built-in providers. const selectedProviderOption = providerOptions.find( (opt: { value: string; modelPrefix?: string }) => opt.value === provider ); - const modelFilterKey = selectedProviderOption?.modelPrefix || provider || undefined; + // #3731: a custom OpenAI/Anthropic-compatible connection emits catalog models under a + // node prefix, NOT under its connection id. When the prefix doesn't resolve, filtering + // by the raw connection id matched nothing and emptied the selector ("NONE shown"). + const isCompatibleConnectionId = + provider.startsWith(OPENAI_COMPATIBLE_PREFIX) || + provider.startsWith(ANTHROPIC_COMPATIBLE_PREFIX) || + provider.startsWith(CLAUDE_CODE_COMPATIBLE_PREFIX); + const modelFilterKey = resolveModelFilterKey( + provider, + selectedProviderOption?.modelPrefix, + isCompatibleConnectionId + ); const { availableModels, loading: loadingModels } = useAvailableModels(modelFilterKey); + // #3731: selecting a provider resets the model to "", and nothing picked a default — + // so the active model stayed empty and the chat failed with "Set a model". Auto-select + // the first available model once the list resolves (mirrors the provider-detail chat). + useEffect(() => { + const next = pickDefaultModel(configState.model, availableModels); + if (next !== null) setConfigState({ ...configState, model: next }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [availableModels, configState.model]); + function update(key: K, value: ConfigState[K]) { setConfigState({ ...configState, [key]: value }); } @@ -196,10 +225,7 @@ export default function StudioConfigPane({ configState, setConfigState }: Studio Parameters - update("params", p)} - /> + update("params", p)} /> diff --git a/src/app/(dashboard)/dashboard/playground/components/modelSelection.ts b/src/app/(dashboard)/dashboard/playground/components/modelSelection.ts new file mode 100644 index 0000000000..c4d74f1c7c --- /dev/null +++ b/src/app/(dashboard)/dashboard/playground/components/modelSelection.ts @@ -0,0 +1,46 @@ +// Playground model-selection helpers (#3731 / #3009). +// +// Two defects made the Playground model selector unusable for custom +// OpenAI-compatible providers: +// A. The model list was filtered by the selected provider's catalog prefix, falling +// back to the raw connection id when no prefix resolved. A custom connection id +// (e.g. "openai-compatible-") matches nothing in the catalog, so the selector +// went empty ("NONE shown"). +// B. Selecting a provider reset the model to "" and nothing ever picked a default, so +// even when the dropdown had options the active model stayed empty and the chat +// failed with "Set a model in the config pane". +// +// These pure helpers encode the fix and are unit-tested directly. + +/** + * Resolve the catalog-namespace key used to filter the model list for a provider. + * - Built-in providers ("openai", "anthropic", …) filter by their id. + * - Compatible providers emit catalog models under a node prefix; when that prefix is + * known we filter by it. + * - A compatible provider WITHOUT a resolved prefix must NOT be filtered by its raw + * connection id (that matches nothing and empties the selector) — return `undefined` + * so the full catalog is shown and a model can still be picked. + */ +export function resolveModelFilterKey( + provider: string, + modelPrefix: string | undefined, + isCompatibleConnectionId: boolean +): string | undefined { + if (modelPrefix) return modelPrefix; + if (isCompatibleConnectionId) return undefined; + return provider || undefined; +} + +/** + * Pick the model that should be auto-selected once the available-model list resolves. + * Returns the model id to set, or `null` when the current selection is already valid + * (or there is nothing to select) so callers can skip a redundant state update. + */ +export function pickDefaultModel( + currentModel: string | undefined, + availableModels: string[] +): string | null { + if (availableModels.length === 0) return null; + if (currentModel && availableModels.includes(currentModel)) return null; + return availableModels[0]; +} diff --git a/src/app/(dashboard)/dashboard/plugins/page.tsx b/src/app/(dashboard)/dashboard/plugins/page.tsx index 57f615e0c8..def30a2153 100644 --- a/src/app/(dashboard)/dashboard/plugins/page.tsx +++ b/src/app/(dashboard)/dashboard/plugins/page.tsx @@ -1,7 +1,7 @@ "use client"; import { useState, useEffect, useCallback } from "react"; -import { Card, Button, EmptyState } from "@/shared/components"; +import { Card, Button, EmptyState, Badge } from "@/shared/components"; import { useNotificationStore } from "@/store/notificationStore"; import { useTranslations } from "next-intl"; @@ -19,6 +19,10 @@ export default function PluginsPage() { const { addNotification } = useNotificationStore(); const t = useTranslations("plugins"); const [plugins, setPlugins] = useState([]); + const [activeTab, setActiveTab] = useState<"installed" | "marketplace">("installed"); + const [marketplacePlugins, setMarketplacePlugins] = useState([]); + const [marketplaceUrl, setMarketplaceUrl] = useState(""); + const [savingUrl, setSavingUrl] = useState(false); const [loading, setLoading] = useState(true); const [scanning, setScanning] = useState(false); @@ -38,7 +42,51 @@ export default function PluginsPage() { useEffect(() => { fetchPlugins(); + fetch("/api/settings") + .then((res) => res.ok ? res.json() : null) + .then((data) => { + if (data?.pluginMarketplaceUrl) setMarketplaceUrl(data.pluginMarketplaceUrl); + }) + .catch(() => {}); }, [fetchPlugins]); + + const fetchMarketplace = useCallback(async () => { + try { + const res = await fetch("/api/plugins/marketplace"); + if (res.ok) { + const data = await res.json(); + setMarketplacePlugins(data.plugins || []); + } + } catch {} + }, []); + + useEffect(() => { + if (activeTab === "marketplace") { + fetchMarketplace(); + } + }, [activeTab, fetchMarketplace]); + + const handleSaveUrl = async () => { + setSavingUrl(true); + try { + const res = await fetch("/api/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ pluginMarketplaceUrl: marketplaceUrl || null }), + }); + if (!res.ok) { + const errData = await res.json().catch(() => null); + addNotification({ type: "error", message: errData?.error || "Failed to save" }); + return; + } + addNotification({ type: "success", message: t("marketplaceUrlSaved") }); + await fetchMarketplace(); + } catch { + addNotification({ type: "error", message: t("saveConfigurationFailed") }); + } finally { + setSavingUrl(false); + } + }; const handleScan = async () => { setScanning(true); @@ -89,56 +137,127 @@ export default function PluginsPage() {

{t("title")}

- +
+ + +
- {plugins.length === 0 ? ( - + {activeTab === "marketplace" && ( + +
+ + setMarketplaceUrl(e.target.value)} + /> +
+ +
+ )} + + {activeTab === "installed" ? ( + <> +
+ +
+ {plugins.length === 0 ? ( + + ) : ( +
+ {plugins.map((plugin) => ( + +
+
+

{plugin.name}

+

+ v{plugin.version} + {plugin.author ? ` by ${plugin.author}` : ""} + {plugin.description ? ` — ${plugin.description}` : ""} +

+
+ {plugin.hooks.map((hook) => ( + + {hook} + + ))} +
+
+
+ + +
+
+
+ ))} +
+ )} + ) : (
- {plugins.map((plugin) => ( - -
-
-

{plugin.name}

-

- v{plugin.version} - {plugin.author ? ` by ${plugin.author}` : ""} - {plugin.description ? ` — ${plugin.description}` : ""} -

-
- {plugin.hooks.map((hook) => ( - - {hook} - - ))} + {marketplacePlugins.length === 0 ? ( +
{t("marketplaceEmpty")}
+ ) : ( + marketplacePlugins.map((plugin) => ( + +
+
+

+ {plugin.name} + {plugin.verified && {t("verified")}} +

+

+ v{plugin.version} by {plugin.author} — {plugin.description} +

+
+ {plugin.tags?.map((tag: string) => ( + + {tag} + + ))} +
+
+
+
-
- - -
-
- - ))} + + )) + )}
)}
diff --git a/src/app/(dashboard)/dashboard/settings/components/FeatureFlagCard.tsx b/src/app/(dashboard)/dashboard/settings/components/FeatureFlagCard.tsx index f758d4b867..3443b6063a 100644 --- a/src/app/(dashboard)/dashboard/settings/components/FeatureFlagCard.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/FeatureFlagCard.tsx @@ -20,23 +20,68 @@ interface FeatureFlagCardProps { const CATEGORY_STYLES: Record< FeatureFlagCardProps["flag"]["category"], - { bg: string; text: string; label: string } + { bg: string; border: string; text: string; label: string } > = { - security: { bg: "bg-red-500/15", text: "text-red-400", label: "Security" }, - network: { bg: "bg-blue-500/15", text: "text-blue-400", label: "Network" }, - policies: { bg: "bg-amber-500/15", text: "text-amber-400", label: "Policies" }, - runtime: { bg: "bg-purple-500/15", text: "text-purple-400", label: "Runtime" }, - cli: { bg: "bg-green-500/15", text: "text-green-400", label: "CLI" }, - health: { bg: "bg-cyan-500/15", text: "text-cyan-400", label: "Health" }, + security: { + bg: "bg-red-50 dark:bg-red-500/15", + border: "border-red-200 dark:border-red-500/20", + text: "text-red-700 dark:text-red-300", + label: "Security", + }, + network: { + bg: "bg-sky-50 dark:bg-blue-500/15", + border: "border-sky-200 dark:border-blue-500/20", + text: "text-sky-700 dark:text-blue-300", + label: "Network", + }, + policies: { + bg: "bg-amber-50 dark:bg-amber-500/15", + border: "border-amber-200 dark:border-amber-500/20", + text: "text-amber-700 dark:text-amber-300", + label: "Policies", + }, + runtime: { + bg: "bg-violet-50 dark:bg-purple-500/15", + border: "border-violet-200 dark:border-purple-500/20", + text: "text-violet-700 dark:text-purple-300", + label: "Runtime", + }, + cli: { + bg: "bg-emerald-50 dark:bg-green-500/15", + border: "border-emerald-200 dark:border-green-500/20", + text: "text-emerald-700 dark:text-green-300", + label: "CLI", + }, + health: { + bg: "bg-cyan-50 dark:bg-cyan-500/15", + border: "border-cyan-200 dark:border-cyan-500/20", + text: "text-cyan-700 dark:text-cyan-300", + label: "Health", + }, }; const SOURCE_STYLES: Record< FeatureFlagCardProps["flag"]["source"], - { bg: string; text: string; label: string } + { bg: string; border: string; text: string; label: string } > = { - db: { bg: "bg-blue-500/20", text: "text-blue-300", label: "DB" }, - env: { bg: "bg-amber-500/20", text: "text-amber-300", label: "ENV" }, - default: { bg: "bg-slate-500/20", text: "text-slate-400", label: "DEF" }, + db: { + bg: "bg-sky-50 dark:bg-blue-500/20", + border: "border-sky-200 dark:border-blue-500/30", + text: "text-sky-700 dark:text-blue-300", + label: "DB", + }, + env: { + bg: "bg-amber-50 dark:bg-amber-500/20", + border: "border-amber-200 dark:border-amber-500/30", + text: "text-amber-700 dark:text-amber-300", + label: "ENV", + }, + default: { + bg: "bg-slate-100 dark:bg-slate-500/20", + border: "border-slate-200 dark:border-slate-500/30", + text: "text-slate-600 dark:text-slate-300", + label: "DEF", + }, }; function isEnabled(value: string): boolean { @@ -46,7 +91,7 @@ function isEnabled(value: string): boolean { function Spinner() { return (