mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-05 23:02:10 +03:00
Release v3.8.30 (#4267)
Release v3.8.30 — see CHANGELOG.md [3.8.30] for the full release notes.
This commit is contained in:
committed by
GitHub
parent
ab8096071c
commit
db362b0126
144
scripts/build/colocateOptionals.mjs
Normal file
144
scripts/build/colocateOptionals.mjs
Normal file
@@ -0,0 +1,144 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* OmniRoute — Co-locate the LLMLingua-2 optional dependency closure into the standalone bundle.
|
||||
*
|
||||
* The compression "ultra" SLM tier (PR #4257) runs `@atjsh/llmlingua-2` +
|
||||
* `@huggingface/transformers` + `@tensorflow/tfjs` + `js-tiktoken` inside a worker thread
|
||||
* (`open-sse/services/compression/engines/llmlingua/onnxWorker.js`, shipped under `dist/`). These
|
||||
* are `optionalDependencies`: npm installs them into the ROOT `node_modules` on
|
||||
* `--include=optional`, but the Next.js standalone trace bundles ONLY `@huggingface/transformers`
|
||||
* (3.5.2, pinned) into `dist/node_modules` — it does NOT trace the optional, dynamically-imported
|
||||
* SLM packages.
|
||||
*
|
||||
* ## Why this matters (the instance-split bug)
|
||||
*
|
||||
* The worker lives under `dist/`, so its `import("@huggingface/transformers")` resolves
|
||||
* `dist/node_modules/@huggingface/transformers` (3.5.2) and the worker sets the model `cacheDir`
|
||||
* on THAT instance's `env`. But its `import("@atjsh/llmlingua-2")` walks past `dist/node_modules`
|
||||
* (no `@atjsh` there) up to the ROOT `node_modules`, and llmlingua-2's own
|
||||
* `import("@huggingface/transformers")` then resolves the ROOT transformers — a DIFFERENT instance.
|
||||
* The `cacheDir`/`localModelPath` config the worker set never reaches the instance llmlingua-2
|
||||
* actually uses, so the local model under `DATA_DIR/models/llmlingua` is never found and the SLM
|
||||
* tier silently fails-open (no compression). Worse, if the root transformers is a 4.x line,
|
||||
* llmlingua-2 throws on a tokenizer-API change (`decoder.decode` is undefined).
|
||||
*
|
||||
* ## The fix
|
||||
*
|
||||
* Co-locate the SLM optional dependency CLOSURE from the root `node_modules` into
|
||||
* `dist/node_modules` (NO-CLOBBER, so the pinned `dist` transformers 3.5.2 / onnxruntime / sharp
|
||||
* stay). Then the worker resolves `@atjsh/llmlingua-2` AND `@huggingface/transformers` from the
|
||||
* SAME `dist/node_modules` — a single 3.5.2 instance — so the env config applies and the local
|
||||
* model loads.
|
||||
*
|
||||
* `@huggingface/transformers` is intentionally NOT a closure seed: it is a PEER of
|
||||
* `@atjsh/llmlingua-2` (not a regular dependency) and is already bundled in `dist/node_modules`,
|
||||
* so the closure walk never reaches it and the no-clobber guard would skip it anyway.
|
||||
*
|
||||
* ## Validation (Hard Rule #18)
|
||||
*
|
||||
* Manual co-location of this exact closure on the production VPS produced real 54.8% compression
|
||||
* (11520 → 5203 chars) via real ONNX inference — both the default and the `modelPath` (PR #4257)
|
||||
* code paths. See the unit test for the closure-walk + no-clobber contract.
|
||||
*
|
||||
* Idempotent + fail-soft: skips when the optionals are absent (the common case — they are OPTIONAL)
|
||||
* or already co-located; a per-package copy failure only disables the SLM tier, which is itself
|
||||
* fail-open, so this never throws into the install.
|
||||
*/
|
||||
|
||||
import { cpSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
/**
|
||||
* Entry packages of the SLM optional stack (the closure roots). `@huggingface/transformers` is
|
||||
* deliberately absent — it is the pinned instance already present in `dist/node_modules`.
|
||||
*/
|
||||
export const SEED_PACKAGES = ["@atjsh/llmlingua-2", "@tensorflow/tfjs", "js-tiktoken"];
|
||||
|
||||
/**
|
||||
* Compute the transitive dependency closure of `seeds` by walking each package's `dependencies` +
|
||||
* `optionalDependencies` from a `node_modules` directory. Packages that are not present in that
|
||||
* tree (e.g. peers provided elsewhere, like `@huggingface/transformers` in `dist`) are skipped —
|
||||
* the closure only contains packages that actually exist in `nodeModulesDir`.
|
||||
*
|
||||
* @param {string} nodeModulesDir absolute path to the source `node_modules`
|
||||
* @param {string[]} [seeds] closure roots (defaults to {@link SEED_PACKAGES})
|
||||
* @returns {string[]} package names in discovery order, seeds first
|
||||
*/
|
||||
export function computeDependencyClosure(nodeModulesDir, seeds = SEED_PACKAGES) {
|
||||
const closure = [];
|
||||
const seen = new Set();
|
||||
const stack = [...seeds];
|
||||
|
||||
while (stack.length) {
|
||||
const name = stack.shift();
|
||||
if (seen.has(name)) continue;
|
||||
seen.add(name);
|
||||
|
||||
const pkgDir = join(nodeModulesDir, name);
|
||||
if (!existsSync(pkgDir)) continue; // absent in this tree (peer provided elsewhere) — skip
|
||||
|
||||
closure.push(name);
|
||||
|
||||
let manifest;
|
||||
try {
|
||||
manifest = JSON.parse(readFileSync(join(pkgDir, "package.json"), "utf8"));
|
||||
} catch {
|
||||
continue; // unreadable/absent manifest — copy the dir but do not recurse
|
||||
}
|
||||
|
||||
const deps = { ...manifest.dependencies, ...manifest.optionalDependencies };
|
||||
for (const dep of Object.keys(deps)) {
|
||||
if (!seen.has(dep)) stack.push(dep);
|
||||
}
|
||||
}
|
||||
|
||||
return closure;
|
||||
}
|
||||
|
||||
/**
|
||||
* Co-locate the SLM optional closure from `<rootDir>/node_modules` into
|
||||
* `<rootDir>/dist/node_modules`. No-op when the standalone `dist` bundle or the optional seeds are
|
||||
* absent, and idempotent once co-located. Never throws.
|
||||
*
|
||||
* @param {{ rootDir: string, log?: (message: string) => void }} opts
|
||||
* @returns {{ skipped: true, reason: string }
|
||||
* | { skipped: false, copied: number, closure: number }}
|
||||
*/
|
||||
export function colocateLlmlinguaOptionals({ rootDir, log = () => {} }) {
|
||||
const rootNm = join(rootDir, "node_modules");
|
||||
const distNm = join(rootDir, "dist", "node_modules");
|
||||
|
||||
if (!existsSync(distNm)) {
|
||||
return { skipped: true, reason: "no standalone dist/node_modules" };
|
||||
}
|
||||
// Gate: only run when the optional stack was actually installed (`npm install --include=optional`).
|
||||
if (!SEED_PACKAGES.every((seed) => existsSync(join(rootNm, seed)))) {
|
||||
return { skipped: true, reason: "SLM optionals not installed at root" };
|
||||
}
|
||||
// Idempotent: the entry package is already co-located → nothing to do.
|
||||
if (existsSync(join(distNm, "@atjsh", "llmlingua-2"))) {
|
||||
return { skipped: true, reason: "already co-located" };
|
||||
}
|
||||
|
||||
const closure = computeDependencyClosure(rootNm);
|
||||
let copied = 0;
|
||||
|
||||
for (const name of closure) {
|
||||
const dest = join(distNm, name);
|
||||
if (existsSync(dest)) continue; // no-clobber: keep dist's pinned copy (transformers 3.5.2, …)
|
||||
try {
|
||||
mkdirSync(dirname(dest), { recursive: true });
|
||||
cpSync(join(rootNm, name), dest, { recursive: true });
|
||||
copied++;
|
||||
} catch (err) {
|
||||
log(` ⚠️ LLMLingua optional co-location failed for ${name}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (copied > 0) {
|
||||
log(` ✅ Co-located ${copied} LLMLingua SLM optional package(s) into dist/node_modules.\n`);
|
||||
}
|
||||
|
||||
return { skipped: false, copied, closure: closure.length };
|
||||
}
|
||||
@@ -95,6 +95,7 @@ export const PACK_ARTIFACT_ROOT_ALLOWED_EXACT_PATHS: string[] = [
|
||||
"scripts/build/native-binary-compat.mjs",
|
||||
"scripts/build/postinstall.mjs",
|
||||
"scripts/build/postinstallSupport.mjs",
|
||||
"scripts/build/colocateOptionals.mjs",
|
||||
"scripts/build/sync-env.mjs",
|
||||
"scripts/dev/responses-ws-proxy.mjs",
|
||||
"scripts/dev/sync-env.mjs",
|
||||
@@ -135,6 +136,7 @@ export const PACK_ARTIFACT_REQUIRED_PATHS: string[] = [
|
||||
"scripts/build/native-binary-compat.mjs",
|
||||
"scripts/build/postinstall.mjs",
|
||||
"scripts/build/postinstallSupport.mjs",
|
||||
"scripts/build/colocateOptionals.mjs",
|
||||
"src/shared/utils/nodeRuntimeSupport.ts",
|
||||
];
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ import { fileURLToPath } from "node:url";
|
||||
|
||||
import { PUBLISHED_BUILD_ARCH, PUBLISHED_BUILD_PLATFORM } from "./native-binary-compat.mjs";
|
||||
import { hasStandaloneAppBundle, isTermux } from "./postinstallSupport.mjs";
|
||||
import { colocateLlmlinguaOptionals } from "./colocateOptionals.mjs";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
@@ -327,9 +328,24 @@ async function syncProjectEnv() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Co-locate the LLMLingua-2 SLM optional dependency closure into dist/node_modules so the
|
||||
* compression "ultra" SLM tier (PR #4257) resolves a single @huggingface/transformers instance at
|
||||
* runtime. No-op unless the optionals were installed (`--include=optional`). See colocateOptionals.mjs.
|
||||
*/
|
||||
async function ensureLlmlinguaOptionals() {
|
||||
try {
|
||||
colocateLlmlinguaOptionals({ rootDir: ROOT, log: (m) => console.log(m) });
|
||||
} catch (err) {
|
||||
// Best-effort: the SLM tier is itself fail-open, so a co-location hiccup never fails the install.
|
||||
console.warn(` ⚠️ LLMLingua optional co-location skipped: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
await fixBetterSqliteBinary();
|
||||
await fixWreqJsBinary();
|
||||
await ensureSwcHelpers();
|
||||
await ensureLlmlinguaOptionals();
|
||||
await syncProjectEnv();
|
||||
|
||||
// Warm up native runtimes (better-sqlite3 in ~/.omniroute/runtime/).
|
||||
|
||||
@@ -23,7 +23,12 @@ const BASELINE_PATH = path.resolve(
|
||||
);
|
||||
const UPDATE = process.argv.includes("--update");
|
||||
const CONFIG_PATH = path.join(ROOT, "eslint.complexity.config.mjs");
|
||||
const ESLINT_ARGS = [
|
||||
// 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",
|
||||
@@ -32,6 +37,8 @@ const ESLINT_ARGS = [
|
||||
"json",
|
||||
"src",
|
||||
"open-sse",
|
||||
"electron",
|
||||
"bin",
|
||||
];
|
||||
|
||||
/** Avalia a contagem atual de violações contra o baseline. */
|
||||
|
||||
@@ -40,6 +40,8 @@ const HANDLERS_DIR = path.join(cwd, "open-sse/handlers");
|
||||
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)
|
||||
"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
|
||||
|
||||
@@ -106,6 +106,8 @@ const ENV_VAR_ALLOWLIST = new Set([
|
||||
"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)
|
||||
"GEMINI_API_KEY", // Gemini CLI's own API-key env var, set by `omniroute setup-gemini` (REMOTE-MODE.md)
|
||||
"GOOGLE_GEMINI_BASE_URL", // Gemini CLI's own base-URL env var, set by `omniroute setup-gemini` (REMOTE-MODE.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)
|
||||
|
||||
@@ -20,6 +20,8 @@ const BASELINE_PATH = path.resolve(
|
||||
);
|
||||
const UPDATE = process.argv.includes("--update");
|
||||
const SCAN_DIRS = ["src", "open-sse", "electron", "bin"];
|
||||
// Test files live under tests/ plus co-located *.test.ts(x) inside the source dirs.
|
||||
const TEST_SCAN_DIRS = ["tests", ...SCAN_DIRS];
|
||||
// Directories to skip when walking — build artifacts and installed packages.
|
||||
const SKIP_DIRS = new Set(["node_modules", "dist-electron", ".next", ".build", "dist", "coverage"]);
|
||||
|
||||
@@ -71,6 +73,29 @@ function collectLoc() {
|
||||
return out;
|
||||
}
|
||||
|
||||
// Walk for TEST files: collects *.test.ts / *.test.tsx (the inverse of walk(),
|
||||
// which deliberately excludes them). Skips .d.ts and the same SKIP_DIRS.
|
||||
function walkTests(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()) {
|
||||
if (!SKIP_DIRS.has(e.name)) walkTests(p, acc);
|
||||
} else if (/\.test\.tsx?$/.test(e.name) && !/\.d\.ts$/.test(e.name)) {
|
||||
acc.push(p);
|
||||
}
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
|
||||
function collectTestLoc() {
|
||||
const out = {};
|
||||
for (const d of TEST_SCAN_DIRS)
|
||||
for (const f of walkTests(path.join(ROOT, d)))
|
||||
out[path.relative(ROOT, f).replace(/\\/g, "/")] = countLines(f);
|
||||
return out;
|
||||
}
|
||||
|
||||
function main() {
|
||||
if (!fs.existsSync(BASELINE_PATH)) {
|
||||
console.error(`[file-size] FAIL — ${path.basename(BASELINE_PATH)} ausente.`);
|
||||
@@ -82,28 +107,73 @@ function main() {
|
||||
const current = collectLoc();
|
||||
const { violations, improvements } = evaluateFileSizes(current, frozen, cap);
|
||||
|
||||
if (UPDATE && violations.length === 0 && improvements.length) {
|
||||
for (const [file, loc] of improvements) {
|
||||
if (loc <= cap)
|
||||
delete frozen[file]; // caiu para dentro do cap → sai do baseline
|
||||
else frozen[file] = loc; // continua grande mas encolheu → trava no novo valor
|
||||
// Test-file gate (Layer 1 anti-reinflation): same shrink-only + new-≤cap semantics,
|
||||
// reusing evaluateFileSizes against the testFrozen baseline + testCap.
|
||||
const testCap = baseline.testCap;
|
||||
const testFrozen = baseline.testFrozen || {};
|
||||
const currentTests = collectTestLoc();
|
||||
const { violations: testViolations, improvements: testImprovements } =
|
||||
typeof testCap === "number"
|
||||
? evaluateFileSizes(currentTests, testFrozen, testCap)
|
||||
: { violations: [], improvements: [] };
|
||||
|
||||
if (UPDATE) {
|
||||
let changed = false;
|
||||
if (violations.length === 0 && improvements.length) {
|
||||
for (const [file, loc] of improvements) {
|
||||
if (loc <= cap)
|
||||
delete frozen[file]; // caiu para dentro do cap → sai do baseline
|
||||
else frozen[file] = loc; // continua grande mas encolheu → trava no novo valor
|
||||
}
|
||||
baseline.frozen = Object.fromEntries(Object.entries(frozen).sort());
|
||||
changed = true;
|
||||
console.log(`[file-size] baseline ratcheado: ${improvements.length} arquivo(s) encolheram`);
|
||||
}
|
||||
baseline.frozen = Object.fromEntries(Object.entries(frozen).sort());
|
||||
fs.writeFileSync(BASELINE_PATH, JSON.stringify(baseline, null, 2) + "\n");
|
||||
console.log(`[file-size] baseline ratcheado: ${improvements.length} arquivo(s) encolheram`);
|
||||
if (typeof testCap === "number" && testViolations.length === 0 && testImprovements.length) {
|
||||
for (const [file, loc] of testImprovements) {
|
||||
if (loc <= testCap)
|
||||
delete testFrozen[file]; // caiu para dentro do testCap → sai do baseline
|
||||
else testFrozen[file] = loc; // continua grande mas encolheu → trava no novo valor
|
||||
}
|
||||
baseline.testFrozen = Object.fromEntries(Object.entries(testFrozen).sort());
|
||||
changed = true;
|
||||
console.log(
|
||||
`[test-file-size] baseline ratcheado: ${testImprovements.length} arquivo(s) de teste encolheram`
|
||||
);
|
||||
}
|
||||
if (changed) fs.writeFileSync(BASELINE_PATH, JSON.stringify(baseline, null, 2) + "\n");
|
||||
}
|
||||
|
||||
let failed = false;
|
||||
if (violations.length) {
|
||||
console.error(
|
||||
`[file-size] ${violations.length} violação(ões):\n` +
|
||||
violations.map((v) => " ✗ " + v).join("\n") +
|
||||
`\n → modularize/extraia (DRY) para encolher, ou (último caso) ajuste file-size-baseline.json com justificativa.`
|
||||
);
|
||||
process.exit(1);
|
||||
failed = true;
|
||||
} else {
|
||||
console.log(
|
||||
`[file-size] OK — ${Object.keys(frozen).length} arquivos congelados, cap ${cap} para novos (${Object.keys(current).length} arquivos verificados)`
|
||||
);
|
||||
}
|
||||
console.log(
|
||||
`[file-size] OK — ${Object.keys(frozen).length} arquivos congelados, cap ${cap} para novos (${Object.keys(current).length} arquivos verificados)`
|
||||
);
|
||||
|
||||
if (typeof testCap === "number") {
|
||||
if (testViolations.length) {
|
||||
console.error(
|
||||
`[test-file-size] ${testViolations.length} test file violation(s) (testCap ${testCap}):\n` +
|
||||
testViolations.map((v) => " ✗ " + v).join("\n") +
|
||||
`\n → split the test file (extract helpers/sub-suites) to shrink it, or (last resort) adjust testFrozen in file-size-baseline.json with justification.`
|
||||
);
|
||||
failed = true;
|
||||
} else {
|
||||
console.log(
|
||||
`[test-file-size] OK — ${Object.keys(testFrozen).length} test files congelados, testCap ${testCap} para novos (${Object.keys(currentTests).length} test files verificados)`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (failed) process.exit(1);
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();
|
||||
|
||||
@@ -68,18 +68,28 @@ export function mutationScoreForFile(fileData) {
|
||||
/**
|
||||
* Score por arquivo a partir de um ou mais reports (batches). Arquivos sem mutante
|
||||
* coberto (score null) são omitidos.
|
||||
*
|
||||
* ⭐ Sibling batches que fatiam o MESMO arquivo (auth.ts split em a1:1-1109 + a2:1110-2218
|
||||
* por mutation range; accountFallback em b1/b2) carregam fatias DISJUNTAS dos mutantes
|
||||
* daquele arquivo. O score verdadeiro do arquivo precisa de TODAS as fatias juntas, então
|
||||
* unimos `files[<arquivo>].mutants` entre os reports ANTES de pontuar — não sobrescrever
|
||||
* (senão a última fatia venceria e reportaria só metade do arquivo).
|
||||
* @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 = {};
|
||||
const mutantsByFile = {};
|
||||
for (const report of reports) {
|
||||
for (const [file, data] of Object.entries(report?.files || {})) {
|
||||
const score = mutationScoreForFile(data);
|
||||
if (score !== null) out[file] = score;
|
||||
(mutantsByFile[file] ||= []).push(...(data?.mutants || []));
|
||||
}
|
||||
}
|
||||
const out = {};
|
||||
for (const [file, mutants] of Object.entries(mutantsByFile)) {
|
||||
const score = mutationScoreForFile({ mutants });
|
||||
if (score !== null) out[file] = score;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
@@ -88,8 +88,8 @@ const ENV_KEY_RE = /(clientId|clientSecret|apiKey)Env\s*:/;
|
||||
// TODO(6A.8): Consider tightening CRED_KEY_RE to exclude function-signature contexts — but
|
||||
// that adds complexity; the FP rate is low (1 file). Frozen by file:line:value key.
|
||||
export const KNOWN_LITERAL_CREDS = new Set([
|
||||
"open-sse/services/usage.ts:546:minimax", // TODO(6A.8): pre-existing FP — TS fn-param type, not a credential (moved 543→546 by #3838 usage.ts comment)
|
||||
"open-sse/services/usage.ts:546:minimax-cn", // TODO(6A.8): pre-existing FP — TS fn-param type, not a credential (moved 543→546 by #3838 usage.ts comment)
|
||||
"open-sse/services/usage.ts:547:minimax", // TODO(6A.8): pre-existing FP — TS fn-param type, not a credential (moved 543→547 by #3838 usage.ts comment + #4293 Codex Spark extraction)
|
||||
"open-sse/services/usage.ts:547:minimax-cn", // TODO(6A.8): pre-existing FP — TS fn-param type, not a credential (moved 543→547 by #3838 usage.ts comment + #4293 Codex Spark extraction)
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -53,7 +53,7 @@ export const COLLECTORS = [
|
||||
// "vitest" e explodem no node runner). Subdir novo: adicione aqui E nos scripts
|
||||
// (o drift-check + o gate de órfãos forçam a manutenção em sincronia).
|
||||
{
|
||||
glob: "tests/unit/{api,auth,authz,build,cli,cli-helper,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui}/**/*.test.ts",
|
||||
glob: "tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui}/**/*.test.ts",
|
||||
sources: ["package.json", ".github/workflows/ci.yml"],
|
||||
},
|
||||
// Node native runner — test:integration (top-level only; tests/integration/services/ NÃO roda)
|
||||
|
||||
@@ -60,7 +60,7 @@ function sourceDepsOf(entry) {
|
||||
const testFiles = globSync(
|
||||
[
|
||||
"tests/unit/*.test.ts",
|
||||
"tests/unit/{api,auth,authz,build,cli,cli-helper,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui}/**/*.test.ts",
|
||||
"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui}/**/*.test.ts",
|
||||
],
|
||||
{ cwd: ROOT, absolute: true }
|
||||
);
|
||||
|
||||
@@ -137,6 +137,34 @@ export function aggregateRadiography(reports, allTestFiles) {
|
||||
return materialize(total, [...universe]);
|
||||
}
|
||||
|
||||
/**
|
||||
* R1 prune-candidate list: the test files with ZERO unique kills — 🔴 empty (kills no
|
||||
* mutant) ∪ 🟠 redundant (every mutant it kills is also killed by ≥1 other file). Files
|
||||
* with ≥1 unique kill (🟢 unique / 🟡 overlapping) are NEVER candidates — removing one
|
||||
* would drop a mutant's only killer and lower the mutation score.
|
||||
*
|
||||
* IMPORTANT: 🟠 redundant is only ACCURATE when the reports come from a `disableBail:true`
|
||||
* run (killedBy lists EVERY killer). Under the bail-on-first nightly, redundant is
|
||||
* understated — see the module caveat. Pass disableBail reports here (mutation-redundancy.yml).
|
||||
*
|
||||
* @param {object[]} reports parsed mutation.json objects (one per batch)
|
||||
* @param {string[]} [allTestFiles] universe; defaults to the union of testFiles keys
|
||||
* @returns {{ classification: object, empty: string[], redundant: string[], candidates: string[] }}
|
||||
*/
|
||||
export function redundancyCandidates(reports, allTestFiles) {
|
||||
const classification = aggregateRadiography(reports, allTestFiles);
|
||||
const empty = [];
|
||||
const redundant = [];
|
||||
for (const [file, info] of Object.entries(classification)) {
|
||||
if (info.class === "empty") empty.push(file);
|
||||
else if (info.class === "redundant") redundant.push(file);
|
||||
}
|
||||
empty.sort((a, b) => a.localeCompare(b));
|
||||
redundant.sort((a, b) => a.localeCompare(b));
|
||||
const candidates = [...empty, ...redundant].sort((a, b) => a.localeCompare(b));
|
||||
return { classification, empty, redundant, candidates };
|
||||
}
|
||||
|
||||
// ── CLI ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
function tapTestFilesUniverse() {
|
||||
@@ -206,18 +234,54 @@ function renderMarkdown(classification) {
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
const FLAGS = new Set(["--no-conf-universe", "--candidates"]);
|
||||
|
||||
function renderCandidates({ empty, redundant, candidates }) {
|
||||
const lines = [];
|
||||
lines.push("# R1 — Test-redundancy prune candidates (disableBail)");
|
||||
lines.push("");
|
||||
lines.push(
|
||||
`Test files with ZERO unique kills: **${candidates.length}** ` +
|
||||
`(🔴 empty ${empty.length} + 🟠 redundant ${redundant.length}).`
|
||||
);
|
||||
lines.push("");
|
||||
lines.push(
|
||||
"> Accurate ONLY for a `disableBail:true` run (killedBy lists every killer). " +
|
||||
"These are CANDIDATES, not deletions: exclude security/contract/repro tests " +
|
||||
"(routeGuard, OAuth, error-sanitization, *-repro*/*-regression*/issue-linked) and " +
|
||||
"require human review before removing any (R1 human gate)."
|
||||
);
|
||||
lines.push("");
|
||||
lines.push(`## 🔴 empty — kills no mutant (${empty.length})`);
|
||||
lines.push("");
|
||||
if (empty.length === 0) lines.push("_none_");
|
||||
else for (const f of empty) lines.push(`- ${f}`);
|
||||
lines.push("");
|
||||
lines.push(`## 🟠 redundant — every kill shared with another file (${redundant.length})`);
|
||||
lines.push("");
|
||||
if (redundant.length === 0) lines.push("_none_");
|
||||
else for (const f of redundant) lines.push(`- ${f}`);
|
||||
lines.push("");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function main(argv) {
|
||||
const args = argv.filter((a) => a !== "--no-conf-universe");
|
||||
const wantCandidates = argv.includes("--candidates");
|
||||
const useConfUniverse = !argv.includes("--no-conf-universe");
|
||||
const paths = args.slice(2);
|
||||
const paths = argv.slice(2).filter((a) => !FLAGS.has(a));
|
||||
if (paths.length === 0) {
|
||||
process.stderr.write(
|
||||
"usage: mutation-radiography.mjs <mutation-1.json> [<mutation-2.json> ...] [--no-conf-universe]\n"
|
||||
"usage: mutation-radiography.mjs <mutation-1.json> [<mutation-2.json> ...] " +
|
||||
"[--candidates] [--no-conf-universe]\n"
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
const reports = paths.map(loadMutationReport);
|
||||
const universe = useConfUniverse ? tapTestFilesUniverse() : null;
|
||||
if (wantCandidates) {
|
||||
process.stdout.write(renderCandidates(redundancyCandidates(reports, universe || undefined)) + "\n");
|
||||
return;
|
||||
}
|
||||
const classification = aggregateRadiography(reports, universe || undefined);
|
||||
process.stdout.write(renderMarkdown(classification) + "\n");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user