#!/usr/bin/env bun /** * copy-audit — Layer 1 deterministic copy-editor engine. * * Reads a `.copy-editor.yaml` config (or accepts paths on the CLI), * loads a language profile from ../rules/, extracts reviewable text * chunks per file type, runs the profile's rules, and emits findings. * * Usage: * bun copy-audit.ts --config .copy-editor.yaml * bun copy-audit.ts --lang cs --paths "content/**\/*.md" * bun copy-audit.ts --self-test * bun copy-audit.ts --config .copy-editor.yaml --json * * Exit codes: * 0 — clean (no error-severity findings) * 1 — one or more error-severity findings * 2 — configuration or invocation error * * Non-goals: * - Fixing findings (Layer 2 job, and not even blocking) * - Voice or rhythm judgement (Layer 2, not here) * - Understanding file semantics beyond text extraction */ import { readFileSync, writeFileSync, existsSync, statSync, readdirSync, } from "node:fs"; import { dirname, extname, join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import yaml from "js-yaml"; import type { Finding, LanguageProfile, TextChunk } from "../rules/types.ts"; import { getProfile, listProfiles } from "../rules/index.ts"; import { hashFileBytes, readLockfile, writeLockfile, type Lockfile, type LockfileEntry, type LockfileSpan, } from "./lockfile.ts"; import { filterFindingsBySpans } from "./finding-filter.ts"; const DEFAULT_LOCKFILE_NAME = ".copy-editor.lock.json"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); // ──────────────────────────────────────────────────────────────────── // CLI parsing // ──────────────────────────────────────────────────────────────────── interface CliOptions { config?: string; lang?: string; paths?: string[]; json: boolean; verbose: boolean; selfTest: boolean; fix: boolean; help: boolean; listUnsegmented: boolean; requireReviewed: boolean; } function parseArgs(argv: string[]): CliOptions { const options: CliOptions = { json: false, verbose: false, selfTest: false, fix: false, help: false, listUnsegmented: false, requireReviewed: false, }; for (let i = 0; i < argv.length; i++) { const arg = argv[i]; switch (arg) { case "--config": options.config = argv[++i]; break; case "--lang": options.lang = argv[++i]; break; case "--paths": { const next = argv[++i]; if (next) { options.paths = next.split(",").map((s) => s.trim()).filter(Boolean); } break; } case "--json": options.json = true; break; case "--verbose": options.verbose = true; break; case "--self-test": options.selfTest = true; break; case "--fix": options.fix = true; break; case "--list-unsegmented": options.listUnsegmented = true; break; case "--require-reviewed": options.requireReviewed = true; break; case "--help": case "-h": options.help = true; break; default: if (arg.startsWith("--")) { console.error(`Unknown option: ${arg}`); process.exit(2); } } } return options; } function printHelp(): void { console.log(`copy-audit — Layer 1 deterministic copy-editor engine Usage: bun copy-audit.ts [options] Options: --config Path to .copy-editor.yaml --lang Language profile (e.g. cs, en). Overrides config. --paths Comma-separated list of paths/globs to scan. Overrides config. --json Emit structured JSON instead of human-readable text. --verbose Print per-file progress. --self-test Run fixture-based self tests. Ignores other options. --fix Apply mechanical fixes in place for rules that provide a safe fix descriptor (R1, R1b, R3). Other findings still report but are not mutated. --list-unsegmented Skip the audit. Print the list of files that have no lockfile entry or whose entry is stale (file changed since segmentation). Exit non-zero if any files need segmentation. The skill loop uses this to drive agent-side segmentation. --require-reviewed Fail the audit (exit non-zero) if any included file has a lockfile entry with reviewedBy: null. Opt-in CI gate. Files without any lockfile entry are reported separately and also fail the gate. -h, --help Show this help. Known language profiles: ${listProfiles().join(", ")} Exit codes: 0 = clean 1 = error-severity findings present 2 = configuration or invocation error `); } // ──────────────────────────────────────────────────────────────────── // Config loading // ──────────────────────────────────────────────────────────────────── interface CopyEditorConfig { language?: string; extends?: string[]; paths?: { include?: string[]; exclude?: string[]; }; ignore_marker?: string; rules?: Record; voice_doctrine?: string; output?: { review_notes_dir?: string; structured_findings_dir?: string; }; } function loadConfig(configPath: string): { config: CopyEditorConfig; configDir: string; } { const absPath = resolve(configPath); if (!existsSync(absPath)) { console.error(`Config file not found: ${absPath}`); process.exit(2); } const raw = readFileSync(absPath, "utf8"); const parsed = yaml.load(raw) as CopyEditorConfig | undefined; if (!parsed || typeof parsed !== "object") { console.error(`Config file is empty or invalid: ${absPath}`); process.exit(2); } return { config: parsed, configDir: dirname(absPath) }; } // ──────────────────────────────────────────────────────────────────── // File discovery — tiny portable globber // ──────────────────────────────────────────────────────────────────── /** * Convert a simple glob pattern to a RegExp. Supports: * - `**` any depth of directories * - `*` any sequence within a single path segment * - `?` any single character * - literal segments * * Enough for the path patterns in .copy-editor.yaml. Not a full * micromatch replacement — if we need more, swap in a library. */ function globToRegExp(glob: string): RegExp { const special = /[.+^${}()|[\]\\]/g; let re = ""; let i = 0; while (i < glob.length) { const c = glob[i]; if (c === "*") { if (glob[i + 1] === "*") { // ** — match any depth re += ".*"; i += 2; if (glob[i] === "/") i += 1; } else { // * — match within one segment re += "[^/]*"; i += 1; } } else if (c === "?") { re += "[^/]"; i += 1; } else if (c.match(special)) { re += "\\" + c; i += 1; } else { re += c; i += 1; } } return new RegExp("^" + re + "$"); } function walkDir(root: string): string[] { const results: string[] = []; const stack: string[] = [root]; const SKIP_DIRS = new Set([ "node_modules", ".git", ".next", "dist", "build", ".turbo", ".cache", "coverage", "__pycache__", ]); while (stack.length > 0) { const dir = stack.pop(); if (!dir) continue; let entries; try { entries = readdirSync(dir, { withFileTypes: true }); } catch { continue; } for (const entry of entries) { const full = join(dir, entry.name); if (entry.isDirectory()) { if (SKIP_DIRS.has(entry.name)) continue; stack.push(full); } else if (entry.isFile()) { results.push(full); } } } return results; } function resolveFiles( baseDir: string, include: string[], exclude: string[], ): string[] { const all = walkDir(baseDir); const includeRes = include.map(globToRegExp); const excludeRes = exclude.map(globToRegExp); const out: string[] = []; for (const absPath of all) { const rel = relative(baseDir, absPath); if (!includeRes.some((re) => re.test(rel))) continue; if (excludeRes.some((re) => re.test(rel))) continue; out.push(absPath); } return out.sort(); } // ──────────────────────────────────────────────────────────────────── // Text extraction per file type // ──────────────────────────────────────────────────────────────────── /** * For markdown files, emit chunks that exclude fenced code blocks, * inline code, and link URLs. Preserve absolute line/column. */ function extractMarkdownChunks( filePath: string, text: string, ignoreMarker: string, ): TextChunk[] { const chunks: TextChunk[] = []; const ignoreLines = collectIgnoreLines(text, ignoreMarker); // Strip fenced code blocks by replacing them with blank lines of the // same count (preserves line numbers). const lines = text.split("\n"); let inFence = false; const cleanedLines: string[] = []; for (const line of lines) { if (/^\s*```/.test(line)) { inFence = !inFence; cleanedLines.push(""); continue; } if (inFence) { cleanedLines.push(""); continue; } // Strip inline code spans let stripped = line.replace(/`[^`]*`/g, (match) => " ".repeat(match.length)); // Strip markdown link URLs: keep [label], drop (url) stripped = stripped.replace( /\[([^\]]*)\]\(([^)]*)\)/g, (_, label, url) => "[" + label + "]" + " ".repeat(url.length + 2), ); cleanedLines.push(stripped); } const cleaned = cleanedLines.join("\n"); chunks.push({ text: cleaned, filePath, startLine: 1, startColumn: 1, ignoreLines, }); return chunks; } /** * For JSON files, walk the parsed structure and emit one chunk per * string value, with absolute file position computed by searching for * the value in the raw text. For small agenda.json this is fine. */ function extractJsonChunks( filePath: string, text: string, ignoreMarker: string, ): TextChunk[] { const chunks: TextChunk[] = []; const ignoreLines = collectIgnoreLines(text, ignoreMarker); // Use a streaming regex to find all string literals and their positions. // JSON strings cannot contain unescaped newlines, so a single-line match // is safe and simpler than AST walking. const stringRe = /"((?:\\.|[^"\\])*)"/g; let match: RegExpExecArray | null; // We want to skip keys — a string immediately followed by `:` is a key. while ((match = stringRe.exec(text)) !== null) { const afterEnd = match.index + match[0].length; // Skip keys (followed by `:` with optional whitespace) let k = afterEnd; while (k < text.length && /\s/.test(text[k])) k++; if (text[k] === ":") continue; // Decode the string content (JSON escapes) — but keep it simple // by replacing common escapes only. Rules operate on the decoded // text, and positions below still reference the raw JSON. const raw = match[1]; const decoded = raw .replace(/\\"/g, '"') .replace(/\\\\/g, "\\") .replace(/\\n/g, "\n") .replace(/\\t/g, "\t"); // Compute absolute line/column at the start of the content (one // char after the opening quote). const contentStart = match.index + 1; const before = text.slice(0, contentStart); const line = (before.match(/\n/g) || []).length + 1; const lastNl = before.lastIndexOf("\n"); const column = contentStart - (lastNl + 1) + 1; chunks.push({ text: decoded, filePath, startLine: line, startColumn: column, ignoreLines, }); } return chunks; } /** * For TypeScript files, extract string literals and template literals. * Ignores identifiers, code, comments, and imports. Good enough for * harness-lab's localized-content.ts which is mostly string data. */ function extractTypescriptChunks( filePath: string, text: string, ignoreMarker: string, ): TextChunk[] { const chunks: TextChunk[] = []; const ignoreLines = collectIgnoreLines(text, ignoreMarker); // Match double-quoted, single-quoted, and backtick template literals. // We don't attempt to handle nested template expressions — those are // treated as opaque and skipped. const literalRe = /"((?:\\.|[^"\\\n])*)"|'((?:\\.|[^'\\\n])*)'|`((?:\\.|[^`\\])*)`/g; let match: RegExpExecArray | null; while ((match = literalRe.exec(text)) !== null) { const raw = match[1] ?? match[2] ?? match[3] ?? ""; const decoded = raw .replace(/\\n/g, "\n") .replace(/\\t/g, "\t") .replace(/\\"/g, '"') .replace(/\\'/g, "'") .replace(/\\`/g, "`") .replace(/\\\\/g, "\\"); const contentStart = match.index + 1; const before = text.slice(0, contentStart); const line = (before.match(/\n/g) || []).length + 1; const lastNl = before.lastIndexOf("\n"); const column = contentStart - (lastNl + 1) + 1; chunks.push({ text: decoded, filePath, startLine: line, startColumn: column, ignoreLines, }); } return chunks; } function collectIgnoreLines(text: string, marker: string): Set { const set = new Set(); if (!marker) return set; const lines = text.split("\n"); const needle = marker; for (let i = 0; i < lines.length; i++) { if (lines[i].includes(needle)) { // Mark the same line and the next line (where the intended // exception usually lives). set.add(i + 1); set.add(i + 2); } } return set; } function extractChunks( filePath: string, text: string, ignoreMarker: string, ): TextChunk[] { const ext = extname(filePath).toLowerCase(); if (ext === ".md" || ext === ".markdown" || ext === ".mdx") { return extractMarkdownChunks(filePath, text, ignoreMarker); } if (ext === ".json") { return extractJsonChunks(filePath, text, ignoreMarker); } if (ext === ".ts" || ext === ".tsx" || ext === ".js" || ext === ".jsx") { return extractTypescriptChunks(filePath, text, ignoreMarker); } // Default: treat as plain text return [ { text, filePath, startLine: 1, startColumn: 1, ignoreLines: collectIgnoreLines(text, ignoreMarker), }, ]; } // ──────────────────────────────────────────────────────────────────── // Fix application // ──────────────────────────────────────────────────────────────────── /** * Convert a 1-indexed (line, column) position to a UTF-16 offset in * the file text. Used by fix mode to locate where to apply a * replacement. */ function offsetFromLineCol(text: string, line: number, column: number): number { let currentLine = 1; let currentCol = 1; for (let i = 0; i < text.length; i++) { if (currentLine === line && currentCol === column) return i; if (text[i] === "\n") { currentLine += 1; currentCol = 1; } else { currentCol += 1; } } // End of text if (currentLine === line && currentCol === column) return text.length; return -1; } /** * Apply fixable findings to a single file's text. Returns the new text * and the count of fixes applied. Fixes are applied back-to-front so * earlier offsets remain valid. */ function applyFixes( text: string, findings: Finding[], ): { text: string; applied: number } { const fixable = findings .filter((f) => f.fix) .map((f) => { const offset = offsetFromLineCol(text, f.fix!.line, f.fix!.column); return { finding: f, offset }; }) .filter((entry) => entry.offset >= 0) .sort((a, b) => b.offset - a.offset); let out = text; let applied = 0; for (const { finding, offset } of fixable) { const fix = finding.fix!; const before = out.slice(0, offset); const after = out.slice(offset + fix.length); out = before + fix.replacement + after; applied += 1; } return { text: out, applied }; } // ──────────────────────────────────────────────────────────────────── // Rule execution // ──────────────────────────────────────────────────────────────────── function runProfile(profile: LanguageProfile, chunks: TextChunk[]): Finding[] { const findings: Finding[] = []; for (const chunk of chunks) { if (chunk.kind === "code" || chunk.kind === "data") continue; for (const rule of profile.rules) { if ( rule.languages && chunk.language && !rule.languages.includes(chunk.language) ) { continue; } try { const ruleFindings = rule.check(chunk); findings.push(...ruleFindings); } catch (err) { console.error( `Rule ${rule.id} crashed on ${chunk.filePath}: ${(err as Error).message}`, ); } } } return findings; } // ──────────────────────────────────────────────────────────────────── // Report formatting // ──────────────────────────────────────────────────────────────────── interface Report { totalFiles: number; totalFindings: number; errorFindings: number; warningFindings: number; infoFindings: number; findings: Finding[]; byRule: Record; } function buildReport(files: string[], findings: Finding[]): Report { const byRule: Record = {}; let errorFindings = 0; let warningFindings = 0; let infoFindings = 0; for (const f of findings) { byRule[f.ruleId] = (byRule[f.ruleId] || 0) + 1; if (f.severity === "error") errorFindings++; else if (f.severity === "warning") warningFindings++; else infoFindings++; } return { totalFiles: files.length, totalFindings: findings.length, errorFindings, warningFindings, infoFindings, findings, byRule, }; } function printHumanReport(report: Report, baseDir: string): void { console.log(""); console.log("copy-audit report"); console.log("═════════════════"); console.log(`files scanned: ${report.totalFiles}`); console.log(`findings total: ${report.totalFindings}`); console.log( ` errors: ${report.errorFindings}${ report.errorFindings > 0 ? " ← BLOCKING" : "" }`, ); console.log(` warnings: ${report.warningFindings}`); console.log(` info: ${report.infoFindings}`); console.log(""); if (report.totalFindings === 0) { console.log("✓ clean"); return; } console.log("by rule:"); const sortedRules = Object.entries(report.byRule).sort( (a, b) => b[1] - a[1], ); for (const [rule, count] of sortedRules) { console.log(` ${rule.padEnd(32)} ${count}`); } console.log(""); // Print up to 20 findings, grouped by file const byFile: Record = {}; for (const f of report.findings) { byFile[f.filePath] = byFile[f.filePath] || []; byFile[f.filePath].push(f); } let printed = 0; const limit = 20; for (const [file, items] of Object.entries(byFile)) { if (printed >= limit) break; console.log(relative(baseDir, file) || file); for (const f of items) { if (printed >= limit) break; const sev = f.severity === "error" ? "E" : f.severity === "warning" ? "W" : "I"; console.log( ` ${sev} ${f.line}:${f.column} [${f.ruleId}] ${f.message}`, ); if (f.snippet) { console.log(` → ${JSON.stringify(f.snippet)}`); } printed += 1; } } if (report.totalFindings > printed) { console.log(""); console.log( `… ${report.totalFindings - printed} more findings (use --json for full output)`, ); } } // ──────────────────────────────────────────────────────────────────── // Segmentation handoff (script ↔ agent contract) // ──────────────────────────────────────────────────────────────────── /** * The `segment` subcommand prints a structured handoff payload to * stdout. The host agent reads this, produces span JSON, and writes * it back via `copy-audit lockfile add`. The script itself never * calls a model — segmentation is performed by the agent that runs * the skill. */ const PROMPT_V2_FILENAME = "segmentation-prompt-v2.md"; const CURRENT_PROMPT_VERSION = 2; /** * Resolve a profile reference (ISO code or human name) to the ISO code * used by the profile registry. Returns the input unchanged if no * registered profile matches either by key or by name. */ function resolveProfileRef(ref: string): string { const lower = ref.toLowerCase(); // Direct ISO code match. const direct = getProfile(lower); if (direct) return direct.language; // Name-style match: look up any profile whose display name matches. for (const code of listProfiles()) { const profile = getProfile(code); if (profile && profile.name.toLowerCase() === lower) { return profile.language; } } return lower; } function loadPromptTemplate(): { version: number; template: string } { const promptPath = join( __dirname, "..", "knowledge", PROMPT_V2_FILENAME, ); if (!existsSync(promptPath)) { throw new Error( `Segmentation prompt file not found: ${promptPath}. ` + `Expected ${PROMPT_V2_FILENAME} in the knowledge/ directory.`, ); } const raw = readFileSync(promptPath, "utf8"); const marker = "## The literal prompt template"; const markerIdx = raw.indexOf(marker); if (markerIdx === -1) { throw new Error( `Could not find "${marker}" in ${promptPath}. ` + `The prompt file must contain this section followed by a fenced code block with the template.`, ); } const afterMarker = raw.slice(markerIdx + marker.length); const fenceStart = afterMarker.indexOf("```"); if (fenceStart === -1) { throw new Error(`No opening fence after "${marker}" in ${promptPath}.`); } const contentStart = afterMarker.indexOf("\n", fenceStart) + 1; const fenceEnd = afterMarker.indexOf("```", contentStart); if (fenceEnd === -1) { throw new Error(`No closing fence after prompt template in ${promptPath}.`); } const template = afterMarker.slice(contentStart, fenceEnd).replace(/\s+$/, ""); return { version: CURRENT_PROMPT_VERSION, template }; } function fillPromptTemplate( template: string, filePath: string, declaredProfiles: string[], fileBytes: string, ): string { return template .replaceAll("{{filePath}}", filePath) .replaceAll("{{declaredProfiles}}", JSON.stringify(declaredProfiles)) .replaceAll("{{fileBytes}}", fileBytes); } interface SegmentHandoff { filePath: string; relativePath: string; declaredProfiles: string[]; promptVersion: number; outputSchema: { /** Relative path to lockfile-schema.md for reference. */ reference: string; /** One-line description of the expected JSON shape. */ shape: string; }; nextStep: string; prompt: string; } function buildSegmentHandoff( absFilePath: string, configDir: string, declaredProfiles: string[], ): SegmentHandoff { const relativePath = relative(configDir, absFilePath); const fileBytes = readFileSync(absFilePath, "utf8"); const { version, template } = loadPromptTemplate(); const prompt = fillPromptTemplate( template, relativePath, declaredProfiles, fileBytes, ); return { filePath: absFilePath, relativePath, declaredProfiles, promptVersion: version, outputSchema: { reference: "plugins/marvin/skills/copy-editor/knowledge/lockfile-schema.md", shape: '{ "spans": [ { startLine, startColumn, endLine, endColumn, language, kind, pathHint?, note? } ] }', }, nextStep: `Produce span JSON following the prompt below, then pipe it to: copy-audit lockfile add "${relativePath}" --spans -`, prompt, }; } function printSegmentHandoffHuman(handoff: SegmentHandoff): void { const line = "═".repeat(72); console.log(line); console.log(`SEGMENTATION HANDOFF — ${handoff.relativePath}`); console.log(line); console.log(""); console.log(`Declared profiles: ${JSON.stringify(handoff.declaredProfiles)}`); console.log(`Prompt version: ${handoff.promptVersion}`); console.log(""); console.log("Output schema:"); console.log(` ${handoff.outputSchema.shape}`); console.log(` (reference: ${handoff.outputSchema.reference})`); console.log(""); console.log("Next step (agent):"); console.log(` ${handoff.nextStep}`); console.log(""); console.log("─".repeat(72)); console.log("PROMPT"); console.log("─".repeat(72)); console.log(handoff.prompt); console.log("─".repeat(72)); } async function runSegmentSubcommand( argv: string[], ): Promise { let configPath: string | undefined; let targetPath: string | undefined; let json = false; for (let i = 0; i < argv.length; i++) { const arg = argv[i]; if (arg === "--config") { configPath = argv[++i]; } else if (arg === "--json") { json = true; } else if (arg === "-h" || arg === "--help") { console.log(`copy-audit segment [--config ] [--json] Print a segmentation handoff payload for the host agent. The agent reads the prompt + file content, produces span JSON, and writes it back via \`copy-audit lockfile add\`. The script never calls a model. Segmentation is agent-side work. `); return 0; } else if (!arg.startsWith("-") && !targetPath) { targetPath = arg; } else if (arg.startsWith("-")) { console.error(`segment: unknown option: ${arg}`); return 2; } } if (!targetPath) { console.error("segment: expected a file path argument"); console.error("usage: copy-audit segment [--config ] [--json]"); return 2; } // Load config to discover declared profiles. Resolve any name-style // references (e.g. "czech") to their ISO codes via PROFILES lookup. let configDir = process.cwd(); let declaredProfiles: string[] = []; if (configPath || existsSync(join(process.cwd(), ".copy-editor.yaml"))) { const path = configPath || join(process.cwd(), ".copy-editor.yaml"); const { config, configDir: dir } = loadConfig(path); configDir = dir; if (config.extends && config.extends.length > 0) { declaredProfiles = config.extends.map(resolveProfileRef).filter(Boolean); } else if (config.language) { declaredProfiles = [config.language]; } } if (declaredProfiles.length === 0) { // Fall back to every registered profile. declaredProfiles = listProfiles(); } const absPath = resolve(configDir, targetPath); if (!existsSync(absPath)) { console.error(`segment: file not found: ${absPath}`); return 2; } if (!statSync(absPath).isFile()) { console.error(`segment: not a regular file: ${absPath}`); return 2; } const handoff = buildSegmentHandoff(absPath, configDir, declaredProfiles); if (json) { console.log(JSON.stringify(handoff, null, 2)); } else { printSegmentHandoffHuman(handoff); } return 0; } // ──────────────────────────────────────────────────────────────────── // Lockfile subcommands // ──────────────────────────────────────────────────────────────────── interface LockfileContext { configDir: string; lockfilePath: string; lockfile: Lockfile; } /** * Load the lockfile referenced by --config (or the current directory). * Creates a fresh in-memory lockfile with the agent backend when none * exists on disk — the first `lockfile add` will write it. */ function loadLockfileContext(configPath: string | undefined): LockfileContext { let configDir = process.cwd(); let cachePath: string | undefined; if (configPath || existsSync(join(process.cwd(), ".copy-editor.yaml"))) { const path = configPath || join(process.cwd(), ".copy-editor.yaml"); const { config, configDir: dir } = loadConfig(path); configDir = dir; cachePath = (config as unknown as { segment?: { cache_path?: string } }) .segment?.cache_path; } const lockfilePath = join(configDir, cachePath || DEFAULT_LOCKFILE_NAME); let lockfile: Lockfile; const existing = readLockfile(lockfilePath); if (existing) { lockfile = existing; } else { lockfile = { schemaVersion: 1, segmenter: { backend: "agent", promptVersion: CURRENT_PROMPT_VERSION }, files: {}, }; } return { configDir, lockfilePath, lockfile }; } async function runLockfileSubcommand(argv: string[]): Promise { const sub = argv[0]; if (!sub || sub === "-h" || sub === "--help") { console.log(`copy-audit lockfile [args] Subcommands: add --spans Write a new entry from span JSON. Use - to read JSON from stdin. --force to overwrite an existing entry. mark-reviewed --by Set reviewedBy on an existing entry. Errors if the entry's hash is stale. invalidate Remove entries whose relative path matches the glob. Supports * and ?. list [--json] List entries in the lockfile. Common options: --config Path to .copy-editor.yaml (determines configDir and lockfile location). `); return 0; } const args = argv.slice(1); switch (sub) { case "add": return runLockfileAdd(args); case "mark-reviewed": return runLockfileMarkReviewed(args); case "invalidate": return runLockfileInvalidate(args); case "list": return runLockfileList(args); default: console.error(`lockfile: unknown subcommand: ${sub}`); return 2; } } function extractCommonLockfileFlags(args: string[]): { configPath: string | undefined; positional: string[]; flags: Record; } { let configPath: string | undefined; const positional: string[] = []; const flags: Record = {}; for (let i = 0; i < args.length; i++) { const arg = args[i]; if (arg === "--config") { configPath = args[++i]; } else if (arg === "--spans") { flags.spans = args[++i]; } else if (arg === "--by") { flags.by = args[++i]; } else if (arg === "--force") { flags.force = true; } else if (arg === "--json") { flags.json = true; } else if (arg.startsWith("--")) { console.error(`lockfile: unknown option: ${arg}`); flags.error = true; } else { positional.push(arg); } } return { configPath, positional, flags }; } async function runLockfileAdd(args: string[]): Promise { const { configPath, positional, flags } = extractCommonLockfileFlags(args); if (flags.error) return 2; const target = positional[0]; if (!target) { console.error("lockfile add: expected a file path argument"); return 2; } if (!flags.spans || typeof flags.spans !== "string") { console.error("lockfile add: --spans is required"); return 2; } const { configDir, lockfilePath, lockfile } = loadLockfileContext(configPath); const absPath = resolve(configDir, target); const relPath = relative(configDir, absPath); if (!existsSync(absPath)) { console.error(`lockfile add: file not found: ${absPath}`); return 2; } let spansJson: string; if (flags.spans === "-") { spansJson = readFileSync(0, "utf8"); // stdin } else { const spansPath = resolve(configDir, flags.spans); if (!existsSync(spansPath)) { console.error(`lockfile add: spans file not found: ${spansPath}`); return 2; } spansJson = readFileSync(spansPath, "utf8"); } let parsed: { spans?: unknown }; try { parsed = JSON.parse(spansJson); } catch (err) { console.error(`lockfile add: invalid spans JSON: ${(err as Error).message}`); return 2; } if (!parsed || !Array.isArray(parsed.spans)) { console.error( `lockfile add: spans JSON must be an object with a "spans" array.`, ); return 2; } const fileBytes = readFileSync(absPath, "utf8"); const entry: LockfileEntry = { contentHash: hashFileBytes(fileBytes), segmentedAt: new Date().toISOString(), reviewedBy: null, spans: parsed.spans as LockfileSpan[], }; if (lockfile.files[relPath] && !flags.force) { console.error( `lockfile add: entry already exists for ${relPath}. Use --force to overwrite.`, ); return 2; } lockfile.files[relPath] = entry; try { writeLockfile(lockfilePath, lockfile); } catch (err) { console.error(`lockfile add: write failed: ${(err as Error).message}`); return 2; } console.log( `✓ wrote ${entry.spans.length} span(s) for ${relPath} (reviewedBy: null)`, ); return 0; } async function runLockfileMarkReviewed(args: string[]): Promise { const { configPath, positional, flags } = extractCommonLockfileFlags(args); if (flags.error) return 2; const target = positional[0]; if (!target) { console.error("lockfile mark-reviewed: expected a file path argument"); return 2; } if (!flags.by || typeof flags.by !== "string") { console.error("lockfile mark-reviewed: --by is required"); return 2; } const { configDir, lockfilePath, lockfile } = loadLockfileContext(configPath); const absPath = resolve(configDir, target); const relPath = relative(configDir, absPath); const entry = lockfile.files[relPath]; if (!entry) { console.error(`lockfile mark-reviewed: no entry for ${relPath}`); return 2; } if (!existsSync(absPath)) { console.error(`lockfile mark-reviewed: file not found: ${absPath}`); return 2; } const currentHash = hashFileBytes(readFileSync(absPath, "utf8")); if (currentHash !== entry.contentHash) { console.error( `lockfile mark-reviewed: stale entry for ${relPath}. ` + `File has changed since segmentation. Re-segment before marking reviewed.`, ); return 2; } entry.reviewedBy = flags.by; try { writeLockfile(lockfilePath, lockfile); } catch (err) { console.error(`lockfile mark-reviewed: write failed: ${(err as Error).message}`); return 2; } console.log(`✓ marked ${relPath} reviewed by ${flags.by}`); return 0; } async function runLockfileInvalidate(args: string[]): Promise { const { configPath, positional, flags } = extractCommonLockfileFlags(args); if (flags.error) return 2; const pattern = positional[0]; if (!pattern) { console.error("lockfile invalidate: expected a glob argument"); return 2; } const { lockfilePath, lockfile } = loadLockfileContext(configPath); const regex = globToRegExp(pattern); const removed: string[] = []; for (const path of Object.keys(lockfile.files)) { if (regex.test(path)) { delete lockfile.files[path]; removed.push(path); } } if (removed.length === 0) { console.log(`no entries match ${pattern}`); return 0; } try { writeLockfile(lockfilePath, lockfile); } catch (err) { console.error(`lockfile invalidate: write failed: ${(err as Error).message}`); return 2; } console.log(`✓ removed ${removed.length} entry/entries:`); for (const path of removed) console.log(` ${path}`); return 0; } async function runLockfileList(args: string[]): Promise { const { configPath, flags } = extractCommonLockfileFlags(args); if (flags.error) return 2; const { lockfile } = loadLockfileContext(configPath); if (flags.json) { console.log( JSON.stringify( { schemaVersion: lockfile.schemaVersion, segmenter: lockfile.segmenter, files: Object.entries(lockfile.files).map(([path, entry]) => ({ path, contentHash: entry.contentHash, segmentedAt: entry.segmentedAt, reviewedBy: entry.reviewedBy, spanCount: entry.spans.length, })), }, null, 2, ), ); return 0; } console.log( `lockfile — ${lockfile.segmenter.backend} backend, prompt v${lockfile.segmenter.promptVersion ?? "-"}`, ); const paths = Object.keys(lockfile.files).sort(); if (paths.length === 0) { console.log(" (empty)"); return 0; } for (const path of paths) { const entry = lockfile.files[path]; const review = entry.reviewedBy || "(pending)"; console.log( ` ${path} spans=${entry.spans.length} reviewed=${review}`, ); } return 0; } // ──────────────────────────────────────────────────────────────────── // Cache status helpers // ──────────────────────────────────────────────────────────────────── interface UnsegmentedEntry { path: string; reason: "missing" | "stale"; expectedHash?: string; actualHash?: string; } function findUnsegmentedFiles( files: string[], configDir: string, lockfile: Lockfile | null, ): UnsegmentedEntry[] { const out: UnsegmentedEntry[] = []; for (const file of files) { if (!statSync(file).isFile()) continue; const relPath = relative(configDir, file); const entry = lockfile?.files[relPath]; let bytes: string; try { bytes = readFileSync(file, "utf8"); } catch { continue; } if (!entry) { out.push({ path: relPath, reason: "missing" }); continue; } const currentHash = hashFileBytes(bytes); if (entry.contentHash !== currentHash) { out.push({ path: relPath, reason: "stale", expectedHash: entry.contentHash, actualHash: currentHash, }); } } return out; } function runListUnsegmented( files: string[], configDir: string, lockfile: Lockfile | null, json: boolean, ): number { const unsegmented = findUnsegmentedFiles(files, configDir, lockfile); if (json) { console.log( JSON.stringify( { totalFiles: files.length, unsegmentedCount: unsegmented.length, unsegmented, }, null, 2, ), ); } else { if (unsegmented.length === 0) { console.log(`✓ all ${files.length} file(s) have lockfile entries.`); } else { console.log( `${unsegmented.length} of ${files.length} file(s) need segmentation:`, ); for (const entry of unsegmented) { console.log(` ${entry.reason.padEnd(8)} ${entry.path}`); } console.log(""); console.log( "Hand each file to the host agent via `copy-audit segment `.", ); } } return unsegmented.length === 0 ? 0 : 1; } interface ReviewStatus { unreviewed: string[]; unsegmented: string[]; } function checkReviewStatus( files: string[], configDir: string, lockfile: Lockfile | null, ): ReviewStatus { const unreviewed: string[] = []; const unsegmented: string[] = []; for (const file of files) { if (!statSync(file).isFile()) continue; const relPath = relative(configDir, file); const entry = lockfile?.files[relPath]; if (!entry) { unsegmented.push(relPath); continue; } if (entry.reviewedBy === null) { unreviewed.push(relPath); } } return { unreviewed, unsegmented }; } // ──────────────────────────────────────────────────────────────────── // Self-test harness // ──────────────────────────────────────────────────────────────────── async function runSelfTest(): Promise { // Delegates to self-test.ts so the engine and the test harness can // be maintained separately. const { runAllSelfTests } = await import("./self-test.ts"); return runAllSelfTests(); } // ──────────────────────────────────────────────────────────────────── // Main // ──────────────────────────────────────────────────────────────────── async function main(): Promise { const argv = process.argv.slice(2); // Subcommand dispatch. The first non-flag argument is treated as a // subcommand if it matches a known name. Otherwise the arguments are // parsed as flags for the default audit run. if (argv[0] === "segment") { const code = await runSegmentSubcommand(argv.slice(1)); process.exit(code); } if (argv[0] === "lockfile") { const code = await runLockfileSubcommand(argv.slice(1)); process.exit(code); } const opts = parseArgs(argv); if (opts.help) { printHelp(); process.exit(0); } if (opts.selfTest) { const code = await runSelfTest(); process.exit(code); } // Resolve config, profile, paths let configDir = process.cwd(); let language = opts.lang; let include: string[] = []; let exclude: string[] = []; let ignoreMarker = "copy-editor: ignore"; if (opts.config) { const { config, configDir: dir } = loadConfig(opts.config); configDir = dir; language = opts.lang || config.language; include = opts.paths || config.paths?.include || []; exclude = config.paths?.exclude || []; if (config.ignore_marker) ignoreMarker = config.ignore_marker; } else { include = opts.paths || []; } if (!language) { console.error( "No language specified. Use --lang or set `language:` in .copy-editor.yaml.", ); process.exit(2); } const profile = getProfile(language); if (!profile) { console.error( `Unknown language profile: ${language}. Known: ${listProfiles().join(", ")}`, ); process.exit(2); } if (include.length === 0) { console.error( "No paths to scan. Provide --paths or set `paths.include` in .copy-editor.yaml.", ); process.exit(2); } const files = resolveFiles(configDir, include, exclude); if (opts.verbose) { console.error(`Resolved ${files.length} file(s) under ${configDir}`); } // Read the lockfile once. Each file is looked up by its repo-relative // path. A missing lockfile is the legal initial state. const lockfilePath = join(configDir, DEFAULT_LOCKFILE_NAME); const lockfile = readLockfile(lockfilePath); // --list-unsegmented short-circuits the audit. List files that have no // entry or whose entry is stale, then exit. The skill loop uses this // to know which files to hand to the host agent for segmentation. if (opts.listUnsegmented) { const code = runListUnsegmented(files, configDir, lockfile, opts.json); process.exit(code); } const allFindings: Finding[] = []; let totalFixesApplied = 0; const filesModified: string[] = []; for (const file of files) { let text: string; try { text = readFileSync(file, "utf8"); } catch (err) { console.error(`Failed to read ${file}: ${(err as Error).message}`); continue; } if (!statSync(file).isFile()) continue; const chunks = extractChunks(file, text, ignoreMarker); const rawFindings = runProfile(profile, chunks); // Look up the lockfile entry for this file. On hash match, post-filter // findings using the cached spans. On mismatch or missing entry, the // findings flow through unchanged (legacy single-profile behaviour). const relPath = relative(configDir, file); const entry = lockfile?.files[relPath]; const currentHash = hashFileBytes(text); let findings: Finding[]; if (entry && entry.contentHash === currentHash) { findings = filterFindingsBySpans( rawFindings, entry.spans, profile, language, ); } else { findings = rawFindings; } if (opts.fix && findings.some((f) => f.fix)) { const { text: fixed, applied } = applyFixes(text, findings); if (applied > 0 && fixed !== text) { writeFileSync(file, fixed, "utf8"); totalFixesApplied += applied; filesModified.push(file); if (opts.verbose) { console.error(` ${relative(configDir, file)}: ${applied} fix(es)`); } // Re-run rules against the fixed text. Note: --fix invalidates the // lockfile entry's contentHash for this file, so the next run will // see it as stale and need re-segmentation. That's correct. const newChunks = extractChunks(file, fixed, ignoreMarker); const residualFindings = runProfile(profile, newChunks); allFindings.push(...residualFindings); continue; } } if (opts.verbose && findings.length > 0) { console.error(` ${relative(configDir, file)}: ${findings.length}`); } allFindings.push(...findings); } const report = buildReport(files, allFindings); // --require-reviewed gate. Computed before printing so the report can // surface review status, and used to decide the exit code below. const reviewStatus = opts.requireReviewed ? checkReviewStatus(files, configDir, lockfile) : null; if (opts.json) { const out = reviewStatus ? { ...report, reviewStatus } : report; console.log(JSON.stringify(out, null, 2)); } else { printHumanReport(report, configDir); if (opts.fix) { console.log(""); console.log( `Applied ${totalFixesApplied} fix(es) to ${filesModified.length} file(s).`, ); } if (reviewStatus) { console.log(""); if ( reviewStatus.unreviewed.length === 0 && reviewStatus.unsegmented.length === 0 ) { console.log("✓ all included files have a reviewed lockfile entry."); } else { if (reviewStatus.unsegmented.length > 0) { console.log( `${reviewStatus.unsegmented.length} file(s) have no lockfile entry:`, ); for (const p of reviewStatus.unsegmented) console.log(` ${p}`); } if (reviewStatus.unreviewed.length > 0) { console.log( `${reviewStatus.unreviewed.length} file(s) pending review:`, ); for (const p of reviewStatus.unreviewed) console.log(` ${p}`); } } } } const reviewFailed = reviewStatus !== null && (reviewStatus.unreviewed.length > 0 || reviewStatus.unsegmented.length > 0); const exitCode = report.errorFindings > 0 || reviewFailed ? 1 : 0; process.exit(exitCode); } main().catch((err) => { console.error(err); process.exit(2); });