chore(lint): vendor anti-slop oxlint plugin and add batched cleanup pipeline

Vendor the anti-slop Oxlint plugin at tools/oxlint/anti-slop and register it
in oxlint.config.ts, with Oxlint's own rule categories disabled so ESLint
stays the general-purpose linter.

Add scripts/anti-slop.mjs (bun run deslop) mirroring the React Doctor batch
interface: next-batch, check-batch, active, release, top, file. Batch handoff
directories now double as file claims shared across clones via
~/.openchamber/maintenance-claims, so concurrent maintenance batches from
either pipeline never select the same file.

Harden both scheduled maintenance flows: stop on a dirty worktree, stop on
NO BATCH AVAILABLE, validate per package instead of workspace-wide, and pin
react-doctor to 0.9.12. The anti-slop task command documents concrete
good and bad fixes and forbids laundering types to satisfy a rule.
This commit is contained in:
Bohdan Triapitsyn
2026-08-16 15:55:08 +03:00
parent e3094ee676
commit 51aef5e316
30 changed files with 3512 additions and 33 deletions
+540
View File
@@ -0,0 +1,540 @@
#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import {
claimedFilePaths,
printClaims,
readActiveClaims,
releaseRun,
resolveRunsDir,
runDirPath,
} from "./lib/batch-claims.mjs";
const PIPELINE = "as";
// Resolved before command dispatch so every command shares one claims location.
const { runsDir: RUNS_DIR, shared: SHARED_CLAIMS } = resolveRunsDir(parseArgs(process.argv.slice(2))["claims-dir"]);
const DEFAULT_MAX_ACTIVE = 3;
const DEFAULT_CLAIM_TTL_DAYS = 3;
// Rules ordered by how mechanical and behavior-safe their fixes are. Higher
// scores are preferred when selecting the next batch.
const PRIORITY_RULES = new Map([
["no-object-parameters", 100],
["no-shape-in-symbol-names", 95],
["no-unknown-type-aliases", 90],
["no-unknown-returns", 85],
["no-unknown-parameters", 80],
["no-unsafe-dictionary-type", 75],
["no-conditional-empty-object-spread", 70],
["no-known-value-widening", 65],
["no-chained-type-assertions", 60],
["no-widen-then-assert", 55],
["no-reflect-get", 50],
["no-reflect-apply", 50],
["no-module-mocking", 30],
["require-safety-comment-for-type-assertion", 20],
["no-runtime-typeof", 10],
]);
// Excluded by default because they account for most of the existing backlog and
// their fixes are the least mechanical. Opt in with --include-noisy.
const NOISY_RULES = new Set(["no-runtime-typeof", "require-safety-comment-for-type-assertion"]);
function usage(exitCode = 0) {
const out = exitCode === 0 ? console.log : console.error;
out(`Usage:
bun run deslop -- next-batch [--min-issues 25] [--max-issues 60] [--max-files 4]
[--max-active ${DEFAULT_MAX_ACTIVE}] [--claim-ttl ${DEFAULT_CLAIM_TTL_DAYS}] [--include-noisy]
bun run deslop -- check-batch --run <run-id>
bun run deslop -- active [--claim-ttl ${DEFAULT_CLAIM_TTL_DAYS}]
Every command accepts --claims-dir <path> to isolate a working copy.
bun run deslop -- release --run <run-id>
bun run deslop -- file <path> [--include-noisy]
bun run deslop -- top [--limit 10] [--include-noisy]
Files selected by an active batch are excluded from later batches, so concurrent
batches never touch the same file, including batches created by the React Doctor
pipeline. Claims are shared across clones by default. A batch stays active until
it is released.
Examples:
bun run deslop -- next-batch --min-issues 25 --max-issues 60
bun run deslop -- file packages/ui/src/lib/settings/metadata.ts
bun run deslop -- check-batch --run 2026-08-16T10-12-44Z
bun run deslop -- release --run 2026-08-16T10-12-44Z`);
process.exit(exitCode);
}
function parseArgs(argv) {
const args = { _: [] };
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (!arg.startsWith("--")) {
args._.push(arg);
continue;
}
const key = arg.slice(2);
const next = argv[i + 1];
if (!next || next.startsWith("--")) {
args[key] = true;
continue;
}
args[key] = next;
i += 1;
}
return args;
}
function asPositiveInt(value, fallback, name) {
if (value === undefined) return fallback;
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed <= 0) {
throw new Error(`Invalid --${name}: expected a positive integer.`);
}
return parsed;
}
function runOxlint() {
// Oxlint exits non-zero whenever it reports findings, so the report has to be
// read from stdout of the failed invocation rather than treated as an error.
let output;
try {
output = execFileSync("bunx", ["oxlint", "--format", "json"], {
cwd: process.cwd(),
encoding: "utf8",
maxBuffer: 256 * 1024 * 1024,
stdio: ["ignore", "pipe", "pipe"],
});
} catch (error) {
// The utf8 encoding above makes stdout a string whenever the run produced
// a report; an empty stdout means the run itself failed.
if (!error.stdout) throw error;
output = error.stdout;
}
const report = JSON.parse(output);
return { diagnostics: normalizeDiagnostics(report.diagnostics ?? []) };
}
function ruleOf(code) {
const match = /^anti-slop\((.+)\)$/.exec(code ?? "");
return match ? match[1] : (code ?? "unknown");
}
function normalizeDiagnostics(rawDiagnostics) {
return rawDiagnostics.map((diagnostic) => {
const span = diagnostic.labels?.[0]?.span;
return {
filePath: diagnostic.filename,
rule: ruleOf(diagnostic.code),
severity: diagnostic.severity ?? "error",
message: diagnostic.message,
line: span?.line,
column: span?.column,
};
});
}
function selectableDiagnostics(report, includeNoisy) {
if (includeNoisy) return report.diagnostics;
return report.diagnostics.filter((diagnostic) => !NOISY_RULES.has(diagnostic.rule));
}
function groupByFile(diagnostics) {
const byFile = new Map();
for (const diagnostic of diagnostics) {
const list = byFile.get(diagnostic.filePath) ?? [];
list.push(diagnostic);
byFile.set(diagnostic.filePath, list);
}
return byFile;
}
function rulePriority(rule) {
return PRIORITY_RULES.get(rule) ?? 50;
}
function filePriority(diagnostics) {
const score = diagnostics.reduce((sum, diagnostic) => sum + rulePriority(diagnostic.rule), 0);
const mechanicalCount = diagnostics.filter((diagnostic) => rulePriority(diagnostic.rule) >= 75).length;
return score + mechanicalCount * 20;
}
function sortedFileEntries(diagnostics) {
return [...groupByFile(diagnostics).entries()].sort((a, b) => {
const scoreDiff = filePriority(b[1]) - filePriority(a[1]);
if (scoreDiff !== 0) return scoreDiff;
const countDiff = b[1].length - a[1].length;
if (countDiff !== 0) return countDiff;
return a[0].localeCompare(b[0]);
});
}
function summarizeRules(diagnostics) {
const counts = new Map();
for (const diagnostic of diagnostics) {
counts.set(diagnostic.rule, (counts.get(diagnostic.rule) ?? 0) + 1);
}
return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
}
function createRunId() {
return new Date().toISOString().replace(/:/g, "-").replace(/\.\d{3}Z$/, "Z");
}
function titleCase(value) {
return value
.replace(/[-_]+/g, " ")
.replace(/\b\w/g, (char) => char.toUpperCase())
.trim();
}
function fileNameWithoutExtension(filePath) {
const fileName = filePath.split("/").at(-1) ?? filePath;
return fileName.replace(/\.[^.]+$/, "");
}
function slugify(value) {
return value
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 80);
}
function createBatchMetadata(runId, selectedFiles) {
const [datePart, timePart = ""] = runId.replace(/Z$/, "").split("T");
const timestamp = `${datePart.replace(/-/g, "")}-${timePart.replace(/-/g, "")}`;
const stems = selectedFiles.map((file) => fileNameWithoutExtension(file.filePath));
const readableArea = stems.length === 1
? stems[0]
: `${stems.slice(0, 2).join(" and ")}${stems.length > 2 ? ` plus ${stems.length - 2}` : ""}`;
const areaSlug = slugify(stems.slice(0, 3).join("-")) || "batch";
const batchName = `as-${timestamp}-${areaSlug}`;
return {
batchName,
branchName: `anti-slop/${batchName}`,
prTitle: `Reduce anti-slop findings in ${titleCase(readableArea)}`,
};
}
function selectBatch(entries, minIssues, maxIssues, maxFiles) {
if (entries.length === 0) {
return { selected: [], oversized: false, belowTarget: false, reason: "No findings available for selection." };
}
const firstFitting = entries.find(([, diagnostics]) => diagnostics.length >= minIssues && diagnostics.length <= maxIssues);
if (firstFitting) {
return {
selected: [firstFitting],
oversized: false,
belowTarget: false,
reason: "A prioritized file already fits the target window.",
};
}
const oversized = entries.find(([, diagnostics]) => diagnostics.length > maxIssues);
if (oversized) {
return {
selected: [oversized],
oversized: true,
belowTarget: false,
reason: "A prioritized file exceeds the target window and was selected as a single complete-file batch.",
};
}
const selected = [];
let total = 0;
for (const entry of entries) {
if (selected.length >= maxFiles) break;
const count = entry[1].length;
if (total + count > maxIssues) {
if (total >= minIssues) break;
continue;
}
selected.push(entry);
total += count;
if (total >= minIssues) break;
}
if (selected.length > 0) {
return {
selected,
oversized: false,
belowTarget: total < minIssues,
reason: total >= minIssues
? "Added complete files until the batch reached the target window."
: "No combination reached the minimum without exceeding the maximum; selected the best smaller complete-file batch.",
};
}
return {
selected: [entries[0]],
oversized: false,
belowTarget: entries[0][1].length < minIssues,
reason: "Selected the best available complete file below the target window.",
};
}
function writeRun(runId, payload) {
const dir = runDirPath(RUNS_DIR, PIPELINE, runId);
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, "baseline.json"), `${JSON.stringify(payload.report, null, 2)}\n`);
writeFileSync(join(dir, "batch.json"), `${JSON.stringify(payload.batch, null, 2)}\n`);
return dir;
}
function readRun(runId) {
const dir = runDirPath(RUNS_DIR, PIPELINE, runId);
const baselinePath = join(dir, "baseline.json");
const batchPath = join(dir, "batch.json");
if (!existsSync(baselinePath) || !existsSync(batchPath)) {
throw new Error(`Unknown run: ${runId}`);
}
return {
baseline: JSON.parse(readFileSync(baselinePath, "utf8")),
batch: JSON.parse(readFileSync(batchPath, "utf8")),
};
}
function printReportHeader(report) {
const total = report.diagnostics.length;
const affected = groupByFile(report.diagnostics).size;
const noisy = report.diagnostics.filter((diagnostic) => NOISY_RULES.has(diagnostic.rule)).length;
console.log(`Total findings: ${total} across ${affected} files`);
console.log(`Excluded-by-default findings: ${noisy} (${[...NOISY_RULES].join(", ")})`);
}
function commandNextBatch(args) {
const minIssues = asPositiveInt(args["min-issues"], 25, "min-issues");
const maxIssues = asPositiveInt(args["max-issues"], 60, "max-issues");
const maxFiles = asPositiveInt(args["max-files"], 4, "max-files");
if (minIssues > maxIssues) throw new Error("--min-issues cannot be greater than --max-issues.");
const maxActive = asPositiveInt(args["max-active"], DEFAULT_MAX_ACTIVE, "max-active");
const claimTtlDays = asPositiveInt(args["claim-ttl"], DEFAULT_CLAIM_TTL_DAYS, "claim-ttl");
const includeNoisy = args["include-noisy"] === true;
const claims = readActiveClaims(RUNS_DIR, claimTtlDays);
if (claims.length >= maxActive) {
console.log("Anti-Slop Next Batch");
console.log("");
console.log("NO BATCH AVAILABLE");
console.log(`Reason: ${claims.length} active batches already exist and the limit is ${maxActive}.`);
console.log("Stop here. Do not create a branch or a pull request.");
console.log("");
printClaims(claims, PIPELINE);
return;
}
const report = runOxlint();
const claimedPaths = claimedFilePaths(claims);
const candidates = selectableDiagnostics(report, includeNoisy)
.filter((diagnostic) => !claimedPaths.has(diagnostic.filePath));
const entries = sortedFileEntries(candidates);
if (entries.length === 0) {
console.log("Anti-Slop Next Batch");
console.log("");
console.log("NO BATCH AVAILABLE");
console.log("Reason: no unclaimed findings remain for the selected rules.");
console.log("Stop here. Do not create a branch or a pull request.");
console.log("");
printClaims(claims, PIPELINE);
return;
}
const selection = selectBatch(entries, minIssues, maxIssues, maxFiles);
const runId = createRunId();
const selectedFiles = selection.selected.map(([filePath, fileDiagnostics]) => ({
filePath,
diagnosticCount: fileDiagnostics.length,
rules: summarizeRules(fileDiagnostics),
}));
const metadata = createBatchMetadata(runId, selectedFiles);
const batch = {
runId,
...metadata,
minIssues,
maxIssues,
maxFiles,
maxActive,
includeNoisy,
selectedFiles,
oversized: selection.oversized,
belowTarget: selection.belowTarget,
reason: selection.reason,
};
const runDir = writeRun(runId, { report, batch });
console.log("Anti-Slop Next Batch");
console.log("");
console.log(`Run ID: ${runId}`);
console.log(`Batch name: ${batch.batchName}`);
console.log(`Branch name: ${batch.branchName}`);
console.log(`PR title: ${batch.prTitle}`);
console.log(`Baseline: ${join(runDir, "baseline.json")}`);
console.log(`Batch metadata: ${join(runDir, "batch.json")}`);
console.log("");
printReportHeader(report);
console.log("");
console.log(`Batch window: ${minIssues}-${maxIssues} findings`);
console.log(`Noisy rules included: ${includeNoisy ? "yes" : "no"}`);
console.log(`Active batches before this one: ${claims.length} of ${maxActive}`);
console.log(`Claims directory: ${RUNS_DIR} (${SHARED_CLAIMS ? "shared default" : "override"})`);
console.log(`Files excluded as claimed by active batches: ${claimedPaths.size}`);
console.log(`Selection mode: complete files only`);
console.log(`Batch total: ${selectedFiles.reduce((sum, file) => sum + file.diagnosticCount, 0)} findings`);
console.log(`Oversized: ${selection.oversized ? "yes" : "no"}`);
console.log(`Below target: ${selection.belowTarget ? "yes" : "no"}`);
console.log(`Selection reason: ${selection.reason}`);
console.log("");
console.log("Selected files:");
selection.selected.forEach(([filePath, fileDiagnostics], index) => {
console.log(`${index + 1}. ${filePath}`);
console.log(` Findings: ${fileDiagnostics.length}`);
console.log(" Rules:");
for (const [rule, count] of summarizeRules(fileDiagnostics)) {
console.log(` ${String(count).padStart(3)} ${rule}`);
}
console.log(" Findings detail:");
for (const diagnostic of fileDiagnostics) {
console.log(` line ${diagnostic.line ?? "?"}:${diagnostic.column ?? "?"} ${diagnostic.severity} ${diagnostic.rule}`);
console.log(` ${diagnostic.message}`);
}
console.log("");
});
}
function commandActive(args) {
const claimTtlDays = asPositiveInt(args["claim-ttl"], DEFAULT_CLAIM_TTL_DAYS, "claim-ttl");
console.log(`Claims directory: ${RUNS_DIR} (${SHARED_CLAIMS ? "shared default" : "override"})`);
printClaims(readActiveClaims(RUNS_DIR, claimTtlDays), PIPELINE);
}
function commandRelease(args) {
const runId = args.run;
if (!runId || runId === true) throw new Error("Missing --run <run-id>.");
const dir = releaseRun(RUNS_DIR, PIPELINE, runId);
console.log(`Released batch ${runId}`);
console.log(`Removed ${dir}`);
}
function commandTop(args) {
const limit = asPositiveInt(args.limit, 10, "limit");
const includeNoisy = args["include-noisy"] === true;
const report = runOxlint();
const entries = sortedFileEntries(selectableDiagnostics(report, includeNoisy)).slice(0, limit);
console.log(`Top ${limit} files by prioritized anti-slop findings`);
console.log("");
for (const [filePath, diagnostics] of entries) {
console.log(`${String(diagnostics.length).padStart(4)} ${filePath}`);
console.log(` ${summarizeRules(diagnostics).map(([rule, count]) => `${rule} ${count}`).join(", ")}`);
}
}
function commandFile(args) {
const filePath = args._[1];
if (!filePath) throw new Error("Missing file path. Usage: bun run deslop -- file <path>");
const includeNoisy = args["include-noisy"] === true;
const report = runOxlint();
const diagnostics = groupByFile(selectableDiagnostics(report, includeNoisy)).get(filePath) ?? [];
console.log(filePath);
console.log(`${diagnostics.length} findings`);
console.log("");
if (diagnostics.length === 0) return;
console.log("Rules:");
for (const [rule, count] of summarizeRules(diagnostics)) {
console.log(`${String(count).padStart(4)} ${rule}`);
}
console.log("");
console.log("Findings:");
for (const diagnostic of diagnostics) {
console.log(`line ${diagnostic.line ?? "?"}:${diagnostic.column ?? "?"} ${diagnostic.severity} ${diagnostic.rule}`);
console.log(` ${diagnostic.message}`);
}
}
function commandCheckBatch(args) {
const runId = args.run;
if (!runId || runId === true) throw new Error("Missing --run <run-id>.");
const { baseline, batch } = readRun(runId);
const current = runOxlint();
const includeNoisy = batch.includeNoisy === true;
const beforeDiagnostics = selectableDiagnostics(baseline, includeNoisy);
const afterDiagnostics = selectableDiagnostics(current, includeNoisy);
const beforeByFile = groupByFile(beforeDiagnostics);
const afterByFile = groupByFile(afterDiagnostics);
const selected = batch.selectedFiles ?? [];
let beforeTotal = 0;
let afterTotal = 0;
console.log("Anti-Slop Batch Check");
console.log("");
console.log(`Run ID: ${runId}`);
if (batch.batchName) console.log(`Batch name: ${batch.batchName}`);
if (batch.branchName) console.log(`Branch name: ${batch.branchName}`);
if (batch.prTitle) console.log(`PR title: ${batch.prTitle}`);
console.log("");
console.log("Selected files:");
for (const file of selected) {
const before = beforeByFile.get(file.filePath)?.length ?? 0;
const after = afterByFile.get(file.filePath)?.length ?? 0;
beforeTotal += before;
afterTotal += after;
console.log(file.filePath);
console.log(` Before: ${before}`);
console.log(` After: ${after}`);
console.log(` Delta: ${after - before}`);
}
const selectedPaths = new Set(selected.map((file) => file.filePath));
const beforeOutside = beforeDiagnostics.filter((diagnostic) => !selectedPaths.has(diagnostic.filePath)).length;
const afterOutside = afterDiagnostics.filter((diagnostic) => !selectedPaths.has(diagnostic.filePath)).length;
console.log("");
console.log("Batch result:");
console.log(`Fixed findings in selected files: ${Math.max(0, beforeTotal - afterTotal)}`);
console.log(`Remaining findings in selected files: ${afterTotal}`);
console.log(`Findings outside selected files delta: ${afterOutside - beforeOutside}`);
console.log("");
console.log("Current repository summary:");
printReportHeader(current);
}
async function main() {
const args = parseArgs(process.argv.slice(2));
const command = args._[0];
if (!command || command === "help" || args.help) usage(0);
switch (command) {
case "next-batch":
commandNextBatch(args);
break;
case "top":
commandTop(args);
break;
case "file":
commandFile(args);
break;
case "check-batch":
commandCheckBatch(args);
break;
case "active":
commandActive(args);
break;
case "release":
commandRelease(args);
break;
default:
throw new Error(`Unknown command: ${command}`);
}
}
main().catch((error) => {
console.error(error.message);
process.exit(1);
});
+107
View File
@@ -0,0 +1,107 @@
import { readdirSync, readFileSync, rmSync, existsSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
// Batch handoff directories double as file claims. A run directory exists from
// the moment its batch is generated until its follow-up task releases it, so
// concurrent maintenance batches can be kept file-disjoint.
//
// Maintenance pipelines are expected to run from dedicated clones of the same
// repository, so claims live outside the working copy by default. Every clone
// and every pipeline therefore sees the same claims without any per-scheduler
// configuration. Override with --claims-dir or OPENCHAMBER_BATCH_CLAIMS_DIR
// only when a working copy must be isolated, for example while experimenting.
const DAY_MS = 24 * 60 * 60 * 1000;
const SHARED_CLAIMS_ENV = "OPENCHAMBER_BATCH_CLAIMS_DIR";
const DEFAULT_CLAIMS_DIR = join(homedir(), ".openchamber", "maintenance-claims");
function expandHome(path) {
if (path === "~") return homedir();
if (path.startsWith("~/")) return join(homedir(), path.slice(2));
return path;
}
export function resolveRunsDir(claimsDirArgument) {
const override = claimsDirArgument ?? process.env[SHARED_CLAIMS_ENV];
if (override !== undefined && override !== true) {
return { runsDir: join(expandHome(override), "runs"), shared: false };
}
return { runsDir: join(DEFAULT_CLAIMS_DIR, "runs"), shared: true };
}
export function runDirName(pipeline, runId) {
return `${pipeline}-${runId}`;
}
export function runDirPath(runsDir, pipeline, runId) {
return join(runsDir, runDirName(pipeline, runId));
}
function parseDirName(dirName) {
const match = /^([a-z]+)-(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z?)$/.exec(dirName);
if (!match) return undefined;
const [, pipeline, stamp] = match;
const [date, time] = stamp.replace(/Z$/, "").split("T");
const createdAt = Date.parse(`${date}T${time.replace(/-/g, ":")}Z`);
return { pipeline, runId: stamp, createdAt: Number.isNaN(createdAt) ? undefined : createdAt };
}
export function readActiveClaims(runsDir, claimTtlDays) {
if (!existsSync(runsDir)) return [];
const now = Date.now();
const claims = [];
for (const entry of readdirSync(runsDir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const parsed = parseDirName(entry.name);
if (!parsed) continue;
const batchPath = join(runsDir, entry.name, "batch.json");
if (!existsSync(batchPath)) continue;
let batch;
try {
batch = JSON.parse(readFileSync(batchPath, "utf8"));
} catch {
continue;
}
const expired = parsed.createdAt !== undefined && now - parsed.createdAt > claimTtlDays * DAY_MS;
if (expired) continue;
claims.push({
pipeline: parsed.pipeline,
runId: batch.runId ?? parsed.runId,
branchName: batch.branchName,
createdAt: parsed.createdAt,
filePaths: (batch.selectedFiles ?? []).map((file) => file.filePath),
});
}
return claims.sort((a, b) => (a.createdAt ?? 0) - (b.createdAt ?? 0));
}
export function claimedFilePaths(claims) {
return new Set(claims.flatMap((claim) => claim.filePaths));
}
export function releaseRun(runsDir, pipeline, runId) {
const dir = runDirPath(runsDir, pipeline, runId);
if (!existsSync(dir)) throw new Error(`Unknown run: ${runId}`);
rmSync(dir, { recursive: true, force: true });
return dir;
}
export function printClaims(claims, ownPipeline) {
if (claims.length === 0) {
console.log("Active batches: none");
return;
}
console.log(`Active batches: ${claims.length}`);
for (const claim of claims) {
const owner = claim.pipeline === ownPipeline ? "this pipeline" : `pipeline ${claim.pipeline}`;
console.log(` ${claim.runId} ${claim.branchName ?? "(no branch)"} [${owner}]`);
for (const filePath of claim.filePaths) console.log(` ${filePath}`);
}
}
+83 -7
View File
@@ -4,8 +4,24 @@ import { execFileSync } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import {
claimedFilePaths,
printClaims,
readActiveClaims,
releaseRun,
resolveRunsDir,
runDirPath,
} from "./lib/batch-claims.mjs";
const PROJECT_NAME = "openchamber-monorepo";
const RUNS_DIR = join(process.cwd(), ".tmp", "react-doctor", "runs");
// Pinned so unattended batch runs cannot change diagnostics or output shape
// without an explicit update here.
const REACT_DOCTOR_VERSION = "0.9.12";
const PIPELINE = "rd";
// Resolved before command dispatch so every command shares one claims location.
const { runsDir: RUNS_DIR, shared: SHARED_CLAIMS } = resolveRunsDir(parseArgs(process.argv.slice(2))["claims-dir"]);
const DEFAULT_MAX_ACTIVE = 3;
const DEFAULT_CLAIM_TTL_DAYS = 3;
const PRIORITY_RULES = new Map([
["effect-needs-cleanup", 100],
@@ -88,14 +104,25 @@ function usage(exitCode = 0) {
const out = exitCode === 0 ? console.log : console.error;
out(`Usage:
bun run doctor -- next-batch [--min-issues 75] [--max-issues 120] [--max-files 4]
[--max-active ${DEFAULT_MAX_ACTIVE}] [--claim-ttl ${DEFAULT_CLAIM_TTL_DAYS}]
bun run doctor -- check-batch --run <run-id>
bun run doctor -- active [--claim-ttl ${DEFAULT_CLAIM_TTL_DAYS}]
Every command accepts --claims-dir <path> to isolate a working copy.
bun run doctor -- release --run <run-id>
bun run doctor -- file <path>
bun run doctor -- top [--limit 10]
Files selected by an active batch are excluded from later batches, so concurrent
batches never touch the same file, including batches created by the anti-slop
pipeline. Claims are shared across clones by default. A batch stays active until
it is released.
Examples:
bun run doctor -- next-batch --min-issues 75 --max-issues 120
bun run doctor -- file packages/ui/src/components/chat/ChatInput.tsx
bun run doctor -- check-batch --run 2026-05-14T12-31-44`);
bun run doctor -- check-batch --run 2026-05-14T12-31-44Z
bun run doctor -- release --run 2026-05-14T12-31-44Z`);
process.exit(exitCode);
}
@@ -132,7 +159,7 @@ function runReactDoctor() {
const output = execFileSync(
"npx",
[
"react-doctor@latest",
`react-doctor@${REACT_DOCTOR_VERSION}`,
"--project",
PROJECT_NAME,
"--json",
@@ -296,7 +323,7 @@ function selectBatch(entries, minIssues, maxIssues, maxFiles) {
}
function writeRun(runId, payload) {
const dir = join(RUNS_DIR, runId);
const dir = runDirPath(RUNS_DIR, PIPELINE, runId);
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, "baseline.json"), `${JSON.stringify(payload.report, null, 2)}\n`);
writeFileSync(join(dir, "batch.json"), `${JSON.stringify(payload.batch, null, 2)}\n`);
@@ -304,7 +331,7 @@ function writeRun(runId, payload) {
}
function readRun(runId) {
const dir = join(RUNS_DIR, runId);
const dir = runDirPath(RUNS_DIR, PIPELINE, runId);
const baselinePath = join(dir, "baseline.json");
const batchPath = join(dir, "batch.json");
if (!existsSync(baselinePath) || !existsSync(batchPath)) {
@@ -336,10 +363,36 @@ function commandNextBatch(args) {
const maxIssues = asPositiveInt(args["max-issues"], 120, "max-issues");
const maxFiles = asPositiveInt(args["max-files"], 4, "max-files");
if (minIssues > maxIssues) throw new Error("--min-issues cannot be greater than --max-issues.");
const maxActive = asPositiveInt(args["max-active"], DEFAULT_MAX_ACTIVE, "max-active");
const claimTtlDays = asPositiveInt(args["claim-ttl"], DEFAULT_CLAIM_TTL_DAYS, "claim-ttl");
const claims = readActiveClaims(RUNS_DIR, claimTtlDays);
if (claims.length >= maxActive) {
console.log("React Doctor Next Batch");
console.log("");
console.log("NO BATCH AVAILABLE");
console.log(`Reason: ${claims.length} active batches already exist and the limit is ${maxActive}.`);
console.log("Stop here. Do not create a branch or a pull request.");
console.log("");
printClaims(claims, PIPELINE);
return;
}
const report = runReactDoctor();
const diagnostics = allDiagnostics(report);
const claimedPaths = claimedFilePaths(claims);
const diagnostics = allDiagnostics(report).filter((diagnostic) => !claimedPaths.has(diagnostic.filePath));
const entries = sortedFileEntries(diagnostics);
if (entries.length === 0) {
console.log("React Doctor Next Batch");
console.log("");
console.log("NO BATCH AVAILABLE");
console.log("Reason: no unclaimed diagnostics remain.");
console.log("Stop here. Do not create a branch or a pull request.");
console.log("");
printClaims(claims, PIPELINE);
return;
}
const selection = selectBatch(entries, minIssues, maxIssues, maxFiles);
const runId = createRunId();
const selectedFiles = selection.selected.map(([filePath, fileDiagnostics]) => ({
@@ -348,7 +401,7 @@ function commandNextBatch(args) {
rules: summarizeRules(fileDiagnostics),
}));
const metadata = createBatchMetadata(runId, selectedFiles);
const batch = { runId, ...metadata, minIssues, maxIssues, maxFiles, selectedFiles, oversized: selection.oversized, belowTarget: selection.belowTarget, reason: selection.reason };
const batch = { runId, ...metadata, minIssues, maxIssues, maxFiles, maxActive, selectedFiles, oversized: selection.oversized, belowTarget: selection.belowTarget, reason: selection.reason };
const runDir = writeRun(runId, { report, batch });
console.log("React Doctor Next Batch");
@@ -363,6 +416,9 @@ function commandNextBatch(args) {
printReportHeader(report);
console.log("");
console.log(`Batch window: ${minIssues}-${maxIssues} diagnostics`);
console.log(`Active batches before this one: ${claims.length} of ${maxActive}`);
console.log(`Claims directory: ${RUNS_DIR} (${SHARED_CLAIMS ? "shared default" : "override"})`);
console.log(`Files excluded as claimed by active batches: ${claimedPaths.size}`);
console.log(`Selection mode: complete files only`);
console.log(`Batch total: ${selectedFiles.reduce((sum, file) => sum + file.diagnosticCount, 0)} diagnostics`);
console.log(`Oversized: ${selection.oversized ? "yes" : "no"}`);
@@ -387,6 +443,20 @@ function commandNextBatch(args) {
});
}
function commandActive(args) {
const claimTtlDays = asPositiveInt(args["claim-ttl"], DEFAULT_CLAIM_TTL_DAYS, "claim-ttl");
console.log(`Claims directory: ${RUNS_DIR} (${SHARED_CLAIMS ? "shared default" : "override"})`);
printClaims(readActiveClaims(RUNS_DIR, claimTtlDays), PIPELINE);
}
function commandRelease(args) {
const runId = args.run;
if (!runId || runId === true) throw new Error("Missing --run <run-id>.");
const dir = releaseRun(RUNS_DIR, PIPELINE, runId);
console.log(`Released batch ${runId}`);
console.log(`Removed ${dir}`);
}
function commandTop(args) {
const limit = asPositiveInt(args.limit, 10, "limit");
const report = runReactDoctor();
@@ -479,6 +549,12 @@ async function main() {
case "check-batch":
commandCheckBatch(args);
break;
case "active":
commandActive(args);
break;
case "release":
commandRelease(args);
break;
default:
throw new Error(`Unknown command: ${command}`);
}