Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 18 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,15 @@ jobs:
name: Quality Ratchet
runs-on: ubuntu-latest
needs: test-coverage
if: ${{ !cancelled() && needs.test-coverage.result == 'success' }}
# Run even when test-coverage was SKIPPED/FAILED (e.g. a single flaky Coverage
# Shard breaks the shard→coverage→ratchet chain). The DETERMINISTIC ratchets
# (eslint / complexity / cognitive-complexity / duplication / codeql) do NOT need
# the coverage artifact and MUST still run so cycle drift is measured on the
# release PR — otherwise a flake silently skips the whole gate (incident: v3.8.36
# 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 ratchet runs with --allow-missing, so absent coverage is skipped, not failed.
if: ${{ !cancelled() }}
# security-events: read lets the CodeQL ratchet read open code-scanning alerts
# via `gh api .../code-scanning/alerts`. contents: read keeps checkout working.
permissions:
Expand All @@ -156,16 +164,23 @@ jobs:
cache: npm
- uses: ./.github/actions/npm-ci-retry
# 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
# SKIPPED (shard flaky). Nesse caso collect-metrics pula coverage.* (ausente sem
# erro) e o ratchet roda com --allow-missing — as métricas determinísticas
# (eslint/complexity/cognitive) seguem BLOQUEANTES.
- uses: actions/download-artifact@v8
continue-on-error: true
with:
name: coverage-report
path: coverage/
- run: npm run quality:collect
# Catraca: falha se qualquer métrica regredir vs quality-baseline.json (commitado).
# Hoje: contagem de warnings do ESLint. Fase 4 estende com cobertura (lida do
# coverage mergeado). Tamanho de arquivo e duplicação têm gates dedicados.
# --allow-missing: pula métricas do baseline ausentes do collect (coverage.* quando
# o artifact não veio) em vez de falhar — mantém os gates determinísticos ativos.
- name: Ratchet check
run: node scripts/quality/check-quality-ratchet.mjs --summary .artifacts/quality-ratchet.md
run: node scripts/quality/check-quality-ratchet.mjs --allow-missing --summary .artifacts/quality-ratchet.md
# Fase 6A.5: require-tighten — BLOQUEANTE (promovido de advisory no fim do ciclo
# v3.8.27). Falha quando uma métrica MELHOROU sem o baseline ter sido apertado no
# mesmo PR (força capturar ganhos permanentes). As métricas coverage.* carregam
Expand All @@ -174,7 +189,7 @@ jobs:
# mergeada == baseline (ver a nota _require_tighten_flip_blocking em
# config/quality/quality-baseline.json).
- name: Require-tighten (blocking)
run: node scripts/quality/check-quality-ratchet.mjs --require-tighten
run: node scripts/quality/check-quality-ratchet.mjs --allow-missing --require-tighten
# Catraca de duplicação (jscpd@4 sobre src+open-sse). Roda neste job (paralelo)
# para não pesar no caminho crítico do lint.
- name: Duplication ratchet
Expand Down
12 changes: 12 additions & 0 deletions .github/workflows/quality.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,18 @@ jobs:
- run: npm run check:test-discovery
- run: npm run check:test-runner-api
- run: npm run check:any-budget:t11
# Build-scope guard: fails if worktrees/cruft leak into the tsconfig include
# scope (would OOM `next build`). Instant. See incident 2026-06-25 / #5031.
- run: npm run check:build-scope
# Pack-policy (unexpected-files allowlist) WITHOUT a build — catches a stray file
# 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.
- run: npm run check:pack-policy
# Complexity + cognitive-complexity ratchets on the fast-path (PR→release) so
# cycle drift is rebaselined PER-PR instead of cascading onto the release PR's
# Quality Ratchet (v3.8.36: +30 complexity / +15 cognitive surfaced only post-merge).
- run: npm run check:complexity
- run: npm run check:cognitive-complexity
- name: Typecheck (core)
run: npm run typecheck:core
# TIA: build the impact map at runtime (gitignored, ~21MB) and run only the
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@
"i18n:check-ui-coverage": "node scripts/i18n/check-ui-keys-coverage.mjs",
"check:node-runtime": "node --import tsx scripts/check/check-supported-node-runtime.ts",
"check:pack-artifact": "node --import tsx scripts/build/validate-pack-artifact.ts",
"check:pack-policy": "node --import tsx scripts/build/validate-pack-artifact.ts --policy-only",
"check:cli-i18n": "node scripts/check/check-cli-i18n.mjs",
"check:openapi-coverage": "node scripts/check/check-openapi-coverage.mjs",
"check:openapi-security-tiers": "node scripts/check/check-openapi-security-tiers.mjs",
Expand All @@ -137,6 +138,7 @@
"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:build-scope": "node scripts/check/check-build-scope.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",
Expand Down
7 changes: 6 additions & 1 deletion scripts/build/build-next-isolated.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,12 @@ export function resolveNextBuildEnv(baseEnv = process.env) {
// was left unprotected. Respect an existing --max-old-space-size (Docker already
// sets one — don't clobber/duplicate) and let OMNIROUTE_BUILD_MEMORY_MB override.
if (!/--max-old-space-size/.test(env.NODE_OPTIONS || "")) {
const heapMb = Number(baseEnv.OMNIROUTE_BUILD_MEMORY_MB) || 4096;
// Default 8 GB (was 4 GB): the clean module graph peaks ~3.9 GB during the webpack
// production pass, which brushed the old 4 GB ceiling on a borderline OOM. 8 GB gives
// headroom without risk. NOTE: heap size does NOT fix a poisoned scope — if the build
// OOMs/livelocks far above this, check for worktrees/cruft leaking into the tsconfig
// scope (run `npm run check:build-scope`), not for "more heap". See incident 2026-06-25.
const heapMb = Number(baseEnv.OMNIROUTE_BUILD_MEMORY_MB) || 8192;
env.NODE_OPTIONS = `${env.NODE_OPTIONS || ""} --max-old-space-size=${heapMb}`.trim();
}

Expand Down
17 changes: 12 additions & 5 deletions scripts/build/validate-pack-artifact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,18 +74,25 @@ function formatBytes(bytes: number): string {
return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[unitIndex]}`;
}

// --policy-only: skip the build (ensureAppStagingReady → build:cli) and the
// required-runtime-files check (which needs the built dist/), running ONLY the
// unexpected-files allowlist check. The unexpected files (e.g. stray bin/*.sh) are
// SOURCE files that `npm pack --dry-run` lists regardless of build, so this catches
// the "new file leaked into the tarball" regression cheaply on the fast-path (PR→release),
// instead of only on the release PR's full Package Artifact job. See incident v3.8.36 (#5029).
const POLICY_ONLY = process.argv.includes("--policy-only");

try {
ensureAppStagingReady();
if (!POLICY_ONLY) ensureAppStagingReady();
const packReport = runPackDryRun();
const artifactPaths: string[] = packReport.files.map((file: any) => file.path);
const unexpectedPaths: string[] = findUnexpectedArtifactPaths(artifactPaths, {
exactPaths: PACK_ARTIFACT_ALLOWED_EXACT_PATHS,
prefixPaths: PACK_ARTIFACT_ALLOWED_PATH_PREFIXES,
});
const missingRequiredPaths: string[] = findMissingArtifactPaths(
artifactPaths,
PACK_ARTIFACT_REQUIRED_PATHS
);
const missingRequiredPaths: string[] = POLICY_ONLY
? []
: findMissingArtifactPaths(artifactPaths, PACK_ARTIFACT_REQUIRED_PATHS);

console.log("📦 npm pack artifact summary");
console.log(` File: ${packReport.filename}`);
Expand Down
95 changes: 95 additions & 0 deletions scripts/check/check-build-scope.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
#!/usr/bin/env node
// check-build-scope.mjs — guards against worktrees / cruft leaking into the
// TypeScript build scope and poisoning `next build`.
//
// Root cause of the 2026-06-25 build OOM/GC-livelock incident: `tsconfig.json`
// uses `include: ["**/*.ts","**/*.tsx","**/*.js","**/*.jsx"]` (recursive glob),
// and 69 git worktrees under `.claude/worktrees/` were NOT in `exclude` — so the
// TS scope ballooned to 355,215 files (vs 4,547 real source files) and `next build`
// processed ~70x the codebase, OOMing even at a 64 GB heap. The CI built fine
// because its checkout is clean.
//
// This gate counts the .ts/.tsx/.js/.jsx files that tsconfig's include would match
// (respecting its top-level exclude dirs) and FAILS if the count exceeds a
// threshold — catching a leak BEFORE it detonates the build. Heap size is NOT the
// fix for an over-large scope; a clean scope is.
//
// Usage: node scripts/check/check-build-scope.mjs [--max N] [--json]

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 args = process.argv.slice(2);
const MAX = Number(args[args.indexOf("--max") + 1]) || 12000;
const JSON_OUT = args.includes("--json");

const EXT = new Set([".ts", ".tsx", ".js", ".jsx"]);

// Read tsconfig.json exclude (the source of truth for what's out of scope).
const tsconfigPath = path.join(ROOT, "tsconfig.json");
let exclude = [];
try {
exclude = JSON.parse(fs.readFileSync(tsconfigPath, "utf8")).exclude || [];
} catch {
console.error("[build-scope] could not read tsconfig.json exclude — aborting");
process.exit(2);
}
// Always skip VCS + the exclude dirs (normalise to bare top-level names).
const SKIP_DIRS = new Set([".git", ...exclude.map((e) => e.replace(/^\.\//, "").replace(/\/.*$/, ""))]);

let count = 0;
const byTop = {};
function walk(dir, top) {
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return;
}
for (const e of entries) {
if (e.isSymbolicLink()) continue; // don't follow symlinks (e.g. node_modules)
const full = path.join(dir, e.name);
if (e.isDirectory()) {
walk(full, top);
} else if (EXT.has(path.extname(e.name))) {
count++;
byTop[top] = (byTop[top] || 0) + 1;
}
}
}

for (const e of fs.readdirSync(ROOT, { withFileTypes: true })) {
if (!e.isDirectory()) {
if (EXT.has(path.extname(e.name))) {
count++;
byTop["(root)"] = (byTop["(root)"] || 0) + 1;
}
continue;
}
if (SKIP_DIRS.has(e.name)) continue;
walk(path.join(ROOT, e.name), e.name);
}

if (JSON_OUT) {
console.log(JSON.stringify({ count, max: MAX, byTop }, null, 2));
} else {
console.log(`[build-scope] ${count} .ts/.tsx/.js/.jsx files in tsconfig scope (max ${MAX})`);
const top = Object.entries(byTop)
.sort((a, b) => b[1] - a[1])
.slice(0, 8);
for (const [d, n] of top) console.log(` ${String(n).padStart(7)} ${d}`);
}

if (count > MAX) {
console.error(
`\n❌ [build-scope] scope of ${count} files exceeds ${MAX} — something is leaking into the\n` +
` tsconfig include scope (worktree, vendored copy, or build output). This poisons\n` +
` \`next build\` (OOM/GC-livelock). Add the offending dir to tsconfig.json "exclude"\n` +
` (and .dockerignore). Worktrees MUST live under .claude/worktrees/ (already excluded).\n` +
` Heap size does NOT fix this — a clean scope does. See incident 2026-06-25.`
);
process.exit(1);
}
console.log("✅ [build-scope] OK — no leak into the build scope.");
Loading