Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ jobs:
- name: Install dependencies
run: npm ci

- name: Repository hygiene check
run: npm run clean:repo:check

- name: Security audit (CI policy)
run: npm run audit:ci

Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ winston/
task_plan.md
findings.md
progress.md
test-results.md

# local env files
.env
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@
"test:model-matrix": "node scripts/test-model-matrix.js",
"test:model-matrix:smoke": "node scripts/test-model-matrix.js --smoke",
"test:model-matrix:report": "node scripts/test-model-matrix.js --smoke --report-json=.tmp/model-matrix-report.json",
"clean:repo": "node scripts/repo-hygiene.js clean --mode aggressive",
"clean:repo:check": "node scripts/repo-hygiene.js check",
"bench:edit-formats": "node scripts/benchmark-edit-formats.mjs --preset=codex-core",
"bench:edit-formats:smoke": "node scripts/benchmark-edit-formats.mjs --smoke --preset=codex-core",
"bench:edit-formats:render": "node scripts/benchmark-render-dashboard.mjs",
Expand Down
297 changes: 297 additions & 0 deletions scripts/repo-hygiene.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,297 @@
#!/usr/bin/env node

import { promises as fs } from "node:fs";
import path from "node:path";
import process from "node:process";
import { execFileSync } from "node:child_process";

const ROOT_TEMP_PATTERN = /^\.?tmp/i;
const GENERATED_DIRS = new Set([
"coverage",
"dist",
"node_modules",
".omx",
".opencode",
".sisyphus",
]);
const TRACKED_SCRATCH_FILES = new Set([
"task_plan.md",
"findings.md",
"progress.md",
"test-results.md",
]);
const TRAVERSAL_SKIP_DIRS = new Set([
".git",
"node_modules",
"dist",
"coverage",
"vendor",
".omx",
".opencode",
".sisyphus",
".history",
]);
const REQUIRED_GITIGNORE_PATTERNS = [
"tmp",
".tmp",
"coverage/",
"dist/",
"node_modules/",
".omx/",
".opencode/",
".sisyphus/",
"task_plan.md",
"findings.md",
"progress.md",
"test-results.md",
];

function parseArgs(argv) {
const args = {
command: "",
mode: "safe",
root: process.cwd(),
dryRun: false,
};

if (argv.length === 0) {
throw new Error("Usage: repo-hygiene.js <clean|check> [--mode aggressive] [--root path] [--dry-run]");
}
args.command = argv[0] ?? "";

for (let i = 1; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === "--mode") {
const value = argv[i + 1];
if (!value || value.startsWith("--")) {
throw new Error("--mode requires a value");
}
args.mode = value;
i += 1;
} else if (arg === "--root") {
const value = argv[i + 1];
if (!value || value.startsWith("--")) {
throw new Error("--root requires a value");
}
args.root = value;
i += 1;
} else if (arg === "--dry-run") {
args.dryRun = true;
} else if (arg.startsWith("--")) {
throw new Error(`Unknown flag: ${arg}`);
} else {
throw new Error(`Unexpected positional argument: ${arg}`);
}
}
Comment thread
ndycode marked this conversation as resolved.

if (!args.root) {
throw new Error("--root requires a value");
}
if (args.command === "clean" && args.mode !== "aggressive") {
throw new Error('Only "--mode aggressive" is allowed for clean');
}

return args;
}

async function ensureRepoRoot(rootPath) {
const resolved = path.resolve(rootPath);
const gitDir = path.join(resolved, ".git");
try {
await fs.stat(gitDir);
return resolved;
} catch {
throw new Error(`Root path is not a git working tree: ${resolved}`);
}
}

function isDeletionCandidate(name, isDirectory) {
if (TRACKED_SCRATCH_FILES.has(name)) {
return true;
}
if (isDirectory && GENERATED_DIRS.has(name)) {
return true;
}
return ROOT_TEMP_PATTERN.test(name);
}

function isProtectedName(name) {
return [
".git",
"lib",
"test",
"docs",
"scripts",
"config",
"assets",
"vendor",
].includes(name);
}

function isDeepTempCandidate(name, isDirectory) {
if (isDirectory && (name === "tmp" || ROOT_TEMP_PATTERN.test(name))) {
return true;
}
if (!isDirectory && (name.startsWith(".tmp") || name.endsWith(".tmp"))) {
return true;
}
return false;
}

async function collectNestedTempCandidates(rootPath, dirPath, addCandidate) {
const entries = await fs.readdir(dirPath, { withFileTypes: true });
for (const entry of entries) {
const entryPath = path.join(dirPath, entry.name);
if (entry.isDirectory() && TRAVERSAL_SKIP_DIRS.has(entry.name)) {
continue;
}
if (isDeepTempCandidate(entry.name, entry.isDirectory())) {
addCandidate(
path.relative(rootPath, entryPath).replaceAll("\\", "/"),
entryPath,
entry.isDirectory(),
);
if (entry.isDirectory()) {
continue;
}
}
if (entry.isDirectory()) {
await collectNestedTempCandidates(rootPath, entryPath, addCandidate);
}
}
}

