diff --git a/src/cli.ts b/src/cli.ts index 4063ddd3..75fddf46 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -919,6 +919,21 @@ program } }); +program + .command("export") + .description("Bundle the whole scaffold into a single Markdown document") + .option("--out ", "Write to a file instead of stdout") + .action(async (opts) => { + try { + const config = loadConfig(); + const { runExport } = await import("./export.js"); + await runExport(config, opts); + } catch (err) { + console.error((err as Error).message); + process.exit(1); + } + }); + program .command("timeline") .description("Show recent mex event log entries") diff --git a/src/export.ts b/src/export.ts new file mode 100644 index 00000000..8ae3d289 --- /dev/null +++ b/src/export.ts @@ -0,0 +1,343 @@ +import { + closeSync, + constants as fsConstants, + existsSync, + fstatSync, + ftruncateSync, + lstatSync, + mkdirSync, + openSync, + readSync, + realpathSync, + writeSync, +} from "node:fs"; +import { basename, dirname, join, relative, resolve } from "node:path"; +import { DEFAULT_SCAFFOLD_PATTERNS, findScaffoldFiles } from "./drift/index.js"; +import { toPosix } from "./paths.js"; +import type { MexConfig } from "./types.js"; + +export interface ExportOpts { + /** Write the bundle to this file instead of stdout. */ + out?: string; +} + +/** + * Hard caps so export refuses instead of exhausting the heap: the file list + * is bounded before anything is read, each file is read through its own file + * descriptor with a per-file byte cap (never sized from `stat`), and the + * running total of bytes actually read is checked before the joined document + * is allocated. + */ +export const MAX_EXPORT_FILES = 1000; +export const MAX_EXPORT_FILE_BYTES = 1024 * 1024; +export const MAX_EXPORT_TOTAL_BYTES = 8 * 1024 * 1024; + +/** + * First line of every bundle this command writes. An existing `--out` target + * carrying this marker is a previous export, not project state, so repeating + * the export overwrites it (after excluding it from its own inputs) instead + * of refusing. + */ +const BUNDLE_MARKER = "# mex scaffold export\n"; + +/** Resolve symlinks as far as the path exists, keeping any missing tail literal. */ +function realpathBestEffort(target: string): string { + const missing: string[] = []; + let current = target; + while (!existsSync(current)) { + const parent = dirname(current); + if (parent === current) return target; + missing.unshift(basename(current)); + current = parent; + } + return join(realpathSync(current), ...missing); +} + +/** Whether an existing path is a previous export bundle (marker prefix, bounded read). */ +function isPreviousExportBundle(target: string): boolean { + let fd: number | undefined; + try { + // O_NONBLOCK + regular-file check: a FIFO at the target must neither hang + // this probe nor be mistaken for a bundle. + fd = openSync(target, fsConstants.O_RDONLY | fsConstants.O_NONBLOCK); + if (!fstatSync(fd).isFile()) { + return false; + } + const prefix = Buffer.alloc(BUNDLE_MARKER.length); + const read = readSync(fd, prefix, 0, prefix.length, 0); + return read === prefix.length && prefix.toString("utf-8") === BUNDLE_MARKER; + } catch { + // Missing or unreadable: not a previous bundle. The write itself will + // surface permission errors; refusal logic only treats markers as outputs. + return false; + } finally { + if (fd !== undefined) { + try { + closeSync(fd); + } catch { + // Best effort: the descriptor is already open read-only and unused. + } + } + } +} + +/** + * Read exactly the bytes of one scaffold file, bounded by the per-file cap. + * + * The file is opened `O_NONBLOCK` and read through the descriptor, so a FIFO + * can neither hang the open nor be mistaken for content (`fstat` must report + * a regular file). The cap is enforced on the bytes actually transferred — + * never on an earlier `stat` — so a file that grows between discovery and + * reading is still refused instead of exhausting the heap. Opening the + * descriptor also pins the inode, so replacement races after open cannot + * change what is read. + */ +function readBoundedFileSync(file: string, display: string): Buffer { + const fd = openSync(file, fsConstants.O_RDONLY | fsConstants.O_NONBLOCK); + try { + if (!fstatSync(fd).isFile()) { + throw new Error(`Refusing to export: "${display}" is not a regular file.`); + } + const chunks: Buffer[] = []; + let remaining = MAX_EXPORT_FILE_BYTES + 1; + const slab = Buffer.alloc(Math.min(64 * 1024, remaining)); + for (;;) { + const got = readSync(fd, slab, 0, Math.min(slab.length, remaining), null); + if (got === 0) break; + chunks.push(Buffer.from(slab.subarray(0, got))); + remaining -= got; + if (remaining === 0) { + throw new Error( + `${display} is larger than ${MAX_EXPORT_FILE_BYTES} bytes; ` + + `export supports at most ${MAX_EXPORT_FILE_BYTES} bytes per file.` + ); + } + } + return Buffer.concat(chunks); + } finally { + try { + closeSync(fd); + } catch { + // Best effort: the descriptor is read-only and no longer needed. + } + } +} + +/** Whether anything (file, symlink, directory, FIFO, hardlink) exists at `target`. */ +function pathExists(target: string): boolean { + try { + lstatSync(target); + return true; + } catch { + return false; + } +} + +/** + * Write the whole document through an open descriptor, looping over partial + * writes. A single `writeSync` may transfer only a prefix (disk quota, file + * size limits), which must never be reported as success. + */ +function writeAllSync(fd: number, document: string, out: string): void { + const bytes = Buffer.from(document, "utf-8"); + let offset = 0; + while (offset < bytes.length) { + const written = writeSync(fd, bytes, offset, bytes.length - offset, null); + if (written === 0) { + throw new Error( + `Refusing to export: "${out}" could not be fully written ` + + `(${offset} of ${bytes.length} bytes stored). Choose a different --out path.` + ); + } + offset += written; + } +} + +/** + * Overwrite a previous export bundle through a fresh descriptor, re-verifying + * the bundle marker on the descriptor itself before truncating. This closes + * the check→write race: a file swapped in after the pre-read check is refused + * instead of truncated. + */ +function overwritePreviousBundle(target: string, out: string, document: string): void { + // O_NONBLOCK is a no-op for regular files but keeps this open from hanging + // if the target was replaced by a FIFO; the descriptor check below then + // refuses the non-regular object instead of reading or truncating it. + const fd = openSync(target, fsConstants.O_RDWR | fsConstants.O_NONBLOCK); + try { + if (!fstatSync(fd).isFile()) { + throw new Error( + `Refusing to export: "${out}" already exists and is not a previous export bundle. ` + + `Choose a different --out path.` + ); + } + const prefix = Buffer.alloc(BUNDLE_MARKER.length); + const read = readSync(fd, prefix, 0, prefix.length, 0); + if (read !== prefix.length || prefix.toString("utf-8") !== BUNDLE_MARKER) { + throw new Error( + `Refusing to export: "${out}" already exists and is not a previous export bundle. ` + + `Choose a different --out path.` + ); + } + ftruncateSync(fd, 0); + writeAllSync(fd, document, out); + } finally { + try { + closeSync(fd); + } catch { + // Best effort: the bundle bytes are already durable or an error propagates. + } + } +} + +/** + * Create a brand-new output file, refusing anything already present. The + * exclusive `wx` open closes the check→write race: a concurrent creator wins + * with `EEXIST`, which becomes the same refusal instead of a truncation. + */ +function writeNewBundleFile(target: string, out: string, document: string): void { + let fd: number | undefined; + try { + fd = openSync(target, "wx", 0o666); + } catch (error) { + if ((error as NodeJS.ErrnoException)?.code === "EEXIST") { + throw new Error( + `Refusing to export: "${out}" already exists and is not a previous export bundle. ` + + `Choose a different --out path.` + ); + } + throw error; + } + try { + writeAllSync(fd, document, out); + } finally { + try { + closeSync(fd); + } catch { + // Best effort: the bundle bytes are already durable or an error propagates. + } + } +} + +/** + * Bundle the whole scaffold into one Markdown document (#56). + * + * Section headers name the source file so a pasted copy stays navigable, and + * files are emitted in a deterministic order (sorted by path). Reuses the + * drift scanner's own file discovery, so what gets exported is exactly what + * `mex check` scans — nothing drifts between the two. + * + * Safety: `--out` never overwrites existing state that is not a previous + * export bundle — any pre-existing file, symlink, directory, FIFO, or + * hardlink at the target is refused before anything is read or written, so + * the original bytes always survive. A previous bundle (marker prefix) may be + * overwritten, after re-verification on the write descriptor, and is excluded + * from its own inputs. Reads are descriptor-bound: only regular files, with + * per-file and aggregate caps enforced on the bytes actually transferred. + */ +export async function runExport(config: MexConfig, opts: ExportOpts = {}): Promise { + let files = findScaffoldFiles(config.projectRoot, config.scaffoldRoot, DEFAULT_SCAFFOLD_PATTERNS) + .sort((left, right) => (left < right ? -1 : left > right ? 1 : 0)); + + if (files.length === 0) { + throw new Error("No scaffold files found. Run: mex setup"); + } + + const configPath = resolve(config.scaffoldRoot, "config.json"); + if (opts.out) { + const target = resolve(config.projectRoot, opts.out); + const targetReal = realpathBestEffort(target); + // A previous bundle is output, not project state: allow overwriting it + // (it is still excluded from its own inputs below). + const previousBundle = isPreviousExportBundle(target); + assertExportTarget(config, files, configPath, opts.out, previousBundle ? targetReal : undefined); + if (!previousBundle && pathExists(target)) { + // Anything already present — scaffold or config missed above, + // event history, README, hardlink/symlink/FIFO/dir aliases — is state, + // not a fresh destination. Refuse before reading or writing anything. + throw new Error( + `Refusing to export: "${opts.out}" already exists and is not a previous export bundle. ` + + `Choose a different --out path.` + ); + } + // A previous bundle inside the scaffold must not become an input: + // repeated exports would otherwise duplicate the whole scaffold. + files = files.filter((file) => realpathBestEffort(file) !== targetReal); + } + + if (files.length === 0) { + throw new Error("No scaffold files found. Run: mex setup"); + } + if (files.length > MAX_EXPORT_FILES) { + throw new Error( + `Scaffold has ${files.length} files; export supports at most ${MAX_EXPORT_FILES}.` + ); + } + + let totalBytes = 0; + const contents: string[] = []; + for (const file of files) { + const display = toPosix(relative(config.scaffoldRoot, file)); + const data = readBoundedFileSync(file, display); + totalBytes += data.length; + if (totalBytes > MAX_EXPORT_TOTAL_BYTES) { + throw new Error( + `Scaffold totals more than ${MAX_EXPORT_TOTAL_BYTES} bytes; ` + + `export supports at most ${MAX_EXPORT_TOTAL_BYTES} bytes in total.` + ); + } + contents.push(data.toString("utf-8")); + } + + const bundle: string[] = ["# mex scaffold export", ""]; + for (let index = 0; index < files.length; index += 1) { + const relativePath = toPosix(relative(config.scaffoldRoot, files[index])); + bundle.push(`## ${relativePath}`, "", contents[index].trimEnd(), ""); + } + const document = bundle.join("\n"); + + if (opts.out) { + const target = resolve(config.projectRoot, opts.out); + mkdirSync(dirname(target), { recursive: true }); + if (isPreviousExportBundle(target)) { + overwritePreviousBundle(target, opts.out, document); + } else { + writeNewBundleFile(target, opts.out, document); + } + console.log(`Wrote ${files.length} scaffold file(s) to ${opts.out}`); + return; + } + process.stdout.write(document); +} + +/** + * Refuse an `--out` target that would overwrite project state: an existing + * scaffold file, the project configuration, or a symlink alias of either. + * Throws before anything is written, so the original bytes always survive. + * `excludeReal`, when given, names a previous bundle output, which is output + * rather than project state and is therefore not protected. + */ +function assertExportTarget( + config: MexConfig, + files: string[], + configPath: string, + out: string, + excludeReal?: string +): void { + const target = resolve(config.projectRoot, out); + const targetReal = realpathBestEffort(target); + const protectedPaths = [...files, configPath]; + for (const protectedPath of protectedPaths) { + const resolved = resolve(protectedPath); + if (excludeReal !== undefined && realpathBestEffort(resolved) === excludeReal) continue; + if (target === resolved || targetReal === realpathBestEffort(resolved)) { + const label = + resolve(protectedPath) === resolve(configPath) + ? `project configuration ${toPosix(relative(config.projectRoot, resolved))}` + : `scaffold file ${toPosix(relative(config.scaffoldRoot, resolved))}`; + throw new Error( + `Refusing to export: "${out}" would overwrite ${label}. Choose a different --out path.` + ); + } + } +} diff --git a/test/export.test.ts b/test/export.test.ts new file mode 100644 index 00000000..d467ff4c --- /dev/null +++ b/test/export.test.ts @@ -0,0 +1,294 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { + existsSync, + linkSync, + mkdtempSync, + mkdirSync, + mkfifoSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { dirname, join } from "node:path"; +import { tmpdir } from "node:os"; +import { + MAX_EXPORT_FILES, + MAX_EXPORT_FILE_BYTES, + MAX_EXPORT_TOTAL_BYTES, + runExport, +} from "../src/export.js"; +import type { MexConfig } from "../src/types.js"; + +let tmpDir: string; +let config: MexConfig; +let stdoutSpy: ReturnType; + +beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "mex-export-")); + mkdirSync(join(tmpDir, ".mex/context"), { recursive: true }); + mkdirSync(join(tmpDir, ".mex/patterns"), { recursive: true }); + writeFileSync(join(tmpDir, ".mex/ROUTER.md"), "# Router\n\nEntry point.\n"); + writeFileSync(join(tmpDir, ".mex/context/stack.md"), "# Stack\n\nNode 22.\n"); + writeFileSync(join(tmpDir, ".mex/patterns/retry.md"), "# Retry\n"); + config = { projectRoot: tmpDir, scaffoldRoot: join(tmpDir, ".mex"), aiTools: [] }; + stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); +}); + +afterEach(() => { + vi.restoreAllMocks(); + rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe("mex export (#56)", () => { + it("bundles every scaffold file under a header per source file", async () => { + await runExport(config, {}); + const document = stdoutSpy.mock.calls.map((call) => String(call[0])).join(""); + + expect(document).toContain("# mex scaffold export"); + expect(document).toContain("## ROUTER.md"); + expect(document).toContain("## context/stack.md"); + expect(document).toContain("## patterns/retry.md"); + // Content survives intact under its own header. + expect(document).toContain("Entry point."); + expect(document).toContain("Node 22."); + // Deterministic order: sorted by path. + expect(document.indexOf("## ROUTER.md")).toBeLessThan(document.indexOf("## context/stack.md")); + expect(document.indexOf("## context/stack.md")).toBeLessThan(document.indexOf("## patterns/retry.md")); + }); + + it("writes the same bundle to --out and reports the count", async () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + await runExport(config, { out: "exports/scaffold.md" }); + expect(stdoutSpy.mock.calls.map((call) => String(call[0])).join("")).toBe(""); + const written = readFileSync(join(tmpDir, "exports/scaffold.md"), "utf-8"); + expect(written).toContain("## ROUTER.md"); + expect(logSpy.mock.calls.map((call) => String(call[0])).join("")) + .toContain("Wrote 3 scaffold file(s) to exports/scaffold.md"); + }); + + it("fails with guidance when the scaffold is missing", async () => { + rmSync(join(tmpDir, ".mex"), { recursive: true, force: true }); + await expect(runExport(config, {})).rejects.toThrow("No scaffold files found. Run: mex setup"); + }); +}); + +describe("mex export destination safety (#183 P1)", () => { + it("refuses an --out path that is an existing scaffold file, keeping its bytes", async () => { + const routerPath = join(tmpDir, ".mex/ROUTER.md"); + const before = readFileSync(routerPath, "utf-8"); + await expect(runExport(config, { out: ".mex/ROUTER.md" })).rejects.toThrow( + /Refusing to export: ".mex\/ROUTER\.md" would overwrite scaffold file ROUTER\.md/ + ); + expect(readFileSync(routerPath, "utf-8")).toBe(before); + }); + + it("refuses an --out path that is the project configuration, keeping its bytes", async () => { + const configPath = join(tmpDir, ".mex/config.json"); + writeFileSync(configPath, JSON.stringify({ scaffold_id: "keep-me" })); + await expect(runExport(config, { out: ".mex/config.json" })).rejects.toThrow( + /Refusing to export: ".mex\/config\.json" would overwrite project configuration/ + ); + expect(readFileSync(configPath, "utf-8")).toBe(JSON.stringify({ scaffold_id: "keep-me" })); + }); + + it("refuses a symlink alias of a scaffold file, keeping the target bytes", async () => { + const routerPath = join(tmpDir, ".mex/ROUTER.md"); + const before = readFileSync(routerPath, "utf-8"); + mkdirSync(join(tmpDir, "exports")); + symlinkSync(routerPath, join(tmpDir, "exports/scaffold.md")); + await expect(runExport(config, { out: "exports/scaffold.md" })).rejects.toThrow( + /Refusing to export/ + ); + expect(readFileSync(routerPath, "utf-8")).toBe(before); + }); + + it("refuses a scaffold file reached through a symlinked parent directory", async () => { + // Directory junctions need no special privilege (unlike file symlinks), + // so this exercises the same alias detection on every platform. + const routerPath = join(tmpDir, ".mex/ROUTER.md"); + const before = readFileSync(routerPath, "utf-8"); + symlinkSync(join(tmpDir, ".mex"), join(tmpDir, "xlink"), "junction"); + await expect(runExport(config, { out: "xlink/ROUTER.md" })).rejects.toThrow( + /Refusing to export: "xlink\/ROUTER\.md" would overwrite scaffold file ROUTER\.md/ + ); + expect(readFileSync(routerPath, "utf-8")).toBe(before); + }); + + it("excludes a previous bundle inside the scaffold from its own inputs", async () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const bundleRel = ".mex/context/bundle.md"; + await runExport(config, { out: bundleRel }); + const first = readFileSync(join(tmpDir, bundleRel), "utf-8"); + expect(first).not.toContain("## context/bundle.md"); + await runExport(config, { out: bundleRel }); + const second = readFileSync(join(tmpDir, bundleRel), "utf-8"); + expect(second).toBe(first); + expect(logSpy).toHaveBeenCalled(); + }); + + it("refuses any other existing project state, keeping its bytes", async () => { + mkdirSync(join(tmpDir, ".mex/events"), { recursive: true }); + const decisions = join(tmpDir, ".mex/events/decisions.jsonl"); + writeFileSync(decisions, JSON.stringify({ event: "keep-me" })); + const readme = join(tmpDir, "README.md"); + writeFileSync(readme, "# Keep me\n"); + await expect(runExport(config, { out: ".mex/events/decisions.jsonl" })).rejects.toThrow( + /already exists and is not a previous export bundle/ + ); + await expect(runExport(config, { out: "README.md" })).rejects.toThrow( + /already exists and is not a previous export bundle/ + ); + expect(readFileSync(decisions, "utf-8")).toBe(JSON.stringify({ event: "keep-me" })); + expect(readFileSync(readme, "utf-8")).toBe("# Keep me\n"); + }); + + it("refuses a hardlink alias of a scaffold file, keeping the target bytes", async () => { + const routerPath = join(tmpDir, ".mex/ROUTER.md"); + const before = readFileSync(routerPath, "utf-8"); + mkdirSync(join(tmpDir, "exports")); + // Same inode, different path: realpath comparison cannot see it, but the + // existence refusal still protects the target. + linkSync(routerPath, join(tmpDir, "exports/scaffold.md")); + await expect(runExport(config, { out: "exports/scaffold.md" })).rejects.toThrow( + /Refusing to export/ + ); + expect(readFileSync(routerPath, "utf-8")).toBe(before); + }); + + it("refuses an existing directory as --out", async () => { + mkdirSync(join(tmpDir, "exports")); + await expect(runExport(config, { out: "exports" })).rejects.toThrow( + /Refusing to export/ + ); + }); +}); + +describe("mex export bounds (#183 P2)", () => { + function resetScaffold(files: Array<{ rel: string; bytes: number }>): void { + rmSync(join(tmpDir, ".mex"), { recursive: true, force: true }); + for (const file of files) { + const target = join(tmpDir, ".mex", file.rel); + mkdirSync(dirname(target), { recursive: true }); + writeFileSync(target, Buffer.alloc(file.bytes, "a")); + } + } + + function filler(count: number, bytes: number, prefix = "context/f"): Array<{ rel: string; bytes: number }> { + return Array.from({ length: count }, (_, index) => ({ rel: `${prefix}${index}.md`, bytes })); + } + + it("refuses when the file count exceeds the limit and writes nothing", async () => { + resetScaffold(filler(MAX_EXPORT_FILES + 1, 0)); + await expect(runExport(config, { out: "exports/scaffold.md" })).rejects.toThrow( + new RegExp(`supports at most ${MAX_EXPORT_FILES}`) + ); + expect(existsSync(join(tmpDir, "exports/scaffold.md"))).toBe(false); + }); + + it("accepts exactly the file-count limit", async () => { + resetScaffold(filler(MAX_EXPORT_FILES, 0)); + await expect(runExport(config, {})).resolves.toBeUndefined(); + }); + + it("refuses a file larger than the per-file limit and writes nothing", async () => { + resetScaffold([{ rel: "context/big.md", bytes: MAX_EXPORT_FILE_BYTES + 1 }]); + await expect(runExport(config, { out: "exports/scaffold.md" })).rejects.toThrow( + new RegExp(`supports at most ${MAX_EXPORT_FILE_BYTES} bytes per file`) + ); + expect(existsSync(join(tmpDir, "exports/scaffold.md"))).toBe(false); + }); + + it("accepts a file of exactly the per-file limit", async () => { + resetScaffold([{ rel: "context/big.md", bytes: MAX_EXPORT_FILE_BYTES }]); + await expect(runExport(config, {})).resolves.toBeUndefined(); + }); + + it("refuses when the aggregate exceeds the total limit and writes nothing", async () => { + // Nine 1 MiB files stay within the per-file cap but total 9 MiB. + resetScaffold(filler(9, MAX_EXPORT_FILE_BYTES)); + await expect(runExport(config, { out: "exports/scaffold.md" })).rejects.toThrow( + new RegExp(`supports at most ${MAX_EXPORT_TOTAL_BYTES} bytes in total`) + ); + expect(existsSync(join(tmpDir, "exports/scaffold.md"))).toBe(false); + }); + + it("accepts an aggregate of exactly the total limit", async () => { + resetScaffold(filler(8, MAX_EXPORT_FILE_BYTES)); + await expect(runExport(config, {})).resolves.toBeUndefined(); + }); +}); + +describe("mex export write completion (#183 P2)", () => { + // Force short writes through a per-test node:fs mock: the export module is + // re-imported fresh so only these tests observe the mock. + async function importExportWithWrites( + writeBehavior: (write: typeof import("node:fs").writeSync, fd: number, buf: Buffer, offset: number, length: number, position: null) => number + ): Promise { + vi.resetModules(); + vi.doMock("node:fs", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + writeSync: ((fd: number, buf: Buffer, offset: number, length: number, position: null) => + writeBehavior(actual.writeSync, fd, buf, offset, length, position)) as unknown as typeof actual.writeSync, + }; + }); + try { + return await import("../src/export.js"); + } finally { + vi.doUnmock("node:fs"); + vi.resetModules(); + } + } + + it("fails a new-file export when the descriptor stops accepting bytes", async () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const mocked = await importExportWithWrites(() => 0); + await expect(mocked.runExport(config, { out: "exports/scaffold.md" })).rejects.toThrow( + /could not be fully written/ + ); + // Partial bytes may exist, but success is never reported. + expect(logSpy.mock.calls.map((call) => String(call[0])).join("")).not.toContain("Wrote"); + }); + + it("fails a previous-bundle overwrite when the descriptor stops accepting bytes", async () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + await runExport(config, { out: "exports/scaffold.md" }); + logSpy.mockClear(); + const mocked = await importExportWithWrites(() => 0); + await expect(mocked.runExport(config, { out: "exports/scaffold.md" })).rejects.toThrow( + /could not be fully written/ + ); + expect(logSpy.mock.calls.map((call) => String(call[0])).join("")).not.toContain("Wrote 3"); + }); + + it("completes the bundle when an early write is short", async () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + let shortLeft = 1; + const mocked = await importExportWithWrites((write, fd, buf, offset, length, position) => { + if (shortLeft > 0) { + shortLeft -= 1; + return write(fd, buf, offset, Math.min(10, length), position); + } + return write(fd, buf, offset, length, position); + }); + await mocked.runExport(config, { out: "exports/scaffold.md" }); + const written = readFileSync(join(tmpDir, "exports/scaffold.md"), "utf-8"); + expect(written).toContain("## ROUTER.md"); + expect(written).toContain("Node 22."); + expect(logSpy.mock.calls.map((call) => String(call[0])).join("")) + .toContain("Wrote 3 scaffold file(s) to exports/scaffold.md"); + }); + + it("refuses a FIFO as --out without hanging", async () => { + // Node cannot create FIFOs on Windows; the implementation is still safe + // there because the blocking probe is gone on every platform. + if (process.platform === "win32") return; + mkdirSync(join(tmpDir, "exports")); + mkfifoSync(join(tmpDir, "exports/scaffold.md")); + await expect(runExport(config, { out: "exports/scaffold.md" })).rejects.toThrow( + /already exists and is not a previous export bundle/ + ); + }, 10000); +}); diff --git a/test/wiki-architecture.test.ts b/test/wiki-architecture.test.ts index eceadb85..6ef8dca6 100644 --- a/test/wiki-architecture.test.ts +++ b/test/wiki-architecture.test.ts @@ -753,6 +753,15 @@ describe("no unscoped scaffold writes", () => { // // Every other rule in this file is scoped to `src/wiki/`, so before this // one nothing in `src/` was watching at all. + // + // The inventory detector is deliberately broader than the call-shape ban + // above: `src/export.ts` writes through file descriptors (`wx`-exclusive + // create, marker-verified overwrite) precisely so no `writeFileSync` call + // shape exists to match. A writer the inventory cannot see is worse than + // a longer pattern, so fd-based mutations are pinned here. The guarded + // rules keep the narrow shape on purpose: broadening them would demand + // textual guard counts the wiki modules do not have. + const FD_WRITE_CALLS = /\b(writeSync|ftruncateSync)\s*\(/g; const KNOWN: Readonly> = { "src/agent-skills/installer.ts": "atomically installs fixed packaged skill trees and marker-scoped root instructions", "src/graph/engine-impl.ts": "writes only a private, bounded temporary source spool that is removed before graph publication", @@ -761,6 +770,7 @@ describe("no unscoped scaffold writes", () => { "src/config.ts": "writes config.json", "src/global-config.ts": "writes the global config and telemetry id", "src/events.ts": "appends to events/decisions.jsonl", + "src/export.ts": "writes one export bundle to a user-specified path — a brand-new file (wx-exclusive) or a marker-verified previous bundle, never scaffold bytes", "src/pattern/index.ts": "creates a new pattern file from a template", "src/setup/anchor.ts": "edits root tool configs only, never .mex/, and only inside its own markers", "src/setup/ignore.ts": "creates or appends only the setup-managed .mex/.gitignore rules", @@ -771,7 +781,11 @@ describe("no unscoped scaffold writes", () => { }; const outside = FILES.filter((path) => !path.startsWith("src/wiki/")); - const writers = outside.filter((path) => [...withoutComments(read(path)).matchAll(WRITE_CALLS)].length > 0); + const writers = outside.filter( + (path) => + [...withoutComments(read(path)).matchAll(WRITE_CALLS)].length > 0 || + [...withoutComments(read(path)).matchAll(FD_WRITE_CALLS)].length > 0, + ); expect(writers.sort()).toEqual(Object.keys(KNOWN).sort()); // Vacuity guard: there were files outside the wiki engine to check. expect(outside.length).toBeGreaterThan(20);