chore: add React Doctor maintenance workflow

Adds a React Doctor batching script for targeted diagnostics cleanup
Adds OpenCode commands for cleanup and review follow-up workflows
Ignores generated local React Doctor reports
This commit is contained in:
Bohdan Triapitsyn
2026-05-14 18:31:47 +03:00
parent a4decfd3dd
commit 0eb72eb867
5 changed files with 611 additions and 0 deletions
+1
View File
@@ -16,6 +16,7 @@ release
/tsc
/openchamber@*
local-dev*
.tmp/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
+63
View File
@@ -0,0 +1,63 @@
---
description: Create a React Doctor diagnostics cleanup PR from the next generated batch
agent: build
---
You are working in the OpenChamber repository.
Goal: reduce React Doctor diagnostics in a small, reviewable maintenance PR.
Start by running:
`bun run doctor -- next-batch --min-issues 75 --max-issues 120`
Use the command output as the source of truth for this task scope.
Workflow:
- Before generating the batch, switch to `main` and pull the latest remote changes.
- Read the `next-batch` output carefully.
- Use the exact `Run ID`, `Batch name`, `Branch name`, and `PR title` printed by the command.
- Create the branch using the printed `Branch name`.
- Work only on the selected files listed in the batch output.
- Treat the selected files as complete-file scope. Do not cherry-pick only the first N diagnostics.
- Fix as many diagnostics as practical in the selected files. Your default should be to fix selected diagnostics, not to skip them.
- Prefer direct, behavior-preserving fixes: missing effect cleanup, mutable effect dependencies, accessibility issues with semantic fixes, local performance improvements, Tailwind shorthand replacements, component extraction when the boundary is clear, dead-code removal after verifying no references, and reducer or derived-state cleanup when the state relationship is local and clear.
- Handle larger diagnostics deliberately instead of skipping them: for component splits, extract the smallest coherent subcomponent that reduces the diagnostic while preserving props/state flow; for dead code, verify references with search before deleting exports, types, or files; for state architecture issues, prefer the smallest local reducer or derived-state simplification that preserves behavior; for render-function extraction, extract only stable render helpers that do not depend on large implicit closure state, or pass explicit props; for behavior-sensitive diagnostics, read the surrounding code first and preserve existing runtime behavior.
- Skip a diagnostic only when the fix would require broad architectural changes, unclear behavior changes, or changes outside the selected batch scope. If skipped, mention it in the PR body.
- Do not suppress React Doctor diagnostics unless there is a clear false positive.
- If a listed diagnostic requires changes outside the selected files, make only the minimal required supporting change. Do not expand the cleanup scope.
After edits, run:
`bun run doctor -- check-batch --run <run-id>`
Then run:
`bun run type-check`
`bun run lint`
Validation and delivery:
- Confirm selected files have fewer diagnostics than before.
- If validation fails, fix failures only if the fixes stay within the task scope. Otherwise stop and report the blocker.
- Commit the changes with a concise message.
- Push the branch.
- Create exactly one PR with `gh pr create` using the exact printed `PR title`.
- After the PR is created, switch back to `main` and pull the latest remote changes again.
PR requirements:
- Use the exact printed `PR title`.
- Include the `Run ID`, `Batch name`, and `Branch name`.
- Include selected files.
- Include diagnostics fixed according to `check-batch`.
- Include remaining diagnostics in selected files.
- Include validation results for `bun run type-check` and `bun run lint`.
- Include any skipped diagnostics and why.
Constraints:
- Keep the PR small and reviewable.
- Do not auto-merge.
- Do not modify unrelated files except minimal supporting changes required by selected-file fixes.
- Do not run broad formatting.
- Do not fix diagnostics outside the selected files.
- Leave `.tmp/react-doctor/runs/<run-id>/` intact after creating the PR. These files are the handoff for the review follow-up task.
+56
View File
@@ -0,0 +1,56 @@
---
description: Follow up on a React Doctor PR by addressing Greptile review feedback
agent: build
---
You are working in the OpenChamber repository.
Goal: follow up on an existing React Doctor maintenance PR, address Greptile/review bot feedback, and clean up the local batch handoff files when done.
Inspect local React Doctor batch handoff files:
`find .tmp/react-doctor/runs -maxdepth 2 -name batch.json -print 2>/dev/null || true`
Workflow:
- Read the available `.tmp/react-doctor/runs/*/batch.json` files.
- Find the most recent batch that has `branchName`, `batchName`, and `prTitle`.
- Read its `Run ID`, `Batch name`, `Branch name`, `PR title`, and selected files.
- Use `gh` to find the open PR for that branch or title.
- If no open PR exists for the batch, stop and report that there is no PR to follow up.
- Switch to the batch branch using the exact `branchName`.
- Pull or update the branch from remote if needed.
- Use `gh` to inspect PR review comments, PR issue comments, review threads if available, and check run summaries if relevant.
- Focus specifically on Greptile/review bot feedback and actionable reviewer comments.
- Address actionable comments with minimal follow-up fixes.
- Keep changes within the original selected files whenever possible.
- If a review comment requires changes outside the selected files, make only the minimal required supporting change.
- Do not perform unrelated cleanup.
- Do not rewrite the original PR.
- Do not force-push.
After fixes, run:
`bun run doctor -- check-batch --run <run-id>`
`bun run type-check`
`bun run lint`
Delivery:
- Commit follow-up fixes with a concise message.
- Push the branch.
- Reply to addressed review comments using `gh`.
- For each specific review comment you addressed, reply with what was changed and the follow-up commit hash.
- If the feedback was a general PR comment, add one general PR comment summarizing what was addressed, commit hashes, and validation results.
- If a comment is intentionally not addressed, reply with a concise reason.
- After successful push and replies, delete only the completed batch handoff directory: `.tmp/react-doctor/runs/<run-id>/`.
- After the follow-up is complete, switch back to `main` and pull the latest remote changes.
Constraints:
- Work on exactly one React Doctor batch PR.
- Prefer the most recent batch with an open PR.
- Do not auto-merge.
- Do not close the PR.
- Do not delete handoff files until comments are addressed, validation passes, and follow-up commits are pushed.
- Do not delete unrelated `.tmp/react-doctor/runs/*` directories.
- If validation fails and cannot be fixed safely within scope, do not delete the handoff directory.
+1
View File
@@ -58,6 +58,7 @@
"vscode:package": "bun run --cwd packages/vscode package",
"vscode:type-check": "bun run --cwd packages/vscode type-check",
"docs:validate": "node scripts/docs/validate-docs.mjs",
"doctor": "node scripts/react-doctor.mjs",
"icons:sprite": "node scripts/generate-file-type-sprite.mjs",
"icons:generate": "bun run scripts/generate-icon-sprite.mjs",
"themes:port:opencode": "tsx scripts/port-opencode-theme.ts",
+490
View File
@@ -0,0 +1,490 @@
#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
const PROJECT_NAME = "openchamber-monorepo";
const RUNS_DIR = join(process.cwd(), ".tmp", "react-doctor", "runs");
const PRIORITY_RULES = new Map([
["effect-needs-cleanup", 100],
["no-mutable-in-deps", 95],
["click-events-have-key-events", 90],
["no-static-element-interactions", 88],
["no-direct-state-mutation", 86],
["no-secrets-in-client-code", 85],
["no-danger", 84],
["no-prevent-default", 82],
["async-await-in-loop", 80],
["server-sequential-independent-await", 80],
["async-parallel", 78],
["async-defer-await", 76],
["js-set-map-lookups", 75],
["js-index-maps", 74],
["js-cache-property-access", 72],
["advanced-event-handler-refs", 70],
["client-passive-event-listeners", 70],
["js-combine-iterations", 68],
["js-flatmap-filter", 66],
["js-batch-dom-css", 66],
["js-cache-storage", 64],
["js-hoist-intl", 64],
["js-hoist-regexp", 64],
["js-length-check-first", 64],
["js-min-max-loop", 64],
["js-tosorted-immutable", 62],
["no-fetch-in-effect", 62],
["rerender-state-only-in-handlers", 62],
["rerender-functional-setstate", 60],
["rerender-lazy-state-init", 60],
["rerender-memo-before-early-return", 60],
["rerender-memo-with-default-value", 60],
["rendering-hydration-mismatch-time", 58],
["rendering-hydration-no-flicker", 58],
["rendering-usetransition-loading", 58],
["rendering-conditional-render", 56],
["rendering-svg-precision", 56],
["no-unknown-property", 55],
["no-autofocus", 54],
["no-array-index-as-key", 52],
["no-react19-deprecated-apis", 52],
["client-localstorage-no-version", 50],
["no-dynamic-import-path", 50],
["no-flush-sync", 50],
["no-long-transition-duration", 48],
["no-tiny-text", 48],
["prefer-dynamic-import", 48],
["use-lazy-motion", 48],
["duplicates", 45],
["no-barrel-import", 45],
["no-derived-useState", 44],
["no-derived-state-effect", 42],
["no-effect-chain", 42],
["no-effect-event-handler", 42],
["no-mirror-prop-effect", 42],
["no-prop-callback-in-effect", 42],
["prefer-use-effect-event", 42],
["no-cascading-set-state", 40],
["no-usememo-simple-expression", 40],
["design-no-redundant-size-axes", 35],
["design-no-redundant-padding-axes", 35],
["design-no-em-dash-in-jsx-text", 34],
["design-no-space-on-flex-children", 34],
["design-no-three-period-ellipsis", 34],
["no-inline-prop-on-memo-component", 32],
["no-many-boolean-props", 30],
["no-polymorphic-children", 30],
["no-render-in-render", 28],
["prefer-useReducer", 26],
["no-generic-handler-names", 24],
["no-giant-component", 15],
["exports", 10],
["types", 10],
["files", 5],
]);
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]
bun run doctor -- check-batch --run <run-id>
bun run doctor -- file <path>
bun run doctor -- top [--limit 10]
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`);
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 runReactDoctor() {
const output = execFileSync(
"npx",
[
"react-doctor@latest",
"--project",
PROJECT_NAME,
"--json",
"--offline",
"--fail-on",
"none",
],
{ cwd: process.cwd(), encoding: "utf8", maxBuffer: 64 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"] },
);
return JSON.parse(output);
}
function allDiagnostics(report) {
return report.diagnostics ?? report.projects?.flatMap((project) => project.diagnostics ?? []) ?? [];
}
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 usefulScore = diagnostics.reduce((sum, diagnostic) => sum + rulePriority(diagnostic.rule), 0);
const noisyCount = diagnostics.filter((diagnostic) => rulePriority(diagnostic.rule) <= 35).length;
const highSignalCount = diagnostics.filter((diagnostic) => rulePriority(diagnostic.rule) >= 66).length;
const noisyRatio = diagnostics.length === 0 ? 0 : noisyCount / diagnostics.length;
const noisyPenalty = noisyRatio > 0.5 ? diagnostics.length * 35 : noisyCount * 8;
return usefulScore + highSignalCount * 25 - noisyPenalty;
}
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) {
const key = `${diagnostic.category} / ${diagnostic.rule}`;
counts.set(key, (counts.get(key) ?? 0) + 1);
}
return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
}
function summarizeCategories(diagnostics) {
const counts = new Map();
for (const diagnostic of diagnostics) {
counts.set(diagnostic.category, (counts.get(diagnostic.category) ?? 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 = `rd-${timestamp}-${areaSlug}`;
return {
batchName,
branchName: `react-doctor/${batchName}`,
prTitle: `Reduce React Doctor diagnostics in ${titleCase(readableArea)}`,
};
}
function selectBatch(entries, minIssues, maxIssues, maxFiles) {
if (entries.length === 0) return { selected: [], oversized: false, belowTarget: false, reason: "No diagnostics found." };
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 = join(RUNS_DIR, 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 = join(RUNS_DIR, 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 summary = report.summary ?? {};
console.log(`Repository score: ${summary.score ?? "unknown"} / 100 ${summary.scoreLabel ?? ""}`.trim());
console.log(`Total diagnostics: ${summary.totalDiagnosticCount ?? allDiagnostics(report).length} issues across ${summary.affectedFileCount ?? "unknown"} files`);
console.log(`Severity: ${summary.errorCount ?? 0} errors, ${summary.warningCount ?? 0} warnings`);
}
function printDiagnostics(diagnostics, limit = diagnostics.length) {
for (const diagnostic of diagnostics.slice(0, limit)) {
console.log(`line ${diagnostic.line ?? "?"}:${diagnostic.column ?? "?"} ${diagnostic.severity} ${diagnostic.category} / ${diagnostic.rule}`);
console.log(` ${diagnostic.message}`);
if (diagnostic.help && diagnostic.help !== diagnostic.message) console.log(` Help: ${diagnostic.help}`);
}
}
function commandNextBatch(args) {
const minIssues = asPositiveInt(args["min-issues"], 75, "min-issues");
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 report = runReactDoctor();
const diagnostics = allDiagnostics(report);
const entries = sortedFileEntries(diagnostics);
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, selectedFiles, oversized: selection.oversized, belowTarget: selection.belowTarget, reason: selection.reason };
const runDir = writeRun(runId, { report, batch });
console.log("React Doctor 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} diagnostics`);
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"}`);
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(` Diagnostics: ${fileDiagnostics.length}`);
console.log(" Categories: " + summarizeCategories(fileDiagnostics).map(([category, count]) => `${category} ${count}`).join(", "));
console.log(" Rules:");
for (const [rule, count] of summarizeRules(fileDiagnostics).slice(0, 10)) {
console.log(` ${String(count).padStart(3)} ${rule}`);
}
console.log(" Diagnostics:");
for (const diagnostic of fileDiagnostics) {
console.log(` line ${diagnostic.line ?? "?"}:${diagnostic.column ?? "?"} ${diagnostic.severity} ${diagnostic.category} / ${diagnostic.rule}`);
console.log(` ${diagnostic.message}`);
}
console.log("");
});
}
function commandTop(args) {
const limit = asPositiveInt(args.limit, 10, "limit");
const report = runReactDoctor();
const entries = sortedFileEntries(allDiagnostics(report)).slice(0, limit);
console.log(`Top ${limit} files by prioritized React Doctor diagnostics`);
console.log("");
for (const [filePath, diagnostics] of entries) {
console.log(`${String(diagnostics.length).padStart(4)} ${filePath}`);
console.log(` ${summarizeCategories(diagnostics).map(([category, count]) => `${category} ${count}`).join(", ")}`);
}
}
function commandFile(args) {
const filePath = args._[1];
if (!filePath) throw new Error("Missing file path. Usage: bun run doctor -- file <path>");
const report = runReactDoctor();
const diagnostics = groupByFile(allDiagnostics(report)).get(filePath) ?? [];
console.log(filePath);
console.log(`${diagnostics.length} issues`);
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("Issues:");
printDiagnostics(diagnostics);
}
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 = runReactDoctor();
const beforeByFile = groupByFile(allDiagnostics(baseline));
const afterByFile = groupByFile(allDiagnostics(current));
const selected = batch.selectedFiles ?? [];
let beforeTotal = 0;
let afterTotal = 0;
console.log("React Doctor 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 = allDiagnostics(baseline).filter((diagnostic) => !selectedPaths.has(diagnostic.filePath)).length;
const afterOutside = allDiagnostics(current).filter((diagnostic) => !selectedPaths.has(diagnostic.filePath)).length;
console.log("");
console.log("Batch result:");
console.log(`Fixed diagnostics in selected files: ${Math.max(0, beforeTotal - afterTotal)}`);
console.log(`Remaining diagnostics in selected files: ${afterTotal}`);
console.log(`Diagnostics 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;
default:
throw new Error(`Unknown command: ${command}`);
}
}
main().catch((error) => {
console.error(error.message);
process.exit(1);
});