async function collectCandidates(rootPath) {
const rootEntries = await fs.readdir(rootPath, { withFileTypes: true });
const candidates = [];
const candidatePaths = new Set();
const addCandidate = (name, candidatePath, isDirectory) => {
if (candidatePaths.has(candidatePath)) {
return;
}
candidatePaths.add(candidatePath);
candidates.push({ name, path: candidatePath, isDirectory });
};
for (const entry of rootEntries) {
if (isProtectedName(entry.name)) {
continue;
}
if (!isDeletionCandidate(entry.name, entry.isDirectory())) {
continue;
}
addCandidate(entry.name, path.join(rootPath, entry.name), entry.isDirectory());
}

await collectNestedTempCandidates(rootPath, rootPath, addCandidate);
return candidates;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

async function clean(rootPath, dryRun) {
const candidates = await collectCandidates(rootPath);
if (candidates.length === 0) {
console.log("repo-hygiene: no cleanup candidates found");
return;
}

for (const candidate of candidates) {
const relative = path.relative(rootPath, candidate.path);
if (!relative || relative.includes("..")) {
throw new Error(`Refusing to delete non-root candidate: ${candidate.path}`);
}

if (dryRun) {
console.log(`[dry-run] delete ${candidate.name}`);
continue;
}
await fs.rm(candidate.path, { recursive: true, force: true });
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

missing retry logic for windows filesystem locks (EBUSY, EPERM, ENOTEMPTY). antivirus can lock files during deletion causing this to fail. test/rotation-integration.test.ts already implements removeWithRetry for this exact issue - same pattern needed here.

Suggested change
await fs.rm(candidate.path, { recursive: true, force: true });
const retryableCodes = new Set(["ENOTEMPTY", "EPERM", "EBUSY"]);
const maxAttempts = 6;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
await fs.rm(candidate.path, { recursive: true, force: true });
console.log(`deleted ${candidate.name}`);
break;
} catch (error) {
const maybeCode = error instanceof Error && "code" in error ? (error as { code?: string }).code : undefined;
const shouldRetry = maybeCode !== undefined && retryableCodes.has(maybeCode);
if (!shouldRetry || attempt === maxAttempts) {
throw error;
}
await new Promise((resolve) => setTimeout(resolve, attempt * 25));
}
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: scripts/repo-hygiene.js
Line: 206

Comment:
missing retry logic for windows filesystem locks (EBUSY, EPERM, ENOTEMPTY). antivirus can lock files during deletion causing this to fail. `test/rotation-integration.test.ts` already implements `removeWithRetry` for this exact issue - same pattern needed here.

```suggestion
		const retryableCodes = new Set(["ENOTEMPTY", "EPERM", "EBUSY"]);
		const maxAttempts = 6;
		for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
			try {
				await fs.rm(candidate.path, { recursive: true, force: true });
				console.log(`deleted ${candidate.name}`);
				break;
			} catch (error) {
				const maybeCode = error instanceof Error && "code" in error ? (error as { code?: string }).code : undefined;
				const shouldRetry = maybeCode !== undefined && retryableCodes.has(maybeCode);
				if (!shouldRetry || attempt === maxAttempts) {
					throw error;
				}
				await new Promise((resolve) => setTimeout(resolve, attempt * 25));
			}
		}
```

How can I resolve this? If you propose a fix, please make it concise.

Fix in Codex

console.log(`deleted ${candidate.name}`);
}
}

function getTrackedPaths(rootPath) {
try {
const output = execFileSync("git", ["ls-files"], {
cwd: rootPath,
encoding: "utf-8",
stdio: ["ignore", "pipe", "ignore"],
});
return output.split(/\r?\n/).filter(Boolean);
} catch (error) {
throw new Error(
`git ls-files failed in getTrackedPaths: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

async function check(rootPath) {
let hasError = false;

const tracked = getTrackedPaths(rootPath);
const trackedViolations = new Set();
for (const trackedPath of tracked) {
const normalized = trackedPath.replaceAll("\\", "/");
const basename = normalized.split("/").pop() ?? normalized;
if (TRACKED_SCRATCH_FILES.has(basename)) {
trackedViolations.add(normalized);
}
}

if (trackedViolations.size > 0) {
hasError = true;
console.error("repo-hygiene check failed: tracked scratch files present:");
for (const item of [...trackedViolations].sort()) {
console.error(` - ${item}`);
}
}

const gitignorePath = path.join(rootPath, ".gitignore");
let gitignore = "";
try {
gitignore = await fs.readFile(gitignorePath, "utf-8");
} catch (error) {
console.warn(
`repo-hygiene warning: unable to read .gitignore at ${gitignorePath}: ${error instanceof Error ? error.message : String(error)}`,
);
}
const gitignoreLines = new Set(
gitignore
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0 && !line.startsWith("#")),
);
const missingPatterns = REQUIRED_GITIGNORE_PATTERNS.filter((pattern) => !gitignoreLines.has(pattern));
if (missingPatterns.length > 0) {
hasError = true;
console.error("repo-hygiene check failed: missing required .gitignore patterns:");
for (const pattern of missingPatterns) {
console.error(` - ${pattern}`);
}
}

if (hasError) {
process.exitCode = 1;
return;
}
console.log("repo-hygiene check passed");
}

async function main() {
const args = parseArgs(process.argv.slice(2));
const rootPath = await ensureRepoRoot(args.root);

if (args.command === "clean") {
await clean(rootPath, args.dryRun);
return;
}
if (args.command === "check") {
await check(rootPath);
return;
}

throw new Error(`Unknown command: ${args.command}`);
}

main().catch((error) => {
console.error(`repo-hygiene error: ${error instanceof Error ? error.message : String(error)}`);
process.exitCode = 1;
});
10 changes: 0 additions & 10 deletions test-results.md

This file was deleted.

Loading