Release v3.8.29 (#4126)

OmniRoute v3.8.29 — 115 commits since v3.8.28. Full CHANGELOG + 41 i18n mirrors. All content quality gates green (build, unit 8/8, vitest 188/188, PR test policy, quality gates extended, docs sync, quality ratchet). Remaining red CI checks are pre-existing release flakes (coverage-shard/integration/node-compat teardown), a new transitive undici advisory in electron devDeps, and a workflow-level CodeQL fail (0 open alerts). VPS-validated by the operator.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-19 06:49:01 -03:00
committed by GitHub
parent dd5a3db55e
commit 3c9883bb73
497 changed files with 35580 additions and 5484 deletions

View File

@@ -83,6 +83,15 @@ const NATIVE_ASSET_ENTRIES = [
src: ["node_modules", "better-sqlite3", "build"],
dest: ["node_modules", "better-sqlite3", "build"],
},
{
// TPROXY IP_TRANSPARENT addon (Fase 3 / Epic A). Built by build-tproxy-native
// before assembly; Linux-only + opt-in, so the source is absent on non-Linux
// builds → syncNativeAssetsToDir skips it gracefully. The runtime loader
// (transparentSocket.ts) resolves it cwd-relative to this same dest.
label: "TPROXY transparent-socket addon (Linux-only, opt-in)",
src: ["src", "mitm", "tproxy", "native", "build", "Release", "transparent.node"],
dest: ["src", "mitm", "tproxy", "native", "build", "Release", "transparent.node"],
},
];
/** @type {{label:string, src:string[], dest:string[]}[]} */

View File

