From 9a39a586bddde6a6c1a437b98a1ff151cad96201 Mon Sep 17 00:00:00 2001 From: abhinav-phi Date: Wed, 9 Sep 2026 05:36:42 +0530 Subject: [PATCH 1/6] feat(cli): add mex export to bundle the scaffold into one Markdown file Concatenates every scaffold file the drift scanner discovers (DEFAULT_SCAFFOLD_PATTERNS through findScaffoldFiles) into a single Markdown document with a '## ' section header per source file, so what gets exported is exactly what mex check scans. Output goes to stdout by default, or to a path via --out (parent directories created). An empty scaffold fails with the setup guidance. Resolves #56 --- src/cli.ts | 15 ++++++++++++ src/export.ts | 43 +++++++++++++++++++++++++++++++++ test/export.test.ts | 59 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 117 insertions(+) create mode 100644 src/export.ts create mode 100644 test/export.test.ts 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..5711dc9f --- /dev/null +++ b/src/export.ts @@ -0,0 +1,43 @@ +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, 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; +} + +/** + * 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. + */ +export async function runExport(config: MexConfig, opts: ExportOpts = {}): Promise { + const 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 bundle: string[] = ["# mex scaffold export", ""]; + for (const file of files) { + const relativePath = toPosix(relative(config.scaffoldRoot, file)); + bundle.push(`## ${relativePath}`, "", readFileSync(file, "utf-8").trimEnd(), ""); + } + const document = bundle.join("\n"); + + if (opts.out) { + const target = resolve(config.projectRoot, opts.out); + mkdirSync(dirname(target), { recursive: true }); + writeFileSync(target, document, "utf-8"); + console.log(`Wrote ${files.length} scaffold file(s) to ${opts.out}`); + return; + } + process.stdout.write(document); +} diff --git a/test/export.test.ts b/test/export.test.ts new file mode 100644 index 00000000..7a70b2ad --- /dev/null +++ b/test/export.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { 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"); + }); +}); From 35bdbb0f129750a25883739cf4f768cb9f479ba4 Mon Sep 17 00:00:00 2001 From: abhinav-phi Date: Wed, 9 Sep 2026 06:13:37 +0530 Subject: [PATCH 2/6] test(architecture): register the export writer in the pinned allowlist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit src/export.ts writes one bundle file to a user-specified path — a brand-new file, never scaffold bytes — which is exactly the class the allowlist's own comment carves out. Registered by write call with its exemption, per the rule that a new writer names its scope. --- test/wiki-architecture.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/wiki-architecture.test.ts b/test/wiki-architecture.test.ts index eceadb85..ce34a2bd 100644 --- a/test/wiki-architecture.test.ts +++ b/test/wiki-architecture.test.ts @@ -761,6 +761,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, 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", From 2abae423dd4cecca2c89ace54c5a297e15704656 Mon Sep 17 00:00:00 2001 From: abhinav-phi Date: Fri, 18 Sep 2026 14:51:32 +0530 Subject: [PATCH 3/6] fix(export): refuse overwriting destinations and bound scaffold reads P1: validate --out against scaffold files, the project config, and symlink aliases before writing; a previous bundle output (marker prefix) is excluded from its own inputs instead of refused. P2: enforce file-count, per-file, and aggregate byte limits before retaining content, with clear refusals and boundary tests. --- src/export.ts | 148 +++++++++++++++++++++++++++++++++++++++++++- test/export.test.ts | 129 +++++++++++++++++++++++++++++++++++++- 2 files changed, 271 insertions(+), 6 deletions(-) diff --git a/src/export.ts b/src/export.ts index 5711dc9f..7d799b1a 100644 --- a/src/export.ts +++ b/src/export.ts @@ -1,5 +1,15 @@ -import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { dirname, relative, resolve } from "node:path"; +import { + closeSync, + existsSync, + mkdirSync, + openSync, + readFileSync, + readSync, + realpathSync, + statSync, + writeFileSync, +} 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"; @@ -9,6 +19,60 @@ export interface ExportOpts { out?: string; } +/** + * Hard caps so export refuses instead of exhausting the heap: the file list + * is bounded before anything is read, each file is size-checked with `stat` + * before its bytes are retained, and the running total 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 { + fd = openSync(target, "r"); + 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. + } + } + } +} + /** * Bundle the whole scaffold into one Markdown document (#56). * @@ -16,15 +80,61 @@ export interface ExportOpts { * 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 scaffold or configuration state (an + * existing scaffold file, the project config, or a symlink alias of either + * is refused before anything is written), a previous bundle inside the + * scaffold is excluded from its own inputs, and file-count, per-file, and + * aggregate byte limits are enforced before any content is retained. */ export async function runExport(config: MexConfig, opts: ExportOpts = {}): Promise { - const files = findScaffoldFiles(config.projectRoot, config.scaffoldRoot, DEFAULT_SCAFFOLD_PATTERNS) + 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); + // 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; + for (const file of files) { + const size = statSync(file).size; + if (size > MAX_EXPORT_FILE_BYTES) { + throw new Error( + `${toPosix(relative(config.scaffoldRoot, file))} is ${size} bytes; ` + + `export supports at most ${MAX_EXPORT_FILE_BYTES} bytes per file.` + ); + } + totalBytes += size; + 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.` + ); + } + } + const bundle: string[] = ["# mex scaffold export", ""]; for (const file of files) { const relativePath = toPosix(relative(config.scaffoldRoot, file)); @@ -41,3 +151,35 @@ export async function runExport(config: MexConfig, opts: ExportOpts = {}): Promi } 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 index 7a70b2ad..b820a3df 100644 --- a/test/export.test.ts +++ b/test/export.test.ts @@ -1,8 +1,21 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; -import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { + existsSync, + mkdtempSync, + mkdirSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { dirname, join } from "node:path"; import { tmpdir } from "node:os"; -import { runExport } from "../src/export.js"; +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; @@ -57,3 +70,113 @@ describe("mex export (#56)", () => { 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(); + }); +}); + +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(); + }); +}); From b3c6de5d56ea024defeb9afda626ffe6212130c0 Mon Sep 17 00:00:00 2001 From: abhinav-phi Date: Sat, 19 Sep 2026 18:34:04 +0530 Subject: [PATCH 4/6] fix(export): refuse any existing non-bundle destination and bound actual reads --- src/export.ts | 170 ++++++++++++++++++++++++++++++++++++++------ test/export.test.ts | 37 ++++++++++ 2 files changed, 184 insertions(+), 23 deletions(-) diff --git a/src/export.ts b/src/export.ts index 7d799b1a..f99565f4 100644 --- a/src/export.ts +++ b/src/export.ts @@ -1,13 +1,15 @@ import { closeSync, + constants as fsConstants, existsSync, + fstatSync, + ftruncateSync, + lstatSync, mkdirSync, openSync, - readFileSync, readSync, realpathSync, - statSync, - writeFileSync, + writeSync, } from "node:fs"; import { basename, dirname, join, relative, resolve } from "node:path"; import { DEFAULT_SCAFFOLD_PATTERNS, findScaffoldFiles } from "./drift/index.js"; @@ -21,9 +23,10 @@ export interface ExportOpts { /** * Hard caps so export refuses instead of exhausting the heap: the file list - * is bounded before anything is read, each file is size-checked with `stat` - * before its bytes are retained, and the running total is checked before the - * joined document is allocated. + * 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; @@ -73,6 +76,115 @@ function isPreviousExportBundle(target: string): boolean { } } +/** + * 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; + } +} + +/** + * 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 { + const fd = openSync(target, "r+"); + try { + 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); + writeSync(fd, document, 0, "utf-8"); + } 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 { + writeSync(fd, document, 0, "utf-8"); + } 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). * @@ -81,11 +193,13 @@ function isPreviousExportBundle(target: string): boolean { * 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 scaffold or configuration state (an - * existing scaffold file, the project config, or a symlink alias of either - * is refused before anything is written), a previous bundle inside the - * scaffold is excluded from its own inputs, and file-count, per-file, and - * aggregate byte limits are enforced before any content is retained. + * 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) @@ -103,6 +217,15 @@ export async function runExport(config: MexConfig, opts: ExportOpts = {}): Promi // (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); @@ -118,34 +241,35 @@ export async function runExport(config: MexConfig, opts: ExportOpts = {}): Promi } let totalBytes = 0; + const contents: string[] = []; for (const file of files) { - const size = statSync(file).size; - if (size > MAX_EXPORT_FILE_BYTES) { - throw new Error( - `${toPosix(relative(config.scaffoldRoot, file))} is ${size} bytes; ` + - `export supports at most ${MAX_EXPORT_FILE_BYTES} bytes per file.` - ); - } - totalBytes += size; + 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 (const file of files) { - const relativePath = toPosix(relative(config.scaffoldRoot, file)); - bundle.push(`## ${relativePath}`, "", readFileSync(file, "utf-8").trimEnd(), ""); + 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 }); - writeFileSync(target, document, "utf-8"); + 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; } diff --git a/test/export.test.ts b/test/export.test.ts index b820a3df..0d98dc1b 100644 --- a/test/export.test.ts +++ b/test/export.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { existsSync, + linkSync, mkdtempSync, mkdirSync, readFileSync, @@ -124,6 +125,42 @@ describe("mex export destination safety (#183 P1)", () => { 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)", () => { From 16325845a61581f14b95d8b56d8dff8b17a2864d Mon Sep 17 00:00:00 2001 From: abhinav-phi Date: Sat, 19 Sep 2026 21:30:25 +0530 Subject: [PATCH 5/6] test(architecture): pin fd-based writers in export inventory --- test/wiki-architecture.test.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/test/wiki-architecture.test.ts b/test/wiki-architecture.test.ts index ce34a2bd..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,7 +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, never scaffold bytes", + "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", @@ -772,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); From 6848ce03d5045c94b38e180799a0bb9cc52b865c Mon Sep 17 00:00:00 2001 From: abhinav-phi Date: Tue, 22 Sep 2026 21:06:29 +0530 Subject: [PATCH 6/6] fix(export): complete partial writes and refuse FIFO destinations without blocking P2 review: add writeAllSync that loops descriptor writes so a short write is never reported as success (both new-file and overwrite branches); open the bundle-marker probe and the overwrite descriptor O_NONBLOCK with a regular-file check so a FIFO destination is refused instead of hanging. Tests: partial-write failure coverage for both branches, short-write completion, bounded FIFO refusal. --- src/export.ts | 42 ++++++++++++++++++++++--- test/export.test.ts | 75 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+), 4 deletions(-) diff --git a/src/export.ts b/src/export.ts index f99565f4..8ae3d289 100644 --- a/src/export.ts +++ b/src/export.ts @@ -57,7 +57,12 @@ function realpathBestEffort(target: string): string { function isPreviousExportBundle(target: string): boolean { let fd: number | undefined; try { - fd = openSync(target, "r"); + // 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; @@ -128,6 +133,26 @@ function pathExists(target: string): boolean { } } +/** + * 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 @@ -135,8 +160,17 @@ function pathExists(target: string): boolean { * instead of truncated. */ function overwritePreviousBundle(target: string, out: string, document: string): void { - const fd = openSync(target, "r+"); + // 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) { @@ -146,7 +180,7 @@ function overwritePreviousBundle(target: string, out: string, document: string): ); } ftruncateSync(fd, 0); - writeSync(fd, document, 0, "utf-8"); + writeAllSync(fd, document, out); } finally { try { closeSync(fd); @@ -175,7 +209,7 @@ function writeNewBundleFile(target: string, out: string, document: string): void throw error; } try { - writeSync(fd, document, 0, "utf-8"); + writeAllSync(fd, document, out); } finally { try { closeSync(fd); diff --git a/test/export.test.ts b/test/export.test.ts index 0d98dc1b..d467ff4c 100644 --- a/test/export.test.ts +++ b/test/export.test.ts @@ -4,6 +4,7 @@ import { linkSync, mkdtempSync, mkdirSync, + mkfifoSync, readFileSync, rmSync, symlinkSync, @@ -217,3 +218,77 @@ describe("mex export bounds (#183 P2)", () => { 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); +});