Files

246 lines
7.1 KiB
JavaScript

import { execSync } from "child_process";
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "fs";
import { dirname, join, relative, sep } from "path";
// --- CLI Argument Parsing ---
function parseArgs(argv) {
let projectDir = process.cwd();
let deleteOriginals = false;
let dryRun = false;
let force = false;
for (let i = 2; i < argv.length; i++) {
if (argv[i] === "--project-dir" && argv[i + 1]) {
projectDir = argv[++i];
} else if (argv[i] === "--delete-originals") {
deleteOriginals = true;
} else if (argv[i] === "--dry-run") {
dryRun = true;
} else if (argv[i] === "--force") {
force = true;
}
}
return { projectDir, deleteOriginals, dryRun, force };
}
// --- Discovery ---
const HARDCODED_EXCLUDES = new Set([
"node_modules",
"dist",
"build",
".git",
"vendor",
".rpiv",
".next",
".nuxt",
".output",
"coverage",
"__pycache__",
".venv",
]);
function discoverClaudeMdFiles(projectDir) {
const gitDir = join(projectDir, ".git");
if (existsSync(gitDir)) {
return discoverViaGit(projectDir);
}
return discoverViaWalk(projectDir);
}
function discoverViaGit(projectDir) {
try {
const output = execSync("git ls-files --cached --others --exclude-standard", {
cwd: projectDir,
encoding: "utf-8",
maxBuffer: 10 * 1024 * 1024,
});
return output
.split("\n")
.filter((f) => f.endsWith("/CLAUDE.md") || f === "CLAUDE.md")
.filter((f) => !f.startsWith(".rpiv/"));
} catch {
// git command failed — fall back to walk
return discoverViaWalk(projectDir);
}
}
function discoverViaWalk(projectDir) {
const results = [];
function walk(dir) {
let entries;
try {
entries = readdirSync(dir);
} catch {
return; // permission error, skip
}
for (const entry of entries) {
if (HARDCODED_EXCLUDES.has(entry)) continue;
const fullPath = join(dir, entry);
let stat;
try {
stat = statSync(fullPath);
} catch {
continue;
}
if (stat.isDirectory()) {
walk(fullPath);
} else if (entry === "CLAUDE.md") {
const rel = relative(projectDir, fullPath).split(sep).join("/");
if (!rel.startsWith(".rpiv/")) {
results.push(rel);
}
}
}
}
walk(projectDir);
return results;
}
// --- Path Mapping ---
function computeTargetPath(claudeMdRelative) {
const dir = dirname(claudeMdRelative);
if (dir === ".") {
return ".rpiv/guidance/architecture.md";
}
return join(".rpiv", "guidance", dir, "architecture.md").split(sep).join("/");
}
function transformContent(content, targetPath) {
let refsTransformed = 0;
const warnings = [];
// Pattern 1: Backtick-wrapped path references like `src/core/CLAUDE.md`
let transformed = content.replace(/`((?:[\w][\w./-]*\/)?CLAUDE\.md)`/g, (_match, claudePath) => {
const replacement = claudePathToGuidancePath(claudePath);
refsTransformed++;
return `\`${replacement}\``;
});
// Pattern 2: Bare path references (with directory prefix) not inside backticks
// Match things like "src/core/CLAUDE.md" but not already-backtick-wrapped
transformed = transformed.replace(/(?<!`)([\w][\w./-]*\/CLAUDE\.md)(?!`)/g, (_match, claudePath) => {
const replacement = claudePathToGuidancePath(claudePath);
refsTransformed++;
return replacement;
});
// Pattern 3: Standalone "CLAUDE.md" that references the root file
// Only match when it looks like a file reference (not part of a longer word)
// Avoid matching inside paths already transformed above
transformed = transformed.replace(/(?<![/\w`])CLAUDE\.md(?![/\w`])/g, () => {
refsTransformed++;
return ".rpiv/guidance/architecture.md";
});
// Scan for remaining prose references that might need manual attention
const lines = transformed.split("\n");
for (let i = 0; i < lines.length; i++) {
// Look for prose patterns like "see X CLAUDE.md" or "X layer CLAUDE.md"
if (
/\b\w+\s+CLAUDE\.md\b/i.test(content.split("\n")[i] ?? "") &&
!/(src|lib|app|packages|apps)\//.test(content.split("\n")[i] ?? "")
) {
// Check if this line still has an untransformed prose reference
if (/CLAUDE\.md/i.test(lines[i])) {
warnings.push({
file: targetPath,
line: i + 1,
message: `Prose reference to CLAUDE.md may need manual update: "${lines[i].trim()}"`,
});
}
}
}
return { content: transformed, refsTransformed, warnings };
}
function claudePathToGuidancePath(claudePath) {
const dir = dirname(claudePath);
if (dir === ".") {
return ".rpiv/guidance/architecture.md";
}
return `.rpiv/guidance/${dir}/architecture.md`;
}
// --- Main ---
function main() {
const { projectDir, deleteOriginals, dryRun, force } = parseArgs(process.argv);
process.stderr.write(`[rpiv:migrate] scanning ${projectDir} for CLAUDE.md files\n`);
const claudeFiles = discoverClaudeMdFiles(projectDir);
if (claudeFiles.length === 0) {
const report = {
migrated: [],
conflicts: [],
warnings: [],
originalsDeleted: false,
dryRun,
};
process.stdout.write(JSON.stringify(report, null, 2));
return;
}
process.stderr.write(`[rpiv:migrate] found ${claudeFiles.length} CLAUDE.md file(s)\n`);
const migrated = [];
const conflicts = [];
const allWarnings = [];
const writtenFiles = [];
for (const source of claudeFiles) {
const target = computeTargetPath(source);
const targetAbs = join(projectDir, target);
// Check for conflicts
if (existsSync(targetAbs) && !force) {
conflicts.push(target);
continue;
}
// Read source content
const sourceAbs = join(projectDir, source);
let content;
try {
content = readFileSync(sourceAbs, "utf-8");
} catch (err) {
allWarnings.push({
file: source,
line: 0,
message: `Failed to read: ${err instanceof Error ? err.message : String(err)}`,
});
continue;
}
if (content.trim().length === 0) {
allWarnings.push({
file: source,
line: 0,
message: "Empty file, skipped",
});
continue;
}
// Transform content
const { content: transformed, refsTransformed, warnings } = transformContent(content, target);
const lines = transformed.split("\n").length;
migrated.push({ source, target, lines, refsTransformed });
allWarnings.push(...warnings);
if (!dryRun) {
writtenFiles.push({ targetAbs, content: transformed });
}
}
// Write all files (all-or-nothing approach for safety)
if (!dryRun) {
for (const { targetAbs, content } of writtenFiles) {
mkdirSync(dirname(targetAbs), { recursive: true });
writeFileSync(targetAbs, content, "utf-8");
}
process.stderr.write(`[rpiv:migrate] wrote ${writtenFiles.length} file(s)\n`);
}
// Delete originals only after all writes succeed
let originalsDeleted = false;
if (!dryRun && deleteOriginals && writtenFiles.length > 0) {
for (const entry of migrated) {
const sourceAbs = join(projectDir, entry.source);
try {
unlinkSync(sourceAbs);
} catch (err) {
allWarnings.push({
file: entry.source,
line: 0,
message: `Failed to delete original: ${err instanceof Error ? err.message : String(err)}`,
});
}
}
originalsDeleted = true;
process.stderr.write(`[rpiv:migrate] deleted ${migrated.length} original CLAUDE.md file(s)\n`);
}
const report = {
migrated,
conflicts,
warnings: allWarnings,
originalsDeleted,
dryRun,
};
process.stdout.write(JSON.stringify(report, null, 2));
}
main();