@@ -107,16 +107,32 @@ export function resolveNextBuildBundlerFlag(baseEnv = process.env) {
}
export function resolveNextBuildEnv(baseEnv = process.env) {
return {
const env = {
...baseEnv,
NEXT_PRIVATE_BUILD_WORKER: baseEnv.NEXT_PRIVATE_BUILD_WORKER || "0",
};
// Raise the Node heap for the spawned `next build`. The webpack production pass
// ("Compiling instrumentation" bundles the whole server graph) is the heaviest
// phase and overflows V8's default ~2 GB ceiling on memory-constrained machines,
// stalling/OOMing local `npm run build` (npm-global installs). #4076/#4104 fixed
// this only in the Docker builder stage (ENV NODE_OPTIONS); the local/native path
// 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;
env.NODE_OPTIONS = `${env.NODE_OPTIONS || ""} --max-old-space-size=${heapMb}`.trim();
}
return env;
}
async function resetStandaloneOutput(rootDir = projectRoot, fsImpl = fs) {
// Use the module-level distDir so NEXT_DIST_DIR is respected
const resolvedDistDir =
rootDir === projectRoot ? distDir : path.join(rootDir, process.env.NEXT_DIST_DIR || ".build/next");
rootDir === projectRoot
? distDir
: path.join(rootDir, process.env.NEXT_DIST_DIR || ".build/next");
const standaloneRoot = path.join(resolvedDistDir, "standalone");
if (!(await exists(standaloneRoot))) return;
@@ -128,7 +144,9 @@ async function resetStandaloneOutput(rootDir = projectRoot, fsImpl = fs) {
export async function pruneStandaloneArtifacts(rootDir = projectRoot, fsImpl = fs) {
const resolvedDistDirForPrune =
rootDir === projectRoot ? distDir : path.join(rootDir, process.env.NEXT_DIST_DIR || ".build/next");
rootDir === projectRoot
? distDir
: path.join(rootDir, process.env.NEXT_DIST_DIR || ".build/next");
const standaloneRoot = path.join(resolvedDistDirForPrune, "standalone");
const pruneTargets = [path.join(standaloneRoot, "_tasks")];
@@ -174,11 +192,9 @@ export async function main() {
const standaloneDir = path.join(distDir, "standalone");
if (result.code === 0 && (await exists(standaloneDir))) {
try {
await fs.cp(
path.join(projectRoot, "docs"),
path.join(standaloneDir, "docs"),
{ recursive: true }
);
await fs.cp(path.join(projectRoot, "docs"), path.join(standaloneDir, "docs"), {
recursive: true,
});
console.log("[build-next-isolated] Copied docs/ to standalone output");
} catch (docsCopyErr) {
console.warn("[build-next-isolated] Non-fatal error copying docs/:", docsCopyErr?.message);
@@ -193,8 +209,29 @@ export async function main() {
);
}
// Best-effort: build the TPROXY native addon (Linux-only, opt-in) BEFORE
// assembling, so its transparent.node is present for assembleStandalone's
// NATIVE_ASSET_ENTRIES copy. Non-Linux / no-toolchain is non-fatal — the
// capture mode degrades gracefully when the addon is absent.
try {
console.log("[build-next-isolated] Assembling standalone bundle (static + public + natives + extras)...");
const { buildTproxyNative } = await import("./build-tproxy-native.mjs");
const res = buildTproxyNative(projectRoot);
console.log(
res.built
? "[build-next-isolated] Built TPROXY native addon (transparent.node)"
: `[build-next-isolated] TPROXY native addon skipped: ${res.reason}`
);
} catch (nativeErr) {
console.warn(
"[build-next-isolated] Non-fatal error building TPROXY native addon:",
nativeErr?.message
);
}
try {
console.log(
"[build-next-isolated] Assembling standalone bundle (static + public + natives + extras)..."
);
assembleStandalone({
distDir,
outDir: standaloneDir,
@@ -202,10 +239,7 @@ export async function main() {
copyNatives: true,
});
} catch (assembleErr) {
console.warn(
"[build-next-isolated] Non-fatal error assembling standalone:",
assembleErr
);
console.warn("[build-next-isolated] Non-fatal error assembling standalone:", assembleErr);
}
}
process.exitCode = result.code;

View File

@@ -0,0 +1,54 @@
/**
* Best-effort build of the TPROXY IP_TRANSPARENT native addon so the production
* build can copy `build/Release/transparent.node` into the standalone bundle
* (assembleStandalone's NATIVE_ASSET_ENTRIES). Called from build-next-isolated.mjs
* before the standalone is assembled.
*
* IP_TRANSPARENT is Linux-only, so this is a no-op everywhere else. A missing C
* toolchain is NOT fatal — the TPROXY capture mode degrades gracefully when the
* addon is absent (transparentSocket.ts returns "unavailable"). Every effectful
* seam (platform/run/exists) is injectable so the decision logic is unit-testable.
*
* Hard Rule #13: the command + args are a fixed allowlist (no interpolation of
* external/runtime values); `cwd` is derived from `projectRoot`, never user input.
*/
import { execFileSync } from "node:child_process";
import { existsSync } from "node:fs";
import path from "node:path";
/**
* @param {string} projectRoot
* @param {{ platform?: string, run?: (cmd:string, args:string[], cwd:string) => void,
* exists?: (p:string) => boolean }} [opts]
* @returns {{ built: boolean, reason?: string }}
*/
export function buildTproxyNative(projectRoot, opts = {}) {
const platform = opts.platform ?? process.platform;
const run = opts.run ?? defaultRun;
const exists = opts.exists ?? existsSync;
if (platform !== "linux") {
return { built: false, reason: "non-linux host (IP_TRANSPARENT is Linux-only)" };
}
const nativeDir = path.join(projectRoot, "src", "mitm", "tproxy", "native");
if (!exists(path.join(nativeDir, "binding.gyp"))) {
return { built: false, reason: "native sources absent (binding.gyp not found)" };
}
const out = path.join(nativeDir, "build", "Release", "transparent.node");
try {
run("npx", ["--yes", "node-gyp", "rebuild"], nativeDir);
} catch (err) {
return { built: false, reason: `toolchain/build failed: ${err?.message ?? String(err)}` };
}
if (!exists(out)) {
return { built: false, reason: "node-gyp produced no transparent.node" };
}
return { built: true };
}
/** @type {(cmd: string, args: string[], cwd: string) => void} */
function defaultRun(cmd, args, cwd) {
execFileSync(cmd, args, { cwd, stdio: "inherit" });
}

View File

@@ -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);
});

View File

@@ -39,6 +39,7 @@ const HANDLERS_DIR = path.join(cwd, "open-sse/handlers");
// 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)
"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

View File

@@ -60,6 +60,17 @@ const KNOWN_HOOKS = new Set([
"onActivate",
"onDeactivate",
"onUninstall",
// Real callbacks wired in code that docs reference (verified present in src/):
// onChunk/onFirstChunk — streaming callbacks (src/shared/utils/streamTracker.ts,
// playground ChatTab.tsx); onServerStatus/onPortChanged/onUpdateStatus — Electron
// IPC callbacks (src/shared/hooks/useElectron.ts, HomePageClient.tsx);
// onEmpty — model-metadata registry callback (src/lib/modelMetadataRegistry.ts).
"onChunk",
"onFirstChunk",
"onServerStatus",
"onPortChanged",
"onUpdateStatus",
"onEmpty",
]);
// Common false-positives the heuristic would otherwise flag. Add to this
@@ -85,6 +96,21 @@ const ENV_VAR_ALLOWLIST = new Set([
"OMNIROUTE_URL", // used by ad-hoc tooling, validated elsewhere
"OMNIROUTE_KEY", // ditto
"OPENCODE_API_KEY", // ditto
// ── External-tool / spawn-injected / ops env vars ────────────────────────
// Real environment variables, but they belong to an UPSTREAM CLI/tool, a
// docker-compose/electron-build pipeline, or are injected into a spawned
// subprocess — never read via `process.env.X` in OmniRoute's own source, so the
// code-read index can't see them. Documented (correctly) in the relevant guides.
"COPILOT_PROVIDER_BASE_URL", // GitHub Copilot CLI ≥v1.0.19's own env var (AGENTBRIDGE.md)
"OPENAI_BASE_URL", // env var OmniRoute passes to downstream CLIs (AGENT_PROTOCOLS_GUIDE.md)
"NINEROUTER_API_KEY", // injected into the 9router subprocess at spawn (EMBEDDED-SERVICES.md)
"CLAUDE_CODE_MAX_OUTPUT_TOKENS", // Claude Code CLI's own env var (CODEX-CLI-CONFIGURATION.md)
"CODEX_HOME", // Codex CLI's own config-home env var (CODEX-CLI-CONFIGURATION.md)
"REDIS_PORT", // docker-compose host-port override (DOCKER_GUIDE.md)
"AUTO_UPDATE_HOST_REPO_DIR", // docker-compose self-update mount (DOCKER_GUIDE.md)
"LINUX_GPG_KEY", // electron AppImage signing key, CI/build only (ELECTRON_GUIDE.md)
"BRANCH_LOCK_TOKEN", // release branch-protection ops token (QUALITY_GATE_PLAYBOOK.md)
"NEXT_LOCALE", // next-intl locale cookie name (I18N.md)
]);
// Common pluralized / column-header all-caps that aren't env vars
@@ -275,6 +301,18 @@ const ENV_VAR_DENYLIST = new Set([
"KNOWN_STALE_DOC_REFS", // export const in check-docs-symbols.mjs
"KNOWN_MISSING", // export const in check-fetch-targets.mjs
"KNOWN_RAW_SQL", // export const in check-db-rules.mjs
// ── Error / Node codes documented in prose (string-literal codes, not env vars) ──
"URL_GUARD_BLOCKED", // HTTP 422 guard-violation code (ARCHITECTURE.md)
"AUTHZ_NOT_INITIALIZED", // AuthzAssertionError code (AUTHZ_GUIDE.md)
"MODULE_NOT_FOUND", // Node runtime error code watched by service supervisor (ELECTRON_GUIDE.md)
"ERR_DLOPEN_FAILED", // Node native-module load error code (ELECTRON_GUIDE.md)
// ── Code-symbol / naming-convention examples documented in prose ─────────────
"UPPER_SNAKE", // the literal naming-convention token in the style guide (CODEBASE_DOCUMENTATION.md)
"DEFAULT_TIMEOUT", // example constant name in the UPPER_SNAKE convention row (AGENTS.md)
"SIDEBAR_DEFINITIONS", // code constant referenced in prose (MONITORING_SECTIONS.md)
"LOCAL_ONLY", // routeGuard classification label (AGENTBRIDGE.md)
"SPAWN_CAPABLE", // routeGuard classification label (AGENTBRIDGE.md)
"ZEROGRAVITY_SENSITIVE_WORDS", // cross-project constant named in a comparison (STEALTH_GUIDE.md)
]);
/** Endpoints that don't follow the standard route.ts pattern. */
@@ -306,6 +344,10 @@ const ENDPOINT_ALLOWLIST = new Set([
"/api/mcp/stream", // Streamable HTTP MCP transport
"/api/mcp/sse", // SSE MCP transport
"/api/health",
// Upstream/external provider endpoints documented in provider guides — these are
// paths on the UPSTREAM service (Claude.ai web, Blackbox), not OmniRoute routes.
"/api/organizations/{orgId}/chat_conversations/{convId}/completion", // claude-web upstream
"/api/chat", // Blackbox Web upstream (validated-token target)
]);
/** Doc files to skip (auto-generated, vendored, or third-party). */
@@ -316,12 +358,27 @@ const SKIP_DOC_FILES = new Set([
// Point-in-time documentation audit (v3.8.24): intentionally references drift,
// counts, and not-yet-existing files as part of documenting them — not living docs.
"docs/ops/DOCUMENTATION_AUDIT_REPORT.md",
// Design / research / plan docs: by definition describe not-yet-built files and
// proposed (not-yet-shipped) endpoints (each carries a `Status: Design`/`Active
// research`/`Plano` header). Same rationale as the audit report above — these are
// forward-looking specs, not living API docs, so their forward references are
// expected, not fabrications.
"docs/research", // DISCOVERY_TOOL_DESIGN.md, UNLIMITED_LLM_ACCESS.md, …
"docs/superpowers/plans", // dated implementation plans (files described before they exist)
// Release notes are historical, point-in-time records: they intentionally describe
// modules/paths as they were at that release (e.g. a module later moved or renamed).
// Rewriting them to today's layout would falsify history — out of scope for a
// living-docs accuracy gate.
"docs/releases",
// Forward-looking coverage plan: a `- [ ]` checklist of test targets and helper
// components to be created. Same rationale as the design/plan docs above.
"docs/ops/COVERAGE_PLAN.md",
]);
// ── File discovery ─────────────────────────────────────────────────────────
function walkMarkdown(dir, out = []) {
const abs = path.join(ROOT, dir);
function walkMarkdown(dir, out = [], root = ROOT) {
const abs = path.join(root, dir);
if (!fs.existsSync(abs)) return out;
const stat = fs.statSync(abs);
if (stat.isFile()) {
@@ -332,17 +389,17 @@ function walkMarkdown(dir, out = []) {
if (name === "node_modules" || name.startsWith(".")) continue;
const childAbs = path.join(abs, name);
const s = fs.statSync(childAbs);
if (s.isDirectory()) walkMarkdown(path.relative(ROOT, childAbs), out);
if (s.isDirectory()) walkMarkdown(path.relative(root, childAbs), out, root);
else if (childAbs.endsWith(".md") || childAbs.endsWith(".mdx")) out.push(childAbs);
}
return out;
}
function allScanFiles() {
function allScanFiles(root = ROOT) {
const files = [];
for (const p of SCAN_PATHS) walkMarkdown(p, files);
for (const p of SCAN_PATHS) walkMarkdown(p, files, root);
return files.filter((f) => {
const rel = path.relative(ROOT, f);
const rel = path.relative(root, f);
for (const skip of SKIP_DOC_FILES) {
if (rel === skip || rel.startsWith(skip + path.sep)) return false;
}
@@ -352,22 +409,39 @@ function allScanFiles() {
// ── Codebase index ─────────────────────────────────────────────────────────
function buildCodebaseIndex() {
// Env var helper wrappers used across OmniRoute — envInt(NAME, 5), envBool(NAME),
// envStr(NAME), … — read the named var from the environment, so a string-literal
// argument is a genuine env-var read, equivalent to a direct process.env member read.
// (Comment avoids a literal `process.env.<NAME>` token so the sibling env-doc-sync
// grep does not mistake this example for a real env-var read.)
const ENV_HELPER_CALL =
/\benv(?:Int|Bool|Str|Num|Float|String|Flag|Raw|List|Json)?\(\s*["'`]([A-Z][A-Z0-9_]+)["'`]/g;
export function buildCodebaseIndex(root = ROOT) {
// Set of /api/... paths that have a route.ts handler.
const apiRoutes = new Set();
// Set of /api/... prefixes that are an ancestor of (or equal to) a real route.ts.
// A documented prefix like /api/cloud/ is valid even without a route.ts at that
// exact level, as long as some src/app/api/cloud/**/route.ts exists.
const apiPrefixes = new Set();
// Map of /api/... → methods implemented in route.ts
const apiMethods = new Map();
function dynToBrace(seg) {
// [id] → {id}, [...path] → {path} — match the doc convention for dynamic segments.
return seg.replace(/^\[\.\.\.(.+)\]$/, "{$1}").replace(/^\[(.+)\]$/, "{$1}");
}
function walkApiRoutes(dir) {
const abs = path.join(ROOT, dir);
const abs = path.join(root, dir);
if (!fs.existsSync(abs)) return;
for (const name of fs.readdirSync(abs)) {
const child = path.join(abs, name);
const s = fs.statSync(child);
if (s.isDirectory()) walkApiRoutes(path.relative(ROOT, child));
if (s.isDirectory()) walkApiRoutes(path.relative(root, child));
else if (name === "route.ts" || name === "route.mjs") {
// Build the route path from the directory hierarchy
const rel = path.relative(ROOT, child).replace(/\\/g, "/");
const rel = path.relative(root, child).replace(/\\/g, "/");
const parts = rel.split("/");
// drop "src/app/api" and "route.ts"
parts.shift(); // src
@@ -378,6 +452,15 @@ function buildCodebaseIndex() {
apiRoutes.add(routePath);
apiRoutes.add(routePath + "/"); // trailing slash variant
// Register every ancestor prefix (and its {brace} dynamic-segment variant)
// so documented prefixes-with-subroutes resolve.
for (let i = 1; i <= parts.length; i++) {
const prefix = "/api/" + parts.slice(0, i).join("/");
const bracePrefix = "/api/" + parts.slice(0, i).map(dynToBrace).join("/");
apiPrefixes.add(prefix);
apiPrefixes.add(bracePrefix);
}
// Read the file to find exported HTTP methods
try {
const content = fs.readFileSync(child, "utf8");
@@ -397,29 +480,47 @@ function buildCodebaseIndex() {
}
walkApiRoutes("src/app/api");
// Set of env var names that are actually read in code.
// Set of env var names that are actually read in code, and the set of
// ALL_CAPS code identifiers (export const / enum / object-literal keys). A
// documented `UPPER_SNAKE` that resolves to a code identifier (e.g.
// LOCAL_ONLY_API_PREFIXES, HALF_OPEN) is NOT a fabricated env var.
const envVars = new Set();
const codeIdentifiers = new Set();
function walkForEnv(dir) {
const abs = path.join(ROOT, dir);
const abs = path.join(root, dir);
if (!fs.existsSync(abs)) return;
const skipDirs = new Set(["node_modules", ".next", "dist", ".build", "coverage"]);
for (const name of fs.readdirSync(abs)) {
if (skipDirs.has(name)) continue;
const child = path.join(abs, name);
const s = fs.statSync(child);
if (s.isDirectory()) walkForEnv(path.relative(ROOT, child));
if (s.isDirectory()) walkForEnv(path.relative(root, child));
else if (/\.(ts|tsx|js|mjs|cjs)$/.test(name)) {
try {
const content = fs.readFileSync(child, "utf8");
// process.env.X
const m1 = content.matchAll(/process\.env\.([A-Z][A-Z0-9_]+)/g);
for (const m of m1) envVars.add(m[1]);
for (const m of content.matchAll(/process\.env\.([A-Z][A-Z0-9_]+)/g)) envVars.add(m[1]);
// process.env["X"] / process.env['X'] (bracket notation)
for (const m of content.matchAll(/process\.env\[\s*["'`]([A-Z][A-Z0-9_]+)["'`]\s*\]/g))
envVars.add(m[1]);
// env.X (destructured in some handlers)
const m2 = content.matchAll(/\benv\.([A-Z][A-Z0-9_]+)\b/g);
for (const m of m2) envVars.add(m[1]);
for (const m of content.matchAll(/\benv\.([A-Z][A-Z0-9_]+)\b/g)) envVars.add(m[1]);
// env["X"] (bracket on a destructured env binding)
for (const m of content.matchAll(/\benv\[\s*["'`]([A-Z][A-Z0-9_]+)["'`]\s*\]/g))
envVars.add(m[1]);
// envInt("X", …) / envBool("X") / envStr("X") … helper wrappers
for (const m of content.matchAll(ENV_HELPER_CALL)) envVars.add(m[1]);
// import.meta.env.X (Vite-style, unlikely here but cheap)
const m3 = content.matchAll(/import\.meta\.env\.([A-Z][A-Z0-9_]+)/g);
for (const m of m3) envVars.add(m[1]);
for (const m of content.matchAll(/import\.meta\.env\.([A-Z][A-Z0-9_]+)/g))
envVars.add(m[1]);
// export const / const / let / var / enum NAME — JS identifiers, not env vars.
for (const m of content.matchAll(
/\b(?:export\s+)?(?:const|let|var|enum)\s+([A-Z][A-Z0-9_]{2,})\b/g
))
codeIdentifiers.add(m[1]);
// Object-literal / enum members on their own line: `HALF_OPEN: "HALF_OPEN"`.
for (const m of content.matchAll(/^[ \t]*([A-Z][A-Z0-9_]{2,})[ \t]*[:=]/gm))
codeIdentifiers.add(m[1]);
} catch {
/* ignore */
}
@@ -430,21 +531,49 @@ function buildCodebaseIndex() {
walkForEnv("open-sse");
walkForEnv("bin");
walkForEnv("scripts");
// Env vars that are only read by the test harness (e.g. RUN_CHAOS_INT) are still
// real env vars and must not be flagged as fabricated.
walkForEnv("tests");
// Env contract maintained by the sibling gate (check-env-doc-sync.mjs): a var
// listed in .env.example or docs/reference/ENVIRONMENT.md is, by definition, a
// documented OmniRoute env var (including external-CLI / docker / electron vars
// that are not read via process.env in our own source).
function readEnvContract() {
try {
const t = fs.readFileSync(path.join(root, ".env.example"), "utf8");
for (const line of t.split("\n")) {
const m = line.match(/^#?\s*([A-Z][A-Z0-9_]+)\s*=/);
if (m) envVars.add(m[1]);
}
} catch {
/* ignore */
}
try {
const t = fs.readFileSync(path.join(root, "docs", "reference", "ENVIRONMENT.md"), "utf8");
for (const m of t.matchAll(/`([A-Z][A-Z0-9_]{2,})`/g)) envVars.add(m[1]);
} catch {
/* ignore */
}
}
readEnvContract();
// Set of `omniroute <subcommand>` strings that exist in bin/
const cliCommands = new Set();
function walkCli(dir) {
const abs = path.join(ROOT, dir);
const abs = path.join(root, dir);
if (!fs.existsSync(abs)) return;
for (const name of fs.readdirSync(abs)) {
const child = path.join(abs, name);
const s = fs.statSync(child);
if (s.isDirectory()) walkCli(path.relative(ROOT, child));
if (s.isDirectory()) walkCli(path.relative(root, child));
else if (/\.(mjs|js|ts)$/.test(name)) {
try {
const content = fs.readFileSync(child, "utf8");
// Programmatic API: `command('foo', ...)`, `.command('bar')`
const m1 = content.matchAll(/\.command\(\s*['"`]([a-z][a-z0-9-]+)['"`]/g);
// Programmatic API: `command('foo', ...)`, `.command('bar')`, and
// arg-bearing forms `.command('connect <host>')` / `.command('chat [msg]')`
// — capture the leading subcommand token regardless of trailing args.
const m1 = content.matchAll(/\.command\(\s*['"`]([a-z][a-z0-9-]+)/g);
for (const m of m1) cliCommands.add(m[1]);
// Subcommand names: `${name}Cmd`, `name = "foo"`, etc.
const m2 = content.matchAll(/name:\s*['"`]([a-z][a-z0-9-]+)['"`]/g);
@@ -460,7 +589,7 @@ function buildCodebaseIndex() {
}
walkCli("bin");
return { apiRoutes, apiMethods, envVars, cliCommands };
return { apiRoutes, apiPrefixes, apiMethods, envVars, codeIdentifiers, cliCommands };
}
// ── Doc scanning ───────────────────────────────────────────────────────────
@@ -490,18 +619,44 @@ function lineOf(text, idx) {
return line;
}
function scanDocFile(absPath, index) {
const rel = path.relative(ROOT, absPath);
export function scanDocFile(absPath, index, root = ROOT) {
const rel = path.relative(root, absPath);
const text = fs.readFileSync(absPath, "utf8");
const textNoCode = stripCodeBlocksAndFences(text);
const findings = [];
// 1) API endpoints
for (const m of textNoCode.matchAll(COARSE_PATTERNS.apiPath)) {
const p = m[0].replace(/[\[\]\{\}]/g, ""); // strip wildcards for lookup
const candidate = p.replace(/\/$/, "");
if (ENDPOINT_ALLOWLIST.has(candidate) || ENDPOINT_ALLOWLIST.has(candidate + "/")) continue;
if (index.apiRoutes.has(candidate) || index.apiRoutes.has(candidate + "/")) continue;
// Normalize wildcard segments to {brace} form so [id] / [...path] / {id} all
// compare equally against the indexed routes/prefixes.
const normalized = m[0].replace(/\[\.\.\.(.+?)\]/g, "{$1}").replace(/\[(.+?)\]/g, "{$1}");
const candidate = normalized.replace(/\/$/, "");
const stripped = m[0].replace(/[\[\]\{\}]/g, "").replace(/\/$/, ""); // legacy lookup form
if (
ENDPOINT_ALLOWLIST.has(candidate) ||
ENDPOINT_ALLOWLIST.has(candidate + "/") ||
ENDPOINT_ALLOWLIST.has(stripped) ||
ENDPOINT_ALLOWLIST.has(stripped + "/")
)
continue;
if (
index.apiRoutes.has(stripped) ||
index.apiRoutes.has(stripped + "/") ||
index.apiRoutes.has(candidate) ||
index.apiRoutes.has(candidate + "/")
)
continue;
// Prefix-with-subroutes: a documented prefix like /api/cloud/ or /api/services/{name}/
// is valid when some src/app/api/<prefix>/**/route.ts exists. Accept when the
// documented path is (or is an ancestor of) a real route prefix, or vice-versa.
let isPrefix = false;
for (const pre of index.apiPrefixes) {
if (pre === candidate || pre.startsWith(candidate + "/") || candidate.startsWith(pre + "/")) {
isPrefix = true;
break;
}
}
if (isPrefix) continue;
// Allow docs that describe intended-but-not-yet-shipped routes by skipping lines that say "planned" / "TBD" / "future"
const ln = lineOf(text, m.index);
const lineText = text.split("\n")[ln - 1] || "";
@@ -525,11 +680,30 @@ function scanDocFile(absPath, index) {
if (ENV_VAR_ALLOWLIST.has(name)) continue;
if (!/_/.test(name)) continue; // real env vars have an underscore
if (index.envVars.has(name)) continue;
// Documented ALL_CAPS that resolves to a JS identifier (export const / enum /
// object-literal key) is a code symbol, not a fabricated env var.
// E.g. LOCAL_ONLY_API_PREFIXES, HALF_OPEN, A2A_SKILL_HANDLERS, MCP_SCOPE_PRESETS.
if (index.codeIdentifiers.has(name)) continue;
if (/^X-[A-Z]/.test(name)) continue;
if (ENV_VAR_DENYLIST.has(name)) continue;
const ln = lineOf(text, m.index);
const lineText = text.split("\n")[ln - 1] || "";
// Use the line as seen in the stripped text (where m.index is valid) for context
// checks — fenced-code removal shifts offsets, so text.split()[ln-1] can be wrong.
const lineStart = textNoCode.lastIndexOf("\n", m.index) + 1;
let lineEnd = textNoCode.indexOf("\n", m.index);
if (lineEnd === -1) lineEnd = textNoCode.length;
const lineText = textNoCode.slice(lineStart, lineEnd);
if (/example|placeholder|todo|tbd|\.\.\./i.test(lineText)) continue;
// A doc that explicitly states a var does NOT exist / is not implemented is
// documenting its absence, not fabricating it. Examples:
// "`MEMORY_RRF_VECTOR_WEIGHT` … do not exist"
// "a `ZED_CONFIG_PATH` environment variable override is not yet implemented"
if (
/\b(?:do(?:es)?\s+not\s+exist|no\s+such|not\s+a\s+real|isn't\s+a\s+real|never\s+(?:read|exists?)|not\s+(?:yet\s+)?(?:implemented|supported))\b/i.test(
lineText
)
)
continue;
findings.push({
kind: "env-var",
value: name,
@@ -582,10 +756,20 @@ function scanDocFile(absPath, index) {
// 5) File references
for (const m of textNoCode.matchAll(COARSE_PATTERNS.fileRef)) {
const ref = m[1].replace(/\\/g, "/");
const abs = path.join(ROOT, ref);
const abs = path.join(root, ref);
if (fs.existsSync(abs)) continue;
// Allow README/AGENTS to mention example files explicitly in a non-verified way
if (/\{\{|\.\.\./.test(ref)) continue; // templated / placeholder
// Tutorial placeholders in the "how to add a …" scenarios are intentional
// stand-ins, not real files: src/app/api/your-route/route.ts,
// src/lib/db/yourModule.ts, src/lib/guardrails/myGuardrail.ts, etc.
if (/(?:^|\/)(?:your-|your[A-Z]|my[A-Z])/.test(ref)) continue;
// Skip matches that are only the tail of a longer path, not a repo-root ref:
// the fileRef regex anchors on the `src`/`open-sse`/… token, so a leading `/`
// means the real reference is `<something>/src/...` — either a relative example
// path (`./src/index.ts`, a PII-pattern sample) or a workspace-package path
// (`@omniroute/opencode-provider/src/index.ts`). Neither resolves from repo root.
if (m.index > 0 && textNoCode[m.index - 1] === "/") continue;
const ln = lineOf(text, m.index);
findings.push({
kind: "file-ref",
@@ -601,12 +785,15 @@ function scanDocFile(absPath, index) {
// ── Main ───────────────────────────────────────────────────────────────────
export function runFabricatedDocsCheck(opts = {}) {
const index = buildCodebaseIndex();
const files = allScanFiles();
// `root` lets tests run the full pipeline against a fixture tree (with its own
// docs/, src/, .env.example) instead of the live repo.
const root = opts.root ?? ROOT;
const index = opts.index ?? buildCodebaseIndex(root);
const files = allScanFiles(root);
const allFindings = [];
for (const f of files) {
const result = scanDocFile(f, index);
const result = scanDocFile(f, index, root);
if (result.findings.length > 0) {
allFindings.push(result);
}
@@ -699,6 +886,10 @@ function main() {
console.log(formatHumanReport(result));
console.log();
if (totalFindings === 0) {
// Clean run — pass regardless of mode.
process.exit(0);
}
if (STRICT) {
console.error(`${totalFindings} claim(s) drift from source. Failing (--strict).`);
process.exit(1);

View File

@@ -440,6 +440,9 @@ export function extractCloudAgentRegistryKeys(registrySource: string): Set<strin
*/
export const AGENT_FILE_TO_REGISTRY_KEY: Record<string, string> = {
codex: "codex-cloud",
// #4227: file agents/cursor.ts ↔ registry key "cursor-cloud" (distinct from the
// OAuth chat provider `cursor`).
cursor: "cursor-cloud",
};
// ───────────────────────────────────────────────────────────────────────────

View File

@@ -0,0 +1,160 @@
#!/usr/bin/env node
// scripts/check/check-mutation-ratchet.mjs
// Catraca de mutationScore (Quality Gate v2 / Fase 9 T5 — Onda 2, Task 3).
//
// Mirrors check-bundle-size.mjs: ADVISORY by default (always exit 0), BLOCKING only
// with --ratchet — and even then exits 1 SE — E SOMENTE SE — um módulo medido REGREDIU
// vs o baseline (direction: UP, o score só pode subir). Skip gracioso (exit 0) quando
// não há mutation.json (ex.: o nightly não rodou) ou não há baseline para o módulo —
// falta de dados NUNCA bloqueia, só uma regressão medida bloqueia.
//
// Score por módulo = COVERED score = detected / (detected + survived), onde
// detected = Killed + Timeout. NoCoverage é EXCLUÍDO do denominador (é uma lacuna de
// cobertura, não um sinal de qualidade-de-teste) — mesmo denominador que a radiografia
// (scripts/quality/mutation-radiography.mjs).
//
// O nightly divide o `mutate` em batches paralelos (um reports/mutation/mutation.json
// por job). Este script roda DENTRO de cada job sobre o report daquele batch e compara
// só os módulos presentes nele. Aceita vários paths para uso local/agregado.
//
// Uso:
// node scripts/check/check-mutation-ratchet.mjs (advisory; report default)
// node scripts/check/check-mutation-ratchet.mjs reports/mutation/mutation.json
// node scripts/check/check-mutation-ratchet.mjs <a.json> <b.json> ... --ratchet
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = process.cwd();
const BASELINE_PATH = path.join(ROOT, "config/quality/quality-baseline.json");
const DEFAULT_REPORT = path.join(ROOT, "reports/mutation/mutation.json");
const BASELINE_PREFIX = "mutationScore.";
const RATCHET = process.argv.includes("--ratchet");
const DETECTED = new Set(["Killed", "Timeout"]);
const SURVIVED = new Set(["Survived"]);
/**
* Avalia o score MEDIDO de um módulo contra o baseline. Direction: UP (o score só
* pode subir — menor = regressão).
* @param {number} current
* @param {number} baseline
* @returns {{ regressed: boolean, improved: boolean }}
*/
export function evaluateMutationRatchet(current, baseline) {
return {
regressed: current < baseline,
improved: current > baseline,
};
}
/**
* Covered mutation score de um arquivo: detected/(detected+survived)*100.
* NoCoverage/Ignored/RuntimeError/CompileError ficam fora do denominador.
* @param {{mutants?: Array<{status: string}>}} fileData
* @returns {number|null} score em %, ou null se não houver mutantes cobertos
*/
export function mutationScoreForFile(fileData) {
let detected = 0;
let survived = 0;
for (const m of fileData?.mutants || []) {
if (DETECTED.has(m.status)) detected += 1;
else if (SURVIVED.has(m.status)) survived += 1;
}
const denom = detected + survived;
return denom === 0 ? null : (detected / denom) * 100;
}
/**
* Score por arquivo a partir de um ou mais reports (batches). Arquivos sem mutante
* coberto (score null) são omitidos.
* @param {object|object[]} reportOrReports parsed mutation.json (ou array)
* @returns {Record<string, number>}
*/
export function measureMutationScores(reportOrReports) {
const reports = Array.isArray(reportOrReports) ? reportOrReports : [reportOrReports];
const out = {};
for (const report of reports) {
for (const [file, data] of Object.entries(report?.files || {})) {
const score = mutationScoreForFile(data);
if (score !== null) out[file] = score;
}
}
return out;
}
/**
* Lê metrics["mutationScore.<path>"].value do quality-baseline.json.
* Retorna {} se o arquivo ou as chaves estiverem ausentes (sem baseline não há
* ratchet possível — o caller trata como SKIP gracioso, exit 0).
* @param {string} baselinePath
* @returns {Record<string, number>}
*/
export function readBaselineMutationScores(baselinePath = BASELINE_PATH) {
if (!fs.existsSync(baselinePath)) return {};
let baselineJson;
try {
baselineJson = JSON.parse(fs.readFileSync(baselinePath, "utf8"));
} catch {
return {};
}
const metrics = baselineJson?.metrics || {};
const out = {};
for (const [key, val] of Object.entries(metrics)) {
if (key.startsWith(BASELINE_PREFIX) && val && typeof val.value === "number") {
out[key.slice(BASELINE_PREFIX.length)] = val.value;
}
}
return out;
}
function loadReport(p) {
return JSON.parse(fs.readFileSync(p, "utf8"));
}
function main(argv) {
const paths = argv.slice(2).filter((a) => !a.startsWith("--"));
const reportPaths = paths.length > 0 ? paths : [DEFAULT_REPORT];
const existing = reportPaths.filter((p) => fs.existsSync(p));
if (existing.length === 0) {
process.stdout.write("mutationScore=SKIP reason=no-report\n");
process.exit(0);
}
const measured = measureMutationScores(existing.map(loadReport));
const baseline = readBaselineMutationScores();
const regressions = [];
const modules = Object.keys(measured).sort();
for (const mod of modules) {
const current = measured[mod];
if (!(mod in baseline)) {
process.stdout.write(`mutationScore.${mod}=${current.toFixed(2)} (no baseline — advisory)\n`);
continue;
}
const { regressed } = evaluateMutationRatchet(current, baseline[mod]);
const tag = regressed ? "REGRESSED" : "ok";
process.stdout.write(
`mutationScore.${mod}=${current.toFixed(2)} baseline=${baseline[mod].toFixed(2)} ${tag}\n`
);
if (regressed) regressions.push({ mod, current, baseline: baseline[mod] });
}
if (regressions.length > 0 && RATCHET) {
process.stderr.write(
`\nMutation ratchet FAILED — ${regressions.length} module(s) dropped below baseline:\n`
);
for (const r of regressions) {
process.stderr.write(` ${r.mod}: ${r.current.toFixed(2)} < ${r.baseline.toFixed(2)}\n`);
}
process.exit(1);
}
process.exit(0);
}
if (
import.meta.url === `file://${process.argv[1]}` ||
process.argv[1] === fileURLToPath(import.meta.url)
) {
main(process.argv);
}

View File

@@ -29,13 +29,14 @@
// neste repo: report → ratchet → block).
//
// Base ref:
// • CI passa BASE_REF=${{ github.base_ref }} (ex.: "release/v3.8.27").
// • Local: default origin/release/v3.8.27.
// • CI passa BASE_REF=${{ github.base_ref }} (ex.: "release/vX.Y.Z").
// • Local: default derivado da versão do package.json (releaseBranchForVersion),
// ex.: package 3.8.29 → "origin/release/v3.8.29" — nunca fica stale entre ciclos.
// A spec base é extraída com `git show <BASE_REF>:docs/reference/openapi.yaml`.
//
// Uso:
// node scripts/check/check-openapi-breaking.mjs
// BASE_REF=origin/release/v3.8.27 node scripts/check/check-openapi-breaking.mjs
// BASE_REF=origin/release/vX.Y.Z node scripts/check/check-openapi-breaking.mjs
// node scripts/check/check-openapi-breaking.mjs --json # imprime JSON bruto do oasdiff
// node scripts/check/check-openapi-breaking.mjs --quiet # suprime logs de diagnóstico
// node scripts/check/check-openapi-breaking.mjs --ratchet # falha (exit 1) numa regressão
@@ -53,7 +54,35 @@ const RATCHET = process.argv.includes("--ratchet");
const SPEC_REL = "docs/reference/openapi.yaml";
const SPEC_PATH = path.join(ROOT, "docs", "reference", "openapi.yaml");
const DEFAULT_BASE_REF = "origin/release/v3.8.27";
/**
* Deriva o branch base de release a partir de uma versão semver
* (ex.: "3.8.29" → "origin/release/v3.8.29"). Mantém o default sincronizado com
* o ciclo de release SEM hard-code: o version-bump atualiza package.json a cada
* ciclo, então o default nunca fica stale (era "origin/release/v3.8.27" fixo).
* Ignora sufixos de prerelease/build (ex.: "3.8.29-dev.2" → v3.8.29).
*
* @param {string|null|undefined} version
* @returns {string|null} branch base (sem `origin/` ausente) ou null se não-semver
*/
export function releaseBranchForVersion(version) {
const m = String(version ?? "")
.trim()
.match(/^(\d+)\.(\d+)\.(\d+)/);
return m ? `origin/release/v${m[1]}.${m[2]}.${m[3]}` : null;
}
function readPackageVersion() {
try {
return JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8")).version;
} catch {
return null;
}
}
// CI sempre passa BASE_REF=${{ github.base_ref }} e vence; este default só vale
// para runs locais. Derivado da versão para não re-driftar a cada release.
const DEFAULT_BASE_REF = releaseBranchForVersion(readPackageVersion()) || "origin/release/v3.8.29";
const BASELINE_PATH = path.join(ROOT, "config/quality/quality-baseline.json");
// ---------------------------------------------------------------------------

View File

@@ -0,0 +1,58 @@
{
"lite": {
"tasks": {
"prose": 129,
"tool-output": 127,
"json": 160
}
},
"caveman": {
"tasks": {
"prose": 129,
"tool-output": 127,
"json": 160
}
},
"aggressive": {
"tasks": {
"prose": 129,
"tool-output": 127,
"json": 160
}
},
"ultra": {
"tasks": {
"prose": 92,
"tool-output": 116,
"json": 117
}
},
"rtk": {
"tasks": {
"prose": 129,
"tool-output": 127,
"json": 160
}
},
"session-dedup": {
"tasks": {
"prose": 129,
"tool-output": 127,
"json": 160
}
},
"headroom": {
"tasks": {
"prose": 129,
"tool-output": 127,
"json": 160
}
},
"ccr": {
"tasks": {
"prose": 129,
"tool-output": 56,
"json": 14
}
}
}

View File

@@ -0,0 +1,39 @@
/**
* Compression engine A/B benchmark CLI (F2.4 / L2).
*
* Runs the deterministic compression engines over BENCHMARK_CORPUS through the C1 harness and
* prints a best-first markdown table, so the default engine can be chosen from real numbers
* rather than intuition. Deterministic and API-free (safe in CI / local dev).
*
* Usage:
* npm run bench:compression # the default deterministic engine set
* npm run bench:compression rtk lite # a specific subset
*
* llmlingua is excluded by default — its real compression needs the ONNX model at runtime
* (pass it explicitly once provisioned; the framework is engine-agnostic).
*/
import {
BENCHMARK_CORPUS,
DEFAULT_BENCHMARK_ENGINES,
benchmarkEngines,
compareReports,
formatBenchmarkTable,
} from "../../open-sse/services/compression/harness/benchmark.ts";
async function main(): Promise<void> {
const requested = process.argv.slice(2).filter((arg) => !arg.startsWith("-"));
const engines = requested.length > 0 ? requested : DEFAULT_BENCHMARK_ENGINES;
const reports = await benchmarkEngines(BENCHMARK_CORPUS, engines);
const rows = compareReports(reports);
console.log("# Compression engine A/B benchmark (F2.4)\n");
console.log(`Corpus: ${BENCHMARK_CORPUS.length} cases · engines: ${engines.join(", ")}\n`);
console.log(formatBenchmarkTable(rows));
console.log("");
}
main().catch((err) => {
console.error("benchmark failed:", err instanceof Error ? err.message : err);
process.exitCode = 1;
});

View File

@@ -0,0 +1,227 @@
#!/usr/bin/env node
/**
* Mutation radiography (Quality Gate v2 / Fase 9 T5 — Onda 2, Task 1).
*
* Classifies every COVERING test file by its mutation-kill contribution, using the
* `killedBy` attribution that the Stryker tap-runner emits per mutant
* (`coverageAnalysis: perTest`, validated by the Task 12 spike — see
* docs/ops/MUTATION_GATE_SPIKE_VERDICT.md):
*
* 🔴 empty — the test file never appears in any `killedBy` (kills no mutant
* of the mutated modules). Prime R1-prune candidate (Task 2).
* 🟠 redundant — every mutant it kills is ALSO killed by ≥1 other test file
* (zero unique kills).
* 🟡 overlapping — kills ≥1 unique mutant, but the MAJORITY of its kills are shared.
* 🟢 unique — kills ≥1 mutant that NO other test file kills (and unique kills
* are not outnumbered by shared kills).
*
* CAVEAT — bail-on-first-kill: Stryker bails after the first test kills a mutant
* (we do NOT set `disableBail`), so `killedBy` lists the FIRST killer, not every
* killer. Consequence: 🔴 empty is RELIABLE (a sole killer is always recorded, so a
* file that never appears in killedBy is never the sole killer of any mutant → safe
* R1-prune candidate w.r.t. mutationScore), but 🟢/🟠/🟡 are OPTIMISTIC — "unique" is
* overstated and "redundant" understated, because a non-first coverer that WOULD also
* kill is never recorded. Use 🟢/🟠/🟡 as advisory only; an accurate redundancy split
* (for R2) needs a `disableBail: true` run. R1 (Task 2) acts on 🔴 alone + a line-
* coverage cross-check + human review, so bail-on-first is sufficient there.
*
* IMPORTANT — multi-batch merge: the nightly splits `mutate` across parallel batches
* (one mutation.json per batch). Stryker assigns numeric test ids PER RUN, so id "12"
* in batch c is unrelated to id "12" in batch d. Each report is therefore resolved
* (id -> file name, via its own `testFiles` section) and classified independently;
* `aggregateRadiography` then sums the per-FILE kill counts across batches and
* reclassifies. A file empty in one batch but unique in another is unique overall.
*
* Usage:
* node scripts/quality/mutation-radiography.mjs <mutation-c.json> [<mutation-d.json> ...]
* The universe of test files (so 🔴 empty files are detectable) defaults to
* `stryker.conf.json:tap.testFiles`; pass --no-conf-universe to use only the union
* of the reports' own `testFiles` sections instead.
*/
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(SCRIPT_DIR, "..", "..");
export function loadMutationReport(reportPath) {
return JSON.parse(fs.readFileSync(reportPath, "utf8"));
}
/**
* Threshold rules shared by single-report and aggregated classification.
* @param {number} uniqueKills mutants this file kills ALONE
* @param {number} sharedKills mutants this file kills together with others
*/
export function classifyFromCounts(uniqueKills, sharedKills) {
if (uniqueKills === 0 && sharedKills === 0) return "empty";
if (uniqueKills === 0) return "redundant";
if (sharedKills > uniqueKills) return "overlapping";
return "unique";
}
// Map each numeric test id to its file name via the report's `testFiles` section.
// Real tap-runner reports key killedBy by id; the synthetic test fixtures key it by
// file name directly (no testFiles section) — those pass through unchanged.
function buildIdToFile(report) {
const map = new Map();
for (const [file, data] of Object.entries(report.testFiles || {})) {
for (const t of data.tests || []) {
map.set(String(t.id), t.name || file);
}
}
return map;
}
// Raw per-file kill counts for ONE report (no universe, no classification).
function countKills(report) {
const idToFile = buildIdToFile(report);
const counts = new Map();
const bump = (file, key) => {
const c = counts.get(file) || { uniqueKills: 0, sharedKills: 0 };
c[key] += 1;
counts.set(file, c);
};
for (const data of Object.values(report.files || {})) {
for (const m of data.mutants || []) {
if (m.status !== "Killed") continue;
const killers = [...new Set((m.killedBy || []).map((id) => idToFile.get(String(id)) ?? id))];
if (killers.length === 0) continue;
if (killers.length === 1) bump(killers[0], "uniqueKills");
else for (const k of killers) bump(k, "sharedKills");
}
}
return counts;
}
function materialize(counts, universe) {
const files = new Set(universe || []);
for (const f of counts.keys()) files.add(f);
const out = {};
for (const file of files) {
const { uniqueKills = 0, sharedKills = 0 } = counts.get(file) || {};
out[file] = { class: classifyFromCounts(uniqueKills, sharedKills), uniqueKills, sharedKills };
}
return out;
}
/**
* Classify the test files of a SINGLE mutation report.
* @param {object} report parsed mutation.json
* @param {string[]} [allTestFiles] universe; defaults to the report's testFiles keys
*/
export function classifyTestFiles(report, allTestFiles) {
const universe = allTestFiles || Object.keys(report.testFiles || {});
return materialize(countKills(report), universe);
}
/**
* Merge several per-batch reports at the file level, then classify.
* @param {object[]} reports parsed mutation.json objects (one per batch)
* @param {string[]} [allTestFiles] universe; defaults to the union of testFiles keys
*/
export function aggregateRadiography(reports, allTestFiles) {
const total = new Map();
const universe = new Set(allTestFiles || []);
for (const report of reports) {
if (!allTestFiles) for (const f of Object.keys(report.testFiles || {})) universe.add(f);
for (const [file, c] of countKills(report)) {
const acc = total.get(file) || { uniqueKills: 0, sharedKills: 0 };
acc.uniqueKills += c.uniqueKills;
acc.sharedKills += c.sharedKills;
total.set(file, acc);
}
}
return materialize(total, [...universe]);
}
// ── CLI ──────────────────────────────────────────────────────────────────────
function tapTestFilesUniverse() {
try {
const conf = JSON.parse(fs.readFileSync(path.join(REPO_ROOT, "stryker.conf.json"), "utf8"));
return conf?.tap?.testFiles || null;
} catch {
return null;
}
}
const CLASS_LABEL = {
empty: "🔴 empty",
redundant: "🟠 redundant",
overlapping: "🟡 overlapping",
unique: "🟢 unique",
};
const CLASS_ORDER = ["empty", "redundant", "overlapping", "unique"];
function renderMarkdown(classification) {
const byClass = { empty: [], redundant: [], overlapping: [], unique: [] };
for (const [file, info] of Object.entries(classification))
byClass[info.class].push({ file, ...info });
for (const k of CLASS_ORDER) byClass[k].sort((a, b) => a.file.localeCompare(b.file));
const total = Object.keys(classification).length;
const lines = [];
lines.push("# Mutation Radiography");
lines.push("");
lines.push(
`Test files classified by mutation-kill contribution (\`killedBy\`). Total: **${total}**.`
);
lines.push("");
lines.push("| Class | Count | Meaning |");
lines.push("| --- | --- | --- |");
lines.push(
`| 🔴 empty | ${byClass.empty.length} | kills no mutant of the mutated modules (R1-prune candidate) |`
);
lines.push(
`| 🟠 redundant | ${byClass.redundant.length} | every kill is shared with another file |`
);
lines.push(
`| 🟡 overlapping | ${byClass.overlapping.length} | kills ≥1 unique but mostly shared |`
);
lines.push(`| 🟢 unique | ${byClass.unique.length} | kills ≥1 mutant no other file kills |`);
lines.push("");
lines.push(
"> **Bail caveat:** Stryker bails on the first kill (no `disableBail`), so `killedBy` is the " +
"FIRST killer only. 🔴 empty is reliable (safe R1-prune candidate w.r.t. mutationScore); " +
"🟢/🟠/🟡 are optimistic (unique overstated, redundant understated) — advisory until a " +
"`disableBail` run. R1 prunes 🔴 only, with a line-coverage cross-check + human review."
);
lines.push("");
for (const k of CLASS_ORDER) {
const rows = byClass[k];
lines.push(`## ${CLASS_LABEL[k]} (${rows.length})`);
lines.push("");
if (rows.length === 0) {
lines.push("_none_");
} else {
lines.push("| Test file | unique | shared |");
lines.push("| --- | --- | --- |");
for (const r of rows) lines.push(`| ${r.file} | ${r.uniqueKills} | ${r.sharedKills} |`);
}
lines.push("");
}
return lines.join("\n");
}
function main(argv) {
const args = argv.filter((a) => a !== "--no-conf-universe");
const useConfUniverse = !argv.includes("--no-conf-universe");
const paths = args.slice(2);
if (paths.length === 0) {
process.stderr.write(
"usage: mutation-radiography.mjs <mutation-1.json> [<mutation-2.json> ...] [--no-conf-universe]\n"
);
process.exit(2);
}
const reports = paths.map(loadMutationReport);
const universe = useConfUniverse ? tapTestFilesUniverse() : null;
const classification = aggregateRadiography(reports, universe || undefined);
process.stdout.write(renderMarkdown(classification) + "\n");
}
if (import.meta.url === `file://${process.argv[1]}`) {
main(process.argv);
}