mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
ci(quality): cut PR gate wall time without dropping protection (#6716)
Collapse duplicate CI spend while keeping each gate's existence reason: - quality.yml: TIA __RUN_ALL__ defers full unit to fast-unit 4-shard (#6781); path filters via classify-pr-changes; docs-gates split; draft skip - ci.yml: wire docs/i18n/code path filters; ESLint JSON artifact for quality-gate; drop advisory typecheck:noimplicit; float actions/cache@v6 - TIA parity: memory/usage/combo/serial; **/*.test.mjs any depth; electron/bin no longer force unit __RUN_ALL__ - check:complexity-ratchets: one ESLint walk, ruleId-isolated baselines + cache - check:api-docs-refs + lib/apiRoutes: shared API route inventory - husky pre-push: intentionally light (gates live in pre-commit); CLAUDE.md + QUALITY_GATES.md docs synced - collect-metrics / lint:json: path.resolve cache path; Windows-safe eslint bin - env-doc allowlist for ESLINT_RESULTS_JSON / COMPLEXITY_ESLINT_REPORT - release-green --full-ci expects check:api-docs-refs (not docs-symbols alone) Tests: select-impacted, classify-pr-changes, api-routes lib, complexity-rule-count, validate-release-green. Reconciled after #6781 (fast-unit 2→4 shards) per maintainer request on #6716. Co-authored-by: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com>
This commit is contained in:
142
.github/workflows/ci.yml
vendored
142
.github/workflows/ci.yml
vendored
@@ -38,12 +38,17 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
- uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: ${{ env.CI_NODE_VERSION }}
|
||||||
- id: classify
|
- id: classify
|
||||||
env:
|
env:
|
||||||
EVENT_NAME: ${{ github.event_name }}
|
EVENT_NAME: ${{ github.event_name }}
|
||||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||||
run: |
|
run: |
|
||||||
|
# Single source of truth: scripts/quality/classify-pr-changes.mjs
|
||||||
|
# (unit-tested). Push/dispatch always enable every lane.
|
||||||
if [ "$EVENT_NAME" != "pull_request" ]; then
|
if [ "$EVENT_NAME" != "pull_request" ]; then
|
||||||
{
|
{
|
||||||
echo "code=true"
|
echo "code=true"
|
||||||
@@ -54,51 +59,18 @@ jobs:
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
code=false
|
|
||||||
docs=false
|
|
||||||
i18n=false
|
|
||||||
workflow=false
|
|
||||||
|
|
||||||
git diff --name-only "$BASE_SHA" "$HEAD_SHA" > changed-files.txt
|
git diff --name-only "$BASE_SHA" "$HEAD_SHA" > changed-files.txt
|
||||||
|
node scripts/quality/classify-pr-changes.mjs changed-files.txt >> "$GITHUB_OUTPUT"
|
||||||
while IFS= read -r file; do
|
|
||||||
case "$file" in
|
|
||||||
.github/workflows/*|.zizmor.yml)
|
|
||||||
workflow=true
|
|
||||||
code=true
|
|
||||||
;;
|
|
||||||
docs/*|*.md)
|
|
||||||
docs=true
|
|
||||||
;;
|
|
||||||
src/i18n/*|src/i18n/messages/*|scripts/i18n/*|config/i18n.json)
|
|
||||||
i18n=true
|
|
||||||
code=true
|
|
||||||
;;
|
|
||||||
src/*|open-sse/*|bin/*|electron/*|tests/*|scripts/*|package.json|package-lock.json|tsconfig*.json|next.config.*|vitest*.config.*|playwright.config.*)
|
|
||||||
code=true
|
|
||||||
;;
|
|
||||||
db/*|config/*)
|
|
||||||
code=true
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
code=true
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
done < changed-files.txt
|
|
||||||
|
|
||||||
{
|
|
||||||
echo "code=$code"
|
|
||||||
echo "docs=$docs"
|
|
||||||
echo "i18n=$i18n"
|
|
||||||
echo "workflow=$workflow"
|
|
||||||
} >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
name: Lint
|
name: Lint
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
needs: changes
|
||||||
# P3 (plano mestre): a release-PR viva fica DRAFT o ciclo inteiro — jobs pesados pulam
|
# P3 (plano mestre): a release-PR viva fica DRAFT o ciclo inteiro — jobs pesados pulam
|
||||||
# drafts (ciclo v3.8.44: 123 runs pesados re-disparados por merges na release, 88 cancelados).
|
# drafts (ciclo v3.8.44: 123 runs pesados re-disparados por merges na release, 88 cancelados).
|
||||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }}
|
# Path filter: pure docs / pure message-catalog PRs skip the code lint bag + typecheck.
|
||||||
|
# Existence reason of this job is code regression; docs/i18n have dedicated jobs.
|
||||||
|
if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false && needs.changes.outputs.code == 'true') }}
|
||||||
env:
|
env:
|
||||||
# tsx gates below (known-symbols, route-guard-membership) import modules that
|
# tsx gates below (known-symbols, route-guard-membership) import modules that
|
||||||
# open SQLite on load; provide DB env so a fresh CI DB initializes cleanly.
|
# open SQLite on load; provide DB env so a fresh CI DB initializes cleanly.
|
||||||
@@ -116,7 +88,27 @@ jobs:
|
|||||||
- uses: ./.github/actions/npm-ci-retry
|
- uses: ./.github/actions/npm-ci-retry
|
||||||
- run: npm run check:node-runtime
|
- run: npm run check:node-runtime
|
||||||
- run: npm run audit:deps
|
- run: npm run audit:deps
|
||||||
- run: npm run lint
|
- name: Restore ESLint file cache
|
||||||
|
uses: actions/cache@v6
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
.eslintcache
|
||||||
|
.eslintcache-complexity
|
||||||
|
key: eslint-${{ runner.os }}-${{ hashFiles('eslint.config.mjs', 'eslint.complexity-ratchets.config.mjs', 'config/quality/eslint-suppressions.json', 'package-lock.json') }}
|
||||||
|
restore-keys: |
|
||||||
|
eslint-${{ runner.os }}-
|
||||||
|
# Single ESLint inventory (JSON) — quality-gate reuses the artifact instead of
|
||||||
|
# a second cold full-tree pass for eslintWarnings ratchet counts.
|
||||||
|
- name: ESLint (JSON report)
|
||||||
|
run: npm run lint:json
|
||||||
|
- name: Upload ESLint results
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: eslint-results
|
||||||
|
path: .artifacts/eslint-results.json
|
||||||
|
if-no-files-found: warn
|
||||||
|
retention-days: 7
|
||||||
- run: npm run check:cycles
|
- run: npm run check:cycles
|
||||||
- run: npm run check:route-validation:t06
|
- run: npm run check:route-validation:t06
|
||||||
- run: npm run check:any-budget:t11
|
- run: npm run check:any-budget:t11
|
||||||
@@ -137,16 +129,16 @@ jobs:
|
|||||||
# check:docs-sync is run by the docs-sync-strict job (via check:docs-all) and the
|
# check:docs-sync is run by the docs-sync-strict job (via check:docs-all) and the
|
||||||
# husky pre-commit hook; the standalone copy here was redundant (ROI dedup).
|
# husky pre-commit hook; the standalone copy here was redundant (ROI dedup).
|
||||||
- run: npm run typecheck:core
|
- run: npm run typecheck:core
|
||||||
# typecheck:noimplicit:core is a forward-looking gate (noImplicitAny).
|
# typecheck:noimplicit:core dropped from this job (2026-07 optimize):
|
||||||
# Run informationally for now — many pre-existing call sites still need
|
# it was advisory (continue-on-error) and largely subsumed by the blocking
|
||||||
# explicit annotations; track in a dedicated follow-up.
|
# check:type-coverage ratchet in quality-gate. Local: npm run typecheck:noimplicit:core.
|
||||||
- run: npm run typecheck:noimplicit:core
|
|
||||||
continue-on-error: true
|
|
||||||
|
|
||||||
quality-gate:
|
quality-gate:
|
||||||
name: Quality Ratchet
|
name: Quality Ratchet
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: test-coverage
|
# needs lint so eslint-results artifact is available (same inventory as the
|
||||||
|
# blocking lint step). Allow lint failure so other ratchets still run.
|
||||||
|
needs: [changes, test-coverage, lint]
|
||||||
# Run even when test-coverage was SKIPPED/FAILED (e.g. a single flaky Coverage
|
# Run even when test-coverage was SKIPPED/FAILED (e.g. a single flaky Coverage
|
||||||
# Shard breaks the shard→coverage→ratchet chain). The DETERMINISTIC ratchets
|
# Shard breaks the shard→coverage→ratchet chain). The DETERMINISTIC ratchets
|
||||||
# (eslint / complexity / cognitive-complexity / duplication / codeql) do NOT need
|
# (eslint / complexity / cognitive-complexity / duplication / codeql) do NOT need
|
||||||
@@ -155,7 +147,8 @@ jobs:
|
|||||||
# release PR #4854, where the drift cascade only surfaced post-merge in #5029).
|
# release PR #4854, where the drift cascade only surfaced post-merge in #5029).
|
||||||
# The coverage.* metrics degrade gracefully: the download is continue-on-error and
|
# The coverage.* metrics degrade gracefully: the download is continue-on-error and
|
||||||
# the ratchet runs with --allow-missing, so absent coverage is skipped, not failed.
|
# the ratchet runs with --allow-missing, so absent coverage is skipped, not failed.
|
||||||
if: ${{ !cancelled() && (github.event_name != 'pull_request' || github.event.pull_request.draft == false) }}
|
# Path filter: code-only — pure docs/i18n PRs have nothing for these ratchets to guard.
|
||||||
|
if: ${{ !cancelled() && (github.event_name != 'pull_request' || (github.event.pull_request.draft == false && needs.changes.outputs.code == 'true' && (needs.lint.result == 'success' || needs.lint.result == 'failure'))) }}
|
||||||
# security-events: read lets the CodeQL ratchet read open code-scanning alerts
|
# security-events: read lets the CodeQL ratchet read open code-scanning alerts
|
||||||
# via `gh api .../code-scanning/alerts`. contents: read keeps checkout working.
|
# via `gh api .../code-scanning/alerts`. contents: read keeps checkout working.
|
||||||
permissions:
|
permissions:
|
||||||
@@ -170,6 +163,15 @@ jobs:
|
|||||||
node-version: ${{ env.CI_NODE_VERSION }}
|
node-version: ${{ env.CI_NODE_VERSION }}
|
||||||
cache: npm
|
cache: npm
|
||||||
- uses: ./.github/actions/npm-ci-retry
|
- uses: ./.github/actions/npm-ci-retry
|
||||||
|
- name: Restore ESLint file cache
|
||||||
|
uses: actions/cache@v6
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
.eslintcache
|
||||||
|
.eslintcache-complexity
|
||||||
|
key: eslint-${{ runner.os }}-${{ hashFiles('eslint.config.mjs', 'eslint.complexity-ratchets.config.mjs', 'config/quality/eslint-suppressions.json', 'package-lock.json') }}
|
||||||
|
restore-keys: |
|
||||||
|
eslint-${{ runner.os }}-
|
||||||
# Coverage mergeada (coverage-summary.json) p/ o ratchet de cobertura.
|
# Coverage mergeada (coverage-summary.json) p/ o ratchet de cobertura.
|
||||||
# continue-on-error: o artifact pode não existir se a job test-coverage foi
|
# continue-on-error: o artifact pode não existir se a job test-coverage foi
|
||||||
# SKIPPED (shard flaky). Nesse caso collect-metrics pula coverage.* (ausente sem
|
# SKIPPED (shard flaky). Nesse caso collect-metrics pula coverage.* (ausente sem
|
||||||
@@ -180,6 +182,13 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
name: coverage-report
|
name: coverage-report
|
||||||
path: coverage/
|
path: coverage/
|
||||||
|
# Prefer lint job's ESLint JSON (one inventory, two consumers).
|
||||||
|
- name: Download ESLint results
|
||||||
|
continue-on-error: true
|
||||||
|
uses: actions/download-artifact@v8
|
||||||
|
with:
|
||||||
|
name: eslint-results
|
||||||
|
path: .artifacts/
|
||||||
- run: npm run quality:collect
|
- run: npm run quality:collect
|
||||||
# Catraca: falha se qualquer métrica regredir vs quality-baseline.json (commitado).
|
# Catraca: falha se qualquer métrica regredir vs quality-baseline.json (commitado).
|
||||||
# Hoje: contagem de warnings do ESLint. Fase 4 estende com cobertura (lida do
|
# Hoje: contagem de warnings do ESLint. Fase 4 estende com cobertura (lida do
|
||||||
@@ -201,15 +210,13 @@ jobs:
|
|||||||
# para não pesar no caminho crítico do lint.
|
# para não pesar no caminho crítico do lint.
|
||||||
- name: Duplication ratchet
|
- name: Duplication ratchet
|
||||||
run: npm run check:duplication
|
run: npm run check:duplication
|
||||||
- name: Complexity ratchet
|
# Complexity + cognitive: one ESLint walk, two independent baselines (by ruleId).
|
||||||
run: npm run check:complexity
|
- name: Complexity + cognitive ratchets
|
||||||
# Fase 7 INT: dead-code, cognitive-complexity, type-coverage promovidos de
|
run: npm run check:complexity-ratchets
|
||||||
# advisory (quality-extended) para BLOQUEANTES aqui. Os 3 leem seus baseline
|
# Fase 7 INT: dead-code, type-coverage promovidos de advisory (quality-extended)
|
||||||
# de quality-baseline.json e saem 1 em regressão.
|
# para BLOQUEANTES aqui. cognitive-complexity is folded into the step above.
|
||||||
- name: Dead-code ratchet (knip)
|
- name: Dead-code ratchet (knip)
|
||||||
run: npm run check:dead-code
|
run: npm run check:dead-code
|
||||||
- name: Cognitive complexity ratchet (sonarjs)
|
|
||||||
run: npm run check:cognitive-complexity
|
|
||||||
- name: Type coverage ratchet
|
- name: Type coverage ratchet
|
||||||
run: npm run check:type-coverage
|
run: npm run check:type-coverage
|
||||||
- name: Compression budget ratchet (F2.4 / N4)
|
- name: Compression budget ratchet (F2.4 / N4)
|
||||||
@@ -247,9 +254,11 @@ jobs:
|
|||||||
quality-extended:
|
quality-extended:
|
||||||
name: Quality Gates (Extended)
|
name: Quality Gates (Extended)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
needs: changes
|
||||||
# P3 (plano mestre): a release-PR viva fica DRAFT o ciclo inteiro — jobs pesados pulam
|
# P3 (plano mestre): a release-PR viva fica DRAFT o ciclo inteiro — jobs pesados pulam
|
||||||
# drafts (ciclo v3.8.44: 123 runs pesados re-disparados por merges na release, 88 cancelados).
|
# drafts (ciclo v3.8.44: 123 runs pesados re-disparados por merges na release, 88 cancelados).
|
||||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }}
|
# Path filter: code-only (scanners/ratchets target production surface).
|
||||||
|
if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false && needs.changes.outputs.code == 'true') }}
|
||||||
steps:
|
steps:
|
||||||
# fetch-depth: 0 — the OpenAPI breaking-change gate (oasdiff) reads the base
|
# fetch-depth: 0 — the OpenAPI breaking-change gate (oasdiff) reads the base
|
||||||
# spec via `git show <base_ref>:docs/openapi.yaml`; a shallow clone
|
# spec via `git show <base_ref>:docs/openapi.yaml`; a shallow clone
|
||||||
@@ -357,9 +366,11 @@ jobs:
|
|||||||
docs-sync-strict:
|
docs-sync-strict:
|
||||||
name: Docs Sync (Strict)
|
name: Docs Sync (Strict)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
needs: changes
|
||||||
# P3 (plano mestre): a release-PR viva fica DRAFT o ciclo inteiro — jobs pesados pulam
|
# P3 (plano mestre): a release-PR viva fica DRAFT o ciclo inteiro — jobs pesados pulam
|
||||||
# drafts (ciclo v3.8.44: 123 runs pesados re-disparados por merges na release, 88 cancelados).
|
# drafts (ciclo v3.8.44: 123 runs pesados re-disparados por merges na release, 88 cancelados).
|
||||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }}
|
# Run when docs OR code change: API/route code can break doc/OpenAPI contract gates.
|
||||||
|
if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false && (needs.changes.outputs.docs == 'true' || needs.changes.outputs.code == 'true')) }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
@@ -379,19 +390,20 @@ jobs:
|
|||||||
run: npm run check:openapi-coverage
|
run: npm run check:openapi-coverage
|
||||||
- name: OpenAPI security-tier consistency (advisory)
|
- name: OpenAPI security-tier consistency (advisory)
|
||||||
run: npm run check:openapi-security-tiers
|
run: npm run check:openapi-security-tiers
|
||||||
- name: OpenAPI spec paths resolve to real routes (anti-hallucination)
|
# One FS inventory of src/app/api for both anti-hallucination directions.
|
||||||
run: npm run check:openapi-routes
|
- name: API docs refs (openapi + prose → routes)
|
||||||
- name: Doc /api refs resolve to real routes (anti-hallucination)
|
run: npm run check:api-docs-refs
|
||||||
run: npm run check:docs-symbols
|
|
||||||
- name: i18n translation drift (warn)
|
- name: i18n translation drift (warn)
|
||||||
run: node scripts/i18n/check-translation-drift.mjs --warn
|
run: node scripts/i18n/check-translation-drift.mjs --warn
|
||||||
|
|
||||||
docs-lint:
|
docs-lint:
|
||||||
name: Docs Lint (prose — advisory)
|
name: Docs Lint (prose — advisory)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
needs: changes
|
||||||
# P3 (plano mestre): a release-PR viva fica DRAFT o ciclo inteiro — jobs pesados pulam
|
# P3 (plano mestre): a release-PR viva fica DRAFT o ciclo inteiro — jobs pesados pulam
|
||||||
# drafts (ciclo v3.8.44: 123 runs pesados re-disparados por merges na release, 88 cancelados).
|
# drafts (ciclo v3.8.44: 123 runs pesados re-disparados por merges na release, 88 cancelados).
|
||||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }}
|
# Prose/markdown only — skip when the PR has no doc surface.
|
||||||
|
if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false && needs.changes.outputs.docs == 'true') }}
|
||||||
# Advisory (warning-first): prose/markdown style must not block merges while the
|
# Advisory (warning-first): prose/markdown style must not block merges while the
|
||||||
# existing doc corpus is brought up to style. Promote to blocking once it converges.
|
# existing doc corpus is brought up to style. Promote to blocking once it converges.
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
@@ -418,9 +430,11 @@ jobs:
|
|||||||
i18n-ui-coverage:
|
i18n-ui-coverage:
|
||||||
name: i18n UI Coverage
|
name: i18n UI Coverage
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
needs: changes
|
||||||
# P3 (plano mestre): a release-PR viva fica DRAFT o ciclo inteiro — jobs pesados pulam
|
# P3 (plano mestre): a release-PR viva fica DRAFT o ciclo inteiro — jobs pesados pulam
|
||||||
# drafts (ciclo v3.8.44: 123 runs pesados re-disparados por merges na release, 88 cancelados).
|
# drafts (ciclo v3.8.44: 123 runs pesados re-disparados por merges na release, 88 cancelados).
|
||||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }}
|
# UI keys move with dashboard code OR message catalogs.
|
||||||
|
if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false && (needs.changes.outputs.i18n == 'true' || needs.changes.outputs.code == 'true')) }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
@@ -440,9 +454,11 @@ jobs:
|
|||||||
i18n:
|
i18n:
|
||||||
name: i18n Validation (all languages)
|
name: i18n Validation (all languages)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
needs: changes
|
||||||
# P3 (plano mestre): a release-PR viva fica DRAFT o ciclo inteiro — jobs pesados pulam
|
# P3 (plano mestre): a release-PR viva fica DRAFT o ciclo inteiro — jobs pesados pulam
|
||||||
# drafts (ciclo v3.8.44: 123 runs pesados re-disparados por merges na release, 88 cancelados).
|
# drafts (ciclo v3.8.44: 123 runs pesados re-disparados por merges na release, 88 cancelados).
|
||||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }}
|
# Message-catalog / i18n-tooling only — pure code without i18n surface skips this lane.
|
||||||
|
if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false && needs.changes.outputs.i18n == 'true') }}
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
@@ -961,7 +977,7 @@ jobs:
|
|||||||
- uses: ./.github/actions/npm-ci-retry
|
- uses: ./.github/actions/npm-ci-retry
|
||||||
- run: npm run check:node-runtime
|
- run: npm run check:node-runtime
|
||||||
- name: Cache Playwright browsers
|
- name: Cache Playwright browsers
|
||||||
uses: actions/cache@v6.1.0
|
uses: actions/cache@v6
|
||||||
with:
|
with:
|
||||||
path: ~/.cache/ms-playwright
|
path: ~/.cache/ms-playwright
|
||||||
key: playwright-chromium-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
key: playwright-chromium-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||||
|
|||||||
2
.github/workflows/nightly-release-green.yml
vendored
2
.github/workflows/nightly-release-green.yml
vendored
@@ -142,7 +142,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Upload report artifact
|
- name: Upload report artifact
|
||||||
if: always()
|
if: always()
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: release-green-report
|
name: release-green-report
|
||||||
path: |
|
path: |
|
||||||
|
|||||||
132
.github/workflows/quality.yml
vendored
132
.github/workflows/quality.yml
vendored
@@ -21,8 +21,68 @@ env:
|
|||||||
CI_NODE_VERSION: "24"
|
CI_NODE_VERSION: "24"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
# Same classifier as ci.yml (scripts/quality/classify-pr-changes.mjs) so PR→release
|
||||||
|
# path filters share existence reasons: code / docs / i18n / workflow.
|
||||||
|
changes:
|
||||||
|
name: Change Classification
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
code: ${{ steps.classify.outputs.code }}
|
||||||
|
docs: ${{ steps.classify.outputs.docs }}
|
||||||
|
i18n: ${{ steps.classify.outputs.i18n }}
|
||||||
|
workflow: ${{ steps.classify.outputs.workflow }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
fetch-depth: 0
|
||||||
|
- uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: ${{ env.CI_NODE_VERSION }}
|
||||||
|
- id: classify
|
||||||
|
env:
|
||||||
|
EVENT_NAME: ${{ github.event_name }}
|
||||||
|
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||||
|
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||||
|
run: |
|
||||||
|
if [ "$EVENT_NAME" != "pull_request" ]; then
|
||||||
|
{
|
||||||
|
echo "code=true"
|
||||||
|
echo "docs=true"
|
||||||
|
echo "i18n=true"
|
||||||
|
echo "workflow=true"
|
||||||
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
git diff --name-only "$BASE_SHA" "$HEAD_SHA" > changed-files.txt
|
||||||
|
node scripts/quality/classify-pr-changes.mjs changed-files.txt >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
# Docs/OpenAPI contract gates only — existence reason is doc accuracy + route refs.
|
||||||
|
# Split out of fast-gates so pure-docs PRs skip typecheck/unit while still validating docs.
|
||||||
|
docs-gates:
|
||||||
|
name: Docs Gates (fast-path)
|
||||||
|
needs: changes
|
||||||
|
if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false && (needs.changes.outputs.docs == 'true' || needs.changes.outputs.code == 'true')) }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: ${{ env.CI_NODE_VERSION }}
|
||||||
|
cache: npm
|
||||||
|
- run: npm ci
|
||||||
|
# One walk of src/app/api for openapi-routes + docs-symbols (both still fail independently).
|
||||||
|
- run: npm run check:api-docs-refs
|
||||||
|
- name: Docs accuracy (fabricated-docs + i18n mirrors, strict)
|
||||||
|
run: npm run check:docs-all
|
||||||
|
|
||||||
fast-gates:
|
fast-gates:
|
||||||
name: Fast Quality Gates
|
name: Fast Quality Gates
|
||||||
|
needs: changes
|
||||||
|
# Code surface only — pure docs/i18n PRs skip this bag (docs-gates covers docs).
|
||||||
|
if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false && needs.changes.outputs.code == 'true') }}
|
||||||
# Dynamic runner (same rule as ci.yml): use the self-hosted VPS pool only when the
|
# Dynamic runner (same rule as ci.yml): use the self-hosted VPS pool only when the
|
||||||
# release captain has USE_VPS_RUNNER=true AND this is not a fork PR (own-origin
|
# release captain has USE_VPS_RUNNER=true AND this is not a fork PR (own-origin
|
||||||
# branches only — a fork PR must never execute on the LAN runner). Var unset/false
|
# branches only — a fork PR must never execute on the LAN runner). Var unset/false
|
||||||
@@ -44,12 +104,18 @@ jobs:
|
|||||||
node-version: ${{ env.CI_NODE_VERSION }}
|
node-version: ${{ env.CI_NODE_VERSION }}
|
||||||
cache: npm
|
cache: npm
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
|
- name: Restore ESLint file cache
|
||||||
|
uses: actions/cache@v6
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
.eslintcache
|
||||||
|
.eslintcache-complexity
|
||||||
|
key: eslint-${{ runner.os }}-${{ hashFiles('eslint.config.mjs', 'eslint.complexity-ratchets.config.mjs', 'config/quality/eslint-suppressions.json', 'package-lock.json') }}
|
||||||
|
restore-keys: |
|
||||||
|
eslint-${{ runner.os }}-
|
||||||
- run: npm run check:provider-consistency
|
- run: npm run check:provider-consistency
|
||||||
- run: npm run check:fetch-targets
|
- run: npm run check:fetch-targets
|
||||||
- run: npm run check:openapi-routes
|
# docs-all / openapi-routes / docs-symbols live in docs-gates (path-filtered).
|
||||||
- run: npm run check:docs-symbols
|
|
||||||
- name: Docs accuracy (fabricated-docs + i18n mirrors, strict)
|
|
||||||
run: npm run check:docs-all
|
|
||||||
- run: npm run check:deps
|
- run: npm run check:deps
|
||||||
- run: npm run check:file-size
|
- run: npm run check:file-size
|
||||||
- run: npm run check:error-helper
|
- run: npm run check:error-helper
|
||||||
@@ -72,25 +138,21 @@ jobs:
|
|||||||
# leaking into the npm tarball (v3.8.36: 6 ops bin/*.sh) per-PR instead of only on
|
# leaking into the npm tarball (v3.8.36: 6 ops bin/*.sh) per-PR instead of only on
|
||||||
# the release PR's heavy Package Artifact job.
|
# the release PR's heavy Package Artifact job.
|
||||||
- run: npm run check:pack-policy
|
- run: npm run check:pack-policy
|
||||||
# Complexity + cognitive-complexity ratchets on the fast-path (PR→release) so
|
# Complexity + cognitive-complexity: ONE ESLint walk (both baselines still
|
||||||
# cycle drift is rebaselined PER-PR instead of cascading onto the release PR's
|
# enforced separately by ruleId). Avoids two cold tree walks on fast-path.
|
||||||
# Quality Ratchet (v3.8.36: +30 complexity / +15 cognitive surfaced only post-merge).
|
- run: npm run check:complexity-ratchets
|
||||||
- run: npm run check:complexity
|
|
||||||
- run: npm run check:cognitive-complexity
|
|
||||||
- name: Typecheck (core)
|
- name: Typecheck (core)
|
||||||
run: npm run typecheck:core
|
run: npm run typecheck:core
|
||||||
# TIA: build the impact map at runtime (gitignored, ~21MB) and run only the
|
# TIA: build the impact map at runtime (gitignored, ~21MB) and run only the
|
||||||
# unit tests impacted by this PR's changed files. Fail-safe runs the FULL
|
# unit tests impacted by this PR's changed files. On hub/unmapped changes the
|
||||||
# unit suite on hub/unmapped changes — TIA accelerates, never replaces, the net.
|
# selector returns __RUN_ALL__ — full-suite authority is the parallel
|
||||||
|
# `fast-unit` 4-shard job (test:unit:ci:shard; was 2-shard, #6781), NOT an
|
||||||
|
# unsharded re-run here. Stacking unsharded test:unit:ci on top of fast-unit
|
||||||
|
# doubled wall time (~16 min extra on ubuntu-latest) without extra coverage.
|
||||||
#
|
#
|
||||||
# BLOCKING (flipped 2026-06-17). The pre-existing release unit test-debt that kept
|
# BLOCKING for the *impacted subset* (flipped 2026-06-17). Fail-safe full
|
||||||
# this advisory was cleared: #4030 (16 Zod/registry reds, lossless restore) and
|
# coverage remains required via `Unit Tests fast-path` (fast-unit).
|
||||||
# #4063 (the last red — the LiveWS boot test — root-caused as a real event-loop
|
- name: Impacted unit tests (TIA subset; blocking)
|
||||||
# stall in the WS sidecar, fixed + relocated to the integration suite). A full
|
|
||||||
# ci.yml run on release/v3.8.28 then showed all 8 unit shards green, so PR->release
|
|
||||||
# now blocks on unit-test regressions in the impacted set (typecheck:core already
|
|
||||||
# blocked above). Fail-safe still runs the FULL unit suite on hub/unmapped changes.
|
|
||||||
- name: Impacted unit tests (TIA, fail-safe full; blocking)
|
|
||||||
env:
|
env:
|
||||||
GITHUB_BASE_REF: ${{ github.base_ref }}
|
GITHUB_BASE_REF: ${{ github.base_ref }}
|
||||||
run: |
|
run: |
|
||||||
@@ -104,7 +166,9 @@ jobs:
|
|||||||
# which must not happen on a blocking gate. DATA_DIR isolation keeps the parallel
|
# which must not happen on a blocking gate. DATA_DIR isolation keeps the parallel
|
||||||
# run race-free regardless of concurrency.
|
# run race-free regardless of concurrency.
|
||||||
if echo "$SEL" | grep -q "__RUN_ALL__"; then
|
if echo "$SEL" | grep -q "__RUN_ALL__"; then
|
||||||
echo "Fail-safe: running FULL unit suite (CI concurrency)"; npm run test:unit:ci; exit $?
|
echo "Fail-safe: __RUN_ALL__ — deferring FULL unit suite to fast-unit (4-shard)."
|
||||||
|
echo "Not re-running unsharded test:unit:ci here (duplicate of fast-unit coverage)."
|
||||||
|
exit 0
|
||||||
fi
|
fi
|
||||||
echo "Running impacted tests:"; echo "$SEL"
|
echo "Running impacted tests:"; echo "$SEL"
|
||||||
mapfile -t FILES <<< "$SEL"
|
mapfile -t FILES <<< "$SEL"
|
||||||
@@ -131,6 +195,8 @@ jobs:
|
|||||||
|
|
||||||
fast-vitest:
|
fast-vitest:
|
||||||
name: Vitest (fast-path)
|
name: Vitest (fast-path)
|
||||||
|
needs: changes
|
||||||
|
if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false && needs.changes.outputs.code == 'true') }}
|
||||||
# Dynamic runner — see fast-gates (own-origin + flag; fork/unset → ubuntu-latest).
|
# Dynamic runner — see fast-gates (own-origin + flag; fork/unset → ubuntu-latest).
|
||||||
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)) && fromJSON('["self-hosted","omni-release"]') || 'ubuntu-latest' }}
|
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)) && fromJSON('["self-hosted","omni-release"]') || 'ubuntu-latest' }}
|
||||||
env:
|
env:
|
||||||
@@ -150,11 +216,13 @@ jobs:
|
|||||||
|
|
||||||
fast-unit:
|
fast-unit:
|
||||||
name: Unit Tests fast-path (${{ matrix.shard }}/4)
|
name: Unit Tests fast-path (${{ matrix.shard }}/4)
|
||||||
|
needs: changes
|
||||||
|
if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false && needs.changes.outputs.code == 'true') }}
|
||||||
# Dynamic runner — see fast-gates (own-origin + flag; fork/unset → ubuntu-latest).
|
# Dynamic runner — see fast-gates (own-origin + flag; fork/unset → ubuntu-latest).
|
||||||
# This is the heaviest fast-path job; 4-way sharding (was 2) halves the critical
|
# This is the heaviest fast-path job; 4-way sharding (was 2, #6781) halves the
|
||||||
# path again (~8.5min → ~4.5min on ubuntu-latest; ~2min on the 8-slot runner box).
|
# critical path again (~8.5min → ~4.5min on ubuntu-latest; ~2min on the 8-slot
|
||||||
# Node's native --test-shard=N/total takes any denominator — only this matrix and
|
# runner box). Node's native --test-shard=N/total takes any denominator — only
|
||||||
# the TEST_SHARD env below encode the shard count.
|
# this matrix and the TEST_SHARD env below encode the shard count.
|
||||||
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)) && fromJSON('["self-hosted","omni-release"]') || 'ubuntu-latest' }}
|
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)) && fromJSON('["self-hosted","omni-release"]') || 'ubuntu-latest' }}
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
@@ -194,6 +262,8 @@ jobs:
|
|||||||
# contribuidor NUNCA é bloqueado nem cobrado).
|
# contribuidor NUNCA é bloqueado nem cobrado).
|
||||||
lint-guard:
|
lint-guard:
|
||||||
name: No new ESLint warnings
|
name: No new ESLint warnings
|
||||||
|
needs: changes
|
||||||
|
if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false && needs.changes.outputs.code == 'true') }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
continue-on-error: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true }}
|
continue-on-error: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true }}
|
||||||
steps:
|
steps:
|
||||||
@@ -205,8 +275,18 @@ jobs:
|
|||||||
node-version: ${{ env.CI_NODE_VERSION }}
|
node-version: ${{ env.CI_NODE_VERSION }}
|
||||||
cache: npm
|
cache: npm
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
|
- name: Restore ESLint file cache
|
||||||
|
uses: actions/cache@v6
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
.eslintcache
|
||||||
|
.eslintcache-complexity
|
||||||
|
key: eslint-${{ runner.os }}-${{ hashFiles('eslint.config.mjs', 'eslint.complexity-ratchets.config.mjs', 'config/quality/eslint-suppressions.json', 'package-lock.json') }}
|
||||||
|
restore-keys: |
|
||||||
|
eslint-${{ runner.os }}-
|
||||||
- name: ESLint (baseline congelado — warning novo = vermelho)
|
- name: ESLint (baseline congelado — warning novo = vermelho)
|
||||||
run: npx eslint . --suppressions-location config/quality/eslint-suppressions.json --max-warnings 0
|
# lint:json writes the report; --max-warnings 0 keeps no-new-warnings policy.
|
||||||
|
run: npm run lint:json -- --max-warnings 0
|
||||||
|
|
||||||
# Merge-integrity: pega no PR os dois vazamentos crônicos de merge que hoje só
|
# Merge-integrity: pega no PR os dois vazamentos crônicos de merge que hoje só
|
||||||
# explodem na release-PR. (1) CHANGELOG-eat — o auto-resolve do merge come
|
# explodem na release-PR. (1) CHANGELOG-eat — o auto-resolve do merge come
|
||||||
@@ -221,6 +301,8 @@ jobs:
|
|||||||
# nunca é bloqueado.
|
# nunca é bloqueado.
|
||||||
merge-integrity:
|
merge-integrity:
|
||||||
name: Merge integrity (changelog + generated skills)
|
name: Merge integrity (changelog + generated skills)
|
||||||
|
# Always on non-draft PRs — CHANGELOG/skills can break on docs-only merges too.
|
||||||
|
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
continue-on-error: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true }}
|
continue-on-error: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true }}
|
||||||
env:
|
env:
|
||||||
|
|||||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -234,3 +234,11 @@ omniroute.md
|
|||||||
mise.toml
|
mise.toml
|
||||||
_artifacts/
|
_artifacts/
|
||||||
.claude-flow/
|
.claude-flow/
|
||||||
|
|
||||||
|
# ESLint file cache (npm run lint --cache / complexity ratchets)
|
||||||
|
.eslintcache
|
||||||
|
.eslintcache-complexity
|
||||||
|
|
||||||
|
|
||||||
|
# CI/local quality artifacts (eslint-results.json, etc.)
|
||||||
|
.artifacts/
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
#!/usr/bin/env sh
|
#!/usr/bin/env sh
|
||||||
# .husky/pre-push — fast deterministic gates (<10s total)
|
# .husky/pre-push — intentionally light.
|
||||||
# Intentionally excludes test:unit (slow; covered by CI pre-push remote run).
|
# any-budget + tracked-artifacts already run on pre-commit; re-running them on
|
||||||
# Activated: 2026-06-13 (6A.12 — replaced commented-out test:unit stub)
|
# every push only doubles local wall time for the same existence reason (CI still
|
||||||
|
# enforces both). Keep this hook as a PATH/npm sanity check + reminder.
|
||||||
|
# Intentionally excludes test:unit / typecheck (slow; covered by CI).
|
||||||
|
|
||||||
if ! command -v npm >/dev/null 2>&1; then
|
if ! command -v npm >/dev/null 2>&1; then
|
||||||
echo "⚠️ npm not found in PATH — skipping pre-push hooks"
|
echo "⚠️ npm not found in PATH — skipping pre-push hooks"
|
||||||
@@ -9,4 +11,5 @@ if ! command -v npm >/dev/null 2>&1; then
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
npm run check:any-budget:t11 && npm run check:tracked-artifacts
|
# No-op success: real local gates live in pre-commit; CI owns the rest.
|
||||||
|
exit 0
|
||||||
|
|||||||
@@ -428,8 +428,10 @@ git push -u origin feat/your-feature
|
|||||||
|
|
||||||
**Husky hooks**:
|
**Husky hooks**:
|
||||||
|
|
||||||
- **pre-commit**: lint-staged + `check-docs-sync` + `check:any-budget:t11`
|
- **pre-commit**: lint-staged + `check-docs-sync` + `check:any-budget:t11` + `check:tracked-artifacts`
|
||||||
- **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).
|
- **pre-push**: intentionally light (PATH/npm sanity only). `any-budget` + `tracked-artifacts`
|
||||||
|
already run on pre-commit; re-running them on every push was pure double-pay. CI still
|
||||||
|
enforces both. (Was Fase 6A.12 full pre-push gate; folded into pre-commit in #6716.)
|
||||||
|
|
||||||
### Worktree isolation (MANDATORY for every development task)
|
### Worktree isolation (MANDATORY for every development task)
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ Runs on every PR to `main`. Blocks merge on failure.
|
|||||||
| `audit:deps` | `npm audit` (root + electron) — no high/critical advisories (overlaps osv `check:vuln-ratchet`; see Rationalization Backlog) | Yes |
|
| `audit:deps` | `npm audit` (root + electron) — no high/critical advisories (overlaps osv `check:vuln-ratchet`; see Rationalization Backlog) | Yes |
|
||||||
| `check:lockfile` | `package-lock.json` integrity — https registry, integrity hashes, no host overrides | Yes |
|
| `check:lockfile` | `package-lock.json` integrity — https registry, integrity hashes, no host overrides | Yes |
|
||||||
| `check:licenses` | SPDX license allowlist for production dependencies | Yes |
|
| `check:licenses` | SPDX license allowlist for production dependencies | Yes |
|
||||||
| `check:tracked-artifacts` | No build artifacts / committed `node_modules` symlinks (also runs in husky pre-push) | Yes |
|
| `check:tracked-artifacts` | No build artifacts / committed `node_modules` symlinks (also runs in husky pre-commit; pre-push is intentionally light — #6716) | Yes |
|
||||||
| `check:file-size` | No source file exceeds the per-extension cap (ratchet: frozen large files in `frozen` list) | 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: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:migration-numbering` | Migration SQL files are sequentially numbered, no gaps or duplicates | Yes |
|
||||||
@@ -259,9 +259,10 @@ several "obvious" merges turned out to hide debt and are **not** clean drop-ins.
|
|||||||
- **`check:docs-sync` runs twice** — standalone in the `lint` job and again inside `check:docs-all` (`docs-sync-strict`) and the husky pre-commit hook. ✅ **DONE** — standalone `lint` invocation removed.
|
- **`check:docs-sync` runs twice** — standalone in the `lint` job and again inside `check:docs-all` (`docs-sync-strict`) and the husky pre-commit hook. ✅ **DONE** — standalone `lint` invocation removed.
|
||||||
- **CVE scanning** — ❌ **NOT a clean merge.** `audit:deps` hard-fails on any high/critical CVE; `check:vuln-ratchet` (osv) only fails on a _regression_ vs baseline (currently 1 MODERATE). Different semantics — dropping `audit:deps` would lose the absolute high/critical gate. Keep both.
|
- **CVE scanning** — ❌ **NOT a clean merge.** `audit:deps` hard-fails on any high/critical CVE; `check:vuln-ratchet` (osv) only fails on a _regression_ vs baseline (currently 1 MODERATE). Different semantics — dropping `audit:deps` would lose the absolute high/critical gate. Keep both.
|
||||||
- **Cycle detection** — ❌ **NOT a clean merge.** `check:circular-deps` (dpdm) reports **91 cycles** (that is why it is advisory); it cannot be promoted to blocking without first resolving them, and it has a broader scope than the green, curated `check:cycles`. Keep `check:cycles` blocking; resolving the 91 dpdm cycles is its own backlog.
|
- **Cycle detection** — ❌ **NOT a clean merge.** `check:circular-deps` (dpdm) reports **91 cycles** (that is why it is advisory); it cannot be promoted to blocking without first resolving them, and it has a broader scope than the green, curated `check:cycles`. Keep `check:cycles` blocking; resolving the 91 dpdm cycles is its own backlog.
|
||||||
- **Complexity** — ⏳ valid but real surgery. `check:complexity` (core ESLint) + `check:cognitive-complexity` (sonarjs) are two ESLint passes over `src` + `open-sse`; merging into one config emitting both metrics needs careful ratchet re-wiring. Deferred.
|
- **Complexity** — ✅ **DONE** (`check:complexity-ratchets` / `eslint.complexity-ratchets.config.mjs`): one ESLint walk, counts by ruleId so cyclomatic+max-lines and cognitive baselines stay independent; individual `check:complexity` / `check:cognitive-complexity` remain for local `--update`.
|
||||||
- **`/api` anti-hallucination** — ⏳ valid but script surgery. `check:openapi-routes` (spec→route) + `check:docs-symbols` (prose→route) share resolution logic; collapsing them is a non-trivial script change. Deferred.
|
- **`/api` anti-hallucination** — ✅ **DONE** (`check:api-docs-refs` + `scripts/check/lib/apiRoutes.mjs`): one FS inventory of `src/app/api`, openapi-routes + docs-symbols still report independently; individuals remain for local runs.
|
||||||
- **`check:node-runtime` runs in 11 jobs** — ⚠️ **low ROI.** Each is a separate runner and the check is <1s; total savings ~10s, against losing a cheap per-job guard. Not worth the churn.
|
- **`check:node-runtime` runs in 11 jobs** — ⚠️ **low ROI.** Each is a separate runner and the check is <1s; total savings ~10s, against losing a cheap per-job guard. Not worth the churn.
|
||||||
|
- **`typecheck:noimplicit:core` on CI lint** — ✅ **removed from lint job** (was advisory `continue-on-error`); blocking type surface is `typecheck:core` + `check:type-coverage`. Local script retained.
|
||||||
|
|
||||||
### Flip / decide (operator policy)
|
### Flip / decide (operator policy)
|
||||||
|
|
||||||
|
|||||||
76
eslint.complexity-ratchets.config.mjs
Normal file
76
eslint.complexity-ratchets.config.mjs
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
// eslint.complexity-ratchets.config.mjs
|
||||||
|
// STANDALONE flat config for BOTH complexity ratchets in ONE ESLint walk:
|
||||||
|
// - ESLint core: complexity + max-lines-per-function (src, open-sse, electron, bin)
|
||||||
|
// - sonarjs/cognitive-complexity (src, open-sse only)
|
||||||
|
//
|
||||||
|
// Existence reason: two independent baselines (complexity-baseline.json +
|
||||||
|
// quality-baseline metrics.cognitiveComplexity) must stay isolatable from the
|
||||||
|
// main lint warning budget — but they do NOT need two cold tree walks.
|
||||||
|
//
|
||||||
|
// Counts are taken by ruleId in the check scripts (never file.errorCount), so
|
||||||
|
// cognitive violations cannot inflate the cyclomatic/max-lines ratchet.
|
||||||
|
import tseslint from "typescript-eslint";
|
||||||
|
import sonarjs from "eslint-plugin-sonarjs";
|
||||||
|
|
||||||
|
const SHARED_LANGUAGE = {
|
||||||
|
parser: tseslint.parser,
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: 2022,
|
||||||
|
sourceType: "module",
|
||||||
|
ecmaFeatures: { jsx: true },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const SHARED_LINTER = {
|
||||||
|
noInlineConfig: true,
|
||||||
|
reportUnusedDisableDirectives: "off",
|
||||||
|
};
|
||||||
|
|
||||||
|
const SHARED_IGNORES = {
|
||||||
|
ignores: [
|
||||||
|
"**/*.test.ts",
|
||||||
|
"**/*.test.tsx",
|
||||||
|
"**/__tests__/**",
|
||||||
|
"**/*.d.ts",
|
||||||
|
"node_modules/**",
|
||||||
|
"electron/node_modules/**",
|
||||||
|
"electron/dist-electron/**",
|
||||||
|
".next/**",
|
||||||
|
".build/**",
|
||||||
|
"dist/**",
|
||||||
|
"coverage/**",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** @type {import("eslint").Linter.Config[]} */
|
||||||
|
const config = [
|
||||||
|
{
|
||||||
|
files: [
|
||||||
|
"src/**/*.{ts,tsx}",
|
||||||
|
"open-sse/**/*.{ts,tsx}",
|
||||||
|
"electron/**/*.{ts,tsx}",
|
||||||
|
"bin/**/*.{ts,tsx}",
|
||||||
|
],
|
||||||
|
languageOptions: SHARED_LANGUAGE,
|
||||||
|
linterOptions: SHARED_LINTER,
|
||||||
|
rules: {
|
||||||
|
complexity: ["error", 15],
|
||||||
|
"max-lines-per-function": [
|
||||||
|
"error",
|
||||||
|
{ max: 80, skipBlankLines: true, skipComments: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ["src/**/*.{ts,tsx}", "open-sse/**/*.{ts,tsx}"],
|
||||||
|
languageOptions: SHARED_LANGUAGE,
|
||||||
|
plugins: { sonarjs },
|
||||||
|
linterOptions: SHARED_LINTER,
|
||||||
|
rules: {
|
||||||
|
"sonarjs/cognitive-complexity": ["error", 15],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
SHARED_IGNORES,
|
||||||
|
];
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -87,7 +87,8 @@
|
|||||||
"build:release": "rm -rf .build dist && OMNIROUTE_BUILD_SHA=$(git rev-parse --short HEAD) npm run build && npm run build:cli && node scripts/build/write-build-sha.mjs",
|
"build:release": "rm -rf .build dist && OMNIROUTE_BUILD_SHA=$(git rev-parse --short HEAD) npm run build && npm run build:cli && node scripts/build/write-build-sha.mjs",
|
||||||
"build:native:tproxy": "cd src/mitm/tproxy/native && npx --yes node-gyp rebuild",
|
"build:native:tproxy": "cd src/mitm/tproxy/native && npx --yes node-gyp rebuild",
|
||||||
"start": "node scripts/dev/run-next.mjs start",
|
"start": "node scripts/dev/run-next.mjs start",
|
||||||
"lint": "eslint . --suppressions-location config/quality/eslint-suppressions.json",
|
"lint": "eslint . --cache --cache-location .eslintcache --suppressions-location config/quality/eslint-suppressions.json",
|
||||||
|
"lint:json": "node scripts/quality/run-eslint-json.mjs",
|
||||||
"lint:md": "npx --yes markdownlint-cli2 \"docs/**/*.md\" \"*.md\" \"!docs/i18n\" \"!docs/research\"",
|
"lint:md": "npx --yes markdownlint-cli2 \"docs/**/*.md\" \"*.md\" \"!docs/i18n\" \"!docs/research\"",
|
||||||
"lint:prose": "vale docs",
|
"lint:prose": "vale docs",
|
||||||
"electron:dev": "concurrently \"npm run dev\" \"wait-on http://localhost:20128 && cd electron && npm run dev\"",
|
"electron:dev": "concurrently \"npm run dev\" \"wait-on http://localhost:20128 && cd electron && npm run dev\"",
|
||||||
@@ -136,6 +137,7 @@
|
|||||||
"check:provider-assets": "node scripts/check/check-provider-assets.mjs",
|
"check:provider-assets": "node scripts/check/check-provider-assets.mjs",
|
||||||
"check:fetch-targets": "node scripts/check/check-fetch-targets.mjs",
|
"check:fetch-targets": "node scripts/check/check-fetch-targets.mjs",
|
||||||
"check:openapi-routes": "node scripts/check/check-openapi-routes.mjs",
|
"check:openapi-routes": "node scripts/check/check-openapi-routes.mjs",
|
||||||
|
"check:api-docs-refs": "node scripts/check/check-api-docs-refs.mjs",
|
||||||
"check:openapi-breaking": "node scripts/check/check-openapi-breaking.mjs",
|
"check:openapi-breaking": "node scripts/check/check-openapi-breaking.mjs",
|
||||||
"check:deps": "node scripts/check/check-deps.mjs",
|
"check:deps": "node scripts/check/check-deps.mjs",
|
||||||
"check:file-size": "node scripts/check/check-file-size.mjs",
|
"check:file-size": "node scripts/check/check-file-size.mjs",
|
||||||
@@ -159,6 +161,7 @@
|
|||||||
"check:complexity": "node scripts/check/check-complexity.mjs",
|
"check:complexity": "node scripts/check/check-complexity.mjs",
|
||||||
"check:dead-code": "node scripts/check/check-dead-code.mjs",
|
"check:dead-code": "node scripts/check/check-dead-code.mjs",
|
||||||
"check:cognitive-complexity": "node scripts/check/check-cognitive-complexity.mjs",
|
"check:cognitive-complexity": "node scripts/check/check-cognitive-complexity.mjs",
|
||||||
|
"check:complexity-ratchets": "node scripts/check/check-complexity-ratchets.mjs",
|
||||||
"check:release-green": "node scripts/quality/validate-release-green.mjs",
|
"check:release-green": "node scripts/quality/validate-release-green.mjs",
|
||||||
"check:type-coverage": "node scripts/check/check-type-coverage.mjs",
|
"check:type-coverage": "node scripts/check/check-type-coverage.mjs",
|
||||||
"check:lockfile": "node scripts/check/check-lockfile.mjs",
|
"check:lockfile": "node scripts/check/check-lockfile.mjs",
|
||||||
|
|||||||
32
scripts/check/check-api-docs-refs.mjs
Normal file
32
scripts/check/check-api-docs-refs.mjs
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Combined anti-hallucination gate: OpenAPI paths + docs prose /api refs.
|
||||||
|
*
|
||||||
|
* Existence reasons (both still enforced):
|
||||||
|
* - openapi-routes: invented/obsolete paths in docs/openapi.yaml
|
||||||
|
* - docs-symbols: invented/obsolete /api paths in docs markdown *
|
||||||
|
* Shared walk of src/app/api (lib/apiRoutes.mjs) — one filesystem inventory,
|
||||||
|
* two independent failure messages. Prefer this on docs-gates CI; individual
|
||||||
|
* scripts remain for targeted local runs.
|
||||||
|
*/
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
import { collectApiRouteFiles, collectApiRouteUrlPaths } from "./lib/apiRoutes.mjs";
|
||||||
|
import { runOpenapiRoutesCheck } from "./check-openapi-routes.mjs";
|
||||||
|
import { runDocsSymbolsCheck } from "./check-docs-symbols.mjs";
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const implPaths = collectApiRouteUrlPaths();
|
||||||
|
const routeFiles = collectApiRouteFiles();
|
||||||
|
|
||||||
|
const openapi = runOpenapiRoutesCheck({ implPaths });
|
||||||
|
const docs = runDocsSymbolsCheck({ routeFiles });
|
||||||
|
|
||||||
|
if (openapi.ok) console.log(openapi.message);
|
||||||
|
else console.error(openapi.message);
|
||||||
|
if (docs.ok) console.log(docs.message);
|
||||||
|
else console.error(docs.message);
|
||||||
|
|
||||||
|
process.exit(openapi.ok && docs.ok ? 0 : 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();
|
||||||
@@ -1,32 +1,21 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
// scripts/check/check-cognitive-complexity.mjs
|
// scripts/check/check-cognitive-complexity.mjs
|
||||||
// Ratchet bloqueante para complexidade cognitiva (sonarjs/cognitive-complexity).
|
// Ratchet bloqueante para complexidade cognitiva (sonarjs/cognitive-complexity).
|
||||||
// Fase 7 INT: promovido de ADVISORY para RATCHET.
|
// Shares ESLint walk with check-complexity via complexityEslintReport.mjs.
|
||||||
//
|
|
||||||
// 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.
|
|
||||||
//
|
|
||||||
// Lê o baseline de quality-baseline.json (metrics.cognitiveComplexity).
|
|
||||||
// Falha com exit 1 se a contagem SUBIR. Suporta --update.
|
|
||||||
//
|
|
||||||
// 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
|
|
||||||
// node scripts/check/check-cognitive-complexity.mjs --update # ratcheta baseline se melhorou
|
|
||||||
import { execFileSync } from "node:child_process";
|
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { pathToFileURL } from "node:url";
|
import { pathToFileURL } from "node:url";
|
||||||
|
import {
|
||||||
|
countCognitiveViolations,
|
||||||
|
getComplexityEslintReport,
|
||||||
|
} from "./complexityEslintReport.mjs";
|
||||||
|
|
||||||
|
// Re-export for existing unit tests.
|
||||||
|
export { countCognitiveViolations };
|
||||||
|
|
||||||
const ROOT = process.cwd();
|
const ROOT = process.cwd();
|
||||||
const QUIET = process.argv.includes("--quiet");
|
const QUIET = process.argv.includes("--quiet");
|
||||||
const UPDATE = process.argv.includes("--update");
|
const UPDATE = process.argv.includes("--update");
|
||||||
const CONFIG_PATH = path.join(ROOT, "eslint.sonarjs.config.mjs");
|
|
||||||
|
|
||||||
const ESLINT_BIN = path.join(ROOT, "node_modules", ".bin", "eslint");
|
|
||||||
|
|
||||||
const BASELINE_PATH = path.resolve(
|
const BASELINE_PATH = path.resolve(
|
||||||
process.argv.includes("--baseline")
|
process.argv.includes("--baseline")
|
||||||
@@ -34,43 +23,8 @@ const BASELINE_PATH = path.resolve(
|
|||||||
: path.join(ROOT, "config/quality/quality-baseline.json")
|
: path.join(ROOT, "config/quality/quality-baseline.json")
|
||||||
);
|
);
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Avalia a contagem atual de violações cognitivas contra o baseline.
|
* Avalia a contagem atual de violações cognitivas contra o baseline.
|
||||||
* Direction: down (contagem só pode CAIR).
|
|
||||||
*
|
|
||||||
* Exported for unit testing.
|
|
||||||
*
|
|
||||||
* @param {number} current
|
* @param {number} current
|
||||||
* @param {number} baseline
|
* @param {number} baseline
|
||||||
* @returns {{ regressed: boolean, improved: boolean }}
|
* @returns {{ regressed: boolean, improved: boolean }}
|
||||||
@@ -82,22 +36,6 @@ export function evaluateCognitiveComplexity(current, baseline) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
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() {
|
function main() {
|
||||||
if (!fs.existsSync(BASELINE_PATH)) {
|
if (!fs.existsSync(BASELINE_PATH)) {
|
||||||
process.stderr.write(
|
process.stderr.write(
|
||||||
@@ -116,10 +54,9 @@ function main() {
|
|||||||
}
|
}
|
||||||
const baselineValue = baselineMetric.value;
|
const baselineValue = baselineMetric.value;
|
||||||
|
|
||||||
const report = runEslint();
|
const report = getComplexityEslintReport();
|
||||||
const count = countCognitiveViolations(report);
|
const count = countCognitiveViolations(report);
|
||||||
|
|
||||||
// Canonical machine-readable output consumed by collect-metrics.mjs and shell scripts.
|
|
||||||
console.log(`cognitiveComplexity=${count}`);
|
console.log(`cognitiveComplexity=${count}`);
|
||||||
|
|
||||||
if (!QUIET) {
|
if (!QUIET) {
|
||||||
|
|||||||
105
scripts/check/check-complexity-ratchets.mjs
Normal file
105
scripts/check/check-complexity-ratchets.mjs
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* One ESLint walk → both complexity ratchets.
|
||||||
|
*
|
||||||
|
* Existence reasons (unchanged):
|
||||||
|
* - cyclomatic + max-lines vs complexity-baseline.json
|
||||||
|
* - cognitive-complexity vs quality-baseline metrics.cognitiveComplexity
|
||||||
|
*
|
||||||
|
* CI should call this instead of sequential check:complexity + check:cognitive
|
||||||
|
* so PR→release / quality-gate pay for one tree walk, not two.
|
||||||
|
*/
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
import { evaluateComplexity } from "./check-complexity.mjs";
|
||||||
|
import { evaluateCognitiveComplexity } from "./check-cognitive-complexity.mjs";
|
||||||
|
import {
|
||||||
|
countCognitiveViolations,
|
||||||
|
countComplexityViolations,
|
||||||
|
getComplexityEslintReport,
|
||||||
|
} from "./complexityEslintReport.mjs";
|
||||||
|
|
||||||
|
const ROOT = process.cwd();
|
||||||
|
const UPDATE = process.argv.includes("--update");
|
||||||
|
|
||||||
|
const COMPLEXITY_BASELINE = path.resolve(
|
||||||
|
process.argv.includes("--baseline")
|
||||||
|
? process.argv[process.argv.indexOf("--baseline") + 1]
|
||||||
|
: path.join(ROOT, "config/quality/complexity-baseline.json")
|
||||||
|
);
|
||||||
|
const QUALITY_BASELINE = path.join(ROOT, "config/quality/quality-baseline.json");
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
if (!fs.existsSync(COMPLEXITY_BASELINE)) {
|
||||||
|
console.error(`[complexity-ratchets] FAIL — complexity-baseline.json ausente.`);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
if (!fs.existsSync(QUALITY_BASELINE)) {
|
||||||
|
console.error(`[complexity-ratchets] FAIL — quality-baseline.json ausente.`);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const report = getComplexityEslintReport();
|
||||||
|
const complexityCount = countComplexityViolations(report);
|
||||||
|
const cognitiveCount = countCognitiveViolations(report);
|
||||||
|
|
||||||
|
// Machine-readable lines for collect-metrics / scripts
|
||||||
|
console.log(`complexity=${complexityCount}`);
|
||||||
|
console.log(`cognitiveComplexity=${cognitiveCount}`);
|
||||||
|
|
||||||
|
const complexityBaseline = JSON.parse(fs.readFileSync(COMPLEXITY_BASELINE, "utf8"));
|
||||||
|
const qualityBaseline = JSON.parse(fs.readFileSync(QUALITY_BASELINE, "utf8"));
|
||||||
|
const cognitiveMetric = qualityBaseline.metrics?.cognitiveComplexity;
|
||||||
|
if (!cognitiveMetric || typeof cognitiveMetric.value !== "number") {
|
||||||
|
console.error(
|
||||||
|
"[complexity-ratchets] FAIL — metrics.cognitiveComplexity ausente em quality-baseline.json."
|
||||||
|
);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cyc = evaluateComplexity(complexityCount, complexityBaseline.count);
|
||||||
|
const cog = evaluateCognitiveComplexity(cognitiveCount, cognitiveMetric.value);
|
||||||
|
|
||||||
|
if (UPDATE && cyc.improved) {
|
||||||
|
console.log(
|
||||||
|
`[complexity] baseline ratcheado: ${complexityCount} (era ${complexityBaseline.count})`
|
||||||
|
);
|
||||||
|
complexityBaseline.count = complexityCount;
|
||||||
|
fs.writeFileSync(COMPLEXITY_BASELINE, JSON.stringify(complexityBaseline, null, 2) + "\n");
|
||||||
|
}
|
||||||
|
if (UPDATE && cog.improved) {
|
||||||
|
console.log(
|
||||||
|
`[cognitive-complexity] baseline ratcheado: ${cognitiveCount} (era ${cognitiveMetric.value})`
|
||||||
|
);
|
||||||
|
qualityBaseline.metrics.cognitiveComplexity.value = cognitiveCount;
|
||||||
|
fs.writeFileSync(QUALITY_BASELINE, JSON.stringify(qualityBaseline, null, 2) + "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
let failed = false;
|
||||||
|
if (cyc.regressed) {
|
||||||
|
console.error(
|
||||||
|
`[complexity] REGRESSÃO — ${complexityCount} violações > baseline ${complexityBaseline.count}`
|
||||||
|
);
|
||||||
|
failed = true;
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
`[complexity] OK — ${complexityCount} violações (baseline ${complexityBaseline.count})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cog.regressed) {
|
||||||
|
console.error(
|
||||||
|
`[cognitive-complexity] REGRESSÃO — ${cognitiveCount} violações > baseline ${cognitiveMetric.value}`
|
||||||
|
);
|
||||||
|
failed = true;
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
`[cognitive-complexity] OK — ${cognitiveCount} violações (baseline ${cognitiveMetric.value})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(failed ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();
|
||||||
@@ -1,19 +1,17 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
// scripts/check/check-complexity.mjs
|
// scripts/check/check-complexity.mjs
|
||||||
// Catraca de complexidade de código. Roda o ESLint sobre src+open-sse usando um config
|
// Catraca de complexidade de código (cyclomatic + max-lines-per-function).
|
||||||
// flat STANDALONE (eslint.complexity.config.mjs) que liga APENAS duas regras CORE do
|
// Shares one ESLint walk with cognitive-complexity via complexityEslintReport.mjs
|
||||||
// ESLint — `complexity` (ciclomática) e `max-lines-per-function` (tamanho de função) —
|
// / eslint.complexity-ratchets.config.mjs. Counts by ruleId so cognitive
|
||||||
// e compara a contagem total de violações contra um baseline congelado
|
// violations never inflate this baseline.
|
||||||
// (complexity-baseline.json). Falha se a contagem SUBIR. Completa a dimensão
|
|
||||||
// "complexity" do snapshot de qualidade, ao lado de duplicação/tamanho-de-arquivo.
|
|
||||||
//
|
|
||||||
// O config dedicado evita poluir a contagem de warnings do lint principal (ratcheada
|
|
||||||
// em exatamente 3482): este gate roda isolado, com seu próprio par de regras. --update
|
|
||||||
// ratcheta (a contagem só pode CAIR).
|
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { execFileSync } from "node:child_process";
|
|
||||||
import { pathToFileURL } from "node:url";
|
import { pathToFileURL } from "node:url";
|
||||||
|
import {
|
||||||
|
ESLINT_ARGS,
|
||||||
|
countComplexityViolations,
|
||||||
|
getComplexityEslintReport,
|
||||||
|
} from "./complexityEslintReport.mjs";
|
||||||
|
|
||||||
const ROOT = process.cwd();
|
const ROOT = process.cwd();
|
||||||
const BASELINE_PATH = path.resolve(
|
const BASELINE_PATH = path.resolve(
|
||||||
@@ -22,24 +20,9 @@ const BASELINE_PATH = path.resolve(
|
|||||||
: path.join(ROOT, "config/quality/complexity-baseline.json")
|
: path.join(ROOT, "config/quality/complexity-baseline.json")
|
||||||
);
|
);
|
||||||
const UPDATE = process.argv.includes("--update");
|
const UPDATE = process.argv.includes("--update");
|
||||||
const CONFIG_PATH = path.join(ROOT, "eslint.complexity.config.mjs");
|
|
||||||
// Exported for the gate's own unit test (tests/unit/build/check-complexity.test.ts), which
|
// Re-export for tests that lock scan scope (src+open-sse+electron+bin).
|
||||||
// locks the scan scope to the one documented in eslint.complexity.config.mjs `files` and in
|
export { ESLINT_ARGS };
|
||||||
// complexity-baseline.json. The positional paths MUST match that scope (src+open-sse+electron+bin)
|
|
||||||
// — ESLint flat config only walks the directories passed here, so a `files` glob for bin/electron
|
|
||||||
// is inert unless the directory is also passed as a positional argument.
|
|
||||||
export const ESLINT_ARGS = [
|
|
||||||
"eslint",
|
|
||||||
"--no-config-lookup",
|
|
||||||
"--config",
|
|
||||||
CONFIG_PATH,
|
|
||||||
"--format",
|
|
||||||
"json",
|
|
||||||
"src",
|
|
||||||
"open-sse",
|
|
||||||
"electron",
|
|
||||||
"bin",
|
|
||||||
];
|
|
||||||
|
|
||||||
/** Avalia a contagem atual de violações contra o baseline. */
|
/** Avalia a contagem atual de violações contra o baseline. */
|
||||||
export function evaluateComplexity(current, baseline) {
|
export function evaluateComplexity(current, baseline) {
|
||||||
@@ -50,20 +33,7 @@ export function evaluateComplexity(current, baseline) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function measureComplexityCount() {
|
function measureComplexityCount() {
|
||||||
let stdout;
|
return countComplexityViolations(getComplexityEslintReport());
|
||||||
try {
|
|
||||||
stdout = execFileSync("npx", ["--yes", ...ESLINT_ARGS], {
|
|
||||||
encoding: "utf8",
|
|
||||||
maxBuffer: 64 * 1024 * 1024,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
// ESLint sai com código !=0 quando há erros (e nossas regras são "error"); o relatório
|
|
||||||
// JSON ainda vai no stdout. Só relançamos se não houver stdout parseável.
|
|
||||||
stdout = err.stdout ? String(err.stdout) : "";
|
|
||||||
if (!stdout.trim()) throw err;
|
|
||||||
}
|
|
||||||
const report = JSON.parse(stdout);
|
|
||||||
return report.reduce((sum, file) => sum + file.errorCount, 0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function main() {
|
function main() {
|
||||||
|
|||||||
@@ -19,11 +19,11 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { pathToFileURL } from "node:url";
|
import { pathToFileURL } from "node:url";
|
||||||
import { assertNoStale } from "./lib/allowlist.mjs";
|
import { reportStaleEntries } from "./lib/allowlist.mjs";
|
||||||
|
import { collectApiRouteFiles } from "./lib/apiRoutes.mjs";
|
||||||
|
|
||||||
const ROOT = process.cwd();
|
const ROOT = process.cwd();
|
||||||
const DOCS = path.join(ROOT, "docs");
|
const DOCS = path.join(ROOT, "docs");
|
||||||
const API = path.join(ROOT, "src/app/api");
|
|
||||||
|
|
||||||
// Padrões que NÃO são rotas internas do OmniRoute (ruído estrutural, não drift).
|
// Padrões que NÃO são rotas internas do OmniRoute (ruído estrutural, não drift).
|
||||||
// Adicione aqui (com justificativa) em vez da allowlist quando uma categoria gera
|
// Adicione aqui (com justificativa) em vez da allowlist quando uma categoria gera
|
||||||
@@ -73,10 +73,9 @@ function walk(dir, filter, acc = []) {
|
|||||||
return acc;
|
return acc;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function collectRouteFiles() {
|
/** @deprecated prefer collectApiRouteFiles from lib/apiRoutes.mjs — re-export for tests. */
|
||||||
return new Set(
|
export function collectRouteFiles(root = ROOT) {
|
||||||
walk(API, (n) => /^route\.tsx?$/.test(n)).map((p) => path.relative(ROOT, p).replace(/\\/g, "/"))
|
return collectApiRouteFiles(root);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Normaliza um segmento dinâmico ({param} / [param] / [...param] / :param) para wildcard. */
|
/** Normaliza um segmento dinâmico ({param} / [param] / [...param] / :param) para wildcard. */
|
||||||
@@ -177,45 +176,67 @@ export function findStaleDocApiRefs(docPathsByFile, routeFiles, allowlist) {
|
|||||||
return misses;
|
return misses;
|
||||||
}
|
}
|
||||||
|
|
||||||
function main() {
|
/**
|
||||||
const routeFiles = collectRouteFiles();
|
* @param {{ root?: string, routeFiles?: Set<string> }} [opts]
|
||||||
|
* @returns {{ ok: boolean, exitCode: number, message: string }}
|
||||||
|
*/
|
||||||
|
export function runDocsSymbolsCheck(opts = {}) {
|
||||||
|
const root = opts.root || ROOT;
|
||||||
|
const docsDir = path.join(root, "docs");
|
||||||
|
const routeFiles = opts.routeFiles || collectApiRouteFiles(root);
|
||||||
// docs/i18n/** são espelhos auto-gerados das docs canônicas — validar só o canônico
|
// docs/i18n/** são espelhos auto-gerados das docs canônicas — validar só o canônico
|
||||||
// evita 40× de ruído duplicado (e os mirrors herdam qualquer fix do canônico).
|
// evita 40× de ruído duplicado (e os mirrors herdam qualquer fix do canônico).
|
||||||
// docs/superpowers/** são planos internos de implementação (snapshots históricos
|
// docs/superpowers/** são planos internos de implementação (snapshots históricos
|
||||||
// de intenção — podem citar rotas planejadas/abandonadas), não claims sobre o
|
// de intenção — podem citar rotas planejadas/abandonadas), não claims sobre o
|
||||||
// código atual; fora do escopo do gate (drift surgiu no ciclo v3.8.18).
|
// código atual; fora do escopo do gate (drift surgiu no ciclo v3.8.18).
|
||||||
const docFiles = walk(DOCS, (n) => /\.md$/.test(n)).filter((f) => {
|
const docFiles = walk(docsDir, (n) => /\.md$/.test(n)).filter((f) => {
|
||||||
const rel = path.relative(ROOT, f).replace(/\\/g, "/");
|
const rel = path.relative(root, f).replace(/\\/g, "/");
|
||||||
return !rel.startsWith("docs/i18n/") && !rel.startsWith("docs/superpowers/");
|
return !rel.startsWith("docs/i18n/") && !rel.startsWith("docs/superpowers/");
|
||||||
});
|
});
|
||||||
const docPathsByFile = docFiles.map((f) => ({
|
const docPathsByFile = docFiles.map((f) => ({
|
||||||
file: path.relative(ROOT, f).replace(/\\/g, "/"),
|
file: path.relative(root, f).replace(/\\/g, "/"),
|
||||||
paths: extractDocApiPaths(fs.readFileSync(f, "utf8")),
|
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 allMisses = findStaleDocApiRefs(docPathsByFile, routeFiles, new Set());
|
||||||
const liveMissPaths = allMisses.map((m) => m.split(" → ")[1]);
|
const liveMissPaths = allMisses.map((m) => m.split(" → ")[1]);
|
||||||
assertNoStale(KNOWN_STALE_DOC_REFS, liveMissPaths, "check-docs-symbols");
|
const stale = reportStaleEntries(KNOWN_STALE_DOC_REFS, liveMissPaths, "check-docs-symbols");
|
||||||
|
|
||||||
const misses = findStaleDocApiRefs(docPathsByFile, routeFiles, KNOWN_STALE_DOC_REFS);
|
const misses = findStaleDocApiRefs(docPathsByFile, routeFiles, KNOWN_STALE_DOC_REFS);
|
||||||
|
|
||||||
|
const parts = [];
|
||||||
|
if (stale.length) {
|
||||||
|
parts.push(
|
||||||
|
`[check-docs-symbols] ${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")
|
||||||
|
);
|
||||||
|
}
|
||||||
if (misses.length) {
|
if (misses.length) {
|
||||||
console.error(
|
parts.push(
|
||||||
`[check-docs-symbols] ${misses.length} ref(s) /api em docs sem rota real:\n` +
|
`[check-docs-symbols] ${misses.length} ref(s) /api em docs sem rota real:\n` +
|
||||||
misses.map((m) => " ✗ " + m).join("\n") +
|
misses.map((m) => " ✗ " + m).join("\n") +
|
||||||
`\n → crie o route.ts, corrija o path na doc, ou (se for upstream/placeholder)` +
|
`\n → crie o route.ts, corrija o path na doc, ou (se for upstream/placeholder)` +
|
||||||
` adicione um padrão a IGNORE com justificativa. NÃO adicione à allowlist sem` +
|
` adicione um padrão a IGNORE com justificativa. NÃO adicione à allowlist sem` +
|
||||||
` confirmar que é drift pré-existente real.`
|
` confirmar que é drift pré-existente real.`
|
||||||
);
|
);
|
||||||
process.exitCode = 1;
|
|
||||||
}
|
}
|
||||||
if (!process.exitCode) {
|
if (parts.length) {
|
||||||
console.log(
|
return { ok: false, exitCode: 1, message: parts.join("\n") };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
exitCode: 0,
|
||||||
|
message:
|
||||||
`[check-docs-symbols] OK — ${docFiles.length} docs canônicas, ` +
|
`[check-docs-symbols] OK — ${docFiles.length} docs canônicas, ` +
|
||||||
`${routeFiles.size} rotas conhecidas, ${KNOWN_STALE_DOC_REFS.size} stale congeladas`
|
`${routeFiles.size} rotas conhecidas, ${KNOWN_STALE_DOC_REFS.size} stale congeladas`,
|
||||||
);
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const result = runDocsSymbolsCheck();
|
||||||
|
if (result.ok) console.log(result.message);
|
||||||
|
else console.error(result.message);
|
||||||
|
process.exit(result.exitCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();
|
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();
|
||||||
|
|||||||
@@ -51,6 +51,9 @@ const IGNORE_FROM_CODE = new Set([
|
|||||||
"CI",
|
"CI",
|
||||||
"GITHUB_ACTIONS",
|
"GITHUB_ACTIONS",
|
||||||
"RUNNER_OS",
|
"RUNNER_OS",
|
||||||
|
// Quality-gate harness knobs (optional cache/report paths for CI scripts — not product config).
|
||||||
|
"ESLINT_RESULTS_JSON",
|
||||||
|
"COMPLEXITY_ESLINT_REPORT",
|
||||||
// Agent environment / system execution paths.
|
// Agent environment / system execution paths.
|
||||||
"PROJECT_ROOT",
|
"PROJECT_ROOT",
|
||||||
"ARTIFACTS_DIR",
|
"ARTIFACTS_DIR",
|
||||||
|
|||||||
@@ -10,9 +10,10 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import * as yaml from "js-yaml";
|
import * as yaml from "js-yaml";
|
||||||
|
import { apiRoot, collectApiRouteUrlPaths } from "./lib/apiRoutes.mjs";
|
||||||
|
|
||||||
const ROOT = process.cwd();
|
const ROOT = process.cwd();
|
||||||
const API_ROOT = path.join(ROOT, "src", "app", "api");
|
const API_ROOT = apiRoot(ROOT);
|
||||||
const OPENAPI_PATH = path.join(ROOT, "docs", "openapi.yaml");
|
const OPENAPI_PATH = path.join(ROOT, "docs", "openapi.yaml");
|
||||||
// Floor recorded on 2026-05-26 for release/v3.8.4: 137/365 routes documented.
|
// Floor recorded on 2026-05-26 for release/v3.8.4: 137/365 routes documented.
|
||||||
// The original ≥99% target tracks the OpenAPI audit follow-up (#2701);
|
// The original ≥99% target tracks the OpenAPI audit follow-up (#2701);
|
||||||
@@ -21,30 +22,6 @@ const OPENAPI_PATH = path.join(ROOT, "docs", "openapi.yaml");
|
|||||||
// instead of the absolute target. Raise this back to 99 once the backlog clears.
|
// instead of the absolute target. Raise this back to 99 once the backlog clears.
|
||||||
const THRESHOLD = 36;
|
const THRESHOLD = 36;
|
||||||
|
|
||||||
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}");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!fs.existsSync(API_ROOT)) {
|
if (!fs.existsSync(API_ROOT)) {
|
||||||
console.error(`[openapi-coverage] FAIL — API root not found: ${API_ROOT}`);
|
console.error(`[openapi-coverage] FAIL — API root not found: ${API_ROOT}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
@@ -55,7 +32,7 @@ if (!fs.existsSync(OPENAPI_PATH)) {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const implementedPaths = collectRoutePaths(API_ROOT).map(normalizePath).sort();
|
const implementedPaths = collectApiRouteUrlPaths(ROOT).sort();
|
||||||
const raw = yaml.load(fs.readFileSync(OPENAPI_PATH, "utf-8"));
|
const raw = yaml.load(fs.readFileSync(OPENAPI_PATH, "utf-8"));
|
||||||
const documentedPaths = new Set(Object.keys(raw.paths || {}));
|
const documentedPaths = new Set(Object.keys(raw.paths || {}));
|
||||||
|
|
||||||
|
|||||||
@@ -10,10 +10,10 @@ import fs from "node:fs";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { pathToFileURL } from "node:url";
|
import { pathToFileURL } from "node:url";
|
||||||
import * as yaml from "js-yaml";
|
import * as yaml from "js-yaml";
|
||||||
import { assertNoStale } from "./lib/allowlist.mjs";
|
import { reportStaleEntries } from "./lib/allowlist.mjs";
|
||||||
|
import { apiRoot, collectApiRouteUrlPaths } from "./lib/apiRoutes.mjs";
|
||||||
|
|
||||||
const ROOT = process.cwd();
|
const ROOT = process.cwd();
|
||||||
const API_ROOT = path.join(ROOT, "src", "app", "api");
|
|
||||||
const OPENAPI_PATH = path.join(ROOT, "docs", "openapi.yaml");
|
const OPENAPI_PATH = path.join(ROOT, "docs", "openapi.yaml");
|
||||||
|
|
||||||
// Entradas da spec sem rota real, congeladas para triagem (catraca: bloqueia NOVAS).
|
// Entradas da spec sem rota real, congeladas para triagem (catraca: bloqueia NOVAS).
|
||||||
@@ -33,51 +33,68 @@ export function findSpecPathsWithoutRoute(specPaths, implPaths) {
|
|||||||
return specPaths.filter((p) => !impl.has(normalizeParams(p)));
|
return specPaths.filter((p) => !impl.has(normalizeParams(p)));
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectRoutePaths(dir) {
|
/**
|
||||||
const paths = [];
|
* @param {{ root?: string, openapiPath?: string, implPaths?: string[] }} [opts]
|
||||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
* @returns {{ ok: boolean, exitCode: number, message: string }}
|
||||||
const full = path.join(dir, entry.name);
|
*/
|
||||||
if (entry.isDirectory()) {
|
export function runOpenapiRoutesCheck(opts = {}) {
|
||||||
paths.push(...collectRoutePaths(full));
|
const root = opts.root || ROOT;
|
||||||
} else if (entry.isFile() && entry.name === "route.ts") {
|
const openapiPath = opts.openapiPath || path.join(root, "docs", "openapi.yaml");
|
||||||
const apiPath = path
|
if (!fs.existsSync(openapiPath)) {
|
||||||
.dirname(full)
|
return {
|
||||||
.replace(API_ROOT, "")
|
ok: false,
|
||||||
.replace(/\/\[\.\.\.([^\]]+)\]/g, "/{$1}")
|
exitCode: 1,
|
||||||
.replace(/\[([^\]]+)\]/g, "{$1}");
|
message: `[openapi-routes] FAIL — openapi.yaml não encontrado: ${openapiPath}`,
|
||||||
paths.push(`/api${apiPath}`);
|
};
|
||||||
}
|
}
|
||||||
|
if (!fs.existsSync(apiRoot(root))) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
exitCode: 1,
|
||||||
|
message: `[openapi-routes] FAIL — API root not found: ${apiRoot(root)}`,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return paths;
|
|
||||||
}
|
|
||||||
|
|
||||||
function main() {
|
const raw = yaml.load(fs.readFileSync(openapiPath, "utf-8"));
|
||||||
if (!fs.existsSync(OPENAPI_PATH)) {
|
|
||||||
console.error(`[openapi-routes] FAIL — openapi.yaml não encontrado: ${OPENAPI_PATH}`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
const raw = yaml.load(fs.readFileSync(OPENAPI_PATH, "utf-8"));
|
|
||||||
const specPaths = Object.keys(raw.paths || {}).filter((p) => p.startsWith("/api"));
|
const specPaths = Object.keys(raw.paths || {}).filter((p) => p.startsWith("/api"));
|
||||||
const implPaths = collectRoutePaths(API_ROOT);
|
const implPaths = opts.implPaths || collectApiRouteUrlPaths(root);
|
||||||
|
|
||||||
// Live orphans BEFORE allowlist filtering (needed for stale-enforcement).
|
|
||||||
const liveOrphans = findSpecPathsWithoutRoute(specPaths, implPaths);
|
const liveOrphans = findSpecPathsWithoutRoute(specPaths, implPaths);
|
||||||
assertNoStale(KNOWN_STALE_SPEC, liveOrphans, "openapi-routes");
|
const stale = reportStaleEntries(KNOWN_STALE_SPEC, liveOrphans, "openapi-routes");
|
||||||
|
|
||||||
const orphans = liveOrphans.filter((p) => !KNOWN_STALE_SPEC.has(p));
|
const orphans = liveOrphans.filter((p) => !KNOWN_STALE_SPEC.has(p));
|
||||||
|
|
||||||
|
const parts = [];
|
||||||
|
if (stale.length) {
|
||||||
|
parts.push(
|
||||||
|
`[openapi-routes] ${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")
|
||||||
|
);
|
||||||
|
}
|
||||||
if (orphans.length) {
|
if (orphans.length) {
|
||||||
console.error(
|
parts.push(
|
||||||
`[openapi-routes] ${orphans.length} path(s) documentado(s) sem rota real:\n` +
|
`[openapi-routes] ${orphans.length} path(s) documentado(s) sem rota real:\n` +
|
||||||
orphans.map((p) => " ✗ " + p).join("\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.`
|
`\n → crie a rota, corrija/remova a entrada na spec, ou adicione a KNOWN_STALE_SPEC com justificativa.`
|
||||||
);
|
);
|
||||||
process.exitCode = 1;
|
|
||||||
}
|
}
|
||||||
if (!process.exitCode) {
|
if (parts.length) {
|
||||||
console.log(
|
return { ok: false, exitCode: 1, message: parts.join("\n") };
|
||||||
`[openapi-routes] OK — ${specPaths.length} paths na spec, todos com rota real (${implPaths.length} rotas)`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
exitCode: 0,
|
||||||
|
message: `[openapi-routes] OK — ${specPaths.length} paths na spec, todos com rota real (${implPaths.length} rotas)`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
// Keep assertNoStale side-effect path for CLI parity with other gates when
|
||||||
|
// runOpenapiRoutesCheck is not used alone — here we print structured result.
|
||||||
|
const result = runOpenapiRoutesCheck();
|
||||||
|
if (result.ok) console.log(result.message);
|
||||||
|
else console.error(result.message);
|
||||||
|
process.exit(result.exitCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();
|
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();
|
||||||
|
|||||||
104
scripts/check/complexityEslintReport.mjs
Normal file
104
scripts/check/complexityEslintReport.mjs
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Shared ESLint runner for complexity + cognitive-complexity ratchets.
|
||||||
|
* One tree walk → JSON report; consumers count by ruleId (not errorCount).
|
||||||
|
*/
|
||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||||
|
const CONFIG_PATH = path.join(ROOT, "eslint.complexity-ratchets.config.mjs");
|
||||||
|
|
||||||
|
/** Positional dirs — must match config `files` scopes (see check-complexity tests). */
|
||||||
|
export const ESLINT_SCAN_DIRS = ["src", "open-sse", "electron", "bin"];
|
||||||
|
|
||||||
|
const ESLINT_BIN = path.join(
|
||||||
|
ROOT,
|
||||||
|
"node_modules",
|
||||||
|
".bin",
|
||||||
|
process.platform === "win32" ? "eslint.cmd" : "eslint"
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Args after the eslint binary (tests lock scan dirs on this array). */
|
||||||
|
export const ESLINT_ARGS = [
|
||||||
|
"--no-config-lookup",
|
||||||
|
"--config",
|
||||||
|
CONFIG_PATH,
|
||||||
|
"--format",
|
||||||
|
"json",
|
||||||
|
"--cache",
|
||||||
|
"--cache-location",
|
||||||
|
".eslintcache-complexity",
|
||||||
|
...ESLINT_SCAN_DIRS,
|
||||||
|
];
|
||||||
|
|
||||||
|
const COMPLEXITY_RULES = new Set(["complexity", "max-lines-per-function"]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Array<{messages?: Array<{ruleId?: string}>}>} report
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
export function countComplexityViolations(report) {
|
||||||
|
let count = 0;
|
||||||
|
for (const file of report) {
|
||||||
|
for (const msg of file.messages || []) {
|
||||||
|
if (COMPLEXITY_RULES.has(msg.ruleId)) count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run ESLint once (or reuse COMPLEXITY_ESLINT_REPORT / in-process cache).
|
||||||
|
* @returns {Array<object>}
|
||||||
|
*/
|
||||||
|
export function getComplexityEslintReport() {
|
||||||
|
const fromEnv = process.env.COMPLEXITY_ESLINT_REPORT;
|
||||||
|
if (fromEnv && fs.existsSync(fromEnv)) {
|
||||||
|
return JSON.parse(fs.readFileSync(fromEnv, "utf8"));
|
||||||
|
}
|
||||||
|
if (getComplexityEslintReport._cache) return getComplexityEslintReport._cache;
|
||||||
|
|
||||||
|
let stdout;
|
||||||
|
try {
|
||||||
|
// Prefer local bin (Windows-safe); shell only needed for .cmd shims.
|
||||||
|
stdout = execFileSync(ESLINT_BIN, ESLINT_ARGS, {
|
||||||
|
cwd: ROOT,
|
||||||
|
encoding: "utf8",
|
||||||
|
maxBuffer: 64 * 1024 * 1024,
|
||||||
|
shell: process.platform === "win32",
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
stdout = err.stdout ? String(err.stdout) : "";
|
||||||
|
if (!stdout.trim()) throw err;
|
||||||
|
}
|
||||||
|
const report = JSON.parse(stdout);
|
||||||
|
getComplexityEslintReport._cache = report;
|
||||||
|
|
||||||
|
const outDir = path.join(ROOT, ".artifacts");
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(outDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(outDir, "complexity-eslint.json"), stdout);
|
||||||
|
} catch {
|
||||||
|
// best-effort cache for sibling steps / local inspection
|
||||||
|
}
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
|
||||||
|
getComplexityEslintReport._cache = null;
|
||||||
82
scripts/check/lib/apiRoutes.mjs
Normal file
82
scripts/check/lib/apiRoutes.mjs
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* Shared filesystem inventory of Next.js App Router API routes.
|
||||||
|
*
|
||||||
|
* Existence reason: openapi-routes (spec→route), docs-symbols (prose→route),
|
||||||
|
* and openapi-coverage (route→spec %) all need the same walk of src/app/api.
|
||||||
|
* One collector keeps path normalization consistent and avoids triple walks
|
||||||
|
* when a combined gate runs them together.
|
||||||
|
*/
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} [root] repo root
|
||||||
|
* @returns {string} absolute path to src/app/api
|
||||||
|
*/
|
||||||
|
export function apiRoot(root = process.cwd()) {
|
||||||
|
return path.join(root, "src", "app", "api");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a directory under src/app/api (the folder that contains route.ts)
|
||||||
|
* to an OpenAPI-style /api/... path.
|
||||||
|
* Dynamic segments: [id] → {id}, [...slug] → {slug}.
|
||||||
|
*
|
||||||
|
* @param {string} routeDir absolute directory containing route.ts
|
||||||
|
* @param {string} apiRootAbs absolute src/app/api
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function toApiUrlPath(routeDir, apiRootAbs) {
|
||||||
|
const rel = path.relative(apiRootAbs, routeDir).replace(/\\/g, "/");
|
||||||
|
if (!rel || rel === ".") return "/api";
|
||||||
|
const normalized = rel
|
||||||
|
.replace(/\[\.\.\.([^\]]+)\]/g, "{$1}")
|
||||||
|
.replace(/\[([^\]]+)\]/g, "{$1}");
|
||||||
|
return `/api/${normalized}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walk src/app/api for route.ts(x) → OpenAPI-style URL paths.
|
||||||
|
* @param {string} [root]
|
||||||
|
* @returns {string[]}
|
||||||
|
*/
|
||||||
|
export function collectApiRouteUrlPaths(root = process.cwd()) {
|
||||||
|
const API = apiRoot(root);
|
||||||
|
if (!fs.existsSync(API)) return [];
|
||||||
|
const out = [];
|
||||||
|
function walk(dir) {
|
||||||
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
const full = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
walk(full);
|
||||||
|
} else if (entry.isFile() && /^route\.tsx?$/.test(entry.name)) {
|
||||||
|
out.push(toApiUrlPath(path.dirname(full), API));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(API);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walk src/app/api → relative repo paths to route.ts (docs-symbols resolver).
|
||||||
|
* @param {string} [root]
|
||||||
|
* @returns {Set<string>}
|
||||||
|
*/
|
||||||
|
export function collectApiRouteFiles(root = process.cwd()) {
|
||||||
|
const API = apiRoot(root);
|
||||||
|
const out = new Set();
|
||||||
|
if (!fs.existsSync(API)) return out;
|
||||||
|
function walk(dir) {
|
||||||
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
const full = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
walk(full);
|
||||||
|
} else if (entry.isFile() && /^route\.tsx?$/.test(entry.name)) {
|
||||||
|
out.add(path.relative(root, full).replace(/\\/g, "/"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(API);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -57,10 +57,14 @@ function sourceDepsOf(entry) {
|
|||||||
// The TIA step runs the selected subset via `node --test`, so it must NOT include
|
// The TIA step runs the selected subset via `node --test`, so it must NOT include
|
||||||
// vitest files (`.test.tsx`, `open-sse/**/__tests__`, `tests/unit/autoCombo`), nor
|
// vitest files (`.test.tsx`, `open-sse/**/__tests__`, `tests/unit/autoCombo`), nor
|
||||||
// e2e/integration tests, which can't run under node:test (they 99-false-failed before).
|
// e2e/integration tests, which can't run under node:test (they 99-false-failed before).
|
||||||
|
// Mirror EXACTLY the package.json `test:unit` / `test:unit:ci` globs (incl. memory,
|
||||||
|
// usage, combo, dashboard, serial, and *.test.mjs). Drift here → false __RUN_ALL__.
|
||||||
const testFiles = globSync(
|
const testFiles = globSync(
|
||||||
[
|
[
|
||||||
"tests/unit/*.test.ts",
|
"tests/unit/*.test.ts",
|
||||||
"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,executors,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui}/**/*.test.ts",
|
"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts",
|
||||||
|
"tests/unit/**/*.test.mjs",
|
||||||
|
"tests/unit/dashboard/**/*.test.ts",
|
||||||
// Quarentena serial (P0.3): também são node:test — a TIA precisa mapeá-los.
|
// Quarentena serial (P0.3): também são node:test — a TIA precisa mapeá-los.
|
||||||
"tests/unit/serial/**/*.test.ts",
|
"tests/unit/serial/**/*.test.ts",
|
||||||
],
|
],
|
||||||
|
|||||||
119
scripts/quality/classify-pr-changes.mjs
Normal file
119
scripts/quality/classify-pr-changes.mjs
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* PR change classification for ci.yml path filters.
|
||||||
|
*
|
||||||
|
* Why this exists (not "skip work for free"):
|
||||||
|
* - code → typecheck, unit/vitest, lint bag, quality ratchets (code regressions)
|
||||||
|
* - docs → docs-sync / prose (doc/API contract regressions)
|
||||||
|
* - i18n → message/UI-key validation (translation regressions)
|
||||||
|
* - workflow → CI definition changes (always treat as code — gates protect the gates)
|
||||||
|
*
|
||||||
|
* Pure docs or pure message-catalog PRs should NOT pay full unit/lint wall time.
|
||||||
|
* Unknown paths default to code (fail-safe: better over-run than under-protect).
|
||||||
|
*/
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string[]} files relative paths from git diff
|
||||||
|
* @returns {{ code: boolean, docs: boolean, i18n: boolean, workflow: boolean }}
|
||||||
|
*/
|
||||||
|
export function classifyPaths(files) {
|
||||||
|
let code = false;
|
||||||
|
let docs = false;
|
||||||
|
let i18n = false;
|
||||||
|
let workflow = false;
|
||||||
|
|
||||||
|
for (const raw of files) {
|
||||||
|
const f = String(raw || "")
|
||||||
|
.trim()
|
||||||
|
.replace(/\\/g, "/");
|
||||||
|
if (!f) continue;
|
||||||
|
|
||||||
|
if (f.startsWith(".github/workflows/") || f === ".zizmor.yml") {
|
||||||
|
workflow = true;
|
||||||
|
// Workflow edits can weaken or remove gates — treat as code.
|
||||||
|
code = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message catalogs only: translation content, not runtime TS.
|
||||||
|
if (f.startsWith("src/i18n/messages/")) {
|
||||||
|
i18n = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// i18n tooling / non-message i18n source → also code (scripts, config, loaders).
|
||||||
|
if (
|
||||||
|
f.startsWith("scripts/i18n/") ||
|
||||||
|
f === "config/i18n.json" ||
|
||||||
|
f.startsWith("src/i18n/")
|
||||||
|
) {
|
||||||
|
i18n = true;
|
||||||
|
code = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (f.startsWith("docs/") || f.endsWith(".md")) {
|
||||||
|
docs = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
f.startsWith("src/") ||
|
||||||
|
f.startsWith("open-sse/") ||
|
||||||
|
f.startsWith("bin/") ||
|
||||||
|
f.startsWith("electron/") ||
|
||||||
|
f.startsWith("tests/") ||
|
||||||
|
f.startsWith("scripts/") ||
|
||||||
|
f.startsWith("db/") ||
|
||||||
|
f.startsWith("config/") ||
|
||||||
|
f === "package.json" ||
|
||||||
|
f === "package-lock.json" ||
|
||||||
|
/^tsconfig.*\.json$/.test(f) ||
|
||||||
|
f.startsWith("next.config.") ||
|
||||||
|
f.startsWith("vitest") ||
|
||||||
|
f.startsWith("playwright.config.")
|
||||||
|
) {
|
||||||
|
code = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fail-safe: unknown path class → code (do not skip heavy gates by accident).
|
||||||
|
code = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { code, docs, i18n, workflow };
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const listPath = process.argv[2];
|
||||||
|
let files;
|
||||||
|
if (listPath && listPath !== "-") {
|
||||||
|
files = fs
|
||||||
|
.readFileSync(listPath, "utf8")
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
} else {
|
||||||
|
const stdin = fs.readFileSync(0, "utf8");
|
||||||
|
files = stdin
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
const c = classifyPaths(files);
|
||||||
|
// GitHub Actions output format (also human-readable key=value).
|
||||||
|
process.stdout.write(
|
||||||
|
`code=${c.code}\ndocs=${c.docs}\ni18n=${c.i18n}\nworkflow=${c.workflow}\n`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMain =
|
||||||
|
process.argv[1] &&
|
||||||
|
path.resolve(fileURLToPath(import.meta.url)) === path.resolve(process.argv[1]);
|
||||||
|
|
||||||
|
if (isMain) {
|
||||||
|
main();
|
||||||
|
}
|
||||||
@@ -23,21 +23,41 @@ const out = {};
|
|||||||
// bruto (que driftava +41/+88 por ciclo e era rebaselinado às cegas na release).
|
// bruto (que driftava +41/+88 por ciclo e era rebaselinado às cegas na release).
|
||||||
// O aperto do estoque acontece via --prune-suppressions na release.
|
// O aperto do estoque acontece via --prune-suppressions na release.
|
||||||
function eslintCounts() {
|
function eslintCounts() {
|
||||||
let stdout;
|
// Prefer a precomputed JSON report (same existence reason as lint job: inventory
|
||||||
const args = ["eslint", ".", "--format", "json"];
|
// of net-new warnings vs suppressions). Avoids a second cold full-tree ESLint
|
||||||
if (fs.existsSync(path.join(cwd, "config/quality/eslint-suppressions.json"))) {
|
// when CI/local already produced the report.
|
||||||
args.push("--suppressions-location", "config/quality/eslint-suppressions.json");
|
const cached = path.resolve(
|
||||||
|
cwd,
|
||||||
|
process.env.ESLINT_RESULTS_JSON || path.join(".artifacts", "eslint-results.json")
|
||||||
|
);
|
||||||
|
let results;
|
||||||
|
if (fs.existsSync(cached)) {
|
||||||
|
results = JSON.parse(fs.readFileSync(cached, "utf8"));
|
||||||
|
} else {
|
||||||
|
let stdout;
|
||||||
|
const eslintBin = path.join(
|
||||||
|
cwd,
|
||||||
|
"node_modules",
|
||||||
|
".bin",
|
||||||
|
process.platform === "win32" ? "eslint.cmd" : "eslint"
|
||||||
|
);
|
||||||
|
const args = [".", "--format", "json", "--cache", "--cache-location", ".eslintcache"];
|
||||||
|
if (fs.existsSync(path.join(cwd, "config/quality/eslint-suppressions.json"))) {
|
||||||
|
args.push("--suppressions-location", "config/quality/eslint-suppressions.json");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// Prefer local bin (Windows-safe .cmd); shell only when needed for the shim.
|
||||||
|
stdout = execFileSync(eslintBin, args, {
|
||||||
|
encoding: "utf8",
|
||||||
|
maxBuffer: 256 * 1024 * 1024,
|
||||||
|
shell: process.platform === "win32",
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// eslint sai com código != 0 quando há errors; o JSON ainda vem no stdout
|
||||||
|
stdout = e.stdout?.toString() || "[]";
|
||||||
|
}
|
||||||
|
results = JSON.parse(stdout);
|
||||||
}
|
}
|
||||||
try {
|
|
||||||
stdout = execFileSync("npx", args, {
|
|
||||||
encoding: "utf8",
|
|
||||||
maxBuffer: 256 * 1024 * 1024,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
// eslint sai com código != 0 quando há errors; o JSON ainda vem no stdout
|
|
||||||
stdout = e.stdout?.toString() || "[]";
|
|
||||||
}
|
|
||||||
const results = JSON.parse(stdout);
|
|
||||||
out.eslintWarnings = results.reduce((n, r) => n + (r.warningCount || 0), 0);
|
out.eslintWarnings = results.reduce((n, r) => n + (r.warningCount || 0), 0);
|
||||||
out.eslintErrors = results.reduce((n, r) => n + (r.errorCount || 0), 0);
|
out.eslintErrors = results.reduce((n, r) => n + (r.errorCount || 0), 0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,14 +32,14 @@ const GATES = [
|
|||||||
{ name: "check:public-creds", cmd: ["node", "scripts/check/check-public-creds.mjs"] },
|
{ 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:error-helper", cmd: ["node", "scripts/check/check-error-helper.mjs"] },
|
||||||
{ name: "check:fetch-targets", cmd: ["node", "scripts/check/check-fetch-targets.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:api-docs-refs", cmd: ["node", "scripts/check/check-api-docs-refs.mjs"] },
|
||||||
{ name: "check:deps", cmd: ["node", "scripts/check/check-deps.mjs"] },
|
{ name: "check:deps", cmd: ["node", "scripts/check/check-deps.mjs"] },
|
||||||
|
|
||||||
// Group C — moderate (<15s)
|
// Group C — moderate (<15s)
|
||||||
{ name: "check:db-rules", cmd: ["node", "scripts/check/check-db-rules.mjs"] },
|
{ 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:file-size", cmd: ["node", "scripts/check/check-file-size.mjs"] },
|
||||||
{ name: "check:complexity", cmd: ["node", "scripts/check/check-complexity.mjs"] },
|
{ name: "check:complexity-ratchets", cmd: ["node", "scripts/check/check-complexity-ratchets.mjs"] },
|
||||||
{ name: "check:docs-symbols", cmd: ["node", "scripts/check/check-docs-symbols.mjs"] },
|
// docs-symbols folded into check:api-docs-refs (Group B)
|
||||||
{ name: "check:known-symbols", cmd: ["node", "--import", "tsx", "scripts/check/check-known-symbols.ts"] },
|
{ 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: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-discovery", cmd: ["node", "scripts/check/check-test-discovery.mjs"] },
|
||||||
|
|||||||
61
scripts/quality/run-eslint-json.mjs
Normal file
61
scripts/quality/run-eslint-json.mjs
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Single ESLint pass that always writes a JSON report for quality:collect.
|
||||||
|
*
|
||||||
|
* Existence reason: one inventory of net-new issues (vs suppressions) should
|
||||||
|
* feed both the blocking lint gate and the eslintWarnings ratchet — not two
|
||||||
|
* cold full-tree walks on different runners.
|
||||||
|
*
|
||||||
|
* Exit code: ESLint's own (0 = clean, 1 = errors). Warnings do not fail by
|
||||||
|
* default (same as `npm run lint`); pass --max-warnings=0 for lint-guard.
|
||||||
|
*/
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||||
|
const outFile = path.resolve(
|
||||||
|
root,
|
||||||
|
process.env.ESLINT_RESULTS_JSON || path.join(".artifacts", "eslint-results.json")
|
||||||
|
);
|
||||||
|
|
||||||
|
fs.mkdirSync(path.dirname(outFile), { recursive: true });
|
||||||
|
|
||||||
|
const extra = process.argv.slice(2);
|
||||||
|
const eslintBin = path.join(
|
||||||
|
root,
|
||||||
|
"node_modules",
|
||||||
|
".bin",
|
||||||
|
process.platform === "win32" ? "eslint.cmd" : "eslint"
|
||||||
|
);
|
||||||
|
const args = [
|
||||||
|
".",
|
||||||
|
"--cache",
|
||||||
|
"--cache-location",
|
||||||
|
".eslintcache",
|
||||||
|
"--suppressions-location",
|
||||||
|
"config/quality/eslint-suppressions.json",
|
||||||
|
"--format",
|
||||||
|
"json",
|
||||||
|
"--output-file",
|
||||||
|
outFile,
|
||||||
|
...extra,
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = spawnSync(eslintBin, args, {
|
||||||
|
cwd: root,
|
||||||
|
encoding: "utf8",
|
||||||
|
shell: process.platform === "win32",
|
||||||
|
maxBuffer: 256 * 1024 * 1024,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.stdout) process.stdout.write(result.stdout);
|
||||||
|
if (result.stderr) process.stderr.write(result.stderr);
|
||||||
|
|
||||||
|
if (!fs.existsSync(outFile)) {
|
||||||
|
// ESLint may crash before writing; leave an empty array so collectors don't explode.
|
||||||
|
fs.writeFileSync(outFile, "[]\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(result.status === null ? 1 : result.status);
|
||||||
@@ -9,9 +9,14 @@ const HUB_RE = /(setupPolyfill|tsconfig|package\.json|package-lock\.json|\.env|v
|
|||||||
// step can actually run via `node --test` — i.e. it mirrors the `npm run test:unit` glob.
|
// step can actually run via `node --test` — i.e. it mirrors the `npm run test:unit` glob.
|
||||||
// This EXCLUDES vitest files (`.test.tsx`, `tests/unit/autoCombo/**`), e2e and integration
|
// This EXCLUDES vitest files (`.test.tsx`, `tests/unit/autoCombo/**`), e2e and integration
|
||||||
// tests, and `src/**/__tests__`/`open-sse/**/__tests__`, which can't run under node:test.
|
// tests, and `src/**/__tests__`/`open-sse/**/__tests__`, which can't run under node:test.
|
||||||
|
// Keep in sync with package.json test:unit* braces + serial + dashboard + *.test.mjs.
|
||||||
const UNIT_SUBDIRS =
|
const UNIT_SUBDIRS =
|
||||||
"api|auth|authz|build|cli|cli-helper|compression|correctness|cors|dashboard|db|db-adapters|docs|gamification|guardrails|lib|mcp|runtime|security|services|settings|shared|ui";
|
"api|auth|authz|build|cli|cli-helper|combo|compression|correctness|cors|dashboard|db|db-adapters|docs|gamification|guardrails|lib|mcp|memory|runtime|security|services|settings|shared|ui|usage|serial";
|
||||||
const TEST_RE = new RegExp(`^tests/unit/([^/]+\\.test\\.ts$|(${UNIT_SUBDIRS})/.*\\.test\\.ts$)`);
|
// .ts: top-level + UNIT_SUBDIRS (mirrors package.json brace globs).
|
||||||
|
// .mjs: package.json uses tests/unit/**/*.test.mjs (any depth under tests/unit).
|
||||||
|
const TEST_RE = new RegExp(
|
||||||
|
`^tests/unit/([^/]+\\.test\\.(ts|mjs)$|(${UNIT_SUBDIRS})/.*\\.test\\.(ts|mjs)$|.*\\.test\\.mjs$)`
|
||||||
|
);
|
||||||
|
|
||||||
export function selectImpacted({ changed, map }) {
|
export function selectImpacted({ changed, map }) {
|
||||||
const out = new Set();
|
const out = new Set();
|
||||||
@@ -21,11 +26,10 @@ export function selectImpacted({ changed, map }) {
|
|||||||
out.add(f);
|
out.add(f);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const isSource =
|
// Impact map only indexes imports under src/ + open-sse/. electron/ and bin/
|
||||||
f.startsWith("src/") ||
|
// are not unit-mapped; treating them as unmapped used to force __RUN_ALL__ and
|
||||||
f.startsWith("open-sse/") ||
|
// a full unit suite for pure CLI/desktop PRs. Package/smoke jobs cover those.
|
||||||
f.startsWith("electron/") ||
|
const isSource = f.startsWith("src/") || f.startsWith("open-sse/");
|
||||||
f.startsWith("bin/");
|
|
||||||
if (!isSource) continue;
|
if (!isSource) continue;
|
||||||
const hits = map.sources[f];
|
const hits = map.sources[f];
|
||||||
if (!hits) return ["__RUN_ALL__"];
|
if (!hits) return ["__RUN_ALL__"];
|
||||||
|
|||||||
@@ -399,23 +399,44 @@ async function main() {
|
|||||||
hardCmd("db-rules", "DB rules", npmCmd, ["run", "check:db-rules"]);
|
hardCmd("db-rules", "DB rules", npmCmd, ["run", "check:db-rules"]);
|
||||||
hardCmd("public-creds", "Public creds", npmCmd, ["run", "check:public-creds"]);
|
hardCmd("public-creds", "Public creds", npmCmd, ["run", "check:public-creds"]);
|
||||||
|
|
||||||
// Cognitive-complexity (drift)
|
// Complexity + cognitive (one ESLint walk; both still recorded as drift)
|
||||||
{
|
{
|
||||||
announce("Cognitive complexity (ratchet)");
|
announce("Complexity + cognitive ratchets (shared ESLint walk)");
|
||||||
const { out } = run(npmCmd, ["run", "check:cognitive-complexity"]);
|
const { out } = run(npmCmd, ["run", "check:complexity-ratchets"]);
|
||||||
saveGateLog("cognitive", out);
|
saveGateLog("complexity-ratchets", out);
|
||||||
const current = parseCognitiveCount(out);
|
const cogCurrent = parseCognitiveCount(out);
|
||||||
const base = baselineValue("cognitiveComplexity");
|
const cogBase = baselineValue("cognitiveComplexity");
|
||||||
const over = isDrift(current, base);
|
const cogOver = isDrift(cogCurrent, cogBase);
|
||||||
|
const cycMatch = /(?:^|\n)complexity=(\d+)/.exec(out);
|
||||||
|
const cycOkMatch = /\[complexity\] OK — (\d+)/.exec(out);
|
||||||
|
const cycRegMatch = /\[complexity\] REGRESSÃO — (\d+)/.exec(out);
|
||||||
|
const cycCurrent = cycMatch
|
||||||
|
? Number(cycMatch[1])
|
||||||
|
: cycOkMatch
|
||||||
|
? Number(cycOkMatch[1])
|
||||||
|
: cycRegMatch
|
||||||
|
? Number(cycRegMatch[1])
|
||||||
|
: null;
|
||||||
|
const cycRegressed = /\[complexity\] REGRESSÃO/.test(out);
|
||||||
record({
|
record({
|
||||||
id: "cognitive-complexity",
|
id: "cognitive-complexity",
|
||||||
label: "Cognitive complexity (ratchet)",
|
label: "Cognitive complexity (ratchet)",
|
||||||
kind: "drift",
|
kind: "drift",
|
||||||
ok: !over,
|
ok: !cogOver,
|
||||||
detail:
|
detail:
|
||||||
current == null
|
cogCurrent == null
|
||||||
? "could not parse count"
|
? "could not parse count"
|
||||||
: `${current} vs baseline ${base}${over ? ` (+${current - base} drift → rebaseline at release)` : ""}`,
|
: `${cogCurrent} vs baseline ${cogBase}${cogOver ? ` (+${cogCurrent - cogBase} drift → rebaseline at release)` : ""}`,
|
||||||
|
});
|
||||||
|
record({
|
||||||
|
id: "complexity",
|
||||||
|
label: "Cyclomatic complexity (ratchet)",
|
||||||
|
kind: "drift",
|
||||||
|
ok: !cycRegressed,
|
||||||
|
detail:
|
||||||
|
cycCurrent == null
|
||||||
|
? firstFailureLine(out) || "measured via check:complexity-ratchets"
|
||||||
|
: `complexity=${cycCurrent} (shared walk with cognitive)${cycRegressed ? " REGRESSED" : ""}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -457,7 +478,7 @@ async function main() {
|
|||||||
// fast-gates skip and that historically surfaced — one at a time, because the
|
// fast-gates skip and that historically surfaced — one at a time, because the
|
||||||
// CI Quality Ratchet job is fail-fast — only on the release PR. Running them all
|
// CI Quality Ratchet job is fail-fast — only on the release PR. Running them all
|
||||||
// here (drift, never blocking) means a single rebaseline pass at release.
|
// here (drift, never blocking) means a single rebaseline pass at release.
|
||||||
driftCmd("complexity", "Cyclomatic complexity (ratchet)", npmCmd, ["run", "check:complexity"]);
|
// complexity recorded above with cognitive (check:complexity-ratchets)
|
||||||
driftCmd("dead-code", "Dead-code (ratchet)", npmCmd, ["run", "check:dead-code"]);
|
driftCmd("dead-code", "Dead-code (ratchet)", npmCmd, ["run", "check:dead-code"]);
|
||||||
driftCmd("type-coverage", "Type coverage (ratchet)", npmCmd, ["run", "check:type-coverage"]);
|
driftCmd("type-coverage", "Type coverage (ratchet)", npmCmd, ["run", "check:type-coverage"]);
|
||||||
driftCmd("compression-budget", "Compression budget (ratchet)", npmCmd, [
|
driftCmd("compression-budget", "Compression budget (ratchet)", npmCmd, [
|
||||||
|
|||||||
33
tests/unit/api-routes-lib.test.ts
Normal file
33
tests/unit/api-routes-lib.test.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* Shared API route collector — locks path normalization used by openapi + docs gates.
|
||||||
|
*/
|
||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import path from "node:path";
|
||||||
|
import {
|
||||||
|
collectApiRouteFiles,
|
||||||
|
collectApiRouteUrlPaths,
|
||||||
|
toApiUrlPath,
|
||||||
|
} from "../../scripts/check/lib/apiRoutes.mjs";
|
||||||
|
|
||||||
|
test("toApiUrlPath maps [id] and [...slug] to OpenAPI-style braces", () => {
|
||||||
|
const apiRoot = path.join("C:", "repo", "src", "app", "api");
|
||||||
|
assert.equal(
|
||||||
|
toApiUrlPath(path.join(apiRoot, "providers", "[id]", "models"), apiRoot).replace(/\\/g, "/"),
|
||||||
|
"/api/providers/{id}/models"
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
toApiUrlPath(path.join(apiRoot, "files", "[...path]"), apiRoot).replace(/\\/g, "/"),
|
||||||
|
"/api/files/{path}"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("live repo has route files and matching URL paths", () => {
|
||||||
|
const files = collectApiRouteFiles();
|
||||||
|
const urls = collectApiRouteUrlPaths();
|
||||||
|
assert.ok(files.size > 50, `expected many route files, got ${files.size}`);
|
||||||
|
assert.ok(urls.length > 50, `expected many url paths, got ${urls.length}`);
|
||||||
|
assert.equal(files.size, urls.length, "each route file should yield one URL path");
|
||||||
|
assert.ok([...files].every((f) => f.startsWith("src/app/api/") && /route\.tsx?$/.test(f)));
|
||||||
|
assert.ok(urls.every((u) => u.startsWith("/api")));
|
||||||
|
});
|
||||||
30
tests/unit/build/check-complexity-rule-count.test.ts
Normal file
30
tests/unit/build/check-complexity-rule-count.test.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* Locks that complexity vs cognitive counts stay isolated when sharing one ESLint report.
|
||||||
|
* Existence reason: merging tree walks must NOT change either ratchet baseline semantics.
|
||||||
|
*/
|
||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import {
|
||||||
|
countCognitiveViolations,
|
||||||
|
countComplexityViolations,
|
||||||
|
} from "../../../scripts/check/complexityEslintReport.mjs";
|
||||||
|
|
||||||
|
test("countComplexityViolations ignores cognitive-complexity messages", () => {
|
||||||
|
const report = [
|
||||||
|
{
|
||||||
|
messages: [
|
||||||
|
{ ruleId: "complexity" },
|
||||||
|
{ ruleId: "max-lines-per-function" },
|
||||||
|
{ ruleId: "sonarjs/cognitive-complexity" },
|
||||||
|
{ ruleId: "sonarjs/cognitive-complexity" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
assert.equal(countComplexityViolations(report), 2);
|
||||||
|
assert.equal(countCognitiveViolations(report), 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("empty report → 0 for both counters", () => {
|
||||||
|
assert.equal(countComplexityViolations([]), 0);
|
||||||
|
assert.equal(countCognitiveViolations([]), 0);
|
||||||
|
});
|
||||||
69
tests/unit/classify-pr-changes.test.ts
Normal file
69
tests/unit/classify-pr-changes.test.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* tests/unit/classify-pr-changes.test.ts
|
||||||
|
*
|
||||||
|
* Locks the *existence reason* of each change flag used by ci.yml path filters:
|
||||||
|
* - code → heavy static + unit/vitest (code regression surface)
|
||||||
|
* - docs → docs-sync / prose only
|
||||||
|
* - i18n → translation validation; pure messages must NOT force full unit
|
||||||
|
* - workflow → always code (CI is part of the safety net)
|
||||||
|
* - unknown → code (fail-safe over-run)
|
||||||
|
*/
|
||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
|
import { classifyPaths } from "../../scripts/quality/classify-pr-changes.mjs";
|
||||||
|
|
||||||
|
test("pure docs PR → docs only (no code unit/lint bag)", () => {
|
||||||
|
const c = classifyPaths(["docs/architecture/QUALITY_GATES.md", "README.md"]);
|
||||||
|
assert.deepEqual(c, { code: false, docs: true, i18n: false, workflow: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("openapi under docs/ → docs (contract gates live in docs-sync, not unit)", () => {
|
||||||
|
const c = classifyPaths(["docs/openapi.yaml"]);
|
||||||
|
assert.equal(c.docs, true);
|
||||||
|
assert.equal(c.code, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("pure message catalog → i18n only (not full unit suite)", () => {
|
||||||
|
const c = classifyPaths(["src/i18n/messages/en.json", "src/i18n/messages/ko.json"]);
|
||||||
|
assert.deepEqual(c, { code: false, docs: false, i18n: true, workflow: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("i18n tooling/scripts → i18n + code (tooling can break runtime paths)", () => {
|
||||||
|
const c = classifyPaths(["scripts/i18n/check-ui-keys-coverage.mjs"]);
|
||||||
|
assert.equal(c.i18n, true);
|
||||||
|
assert.equal(c.code, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("src/i18n loader TS (non-messages) → i18n + code", () => {
|
||||||
|
const c = classifyPaths(["src/i18n/request.ts"]);
|
||||||
|
assert.equal(c.i18n, true);
|
||||||
|
assert.equal(c.code, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("workflow change → workflow + code (gates protect the gates)", () => {
|
||||||
|
const c = classifyPaths([".github/workflows/ci.yml"]);
|
||||||
|
assert.equal(c.workflow, true);
|
||||||
|
assert.equal(c.code, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("production source → code", () => {
|
||||||
|
const c = classifyPaths(["open-sse/handlers/chatCore.ts", "src/lib/db/core.ts"]);
|
||||||
|
assert.deepEqual(c, { code: true, docs: false, i18n: false, workflow: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mixed docs + code → both flags (jobs union their filters)", () => {
|
||||||
|
const c = classifyPaths(["docs/README.md", "src/lib/db/core.ts"]);
|
||||||
|
assert.equal(c.docs, true);
|
||||||
|
assert.equal(c.code, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unknown path → code fail-safe (never skip heavy gates by accident)", () => {
|
||||||
|
const c = classifyPaths(["weird/unclassified.bin"]);
|
||||||
|
assert.equal(c.code, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("empty change list → all false (nothing to validate)", () => {
|
||||||
|
const c = classifyPaths([]);
|
||||||
|
assert.deepEqual(c, { code: false, docs: false, i18n: false, workflow: false });
|
||||||
|
});
|
||||||
@@ -60,3 +60,65 @@ test("changed unit test in a curated subdir → run itself", () => {
|
|||||||
const sel = selectImpacted({ changed: ["tests/unit/db/migration.test.ts"], map: MAP });
|
const sel = selectImpacted({ changed: ["tests/unit/db/migration.test.ts"], map: MAP });
|
||||||
assert.deepEqual(sel, ["tests/unit/db/migration.test.ts"]);
|
assert.deepEqual(sel, ["tests/unit/db/migration.test.ts"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Parity with package.json test:unit braces — memory/usage/combo/serial were missing
|
||||||
|
// from UNIT_SUBDIRS and silently failed to self-select.
|
||||||
|
test("changed unit test under memory/ → run itself", () => {
|
||||||
|
const sel = selectImpacted({
|
||||||
|
changed: ["tests/unit/memory/store.test.ts"],
|
||||||
|
map: MAP,
|
||||||
|
});
|
||||||
|
assert.deepEqual(sel, ["tests/unit/memory/store.test.ts"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("changed unit test under usage/ → run itself", () => {
|
||||||
|
const sel = selectImpacted({
|
||||||
|
changed: ["tests/unit/usage/quota.test.ts"],
|
||||||
|
map: MAP,
|
||||||
|
});
|
||||||
|
assert.deepEqual(sel, ["tests/unit/usage/quota.test.ts"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("changed unit test under combo/ → run itself", () => {
|
||||||
|
const sel = selectImpacted({
|
||||||
|
changed: ["tests/unit/combo/routing.test.ts"],
|
||||||
|
map: MAP,
|
||||||
|
});
|
||||||
|
assert.deepEqual(sel, ["tests/unit/combo/routing.test.ts"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("changed unit test under serial/ → run itself", () => {
|
||||||
|
const sel = selectImpacted({
|
||||||
|
changed: ["tests/unit/serial/flaky-once.test.ts"],
|
||||||
|
map: MAP,
|
||||||
|
});
|
||||||
|
assert.deepEqual(sel, ["tests/unit/serial/flaky-once.test.ts"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("changed unit .test.mjs → run itself", () => {
|
||||||
|
const sel = selectImpacted({
|
||||||
|
changed: ["tests/unit/example.test.mjs"],
|
||||||
|
map: MAP,
|
||||||
|
});
|
||||||
|
assert.deepEqual(sel, ["tests/unit/example.test.mjs"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// package.json: tests/unit/**/*.test.mjs (any depth, not only UNIT_SUBDIRS).
|
||||||
|
test("nested .test.mjs outside UNIT_SUBDIRS → run itself", () => {
|
||||||
|
const sel = selectImpacted({
|
||||||
|
changed: ["tests/unit/misc/nested/deep.test.mjs"],
|
||||||
|
map: MAP,
|
||||||
|
});
|
||||||
|
assert.deepEqual(sel, ["tests/unit/misc/nested/deep.test.mjs"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// electron/bin are outside the import-graph map roots — must NOT force __RUN_ALL__.
|
||||||
|
test("changed electron/ file alone → empty (not unit fail-safe)", () => {
|
||||||
|
const sel = selectImpacted({ changed: ["electron/main.js"], map: MAP });
|
||||||
|
assert.deepEqual(sel, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("changed bin/ file alone → empty (not unit fail-safe)", () => {
|
||||||
|
const sel = selectImpacted({ changed: ["bin/omniroute.js"], map: MAP });
|
||||||
|
assert.deepEqual(sel, []);
|
||||||
|
});
|
||||||
|
|||||||
@@ -275,7 +275,8 @@ test("extractCiGates: the REAL ci.yml yields the base-reds that leaked in v3.8.4
|
|||||||
// never ran them — --full-ci now reproduces every one.
|
// never ran them — --full-ci now reproduces every one.
|
||||||
for (const g of [
|
for (const g of [
|
||||||
"check:route-validation:t06",
|
"check:route-validation:t06",
|
||||||
"check:docs-symbols",
|
// openapi-routes + docs-symbols collapsed into one FS walk (#6716).
|
||||||
|
"check:api-docs-refs",
|
||||||
"check:bundle-size",
|
"check:bundle-size",
|
||||||
"check:test-masking",
|
"check:test-masking",
|
||||||
"check:file-size",
|
"check:file-size",
|
||||||
|
|||||||
Reference in New Issue
Block a user