diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index b6cfcb2e8e..1c3fdc5e36 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -40,6 +40,8 @@ jobs: - run: npm run check:fetch-targets - run: npm run check:openapi-routes - 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:file-size - run: npm run check:error-helper @@ -49,6 +51,7 @@ jobs: - run: npm run check:known-symbols - run: npm run check:route-guard-membership - run: npm run check:test-discovery + - run: npm run check:test-runner-api - run: npm run check:any-budget:t11 - name: Typecheck (core) run: npm run typecheck:core @@ -82,3 +85,48 @@ jobs: echo "Running impacted tests:"; echo "$SEL" mapfile -t FILES <<< "$SEL" node --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 "${FILES[@]}" + + fast-vitest: + name: Vitest (fast-path) + runs-on: ubuntu-latest + env: + JWT_SECRET: ci-lint-secret-with-sufficient-length-for-validation + API_KEY_SECRET: ci-lint-api-key-secret-long + DISABLE_SQLITE_AUTO_BACKUP: "true" + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/setup-node@v6 + with: + node-version: ${{ env.CI_NODE_VERSION }} + cache: npm + - run: npm ci + - run: npm run test:vitest + + fast-unit: + name: Unit Tests fast-path (${{ matrix.shard }}/2) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + shard: [1, 2] + env: + JWT_SECRET: ci-lint-secret-with-sufficient-length-for-validation + API_KEY_SECRET: ci-lint-api-key-secret-long + DISABLE_SQLITE_AUTO_BACKUP: "true" + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/setup-node@v6 + with: + node-version: ${{ env.CI_NODE_VERSION }} + cache: npm + - run: npm ci + - run: > + node --max-old-space-size=4096 --import tsx + --import ./tests/_setup/isolateDataDir.ts + --test --test-force-exit --test-concurrency=4 --test-shard=${{ matrix.shard }}/2 + tests/unit/*.test.ts + "tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui}/**/*.test.ts" diff --git a/config/quality/quality-baseline.json b/config/quality/quality-baseline.json index d4e029c2b2..723c6f6b75 100644 --- a/config/quality/quality-baseline.json +++ b/config/quality/quality-baseline.json @@ -122,9 +122,10 @@ "dedicatedGate": true }, "zizmorFindings": { - "value": 155, + "value": 159, "direction": "down", "dedicatedGate": true, + "_rebaseline_2026_06_23_fastpath_gates": "155 -> 159 (+4). Two new jobs added to .github/workflows/quality.yml (fast-vitest, fast-unit) to run vitest + the full unit suite on the PR->release fast-path (release-acceleration plan, _tasks/release-bench/v3.8.35/PLANO-IMPLEMENTACAO.md). The +4 are unpinned-uses: actions/checkout@v7 + actions/setup-node@v6 in each of the 2 jobs — the SAME deliberate @vN convention as every other workflow (see _scanner_harden_workflows_2026_06_16). SHA-pinning only these would violate the convention. No new template-injection/artipacked/cache-poisoning. Measured locally via `npm run check:workflows -- --ratchet` = 159.", "_rebaseline_2026_06_23_v3834_release": "152 -> 155 (+3). The 3 new unpinned-uses are in .github/workflows/nightly-release-green.yml (added by #4622 this cycle): actions/checkout@v7, actions/setup-node@v6, actions/upload-artifact@v4 — the SAME deliberate @vN convention as ci.yml's own checkout@v7/setup-node@v6 and every other workflow (see _scanner_harden_workflows_2026_06_16 + _zizmor_rebaseline_2026_06_20_ci_build_artifact_reuse). SHA-pinning only this workflow would violate the convention. The workflow-lint ratchet does NOT run on PR->release fast-gates, so it surfaced only on the release PR; measured locally via `npm run check:workflows -- --ratchet` = 155. No new template-injection/artipacked/cache-poisoning." }, "vulnCount": { diff --git a/package.json b/package.json index 1517eabfa5..a9960fd774 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "gen:provider-reference": "node --import tsx scripts/docs/gen-provider-reference.ts", "bench:compression": "node --import tsx scripts/compression/benchmark.ts", "eval:compression": "node --import tsx scripts/compression-eval/index.ts", + "release:sync-changelog-i18n": "node scripts/release/sync-changelog-i18n.mjs", "build": "node scripts/build/build-next-isolated.mjs", "build:secure": "OMNIROUTE_BUILD_PROFILE=minimal node scripts/build/build-next-isolated.mjs", "build:cli": "node --import tsx scripts/build/prepublish.ts", @@ -135,6 +136,7 @@ "check:duplication": "node scripts/check/check-duplication.mjs", "check:tracked-artifacts": "node scripts/check/check-tracked-artifacts.mjs", "check:test-masking": "node scripts/check/check-test-masking.mjs", + "check:test-runner-api": "node scripts/check/check-test-runner-api.mjs", "check:error-helper": "node scripts/check/check-error-helper.mjs", "check:migration-numbering": "node scripts/check/check-migration-numbering.mjs", "check:public-creds": "node scripts/check/check-public-creds.mjs", diff --git a/scripts/check/check-test-runner-api.mjs b/scripts/check/check-test-runner-api.mjs new file mode 100644 index 0000000000..f99eda9cc4 --- /dev/null +++ b/scripts/check/check-test-runner-api.mjs @@ -0,0 +1,59 @@ +import fs from "node:fs"; +import path from "node:path"; + +// Dirs collected ONLY by vitest (vitest.mcp.config.ts include globs for .ts tests). +// Keep in sync with vitest.mcp.config.ts. A test here MUST import from "vitest". +const VITEST_ONLY_DIRS = [ + "tests/unit/autoCombo", + "open-sse/services/autoCombo", + "open-sse/mcp-server", +]; + +function walk(dir, root, out = []) { + const abs = path.join(root, dir); + if (!fs.existsSync(abs)) return out; + for (const name of fs.readdirSync(abs)) { + const rel = path.join(dir, name); + const s = fs.statSync(path.join(root, rel)); + if (s.isDirectory()) { + if (name === "node_modules" || name === ".git") continue; + walk(rel, root, out); + } else if (/\.test\.(ts|tsx|js|mjs)$/.test(name)) { + out.push(rel); + } + } + return out; +} + +function isVitestOnly(relFile) { + const norm = relFile.replace(/\\/g, "/"); + return VITEST_ONLY_DIRS.some( + (d) => norm.startsWith(d + "/") && (norm.includes("/__tests__/") || d.startsWith("tests/unit/")) + ); +} + +export function findRunnerMismatches(root) { + const files = VITEST_ONLY_DIRS.flatMap((d) => walk(d, root)); + const bad = []; + for (const f of files) { + if (!isVitestOnly(f)) continue; + const txt = fs.readFileSync(path.join(root, f), "utf8"); + const importsNodeTest = /from\s+["']node:test["']/.test(txt); + const importsVitest = /from\s+["']vitest["']/.test(txt); + if (importsNodeTest && !importsVitest) { + bad.push({ file: f, reason: "vitest-only dir but imports node:test (use the vitest API)" }); + } + } + return bad; +} + +if (import.meta.url === `file://${process.argv[1]}`) { + const root = process.cwd(); + const bad = findRunnerMismatches(root); + if (bad.length) { + console.error(`[test-runner-api] FAIL — ${bad.length} test(s) use the wrong runner API:`); + for (const b of bad) console.error(` ${b.file}: ${b.reason}`); + process.exit(1); + } + console.log("[test-runner-api] OK — vitest-only dirs use the vitest API."); +} diff --git a/scripts/ops/prune-stale-worktrees.sh b/scripts/ops/prune-stale-worktrees.sh new file mode 100755 index 0000000000..8a8a30f716 --- /dev/null +++ b/scripts/ops/prune-stale-worktrees.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Lista (ou remove com --apply) worktrees port-* cuja branch já foi merged ao +# main OU não tem commits à frente do origin/main. NUNCA toca worktrees com +# trabalho não-commitado. Rodar a partir do checkout principal. +set -euo pipefail +APPLY="${1:-}" +git fetch origin main --quiet || true +git worktree list --porcelain | awk '/^worktree /{w=$2} /^branch /{print w" "$2}' | \ +while read -r dir ref; do + case "$dir" in + *"/.claude/worktrees/"*port-*|*"/.worktrees/"*port-*) ;; + *) continue ;; + esac + br="${ref#refs/heads/}" + if [ -n "$(git -C "$dir" status --porcelain 2>/dev/null)" ]; then + echo "SKIP (dirty): $dir" + continue + fi + ahead=$(git -C "$dir" rev-list --count origin/main.."$br" 2>/dev/null || echo "?") + if [ "$ahead" = "0" ]; then + if [ "$APPLY" = "--apply" ]; then + git worktree remove --force "$dir" && git branch -D "$br" 2>/dev/null || true + echo "REMOVED: $dir ($br)" + else + echo "WOULD REMOVE (0 ahead of main): $dir ($br)" + fi + else + echo "KEEP ($ahead ahead): $dir ($br)" + fi +done +echo "Done.${APPLY:+ (applied)}" diff --git a/scripts/release/sync-changelog-i18n.mjs b/scripts/release/sync-changelog-i18n.mjs new file mode 100644 index 0000000000..9fea0daa79 --- /dev/null +++ b/scripts/release/sync-changelog-i18n.mjs @@ -0,0 +1,57 @@ +import fs from "node:fs"; +import path from "node:path"; + +function sectionRange(text, ver, nextVer) { + const lines = text.split("\n"); + const start = lines.findIndex((l) => l.startsWith(`## [${ver}]`)); + if (start < 0) return null; + let end = lines.findIndex((l, i) => i > start && l.startsWith(`## [${nextVer}]`)); + if (end < 0) end = lines.length; + return { lines, start, end }; +} + +// Replace the [ver] section in every docs/i18n//CHANGELOG.md with the root one. +// If a mirror lacks the section, insert it before [nextVer]. Returns count updated. +export function syncChangelogSection(root, ver, nextVer) { + const rootCl = fs.readFileSync(path.join(root, "CHANGELOG.md"), "utf8"); + const r = sectionRange(rootCl, ver, nextVer); + if (!r) throw new Error(`root CHANGELOG missing [${ver}]`); + const block = r.lines.slice(r.start, r.end).join("\n"); + const i18nDir = path.join(root, "docs/i18n"); + if (!fs.existsSync(i18nDir)) return 0; + let n = 0; + for (const loc of fs.readdirSync(i18nDir)) { + const fp = path.join(i18nDir, loc, "CHANGELOG.md"); + if (!fs.existsSync(fp)) continue; + const txt = fs.readFileSync(fp, "utf8"); + const m = sectionRange(txt, ver, nextVer); + let next; + if (m) { + next = + m.lines.slice(0, m.start).join("\n") + + "\n" + + block + + "\n" + + m.lines.slice(m.end).join("\n"); + } else { + const idx = txt.indexOf(`## [${nextVer}]`); + if (idx < 0) continue; + next = txt.slice(0, idx) + block + "\n\n" + txt.slice(idx); + } + if (next !== txt) { + fs.writeFileSync(fp, next); + n++; + } + } + return n; +} + +if (import.meta.url === `file://${process.argv[1]}`) { + const [ver, nextVer] = process.argv.slice(2); + if (!ver || !nextVer) { + console.error("usage: sync-changelog-i18n.mjs "); + process.exit(2); + } + const n = syncChangelogSection(process.cwd(), ver, nextVer); + console.log(`[sync-changelog-i18n] updated ${n} mirror(s) for [${ver}]`); +} diff --git a/tests/unit/build/check-test-runner-api.test.ts b/tests/unit/build/check-test-runner-api.test.ts new file mode 100644 index 0000000000..8de6f9ffde --- /dev/null +++ b/tests/unit/build/check-test-runner-api.test.ts @@ -0,0 +1,35 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { findRunnerMismatches } from "../../../scripts/check/check-test-runner-api.mjs"; + +function tmpRepo() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "runner-api-")); + fs.mkdirSync(path.join(root, "tests/unit/autoCombo"), { recursive: true }); + return root; +} + +test("flags a vitest-only-dir test that imports node:test", () => { + const root = tmpRepo(); + fs.writeFileSync( + path.join(root, "tests/unit/autoCombo/bad.test.ts"), + `import { describe, it } from "node:test";\ndescribe("x", () => it("y", () => {}));\n` + ); + const bad = findRunnerMismatches(root); + assert.equal(bad.length, 1); + assert.match(bad[0].file, /autoCombo\/bad\.test\.ts$/); + assert.match(bad[0].reason, /vitest-only/); + fs.rmSync(root, { recursive: true, force: true }); +}); + +test("accepts a vitest-only-dir test that imports vitest", () => { + const root = tmpRepo(); + fs.writeFileSync( + path.join(root, "tests/unit/autoCombo/good.test.ts"), + `import { describe, it } from "vitest";\ndescribe("x", () => it("y", () => {}));\n` + ); + assert.equal(findRunnerMismatches(root).length, 0); + fs.rmSync(root, { recursive: true, force: true }); +}); diff --git a/tests/unit/build/sync-changelog-i18n.test.ts b/tests/unit/build/sync-changelog-i18n.test.ts new file mode 100644 index 0000000000..14eb8247b0 --- /dev/null +++ b/tests/unit/build/sync-changelog-i18n.test.ts @@ -0,0 +1,47 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { syncChangelogSection } from "../../../scripts/release/sync-changelog-i18n.mjs"; + +function repo() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "cl-i18n-")); + fs.writeFileSync( + path.join(root, "CHANGELOG.md"), + "# Changelog\n\n## [Unreleased]\n\n---\n\n## [9.9.9] — 2026-01-01\n\n- **feat:** big new thing\n- **fix:** another\n\n---\n\n## [9.9.8] — 2025-12-01\n\n- old\n" + ); + for (const loc of ["fr", "de"]) { + fs.mkdirSync(path.join(root, "docs/i18n", loc), { recursive: true }); + fs.writeFileSync( + path.join(root, "docs/i18n", loc, "CHANGELOG.md"), + "# Changelog\n\n---\n\n## [9.9.9] — TBD\n\n_stub_\n\n---\n\n## [9.9.8] — 2025-12-01\n\n- old\n" + ); + } + return root; +} + +test("replaces the version section in every mirror with the root section", () => { + const root = repo(); + const n = syncChangelogSection(root, "9.9.9", "9.9.8"); + assert.equal(n, 2); + const fr = fs.readFileSync(path.join(root, "docs/i18n/fr/CHANGELOG.md"), "utf8"); + assert.match(fr, /big new thing/); + assert.doesNotMatch(fr, /_stub_/); + fs.rmSync(root, { recursive: true, force: true }); +}); + +test("inserts the section when a mirror lacks it", () => { + const root = repo(); + // remove the 9.9.9 section from the fr mirror entirely + fs.writeFileSync( + path.join(root, "docs/i18n/fr/CHANGELOG.md"), + "# Changelog\n\n---\n\n## [9.9.8] — 2025-12-01\n\n- old\n" + ); + const n = syncChangelogSection(root, "9.9.9", "9.9.8"); + assert.equal(n, 2); + const fr = fs.readFileSync(path.join(root, "docs/i18n/fr/CHANGELOG.md"), "utf8"); + assert.match(fr, /## \[9\.9\.9\]/); + assert.match(fr, /big new thing/); + fs.rmSync(root, { recursive: true, force: true }); +});