chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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.");
|
||||
@@ -0,0 +1,285 @@
|
||||
#!/usr/bin/env node
|
||||
// scripts/check/check-bundle-size.mjs
|
||||
// Catraca de bundle size (Task 12 — Fase 7).
|
||||
//
|
||||
// MODO PREFERENCIAL — size-limit + @size-limit/file (ou outro plugin):
|
||||
// Rodar `size-limit --json` via .size-limit.json; extrair o campo `size` de cada
|
||||
// entry e somar. Emite `bundleSize=<bytes>`.
|
||||
//
|
||||
// MODO FALLBACK — raw fs.statSync() (sem plugins instalados):
|
||||
// Quando size-limit retorna "no plugins" (isEmpty — só o core está instalado), o
|
||||
// script lê os `path` declarados em .size-limit.json diretamente via fs.statSync()
|
||||
// e soma os bytes. Mesmas entradas, mesma métrica. Emite `bundleSize=<bytes>`.
|
||||
//
|
||||
// MODO SKIP — entradas inexistentes:
|
||||
// Se nenhuma das entradas do .size-limit.json existir (ex: build não rodou e os
|
||||
// arquivos apontados são artefatos gerados), emite `bundleSize=SKIP reason=no-build`
|
||||
// e sai 0.
|
||||
//
|
||||
// Por default é ADVISORY: sempre sai 0 independente do resultado. Passe --ratchet
|
||||
// para tornar BLOQUEANTE: lê metrics.bundleSize.value de
|
||||
// config/quality/quality-baseline.json, compara o total MEDIDO e SAI 1 SE — E SOMENTE
|
||||
// SE — o medido for MAIOR que o baseline (regressão real, direction:down).
|
||||
//
|
||||
// IMPORTANTE: o baseline (5601) é o valor GZIP do size-limit + @size-limit/file
|
||||
// (instalado por 'npm ci' no CI). O modo FALLBACK-stat lê bytes CRUS (uma métrica
|
||||
// DIFERENTE e maior) — comparar fallback-stat contra o baseline gzip seria um falso-
|
||||
// positivo. Por isso o --ratchet SÓ bloqueia quando a medição veio do size-limit
|
||||
// REAL (plugin presente); o fallback-stat e o no-build são SKIP gracioso (exit 0)
|
||||
// mesmo com --ratchet — falta de plugin/build nunca bloqueia, só uma regressão
|
||||
// medida na MESMA métrica do baseline bloqueia.
|
||||
//
|
||||
// Uso:
|
||||
// node scripts/check/check-bundle-size.mjs
|
||||
// node scripts/check/check-bundle-size.mjs --json (força saída JSON de size-limit se possível)
|
||||
// node scripts/check/check-bundle-size.mjs --ratchet (falha exit 1 numa regressão)
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { pathToFileURL, fileURLToPath } from "node:url";
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const SIZE_LIMIT_CONFIG = path.join(ROOT, ".size-limit.json");
|
||||
const SIZE_LIMIT_BIN = path.join(ROOT, "node_modules", ".bin", "size-limit");
|
||||
const BASELINE_PATH = path.join(ROOT, "config/quality/quality-baseline.json");
|
||||
const RATCHET = process.argv.includes("--ratchet");
|
||||
|
||||
/**
|
||||
* Tenta rodar size-limit --json e retorna o array de resultados.
|
||||
* Lança se size-limit não tiver plugins instalados (plugins.isEmpty).
|
||||
*
|
||||
* @returns {Array<{name: string, size: number, sizeLimit?: number, passed?: boolean}>}
|
||||
* @throws {SizeLimitNoPluginsError}
|
||||
*/
|
||||
export function runSizeLimit(cwd = ROOT, binPath = SIZE_LIMIT_BIN) {
|
||||
if (!fs.existsSync(binPath)) {
|
||||
throw Object.assign(new Error("size-limit binary not found"), { code: "SL_NO_BIN" });
|
||||
}
|
||||
let stdout;
|
||||
try {
|
||||
stdout = execFileSync("node", [binPath, "--json"], {
|
||||
encoding: "utf8",
|
||||
cwd,
|
||||
maxBuffer: 8 * 1024 * 1024,
|
||||
});
|
||||
} catch (err) {
|
||||
const combined = (err.stdout || "") + (err.stderr || "");
|
||||
if (
|
||||
combined.includes("Install Size Limit preset") ||
|
||||
combined.includes("plugins.isEmpty") ||
|
||||
combined.includes("@size-limit/preset")
|
||||
) {
|
||||
throw Object.assign(new Error("size-limit: no plugins installed"), {
|
||||
code: "SL_NO_PLUGINS",
|
||||
});
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
return JSON.parse(stdout.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* Parseia o JSON de saída do size-limit e retorna o total em bytes.
|
||||
* Lança se o JSON não tiver o campo `size` em pelo menos uma entrada.
|
||||
*
|
||||
* @param {Array<{name: string, size?: number}>} results
|
||||
* @returns {number} total em bytes
|
||||
*/
|
||||
export function parseSizeLimitResults(results) {
|
||||
if (!Array.isArray(results)) {
|
||||
throw new TypeError("parseSizeLimitResults: esperado array de resultados");
|
||||
}
|
||||
let total = 0;
|
||||
let hasMeasured = false;
|
||||
for (const entry of results) {
|
||||
if (typeof entry.size === "number") {
|
||||
total += entry.size;
|
||||
hasMeasured = true;
|
||||
}
|
||||
}
|
||||
if (!hasMeasured) {
|
||||
throw new Error("parseSizeLimitResults: nenhuma entrada com campo `size` numérico");
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback: lê os `path` do .size-limit.json via fs.statSync().
|
||||
* Retorna {total, entries, allMissing} onde:
|
||||
* - total: soma dos bytes dos arquivos encontrados
|
||||
* - entries: [{name, path, size}]
|
||||
* - allMissing: true se NENHUM arquivo existia (skip)
|
||||
*
|
||||
* @param {string} configPath
|
||||
* @param {string} cwd
|
||||
*/
|
||||
export function measureViaFileStat(configPath = SIZE_LIMIT_CONFIG, cwd = ROOT) {
|
||||
if (!fs.existsSync(configPath)) {
|
||||
return { total: 0, entries: [], allMissing: true };
|
||||
}
|
||||
const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
||||
let total = 0;
|
||||
let found = 0;
|
||||
const entries = [];
|
||||
for (const entry of config) {
|
||||
const entryPath = path.isAbsolute(entry.path) ? entry.path : path.join(cwd, entry.path);
|
||||
if (!fs.existsSync(entryPath)) {
|
||||
entries.push({ name: entry.name, path: entry.path, size: null });
|
||||
continue;
|
||||
}
|
||||
const size = fs.statSync(entryPath).size;
|
||||
total += size;
|
||||
found++;
|
||||
entries.push({ name: entry.name, path: entry.path, size });
|
||||
}
|
||||
return { total, entries, allMissing: found === 0 };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ratchet (direction:down) — exported for tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Avalia o total MEDIDO de bytes contra o baseline.
|
||||
* Direction: down (o tamanho só pode CAIR — maior = regressão).
|
||||
*
|
||||
* @param {number} current - Total de bytes medido agora (gzip, via size-limit).
|
||||
* @param {number} baseline - Total congelado em quality-baseline.json.
|
||||
* @returns {{ regressed: boolean, improved: boolean }}
|
||||
*/
|
||||
export function evaluateBundleSizeRatchet(current, baseline) {
|
||||
return {
|
||||
regressed: current > baseline,
|
||||
improved: current < baseline,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Lê metrics.bundleSize.value do quality-baseline.json.
|
||||
* Retorna null se o arquivo ou a métrica estiverem ausentes (sem baseline não há
|
||||
* ratchet possível — o caller trata como SKIP gracioso, exit 0).
|
||||
*
|
||||
* @param {string} baselinePath
|
||||
* @returns {number|null}
|
||||
*/
|
||||
export function readBaselineBundleSizeValue(baselinePath = BASELINE_PATH) {
|
||||
if (!fs.existsSync(baselinePath)) return null;
|
||||
let baselineJson;
|
||||
try {
|
||||
baselineJson = JSON.parse(fs.readFileSync(baselinePath, "utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const metric = baselineJson?.metrics?.bundleSize;
|
||||
if (!metric || typeof metric.value !== "number") return null;
|
||||
return metric.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aplica o ratchet (direction:down) sobre o total medido vs o baseline.
|
||||
* Sem --ratchet: advisory (exit 0). Com --ratchet + medição comparável (gzip via
|
||||
* size-limit): exit 1 numa regressão real (medido > baseline). Baseline ausente →
|
||||
* SKIP gracioso (exit 0). Define process.exitCode; não lança.
|
||||
*
|
||||
* @param {number} totalBytes - Total MEDIDO pelo size-limit (gzip).
|
||||
*/
|
||||
function applyRatchet(totalBytes) {
|
||||
if (!RATCHET) {
|
||||
process.exitCode = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const baselineValue = readBaselineBundleSizeValue(BASELINE_PATH);
|
||||
if (baselineValue === null) {
|
||||
console.log("[bundle-size] --ratchet: baseline ausente (metrics.bundleSize) — SKIP, sai 0.");
|
||||
process.exitCode = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const { regressed } = evaluateBundleSizeRatchet(totalBytes, baselineValue);
|
||||
if (regressed) {
|
||||
console.error(
|
||||
`[bundle-size] REGRESSÃO — ${totalBytes} bytes > baseline ${baselineValue}.\n` +
|
||||
" → Reduza o tamanho dos entrypoints, ou re-baseline metrics.bundleSize em\n" +
|
||||
" config/quality/quality-baseline.json se o crescimento for legítimo e justificado."
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
console.log(
|
||||
`[bundle-size] --ratchet OK — ${totalBytes} bytes, baseline ${baselineValue} (sem regressão).`
|
||||
);
|
||||
process.exitCode = 0;
|
||||
}
|
||||
|
||||
function main() {
|
||||
// Step 1: tenta com size-limit + plugin instalado
|
||||
let totalBytes = null;
|
||||
let mode = "size-limit";
|
||||
|
||||
try {
|
||||
const results = runSizeLimit(ROOT, SIZE_LIMIT_BIN);
|
||||
totalBytes = parseSizeLimitResults(results);
|
||||
} catch (err) {
|
||||
if (err.code === "SL_NO_PLUGINS" || err.code === "SL_NO_BIN") {
|
||||
// Step 2: fallback para leitura direta de arquivo
|
||||
mode = "fallback-stat";
|
||||
const { total, entries, allMissing } = measureViaFileStat(SIZE_LIMIT_CONFIG, ROOT);
|
||||
|
||||
if (allMissing) {
|
||||
// Step 3: skip gracioso — entradas não existem (build necessário).
|
||||
// SKIP sai 0 mesmo com --ratchet (build ausente nunca bloqueia).
|
||||
console.log("bundleSize=SKIP reason=no-build");
|
||||
if (process.env.CI) {
|
||||
console.log(
|
||||
"::notice::check-bundle-size skipped — entradas do .size-limit.json não encontradas (build necessário)"
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
totalBytes = total;
|
||||
for (const e of entries) {
|
||||
if (e.size !== null) {
|
||||
const kb = (e.size / 1024).toFixed(2);
|
||||
console.log(` ${e.name}: ${kb} KB (${e.size} bytes)`);
|
||||
} else {
|
||||
console.log(` ${e.name}: ausente (não contabilizado)`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Erro inesperado — reporta mas não falha (advisory).
|
||||
// SKIP sai 0 mesmo com --ratchet (erro de medição nunca bloqueia).
|
||||
console.error(`[bundle-size] Aviso: size-limit retornou erro inesperado: ${err.message}`);
|
||||
console.log("bundleSize=SKIP reason=size-limit-error");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const kb = (totalBytes / 1024).toFixed(2);
|
||||
console.log(`bundleSize=${totalBytes}`);
|
||||
|
||||
// O ratchet só pode comparar a MESMA métrica que congelou o baseline (gzip via
|
||||
// size-limit + @size-limit/file). O fallback-stat lê bytes CRUS — uma métrica
|
||||
// diferente e maior — então com --ratchet ele faz SKIP gracioso (exit 0) em vez
|
||||
// de um falso-positivo. Sem --ratchet, ambos os modos só reportam (advisory).
|
||||
if (mode !== "size-limit") {
|
||||
if (RATCHET) {
|
||||
console.log(
|
||||
`[bundle-size] ${mode}: total ${kb} KB (${totalBytes} bytes) — ` +
|
||||
"--ratchet SKIP (medição não-comparável ao baseline gzip; instale @size-limit/file)."
|
||||
);
|
||||
process.exitCode = 0;
|
||||
return;
|
||||
}
|
||||
console.log(`[bundle-size] ${mode}: total ${kb} KB (${totalBytes} bytes) — advisory, saindo 0`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!RATCHET) {
|
||||
console.log(`[bundle-size] ${mode}: total ${kb} KB (${totalBytes} bytes) — advisory, saindo 0`);
|
||||
}
|
||||
applyRatchet(totalBytes);
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();
|
||||
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env node
|
||||
// scripts/check/check-changelog-integrity.mjs
|
||||
//
|
||||
// Anti "CHANGELOG-eat" gate: no bullet line that exists in the BASE branch's
|
||||
// CHANGELOG.md may disappear in the merge result. The chronic failure mode is
|
||||
// git's merge auto-resolve silently dropping sibling bullets (or whole version
|
||||
// sections) when two branches touch adjacent CHANGELOG lines — incident
|
||||
// 2026-07-05: PR #6193's merge ate 212 lines (the entire [3.8.45] + [3.8.44]
|
||||
// sections, 130 bullets), only recovered by hand from the pre-merge ref.
|
||||
//
|
||||
// On pull_request CI the checkout is refs/pull/N/merge — the auto-resolved
|
||||
// merge result — so comparing it against origin/<base> catches the eat BEFORE
|
||||
// the merge lands, in the PR that would cause it.
|
||||
//
|
||||
// Policy (Princípio Zero): this only ever ADDS work for the maintainer side —
|
||||
// quality.yml runs it blocking for own-origin PRs and report-only for forks.
|
||||
// The release captain's reconciliation rewrites the CHANGELOG legitimately,
|
||||
// but that happens on the release PR (PR → main, ci.yml), which does not run
|
||||
// this gate. Escape hatch for intentional removals (e.g. reverting a reverted
|
||||
// feature's bullet): ALLOW_CHANGELOG_REMOVALS=1 turns failures into a report.
|
||||
//
|
||||
// Usage:
|
||||
// node scripts/check/check-changelog-integrity.mjs
|
||||
// env GITHUB_BASE_REF PR base branch (CI); local fallback: current release/*
|
||||
// env CHANGELOG_BASE_REF explicit ref override (e.g. origin/release/v3.8.45)
|
||||
// env ALLOW_CHANGELOG_REMOVALS=1 report-only (never fails)
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||
const CHANGELOG = "CHANGELOG.md";
|
||||
|
||||
/** Extract the set of bullet lines (trimmed) from a CHANGELOG text. */
|
||||
export function extractBullets(text) {
|
||||
const bullets = new Set();
|
||||
for (const raw of String(text || "").split("\n")) {
|
||||
const line = raw.trim();
|
||||
if (line.startsWith("- ") && line.length > 4) bullets.add(line);
|
||||
}
|
||||
return bullets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bullet lines present in the base CHANGELOG but absent from the head
|
||||
* CHANGELOG — the "eaten" set. Pure so it has a unit test.
|
||||
*/
|
||||
export function findLostBullets(baseText, headText) {
|
||||
const headBullets = extractBullets(headText);
|
||||
const lost = [];
|
||||
for (const b of extractBullets(baseText)) {
|
||||
if (!headBullets.has(b)) lost.push(b);
|
||||
}
|
||||
return lost;
|
||||
}
|
||||
|
||||
function git(args) {
|
||||
return execFileSync("git", args, { cwd: ROOT, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
||||
}
|
||||
|
||||
function resolveBaseRef() {
|
||||
if (process.env.CHANGELOG_BASE_REF) return process.env.CHANGELOG_BASE_REF;
|
||||
if (process.env.GITHUB_BASE_REF) return `origin/${process.env.GITHUB_BASE_REF}`;
|
||||
// Local fallback: the highest release/v* on origin (the active development base).
|
||||
try {
|
||||
const branches = git(["branch", "-r", "--list", "origin/release/v*", "--format=%(refname:short)"])
|
||||
.split("\n")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
|
||||
return branches[branches.length - 1] || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
const baseRef = resolveBaseRef();
|
||||
if (!baseRef) {
|
||||
console.log("[changelog-integrity] SKIP — could not resolve a base ref (offline/fresh clone).");
|
||||
return 0;
|
||||
}
|
||||
|
||||
let baseText;
|
||||
try {
|
||||
baseText = git(["show", `${baseRef}:${CHANGELOG}`]);
|
||||
} catch {
|
||||
console.log(`[changelog-integrity] SKIP — ${CHANGELOG} not readable at ${baseRef}.`);
|
||||
return 0;
|
||||
}
|
||||
const headText = readFileSync(join(ROOT, CHANGELOG), "utf8");
|
||||
|
||||
const lost = findLostBullets(baseText, headText);
|
||||
if (lost.length === 0) {
|
||||
console.log(`[changelog-integrity] OK — no base bullets lost vs ${baseRef}.`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
console.error(
|
||||
`[changelog-integrity] ${lost.length} bullet(s) present in ${baseRef} are MISSING from this tree's ${CHANGELOG}:`
|
||||
);
|
||||
for (const b of lost.slice(0, 15)) console.error(` ✗ ${b.slice(0, 160)}`);
|
||||
if (lost.length > 15) console.error(` … and ${lost.length - 15} more`);
|
||||
console.error(
|
||||
"\nThis is the CHANGELOG-eat pattern (merge auto-resolve dropping sibling bullets)." +
|
||||
"\nFix: restore the base CHANGELOG (`git checkout <base> -- CHANGELOG.md`), re-insert ONLY" +
|
||||
"\nyour own bullet, and prove the net diff is additive. Intentional removals (rare):" +
|
||||
"\nre-run with ALLOW_CHANGELOG_REMOVALS=1 and justify in the PR body."
|
||||
);
|
||||
if (process.env.ALLOW_CHANGELOG_REMOVALS === "1") {
|
||||
console.error("[changelog-integrity] ALLOW_CHANGELOG_REMOVALS=1 — reporting only, not failing.");
|
||||
return 0;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
||||
process.exit(main());
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env node
|
||||
// scripts/check/check-circular-deps.mjs
|
||||
// Gate: dpdm circular-deps cross-check (segunda opinião complementar ao check-cycles.mjs).
|
||||
//
|
||||
// check-cycles.mjs usa AST-TS próprio mas cobre apenas 5 sub-árvores + somente
|
||||
// imports relativos. Este script usa dpdm (v4) que rastreia path-aliases via
|
||||
// tsconfig.json e cobre entrypoints de alto risco.
|
||||
//
|
||||
// Advisory nesta versão: exit 0 sempre; imprime `circularDeps=N` para baseline.
|
||||
// Direção da catraca: down (não pode subir). Adicionar ao quality-baseline.json
|
||||
// como `{ value: N, direction: "down" }` após a primeira run verde no CI.
|
||||
//
|
||||
// Escopo limitado a 4 entrypoints principais para manter o tempo de análise
|
||||
// abaixo de 60s. dpdm rastreia transitivamente todas as deps de cada entry.
|
||||
// Cobrir mais entries aumenta o tempo sem proporcional ganho (as deps core se
|
||||
// repetem via transitividade).
|
||||
//
|
||||
// Nota: dpdm pode reportar mais ciclos que check-cycles.mjs porque conta
|
||||
// permutações de paths que passam pelo mesmo SCC, não apenas SCCs únicos.
|
||||
// Isso é esperado — ferramentas diferentes, métricas complementares.
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve, dirname } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const projectRoot = resolve(__dirname, "../..");
|
||||
|
||||
// Entrypoints: cobrem o pipeline principal (chat, combo, MCP, DB).
|
||||
// Mantido enxuto para que `dpdm -T` (transform) termine em < 60s.
|
||||
const ENTRYPOINTS = [
|
||||
"open-sse/handlers/chatCore.ts",
|
||||
"open-sse/services/combo.ts",
|
||||
"open-sse/mcp-server/index.ts",
|
||||
"src/lib/db/core.ts",
|
||||
];
|
||||
|
||||
const DPDM_BIN = resolve(projectRoot, "node_modules/.bin/dpdm");
|
||||
const TSCONFIG = resolve(projectRoot, "tsconfig.json");
|
||||
|
||||
/**
|
||||
* Parseia a saída JSON do dpdm e retorna a contagem de ciclos.
|
||||
* Função exportada para ser testada isoladamente sem executar o dpdm.
|
||||
*
|
||||
* @param {string} jsonStr - string com o JSON de saída do dpdm (campo "circulars").
|
||||
* @returns {{ count: number, circulars: string[][] }}
|
||||
*/
|
||||
export function parseDpdmOutput(jsonStr) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(jsonStr);
|
||||
} catch {
|
||||
throw new Error(`dpdm JSON parse failed: ${jsonStr.slice(0, 200)}`);
|
||||
}
|
||||
const circulars = Array.isArray(parsed.circulars) ? parsed.circulars : [];
|
||||
return { count: circulars.length, circulars };
|
||||
}
|
||||
|
||||
/**
|
||||
* Executa o dpdm e retorna a string JSON bruta do arquivo de saída.
|
||||
*
|
||||
* @returns {string} conteúdo JSON do arquivo temporário.
|
||||
*/
|
||||
function runDpdm() {
|
||||
if (!existsSync(DPDM_BIN)) {
|
||||
throw new Error(`dpdm binary not found at ${DPDM_BIN}. Run: npm install`);
|
||||
}
|
||||
|
||||
const tmpFile = path.join(os.tmpdir(), `dpdm-output-${process.pid}.json`);
|
||||
|
||||
try {
|
||||
execFileSync(
|
||||
"node",
|
||||
[
|
||||
DPDM_BIN,
|
||||
"--circular",
|
||||
"--no-warning",
|
||||
"--no-tree",
|
||||
"-T",
|
||||
"--tsconfig",
|
||||
TSCONFIG,
|
||||
"-o",
|
||||
tmpFile,
|
||||
...ENTRYPOINTS,
|
||||
],
|
||||
{
|
||||
cwd: projectRoot,
|
||||
stdio: "inherit",
|
||||
timeout: 120_000,
|
||||
}
|
||||
);
|
||||
|
||||
if (!existsSync(tmpFile)) {
|
||||
throw new Error(`dpdm did not produce output file at ${tmpFile}`);
|
||||
}
|
||||
|
||||
const raw = readFileSync(tmpFile, "utf8");
|
||||
return raw;
|
||||
} finally {
|
||||
try {
|
||||
fs.unlinkSync(tmpFile);
|
||||
} catch {
|
||||
// best-effort cleanup
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
console.log("[circular-deps] Running dpdm cross-check...");
|
||||
console.log(`[circular-deps] Entrypoints: ${ENTRYPOINTS.join(", ")}`);
|
||||
|
||||
let raw;
|
||||
try {
|
||||
raw = runDpdm();
|
||||
} catch (err) {
|
||||
console.error(`[circular-deps] ERROR running dpdm: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = parseDpdmOutput(raw);
|
||||
} catch (err) {
|
||||
console.error(`[circular-deps] ERROR parsing dpdm output: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Advisory mode: always exit 0. Catraca pode ser adicionada no quality-baseline.json
|
||||
// após baseline ser estabelecida.
|
||||
console.log(`[circular-deps] circularDeps=${result.count}`);
|
||||
console.log(
|
||||
`[circular-deps] Advisory — add to quality-baseline.json: { "value": ${result.count}, "direction": "down" }`
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Permite que o módulo seja importado em testes sem executar main().
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();
|
||||
@@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Validates that:
|
||||
* 1. All t("key") calls in bin/cli/commands/ resolve to existing keys in en.json.
|
||||
* 2. pt-BR.json has the same top-level shape as en.json (no missing top-level sections).
|
||||
* 3. No raw string literals are passed to .description() in commands without going
|
||||
* through t() — only warns, does not fail hard (many descriptions use || fallback).
|
||||
*/
|
||||
import { readFileSync, readdirSync, statSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(__dirname, "..", "..");
|
||||
const COMMANDS_DIR = join(ROOT, "bin", "cli", "commands");
|
||||
const LOCALES_DIR = join(ROOT, "bin", "cli", "locales");
|
||||
|
||||
// Paths that look like t() keys but are actually import paths — skip them.
|
||||
const IGNORE_AS_KEY = new Set([".", ".."]);
|
||||
const IMPORT_PATH_RE = /^(\.\.?\/|node:|\/)/;
|
||||
|
||||
function walk(dir) {
|
||||
const results = [];
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
if (statSync(full).isDirectory()) {
|
||||
results.push(...walk(full));
|
||||
} else if (entry.endsWith(".mjs") || entry.endsWith(".js")) {
|
||||
results.push(full);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
function flattenKeys(obj, prefix = "") {
|
||||
const keys = new Set();
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
const full = prefix ? `${prefix}.${k}` : k;
|
||||
if (v !== null && typeof v === "object" && !Array.isArray(v)) {
|
||||
for (const sub of flattenKeys(v, full)) keys.add(sub);
|
||||
} else {
|
||||
keys.add(full);
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
function collectTKeys(files) {
|
||||
const used = new Set();
|
||||
const re = /\bt\(\s*["']([^"']+)["']/g;
|
||||
for (const file of files) {
|
||||
const src = readFileSync(file, "utf8");
|
||||
let m;
|
||||
re.lastIndex = 0;
|
||||
while ((m = re.exec(src)) !== null) {
|
||||
const key = m[1];
|
||||
if (IGNORE_AS_KEY.has(key) || IMPORT_PATH_RE.test(key)) continue;
|
||||
used.add(key);
|
||||
}
|
||||
}
|
||||
return used;
|
||||
}
|
||||
|
||||
function loadJson(file) {
|
||||
return JSON.parse(readFileSync(file, "utf8"));
|
||||
}
|
||||
|
||||
const files = walk(COMMANDS_DIR);
|
||||
const usedKeys = collectTKeys(files);
|
||||
const en = loadJson(join(LOCALES_DIR, "en.json"));
|
||||
const ptBR = loadJson(join(LOCALES_DIR, "pt-BR.json"));
|
||||
const enKeys = flattenKeys(en);
|
||||
|
||||
let errors = 0;
|
||||
|
||||
// Check 1: all used keys exist in en.json
|
||||
const missingInEn = [...usedKeys].filter((k) => !enKeys.has(k));
|
||||
if (missingInEn.length > 0) {
|
||||
console.error("[cli-i18n] Keys used in commands but missing in en.json:");
|
||||
for (const k of missingInEn) console.error(` ✗ ${k}`);
|
||||
errors += missingInEn.length;
|
||||
} else {
|
||||
console.log(`[cli-i18n] ✓ All ${usedKeys.size} t() keys found in en.json`);
|
||||
}
|
||||
|
||||
// Check 2: pt-BR.json has the same top-level sections as en.json
|
||||
const enTopLevel = Object.keys(en);
|
||||
const ptTopLevel = new Set(Object.keys(ptBR));
|
||||
const missingTopLevel = enTopLevel.filter((k) => !ptTopLevel.has(k));
|
||||
if (missingTopLevel.length > 0) {
|
||||
console.error("[cli-i18n] Top-level sections in en.json missing from pt-BR.json:");
|
||||
for (const k of missingTopLevel) console.error(` ✗ ${k}`);
|
||||
errors += missingTopLevel.length;
|
||||
} else {
|
||||
console.log(`[cli-i18n] ✓ pt-BR.json has all ${enTopLevel.length} top-level sections`);
|
||||
}
|
||||
|
||||
if (errors > 0) {
|
||||
console.error(`[cli-i18n] FAIL — ${errors} error(s) found`);
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log("[cli-i18n] PASS — CLI i18n is consistent");
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
#!/usr/bin/env node
|
||||
// scripts/check/check-codeql-ratchet.mjs
|
||||
// Catraca de alertas CodeQL (Task 7.3 — Fase 7).
|
||||
//
|
||||
// Usa a GitHub API via `gh` CLI para buscar alertas de code-scanning abertos e
|
||||
// não-dismissed (respeita Hard Rule #14: alertas dismissed não contam).
|
||||
//
|
||||
// Saída (stdout):
|
||||
// codeqlAlerts=N — contagem de alertas CodeQL abertos, não-dismissed
|
||||
// codeqlAlerts=SKIP reason=binary-absent — `gh` não está no PATH
|
||||
// codeqlAlerts=SKIP reason=no-auth — `gh` presente mas sem autenticação
|
||||
// codeqlAlerts=SKIP reason=api-error:<code> — erro da API GitHub
|
||||
//
|
||||
// RATCHET BLOQUEANTE (default): lê metrics.codeqlAlerts.value de
|
||||
// config/quality/quality-baseline.json e SAI 1 SE — E SOMENTE SE — a contagem
|
||||
// MEDIDA for MAIOR que o baseline (regressão real, mais alertas CodeQL abertos).
|
||||
// Qualquer falha de MEDIÇÃO (gh ausente / sem auth / sem repo / erro de API) é um
|
||||
// SKIP gracioso que SAI 0 — nunca bloqueia o build por falta de infraestrutura.
|
||||
// Direction: down (a contagem só pode CAIR). Suporta --update para ratchetar.
|
||||
//
|
||||
// Uso:
|
||||
// node scripts/check/check-codeql-ratchet.mjs
|
||||
// node scripts/check/check-codeql-ratchet.mjs --json # imprime array de alertas
|
||||
// node scripts/check/check-codeql-ratchet.mjs --quiet # suprime logs de diagnóstico
|
||||
// node scripts/check/check-codeql-ratchet.mjs --update # ratcheta o baseline (queda)
|
||||
// node scripts/check/check-codeql-ratchet.mjs --advisory # nunca falha (modo coletor)
|
||||
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const QUIET = process.argv.includes("--quiet");
|
||||
const PRINT_JSON = process.argv.includes("--json");
|
||||
const UPDATE = process.argv.includes("--update");
|
||||
// --advisory: nunca falha pela contagem (modo coletor legado). Sem esta flag o
|
||||
// gate é BLOQUEANTE: sai 1 numa regressão real (medida > baseline).
|
||||
const ADVISORY = process.argv.includes("--advisory");
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const BASELINE_PATH = path.resolve(
|
||||
process.argv.includes("--baseline")
|
||||
? process.argv[process.argv.indexOf("--baseline") + 1]
|
||||
: path.join(ROOT, "config/quality/quality-baseline.json")
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure parsing function (exported for tests)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Conta alertas CodeQL abertos e não-dismissed a partir do JSON da GitHub API.
|
||||
*
|
||||
* A GitHub API /code-scanning/alerts retorna um array de:
|
||||
* {
|
||||
* number: number,
|
||||
* state: "open" | "dismissed" | "fixed",
|
||||
* dismissed_reason: string | null,
|
||||
* dismissed_at: string | null,
|
||||
* tool: { name: string, ... },
|
||||
* rule: { id: string, severity: string, security_severity_level?: string, ... },
|
||||
* ...
|
||||
* }
|
||||
*
|
||||
* Hard Rule #14: alertas com `state="dismissed"` NÃO contam, independente da razão.
|
||||
* Filtramos por state="open" E tool.name contendo "CodeQL" (case-insensitive).
|
||||
* Alertas de outras ferramentas (ex: Semgrep) são ignorados.
|
||||
*
|
||||
* @param {Array|null} alerts - Array de alertas da API GitHub
|
||||
* @returns {{ alertCount: number, bySeverity: Record<string, number>, byRule: Record<string, number> }}
|
||||
*/
|
||||
export function parseCodeQLAlerts(alerts) {
|
||||
if (!Array.isArray(alerts)) {
|
||||
return { alertCount: 0, bySeverity: {}, byRule: {} };
|
||||
}
|
||||
|
||||
let alertCount = 0;
|
||||
const bySeverity = {};
|
||||
const byRule = {};
|
||||
|
||||
for (const alert of alerts) {
|
||||
// Ignorar alertas não-CodeQL (outras ferramentas de code scanning)
|
||||
const toolName = alert?.tool?.name ?? "";
|
||||
if (!toolName.toLowerCase().includes("codeql")) continue;
|
||||
|
||||
// Hard Rule #14: alertas dismissed não contam
|
||||
if (alert.state === "dismissed") continue;
|
||||
|
||||
// Só alertas abertos
|
||||
if (alert.state !== "open") continue;
|
||||
|
||||
alertCount++;
|
||||
|
||||
// Coletar por severidade (security_severity_level ou severity da rule)
|
||||
const severity = (
|
||||
alert?.rule?.security_severity_level ??
|
||||
alert?.rule?.severity ??
|
||||
"unknown"
|
||||
).toLowerCase();
|
||||
bySeverity[severity] = (bySeverity[severity] ?? 0) + 1;
|
||||
|
||||
// Coletar por rule ID
|
||||
const ruleId = alert?.rule?.id ?? "unknown";
|
||||
byRule[ruleId] = (byRule[ruleId] ?? 0) + 1;
|
||||
}
|
||||
|
||||
return { alertCount, bySeverity, byRule };
|
||||
}
|
||||
|
||||
/**
|
||||
* Avalia a contagem MEDIDA de alertas CodeQL contra o baseline.
|
||||
* Direction: down (a contagem só pode CAIR — mais alertas = regressão).
|
||||
*
|
||||
* Exported for unit testing — espelha evaluateDeadCode em check-dead-code.mjs.
|
||||
*
|
||||
* @param {number} current - Contagem de alertas medida agora.
|
||||
* @param {number} baseline - Contagem congelada em quality-baseline.json.
|
||||
* @returns {{ regressed: boolean, improved: boolean }}
|
||||
*/
|
||||
export function evaluateCodeqlRatchet(current, baseline) {
|
||||
return {
|
||||
regressed: current > baseline,
|
||||
improved: current < baseline,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Repository detection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Detecta o owner/repo do repositório atual usando `gh repo view`.
|
||||
* Retorna null se `gh` não estiver disponível ou não autenticado.
|
||||
*
|
||||
* @param {string} ghBin - Caminho para o binário gh
|
||||
* @returns {string|null} "owner/repo" ou null
|
||||
*/
|
||||
export function detectRepo(ghBin) {
|
||||
try {
|
||||
const stdout = execFileSync(
|
||||
ghBin,
|
||||
["repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"],
|
||||
{
|
||||
encoding: "utf8",
|
||||
timeout: 15_000,
|
||||
}
|
||||
);
|
||||
return stdout.trim() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Binary detection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Detecta se o binário `gh` está disponível no PATH.
|
||||
* Usa `which` (Unix) sem interpolação de shell — Hard Rule #13.
|
||||
*
|
||||
* @returns {string|null} Caminho absoluto para o binário, ou null se ausente.
|
||||
*/
|
||||
export function findGhCli() {
|
||||
try {
|
||||
const result = spawnSync("which", ["gh"], {
|
||||
encoding: "utf8",
|
||||
timeout: 5_000,
|
||||
});
|
||||
if (result.status === 0) {
|
||||
return result.stdout.trim();
|
||||
}
|
||||
} catch {
|
||||
// which não disponível
|
||||
}
|
||||
|
||||
// Fallback: tentar executar diretamente para verificar ENOENT
|
||||
try {
|
||||
const result = spawnSync("gh", ["--version"], {
|
||||
encoding: "utf8",
|
||||
timeout: 5_000,
|
||||
});
|
||||
if (result.error?.code === "ENOENT") return null;
|
||||
if (result.status !== null) return "gh"; // found in PATH
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API caller
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Busca alertas CodeQL abertos via `gh api`.
|
||||
* Pagina automaticamente (GitHub retorna max 100 por página).
|
||||
*
|
||||
* @param {string} ghBin - Caminho para o binário gh
|
||||
* @param {string} repo - "owner/repo"
|
||||
* @returns {Array} Array de alertas
|
||||
*/
|
||||
function fetchCodeQLAlerts(ghBin, repo) {
|
||||
const allAlerts = [];
|
||||
let page = 1;
|
||||
const perPage = 100;
|
||||
|
||||
while (true) {
|
||||
const endpoint = `/repos/${repo}/code-scanning/alerts?state=open&tool_name=CodeQL&per_page=${perPage}&page=${page}`;
|
||||
|
||||
if (!QUIET) {
|
||||
process.stderr.write(`[codeql-ratchet] Buscando alertas: página ${page} ...\n`);
|
||||
}
|
||||
|
||||
let stdout;
|
||||
try {
|
||||
stdout = execFileSync(ghBin, ["api", endpoint], {
|
||||
encoding: "utf8",
|
||||
timeout: 30_000,
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
});
|
||||
} catch (err) {
|
||||
const errMsg = String(err.stderr ?? err.message ?? "");
|
||||
|
||||
// Sem autenticação
|
||||
if (
|
||||
errMsg.includes("authentication") ||
|
||||
errMsg.includes("401") ||
|
||||
errMsg.includes("not logged")
|
||||
) {
|
||||
return { error: "no-auth", message: errMsg };
|
||||
}
|
||||
|
||||
// Rate limit ou outro erro HTTP
|
||||
const codeMatch = /HTTP (\d{3})/.exec(errMsg);
|
||||
const code = codeMatch ? codeMatch[1] : "unknown";
|
||||
return { error: `api-error:${code}`, message: errMsg };
|
||||
}
|
||||
|
||||
let page_alerts;
|
||||
try {
|
||||
page_alerts = JSON.parse(stdout);
|
||||
} catch (parseErr) {
|
||||
// A malformed (but HTTP-200) API response is a MEASUREMENT failure, not a
|
||||
// regression. A blocking gate must never red on it — return the same
|
||||
// {error,message} shape the caller already maps to a graceful SKIP (exit 0).
|
||||
return { error: "parse-error", message: String(parseErr.message ?? parseErr) };
|
||||
}
|
||||
|
||||
// A API retorna null quando não há mais páginas (ou array vazio)
|
||||
if (!Array.isArray(page_alerts) || page_alerts.length === 0) break;
|
||||
|
||||
allAlerts.push(...page_alerts);
|
||||
|
||||
// Se retornou menos que perPage, chegamos à última página
|
||||
if (page_alerts.length < perPage) break;
|
||||
|
||||
page++;
|
||||
}
|
||||
|
||||
return allAlerts;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Baseline
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Lê metrics.codeqlAlerts.value do quality-baseline.json.
|
||||
* Retorna null se o arquivo ou a métrica estiverem ausentes (modo coletor puro:
|
||||
* sem baseline não há ratchet, só emissão da contagem).
|
||||
*
|
||||
* @returns {number|null}
|
||||
*/
|
||||
function readBaselineCodeqlValue() {
|
||||
if (!fs.existsSync(BASELINE_PATH)) return null;
|
||||
let baselineJson;
|
||||
try {
|
||||
baselineJson = JSON.parse(fs.readFileSync(BASELINE_PATH, "utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const metric = baselineJson?.metrics?.codeqlAlerts;
|
||||
if (!metric || typeof metric.value !== "number") return null;
|
||||
return metric.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aplica o ratchet (direction:down) sobre a contagem medida vs o baseline.
|
||||
* Define process.exitCode = 1 numa regressão real (medida > baseline) salvo
|
||||
* --advisory. Ratcheta o baseline com --update quando a contagem cai.
|
||||
*
|
||||
* Exported for unit testing (drives o efeito em process.exitCode).
|
||||
*
|
||||
* @param {number} alertCount - Contagem MEDIDA (medição bem-sucedida).
|
||||
*/
|
||||
export function applyRatchet(alertCount) {
|
||||
const baselineValue = readBaselineCodeqlValue();
|
||||
|
||||
// Sem baseline → modo coletor puro (emite a contagem, não falha).
|
||||
if (baselineValue === null) {
|
||||
if (!QUIET) {
|
||||
process.stderr.write(
|
||||
"[codeql-ratchet] baseline ausente (metrics.codeqlAlerts) — modo coletor, sem ratchet.\n"
|
||||
);
|
||||
}
|
||||
process.exitCode = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const { regressed, improved } = evaluateCodeqlRatchet(alertCount, baselineValue);
|
||||
|
||||
if (UPDATE && improved) {
|
||||
const baselineJson = JSON.parse(fs.readFileSync(BASELINE_PATH, "utf8"));
|
||||
baselineJson.metrics.codeqlAlerts.value = alertCount;
|
||||
fs.writeFileSync(BASELINE_PATH, JSON.stringify(baselineJson, null, 2) + "\n");
|
||||
console.log(`[codeql-ratchet] baseline ratcheado: ${alertCount} (era ${baselineValue})`);
|
||||
}
|
||||
|
||||
if (regressed && !ADVISORY) {
|
||||
process.stderr.write(
|
||||
`[codeql-ratchet] REGRESSÃO — ${alertCount} alertas CodeQL abertos > baseline ${baselineValue}\n` +
|
||||
" → Corrija os novos alertas em Security → Code scanning, ou rode\n" +
|
||||
" 'node scripts/check/check-codeql-ratchet.mjs --update' se a contagem caiu legitimamente.\n"
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!QUIET) {
|
||||
const verdict = regressed ? "ADVISORY — regressão ignorada (--advisory)" : "OK — sem regressão";
|
||||
process.stderr.write(
|
||||
`[codeql-ratchet] ${verdict} — ${alertCount} alertas (baseline ${baselineValue})\n`
|
||||
);
|
||||
}
|
||||
process.exitCode = 0;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function main() {
|
||||
const ghBin = findGhCli();
|
||||
|
||||
if (!ghBin) {
|
||||
console.log("codeqlAlerts=SKIP reason=binary-absent");
|
||||
if (!QUIET) {
|
||||
process.stderr.write(
|
||||
"[codeql-ratchet] SKIP — `gh` CLI não encontrado no PATH.\n" +
|
||||
"[codeql-ratchet] Instale via: https://cli.github.com/\n" +
|
||||
"[codeql-ratchet] ADVISORY — este gate sai 0 (ratchet entra no CI da Fase 7 INT).\n"
|
||||
);
|
||||
}
|
||||
process.exitCode = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
// Detectar repositório
|
||||
const repo = detectRepo(ghBin);
|
||||
if (!repo) {
|
||||
console.log("codeqlAlerts=SKIP reason=no-repo");
|
||||
if (!QUIET) {
|
||||
process.stderr.write(
|
||||
"[codeql-ratchet] SKIP — não foi possível detectar o repositório GitHub.\n" +
|
||||
"[codeql-ratchet] Execute dentro de um repositório GitHub com `gh` autenticado.\n"
|
||||
);
|
||||
}
|
||||
process.exitCode = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!QUIET) {
|
||||
process.stderr.write(`[codeql-ratchet] Repositório detectado: ${repo}\n`);
|
||||
}
|
||||
|
||||
// Buscar alertas
|
||||
const result = fetchCodeQLAlerts(ghBin, repo);
|
||||
|
||||
// Tratar erros da API com skip gracioso
|
||||
if (!Array.isArray(result)) {
|
||||
const { error, message } = result;
|
||||
console.log(`codeqlAlerts=SKIP reason=${error}`);
|
||||
if (!QUIET) {
|
||||
process.stderr.write(
|
||||
`[codeql-ratchet] SKIP — erro ao consultar API GitHub: ${message.slice(0, 200)}\n`
|
||||
);
|
||||
}
|
||||
process.exitCode = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
if (PRINT_JSON) {
|
||||
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
||||
return;
|
||||
}
|
||||
|
||||
const { alertCount, bySeverity, byRule } = parseCodeQLAlerts(result);
|
||||
|
||||
// Emitir em formato KEY=VALUE para o coletor de métricas (collect-metrics.mjs)
|
||||
console.log(`codeqlAlerts=${alertCount}`);
|
||||
|
||||
if (!QUIET) {
|
||||
const severitySummary =
|
||||
Object.entries(bySeverity)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join(", ") || "nenhum";
|
||||
const topRules =
|
||||
Object.entries(byRule)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.slice(0, 5)
|
||||
.map(([r, n]) => `${r}(${n})`)
|
||||
.join(", ") || "nenhum";
|
||||
|
||||
process.stderr.write(
|
||||
`[codeql-ratchet] Alertas CodeQL abertos (não-dismissed): ${alertCount}\n`
|
||||
);
|
||||
if (alertCount > 0) {
|
||||
process.stderr.write(`[codeql-ratchet] Por severidade: ${severitySummary}\n`);
|
||||
process.stderr.write(`[codeql-ratchet] Top regras: ${topRules}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
// Medição bem-sucedida → aplica o ratchet (bloqueante salvo --advisory).
|
||||
// Qualquer falha de MEDIÇÃO acima já retornou com exit 0 (skip gracioso).
|
||||
applyRatchet(alertCount);
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();
|
||||
@@ -0,0 +1,155 @@
|
||||
#!/usr/bin/env node
|
||||
// scripts/check/check-cognitive-complexity.mjs
|
||||
// Ratchet bloqueante para complexidade cognitiva (sonarjs/cognitive-complexity).
|
||||
// Fase 7 INT: promovido de ADVISORY para RATCHET.
|
||||
//
|
||||
// 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 path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const QUIET = process.argv.includes("--quiet");
|
||||
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(
|
||||
process.argv.includes("--baseline")
|
||||
? process.argv[process.argv.indexOf("--baseline") + 1]
|
||||
: 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.
|
||||
* Direction: down (contagem só pode CAIR).
|
||||
*
|
||||
* Exported for unit testing.
|
||||
*
|
||||
* @param {number} current
|
||||
* @param {number} baseline
|
||||
* @returns {{ regressed: boolean, improved: boolean }}
|
||||
*/
|
||||
export function evaluateCognitiveComplexity(current, baseline) {
|
||||
return {
|
||||
regressed: current > baseline,
|
||||
improved: 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() {
|
||||
if (!fs.existsSync(BASELINE_PATH)) {
|
||||
process.stderr.write(
|
||||
`[cognitive-complexity] FAIL — ${path.basename(BASELINE_PATH)} ausente.\n`
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const baselineJson = JSON.parse(fs.readFileSync(BASELINE_PATH, "utf8"));
|
||||
const baselineMetric = baselineJson.metrics && baselineJson.metrics.cognitiveComplexity;
|
||||
if (!baselineMetric || typeof baselineMetric.value !== "number") {
|
||||
process.stderr.write(
|
||||
"[cognitive-complexity] FAIL — metrics.cognitiveComplexity ausente em quality-baseline.json.\n"
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
const baselineValue = baselineMetric.value;
|
||||
|
||||
const report = runEslint();
|
||||
const count = countCognitiveViolations(report);
|
||||
|
||||
// Canonical machine-readable output consumed by collect-metrics.mjs and shell scripts.
|
||||
console.log(`cognitiveComplexity=${count}`);
|
||||
|
||||
if (!QUIET) {
|
||||
console.log(
|
||||
`[cognitive-complexity] ${count} function(s) exceed the cognitive-complexity threshold (15).`
|
||||
);
|
||||
}
|
||||
|
||||
const { regressed, improved } = evaluateCognitiveComplexity(count, baselineValue);
|
||||
|
||||
if (UPDATE && improved) {
|
||||
baselineJson.metrics.cognitiveComplexity.value = count;
|
||||
fs.writeFileSync(BASELINE_PATH, JSON.stringify(baselineJson, null, 2) + "\n");
|
||||
console.log(`[cognitive-complexity] baseline ratcheado: ${count} (era ${baselineValue})`);
|
||||
}
|
||||
|
||||
if (regressed) {
|
||||
process.stderr.write(
|
||||
`[cognitive-complexity] REGRESSÃO — ${count} violações > baseline ${baselineValue}\n` +
|
||||
` → Quebre as funções complexas em helpers menores, ou rode\n` +
|
||||
` 'node scripts/check/check-cognitive-complexity.mjs --update' se a contagem caiu legitimamente.\n`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!QUIET) {
|
||||
console.log(`[cognitive-complexity] OK — ${count} violações (baseline ${baselineValue})`);
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();
|
||||
@@ -0,0 +1,94 @@
|
||||
#!/usr/bin/env node
|
||||
// scripts/check/check-complexity.mjs
|
||||
// Catraca de complexidade de código. Roda o ESLint sobre src+open-sse usando um config
|
||||
// flat STANDALONE (eslint.complexity.config.mjs) que liga APENAS duas regras CORE do
|
||||
// ESLint — `complexity` (ciclomática) e `max-lines-per-function` (tamanho de função) —
|
||||
// e compara a contagem total de violações contra um baseline congelado
|
||||
// (complexity-baseline.json). Falha se a contagem SUBIR. Completa a dimensão
|
||||
// "complexity" do snapshot de qualidade, ao lado de duplicação/tamanho-de-arquivo.
|
||||
//
|
||||
// O config dedicado evita poluir a contagem de warnings do lint principal (ratcheada
|
||||
// em exatamente 3482): este gate roda isolado, com seu próprio par de regras. --update
|
||||
// ratcheta (a contagem só pode CAIR).
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const BASELINE_PATH = path.resolve(
|
||||
process.argv.includes("--baseline")
|
||||
? process.argv[process.argv.indexOf("--baseline") + 1]
|
||||
: path.join(ROOT, "config/quality/complexity-baseline.json")
|
||||
);
|
||||
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
|
||||
// locks the scan scope to the one documented in eslint.complexity.config.mjs `files` and in
|
||||
// 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. */
|
||||
export function evaluateComplexity(current, baseline) {
|
||||
return {
|
||||
regressed: current > baseline,
|
||||
improved: current < baseline,
|
||||
};
|
||||
}
|
||||
|
||||
function measureComplexityCount() {
|
||||
let stdout;
|
||||
try {
|
||||
stdout = execFileSync("npx", ["--yes", ...ESLINT_ARGS], {
|
||||
encoding: "utf8",
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
});
|
||||
} catch (err) {
|
||||
// ESLint sai com código !=0 quando há erros (e nossas regras são "error"); o relatório
|
||||
// JSON ainda vai no stdout. Só relançamos se não houver stdout parseável.
|
||||
stdout = err.stdout ? String(err.stdout) : "";
|
||||
if (!stdout.trim()) throw err;
|
||||
}
|
||||
const report = JSON.parse(stdout);
|
||||
return report.reduce((sum, file) => sum + file.errorCount, 0);
|
||||
}
|
||||
|
||||
function main() {
|
||||
if (!fs.existsSync(BASELINE_PATH)) {
|
||||
console.error(`[complexity] FAIL — ${path.basename(BASELINE_PATH)} ausente.`);
|
||||
process.exit(2);
|
||||
}
|
||||
const baseline = JSON.parse(fs.readFileSync(BASELINE_PATH, "utf8"));
|
||||
const current = measureComplexityCount();
|
||||
const { regressed, improved } = evaluateComplexity(current, baseline.count);
|
||||
|
||||
if (UPDATE && improved) {
|
||||
console.log(`[complexity] baseline ratcheado: ${current} (era ${baseline.count})`);
|
||||
baseline.count = current;
|
||||
fs.writeFileSync(BASELINE_PATH, JSON.stringify(baseline, null, 2) + "\n");
|
||||
}
|
||||
if (regressed) {
|
||||
console.error(
|
||||
`[complexity] REGRESSÃO — ${current} violações > baseline ${baseline.count}\n` +
|
||||
` → quebre a função em helpers menores (reduza ramos/tamanho) ou rode\n` +
|
||||
` 'node scripts/check/check-complexity.mjs --update' se a contagem caiu legitimamente.`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`[complexity] OK — ${current} violações (baseline ${baseline.count})`);
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Compression budget gate (F2.4 / N4 ratchet).
|
||||
*
|
||||
* Runs the deterministic compression engines over BENCHMARK_CORPUS and fails if any engine's
|
||||
* mean compressed-tokens-per-task RISES beyond the tolerance versus the frozen baseline — i.e. a
|
||||
* change made compression worse. Falling cost (better compression) always passes.
|
||||
*
|
||||
* node --import tsx scripts/check/check-compression-budget.ts # check (CI)
|
||||
* node --import tsx scripts/check/check-compression-budget.ts --update # refresh the baseline
|
||||
*
|
||||
* The benchmark is deterministic + API-free (chars/4 estimate over a fixed corpus), so the
|
||||
* committed baseline is portable across local/CI.
|
||||
*/
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import {
|
||||
BENCHMARK_CORPUS,
|
||||
DEFAULT_BENCHMARK_ENGINES,
|
||||
benchmarkEngines,
|
||||
runBenchmarkGate,
|
||||
} from "../../open-sse/services/compression/harness/benchmark.ts";
|
||||
import {
|
||||
tokensPerTask,
|
||||
type BudgetBaseline,
|
||||
} from "../../open-sse/services/compression/harness/budgetGate.ts";
|
||||
|
||||
const BASELINE_PATH = path.join(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"compression-budget-baseline.json"
|
||||
);
|
||||
const TOLERANCE_PERCENT = 2;
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const update = process.argv.includes("--update");
|
||||
const reports = await benchmarkEngines(BENCHMARK_CORPUS, DEFAULT_BENCHMARK_ENGINES);
|
||||
|
||||
if (update) {
|
||||
const baselines: Record<string, BudgetBaseline> = {};
|
||||
for (const [engine, report] of Object.entries(reports)) {
|
||||
baselines[engine] = { tasks: tokensPerTask(report) };
|
||||
}
|
||||
fs.writeFileSync(BASELINE_PATH, JSON.stringify(baselines, null, 2) + "\n");
|
||||
console.log(`Updated compression budget baseline (${Object.keys(baselines).length} engines).`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(BASELINE_PATH)) {
|
||||
console.error("compression budget baseline missing — run with --update to generate it.");
|
||||
process.exit(1);
|
||||
}
|
||||
const baselines = JSON.parse(fs.readFileSync(BASELINE_PATH, "utf8")) as Record<
|
||||
string,
|
||||
BudgetBaseline
|
||||
>;
|
||||
|
||||
const results = runBenchmarkGate(reports, baselines, TOLERANCE_PERCENT);
|
||||
const failed = results.filter((r) => !r.gate.passed);
|
||||
|
||||
if (failed.length === 0) {
|
||||
console.log(`✓ compression budget gate: no regressions (tolerance ${TOLERANCE_PERCENT}%)`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.error("✗ compression budget gate: tokens-per-task regressed (compression got worse):");
|
||||
for (const { engine, gate } of failed) {
|
||||
for (const reg of gate.regressions) {
|
||||
console.error(
|
||||
` ${engine}/${reg.task}: ${reg.baseline} -> ${reg.current} tokens (+${reg.deltaPercent}%)`
|
||||
);
|
||||
}
|
||||
}
|
||||
console.error(
|
||||
"\nIf this is an intentional improvement/change, refresh: npm run check:compression-budget -- --update"
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("compression budget gate failed:", err instanceof Error ? err.message : err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const cwd = process.cwd();
|
||||
const defaultRoots = [
|
||||
"src/shared/components",
|
||||
"src/lib/db",
|
||||
"src/lib/compliance",
|
||||
"open-sse/translator",
|
||||
"open-sse/mcp-server",
|
||||
];
|
||||
const roots = process.argv.slice(2).length > 0 ? process.argv.slice(2) : defaultRoots;
|
||||
const sourceExtensions = [".ts", ".tsx", ".js", ".mjs", ".jsx", ".mts", ".cts"];
|
||||
|
||||
function toPosix(filePath) {
|
||||
return filePath.split(path.sep).join("/");
|
||||
}
|
||||
|
||||
function listSourceFiles(rootDir) {
|
||||
const absRoot = path.resolve(cwd, rootDir);
|
||||
if (!fs.existsSync(absRoot)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const stack = [absRoot];
|
||||
const files = [];
|
||||
|
||||
while (stack.length > 0) {
|
||||
const current = stack.pop();
|
||||
const entries = fs.readdirSync(current, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(current, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
stack.push(fullPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (sourceExtensions.includes(path.extname(entry.name))) {
|
||||
files.push(path.resolve(fullPath));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
function resolveRelativeImport(fromFile, specifier) {
|
||||
const base = path.resolve(path.dirname(fromFile), specifier);
|
||||
const ext = path.extname(base);
|
||||
|
||||
if (ext && fs.existsSync(base) && fs.statSync(base).isFile()) {
|
||||
return path.resolve(base);
|
||||
}
|
||||
|
||||
for (const extension of sourceExtensions) {
|
||||
const candidate = `${base}${extension}`;
|
||||
if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
|
||||
return path.resolve(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
for (const extension of sourceExtensions) {
|
||||
const candidate = path.join(base, `index${extension}`);
|
||||
if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
|
||||
return path.resolve(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractImportSpecifiers(fileContents) {
|
||||
const specs = [];
|
||||
const regex = /\b(?:import|export)\s+(?:[^"'`]*?\sfrom\s*)?["'`]([^"'`]+)["'`]/g;
|
||||
let match = regex.exec(fileContents);
|
||||
while (match) {
|
||||
specs.push(match[1]);
|
||||
match = regex.exec(fileContents);
|
||||
}
|
||||
return specs;
|
||||
}
|
||||
|
||||
function buildGraph(files) {
|
||||
const fileSet = new Set(files);
|
||||
const graph = new Map();
|
||||
|
||||
for (const filePath of files) {
|
||||
const code = fs.readFileSync(filePath, "utf8");
|
||||
const dependencies = new Set();
|
||||
const importSpecifiers = extractImportSpecifiers(code);
|
||||
|
||||
for (const specifier of importSpecifiers) {
|
||||
if (!specifier.startsWith(".")) continue;
|
||||
const resolved = resolveRelativeImport(filePath, specifier);
|
||||
if (!resolved) continue;
|
||||
if (!fileSet.has(resolved)) continue;
|
||||
dependencies.add(resolved);
|
||||
}
|
||||
|
||||
graph.set(filePath, dependencies);
|
||||
}
|
||||
|
||||
return graph;
|
||||
}
|
||||
|
||||
function stronglyConnectedComponents(graph) {
|
||||
const indexMap = new Map();
|
||||
const lowLinkMap = new Map();
|
||||
const onStack = new Set();
|
||||
const stack = [];
|
||||
const components = [];
|
||||
let indexCounter = 0;
|
||||
|
||||
function strongConnect(node) {
|
||||
indexMap.set(node, indexCounter);
|
||||
lowLinkMap.set(node, indexCounter);
|
||||
indexCounter += 1;
|
||||
stack.push(node);
|
||||
onStack.add(node);
|
||||
|
||||
for (const neighbor of graph.get(node) || []) {
|
||||
if (!indexMap.has(neighbor)) {
|
||||
strongConnect(neighbor);
|
||||
lowLinkMap.set(node, Math.min(lowLinkMap.get(node), lowLinkMap.get(neighbor)));
|
||||
} else if (onStack.has(neighbor)) {
|
||||
lowLinkMap.set(node, Math.min(lowLinkMap.get(node), indexMap.get(neighbor)));
|
||||
}
|
||||
}
|
||||
|
||||
if (lowLinkMap.get(node) === indexMap.get(node)) {
|
||||
const component = [];
|
||||
while (stack.length > 0) {
|
||||
const candidate = stack.pop();
|
||||
onStack.delete(candidate);
|
||||
component.push(candidate);
|
||||
if (candidate === node) break;
|
||||
}
|
||||
components.push(component);
|
||||
}
|
||||
}
|
||||
|
||||
for (const node of graph.keys()) {
|
||||
if (!indexMap.has(node)) {
|
||||
strongConnect(node);
|
||||
}
|
||||
}
|
||||
|
||||
return components;
|
||||
}
|
||||
|
||||
function isSelfCycle(component, graph) {
|
||||
if (component.length !== 1) return false;
|
||||
const [file] = component;
|
||||
return (graph.get(file) || new Set()).has(file);
|
||||
}
|
||||
|
||||
const files = roots.flatMap((root) => listSourceFiles(root));
|
||||
const graph = buildGraph(files);
|
||||
const components = stronglyConnectedComponents(graph);
|
||||
const cycles = components.filter(
|
||||
(component) => component.length > 1 || isSelfCycle(component, graph)
|
||||
);
|
||||
|
||||
if (cycles.length === 0) {
|
||||
console.log(
|
||||
`[cycles] OK - no cycles detected across ${graph.size} files in: ${roots.join(", ")}`
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.error(`[cycles] FAIL - detected ${cycles.length} strongly connected component(s):`);
|
||||
for (const component of cycles) {
|
||||
const sorted = [...component].sort((a, b) => a.localeCompare(b));
|
||||
console.error(`\n- SCC (${sorted.length} files)`);
|
||||
for (const filePath of sorted) {
|
||||
console.error(` - ${toPosix(path.relative(cwd, filePath))}`);
|
||||
}
|
||||
}
|
||||
|
||||
process.exit(1);
|
||||
@@ -0,0 +1,285 @@
|
||||
#!/usr/bin/env node
|
||||
// scripts/check/check-db-rules.mjs
|
||||
// Gate de convenções de banco (CLAUDE.md Hard Rules #2 e #5). Três verificações:
|
||||
// (a) Todo módulo de domínio em src/lib/db/*.ts deve ser re-exportado por
|
||||
// src/lib/localDb.ts (camada de compat). Um módulo db NOVO que não é
|
||||
// re-exportado (e não está congelado) falha — força a decisão consciente
|
||||
// de expor ou justificar (Hard Rule #2).
|
||||
// (b) src/lib/localDb.ts é APENAS camada de re-export: nada de lógica
|
||||
// (function/class/arrow de negócio). Mata o anti-padrão de "só uma
|
||||
// funçãozinha aqui" que vira regra de negócio fora dos módulos db/.
|
||||
// (c) Nenhum SQL cru em src/app/api/**/route.ts ou open-sse/handlers/*.ts.
|
||||
// SQL deve viver em src/lib/db/ (Hard Rule #5). Ofensores pré-existentes
|
||||
// são congelados; QUALQUER novo SQL cru em rota/handler falha.
|
||||
// Stale-enforcement (6A.3): entradas em INTENTIONALLY_INTERNAL / EXTERNAL_DB_ALLOWED
|
||||
// que não suprimem nenhuma violação real → gate falha com instrução de remoção.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { assertNoStale } from "./lib/allowlist.mjs";
|
||||
|
||||
const cwd = process.cwd();
|
||||
const DB_DIR = path.join(cwd, "src/lib/db");
|
||||
const LOCAL_DB = path.join(cwd, "src/lib/localDb.ts");
|
||||
const API_DIR = path.join(cwd, "src/app/api");
|
||||
const HANDLERS_DIR = path.join(cwd, "open-sse/handlers");
|
||||
|
||||
// (a) Módulos db/ que NÃO são re-exportados por localDb.ts por DESIGN (Hard Rule #2:
|
||||
// "Never barrel-import from localDb.ts — import specific db/ modules instead").
|
||||
// Cada entrada aqui foi auditada e é consumida via import direto de "@/lib/db/X"
|
||||
// (estático ou dinâmico) pelos seus consumidores — exatamente o padrão correto.
|
||||
// Re-exportar esses módulos via localDb.ts INCENTIVARIA o anti-padrão proibido.
|
||||
// O gate ainda bloqueia QUALQUER módulo db/ NOVO que não seja re-exportado E não
|
||||
// esteja nessa lista — mantendo a decisão consciente obrigatória (Hard Rule #2).
|
||||
// Legenda de classificação:
|
||||
// type-only = exporta apenas tipos (sem runtime API), não há o que re-exportar
|
||||
// db-internal = importado apenas dentro de src/lib/db/ (coordenação interna)
|
||||
// intentionally-internal = consumido por import direto fora de db/ (correto per Rule #2)
|
||||
// DEAD? = zero importers encontrados na auditoria de 2026-06-11; não deletar
|
||||
// sem investigação — pode ser reserva de schema ou F2 pendente
|
||||
export const INTENTIONALLY_INTERNAL = new Set([
|
||||
"_rowTypes", // type-only: 5 importers internos em db/ (AgentBridge/Inspector row types)
|
||||
"accessTokens", // intentionally-internal: 4 rotas /api/cli/* (connect, whoami, tokens, tokens/[id]) + server/authz/accessTokenAuth.ts via import direto "@/lib/db/accessTokens" (Rule #2)
|
||||
"apiKeyColumnFallbacks", // db-internal: importado só por db/apiKeys.ts (API_KEY_COLUMN_FALLBACKS — fallbacks de coluna split do apiKeys.ts)
|
||||
"apiKeyUsageLimitFields", // db-internal: importado só por db/apiKeys.ts (helpers de campo de limite de uso split do apiKeys.ts; mig 101)
|
||||
"caseMapping", // db-internal: importado só por db/core.ts (toSnakeCase/toCamelCase/objToSnake — column-mapping snake↔camel split do core.ts, #4947)
|
||||
"cleanup", // intentionally-internal: 3 API routes (purge-quota-snapshots, purge-call-logs, purge-detailed-logs)
|
||||
"cliToolState", // intentionally-internal: 14+ API routes em /api/cli-tools/*-settings
|
||||
"comboForecast", // intentionally-internal: src/lib/usage/comboForecast.ts
|
||||
"commandCodeAuth", // intentionally-internal: 5 API routes em /api/providers/command-code/auth/*
|
||||
"compression", // intentionally-internal: 2 API routes (settings/compression, context/rtk/config)
|
||||
"vacuumScheduler", // intentionally-internal: src/instrumentation-node.ts (dynamic import, lifecycle wiring per Rule #2)
|
||||
"detailedLogs", // intentionally-internal: 3 callers (callLogs.ts, logs/detail route, embeddings handler)
|
||||
"discovery", // DEAD?: 0 importers na auditoria de 2026-06-11; lib/discovery/index.ts não usa db/discovery
|
||||
"domainState", // intentionally-internal: 5 callers (batchWriter, circuitBreaker, costRules, fallbackPolicy, lockoutPolicy)
|
||||
"encryption", // intentionally-internal: 8+ callers (container, webhookDispatcher, cloudAgent/credentials, services/apiKey, 4+ routes, open-sse)
|
||||
"healthCheck", // db-internal: importado por db/core.ts (runDbHealthCheck)
|
||||
"jsonMigration", // intentionally-internal: src/app/api/settings/import-json/route.ts
|
||||
"migrationRunner", // db-internal: importado por db/core.ts (runMigrations ao inicializar o DB)
|
||||
"notion", // intentionally-internal: settings/notion API route + open-sse/mcp-server/tools/notionTools.ts
|
||||
"obsidian", // intentionally-internal: src/lib/obsidianSync.ts + settings/obsidian route + MCP obsidianTools.ts
|
||||
"optimizationSettings", // db-internal: imported by db/core.ts for SQLite PRAGMA application helpers that require the live adapter
|
||||
"pluginMetrics", // DEAD? (production): write path não foi conectado ainda (documentado no cabeçalho do módulo); testado por tests/unit/plugins-metrics.test.ts
|
||||
"prompts", // DEAD? (production): zero callers de produção encontrados; domínio domain/prompts.ts é independente; testado por tests/integration/proxy-pipeline.test.ts
|
||||
"providerNodeSelect", // db-internal: importado só por db/providers.ts (selectProviderNodeForConnection — lógica pura de seleção de provider node split do providers.ts, #4421)
|
||||
"providerStats", // intentionally-internal: src/app/api/provider-stats/route.ts
|
||||
"recovery", // intentionally-internal: bin/cli/runtime.mjs (import() dinâmico) + tests
|
||||
"schemaColumns", // db-internal: importado só por db/core.ts (ensureProviderConnections/UsageHistory/CallLogsColumns + hasColumn/hasTable/getTableColumns — schema-column reconciliation split do core.ts, #4948)
|
||||
"secrets", // intentionally-internal: src/instrumentation-node.ts (import() dinâmico na inicialização)
|
||||
"serviceModels", // intentionally-internal: 3 callers (services/modelSync, services/bootstrap, /api/services/9router/models)
|
||||
"stateReset", // db-internal: 3 callers dentro de src/lib/db/ (core, backup, apiKeys) para coordenação de reset
|
||||
"stats", // intentionally-internal: src/app/api/settings/database/refresh-stats/route.ts
|
||||
"tierConfig", // intentionally-internal: open-sse/services/tierResolver.ts (require() dinâmico)
|
||||
"webSessionDedup", // db-internal: importado só por db/providers.ts (webSessionCredentialKey/parseProviderSpecificData — helpers puros de dedup de credencial web-session split do providers.ts, #3368 PR6)
|
||||
]);
|
||||
|
||||
// Alias para retrocompatibilidade com os testes existentes que importam KNOWN_UNEXPORTED.
|
||||
// O comportamento do gate é idêntico — só o nome e os comentários mudaram (#3499).
|
||||
export const KNOWN_UNEXPORTED = INTENTIONALLY_INTERNAL;
|
||||
|
||||
// (c) Leituras de SQL contra bancos EXTERNOS, permitidas por design (#3500).
|
||||
// Estas rotas NÃO consultam o DB do OmniRoute (getDbInstance) — elas abrem o
|
||||
// SQLite de OUTRO aplicativo (Cursor / Kiro) para auto-importar credenciais.
|
||||
// Por isso NÃO podem viver em src/lib/db/ (que é o domínio do DB do OmniRoute):
|
||||
// são leituras read-only de um arquivo externo, com caminho/escopo próprios.
|
||||
// Continuam no allowlist como exceção DOCUMENTADA — o gate ainda bloqueia
|
||||
// QUALQUER novo SQL cru contra o DB do OmniRoute em rotas/handlers.
|
||||
// Toda a dívida real da Hard Rule #5 (15 rotas internas) foi migrada para
|
||||
// módulos src/lib/db/ nas slices do #3500; este set ficou só com as exceções.
|
||||
const EXTERNAL_DB_ALLOWED = new Set([
|
||||
"src/app/api/oauth/cursor/auto-import/route.ts", // read-only no itemTable do SQLite do Cursor (DB externo)
|
||||
"src/app/api/oauth/kiro/auto-import/route.ts", // read-only no SQLite do Kiro (DB externo)
|
||||
]);
|
||||
|
||||
// Alias de retrocompatibilidade (testes/consumidores que importam KNOWN_RAW_SQL).
|
||||
// Comportamento do gate idêntico — só o nome e o enquadramento mudaram (#3500).
|
||||
const KNOWN_RAW_SQL = EXTERNAL_DB_ALLOWED;
|
||||
|
||||
// Módulos sempre excluídos da checagem (a): não são domínio re-exportável.
|
||||
const DB_MODULE_EXCLUDE = new Set(["core", "localDb", "index"]);
|
||||
|
||||
function walk(dir, acc = []) {
|
||||
if (!fs.existsSync(dir)) return acc;
|
||||
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const p = path.join(dir, e.name);
|
||||
if (e.isDirectory()) walk(p, acc);
|
||||
else acc.push(p);
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
|
||||
// Lista os módulos de domínio em src/lib/db (top-level *.ts), excluindo
|
||||
// core/localDb/index, *.d.ts e qualquer subdiretório (migrations/, adapters/, __tests__/).
|
||||
export function collectDbModules(dbDir = DB_DIR) {
|
||||
if (!fs.existsSync(dbDir)) return [];
|
||||
return fs
|
||||
.readdirSync(dbDir, { withFileTypes: true })
|
||||
.filter((e) => e.isFile() && /\.ts$/.test(e.name) && !/\.d\.ts$/.test(e.name))
|
||||
.map((e) => e.name.replace(/\.ts$/, ""))
|
||||
.filter((name) => !DB_MODULE_EXCLUDE.has(name))
|
||||
.sort();
|
||||
}
|
||||
|
||||
// Extrai os nomes de módulo re-exportados de localDb.ts a partir de
|
||||
// `... from "./db/X"` (cobre export {…}, export * e export type {…}).
|
||||
export function extractReexportedModules(localDbSource) {
|
||||
const re = /from\s+["']\.\/db\/([A-Za-z0-9_]+)["']/g;
|
||||
const out = new Set();
|
||||
let m;
|
||||
while ((m = re.exec(localDbSource))) out.add(m[1]);
|
||||
return out;
|
||||
}
|
||||
|
||||
// (a) Módulos db/ que não são re-exportados e não estão na lista de
|
||||
// intencionalmente-internos (INTENTIONALLY_INTERNAL). O gate falha para
|
||||
// qualquer módulo NOVO que não seja re-exportado nem justificado.
|
||||
export function findMissingReexports(dbModules, reexported, allowlist = INTENTIONALLY_INTERNAL) {
|
||||
return dbModules.filter((mod) => !reexported.has(mod) && !allowlist.has(mod));
|
||||
}
|
||||
|
||||
// (b) localDb.ts deve conter SOMENTE import/export + comentários (sem lógica).
|
||||
// Remove comentários e strings, depois procura declarações de runtime.
|
||||
export function hasLogic(localDbSource) {
|
||||
const stripped = localDbSource
|
||||
// comentários de bloco
|
||||
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||||
// comentários de linha
|
||||
.replace(/\/\/[^\n]*/g, "")
|
||||
// template strings
|
||||
.replace(/`(?:\\[\s\S]|[^\\`])*`/g, '""')
|
||||
// strings simples/duplas (paths de import etc.)
|
||||
.replace(/"(?:\\.|[^"\\])*"/g, '""')
|
||||
.replace(/'(?:\\.|[^'\\])*'/g, '""');
|
||||
|
||||
// function/class declaradas, ou atribuição a função (const X = (…) =>, const X = function).
|
||||
const logicPatterns = [
|
||||
/(^|[^.\w])function\s+[A-Za-z_$]/, // function decl (não method .foo())
|
||||
/(^|[^.\w])class\s+[A-Za-z_$]/, // class decl
|
||||
/(?:const|let|var)\s+[A-Za-z_$][\w$]*\s*=\s*(?:async\s*)?\(/, // const X = (…) ... (arrow/call)
|
||||
/(?:const|let|var)\s+[A-Za-z_$][\w$]*\s*=\s*(?:async\s+)?function\b/, // const X = function
|
||||
];
|
||||
return logicPatterns.some((rx) => rx.test(stripped));
|
||||
}
|
||||
|
||||
// SQL cru é sempre uma STRING passada a db.prepare()/exec(): casamos os padrões
|
||||
// SÓ dentro de literais de string (não em código JS — `import … from`, `.set(`,
|
||||
// `new Set(`, `delete x` etc. são falsos positivos se varrermos o código todo).
|
||||
const SQL_PATTERNS = [
|
||||
/\bSELECT\b[\s\S]*?\bFROM\b/i, // SELECT … FROM (multi-linha)
|
||||
/\bINSERT\s+INTO\b/i,
|
||||
/\bUPDATE\b[\s\S]*?\bSET\b/i, // UPDATE … SET (multi-linha)
|
||||
/\bDELETE\s+FROM\b/i,
|
||||
/\bCREATE\s+TABLE\b/i,
|
||||
];
|
||||
|
||||
// Remove comentários (linha // … e blocos /* */) — SQL em comentário não conta.
|
||||
function stripComments(source) {
|
||||
return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/[^\n]*/g, "");
|
||||
}
|
||||
|
||||
// Extrai o conteúdo de todos os literais de string (template, aspas duplas, aspas
|
||||
// simples) de um trecho de código já sem comentários. Retorna a concatenação dos
|
||||
// corpos — é nesse corpo que SQL cru vive.
|
||||
export function extractStringLiterals(code) {
|
||||
const re = /`(?:\\[\s\S]|[^\\`])*`|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'/g;
|
||||
const out = [];
|
||||
let m;
|
||||
while ((m = re.exec(code))) {
|
||||
// tira as aspas/crases delimitadoras
|
||||
out.push(m[0].slice(1, -1));
|
||||
}
|
||||
return out.join("\n | ||||