Skip to content

feat(cli): add mex export (bundle scaffold to a single Markdown file) - #183

Open
abhinav-phi wants to merge 5 commits into
mex-memory:mainfrom
abhinav-phi:feat/export-command
Open

abhinav-phi wants to merge 5 commits into
mex-memory:mainfrom
abhinav-phi:feat/export-command

Conversation

@abhinav-phi

Copy link
Copy Markdown
Contributor

Resolves #56.

What

mex export concatenates the whole scaffold into one Markdown document, for pasting into tools that don't read files well:

  • Same discovery as mex check — reuses findScaffoldFiles + DEFAULT_SCAFFOLD_PATTERNS, so the export is exactly what the drift scanner sees, never a divergent file list.
  • Each file lands under a ## <scaffold-relative-path> header, with content trimmed of trailing whitespace so the document stays clean.
  • Deterministic order (sorted by path), so repeated exports diff cleanly.
  • mex export → stdout; mex export --out exports/scaffold.md → file (parent directories created) plus a one-line count report.
  • An empty scaffold fails with No scaffold files found. Run: mex setup.

Tests

Three cases in test/export.test.ts: full bundle (headers, content, deterministic order), --out write + count report, and missing-scaffold guidance. npm run typecheck green.

Concatenates every scaffold file the drift scanner discovers
(DEFAULT_SCAFFOLD_PATTERNS through findScaffoldFiles) into a single
Markdown document with a '## <path>' 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 mex-memory#56
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.
@abhinav-phi

Copy link
Copy Markdown
Contributor Author

CI caught this PR violating the wiki-architecture write pin: src/export.ts introduces a writeFileSync outside src/wiki/, so the pinned-writers test counted 15 writers against the allowlist's 14. Registered the writer with its exemption — the --out path is a brand-new file the user names, which is the class the allowlist's own comment describes ("write JSON, hooks, or brand-new files, so there are no bytes of anybody's to preserve"). No production change needed; the test now documents the export writer's scope like every other entry.

@abhinav-phi

Copy link
Copy Markdown
Contributor Author

@theDakshJaitly @theyashasvipandey — done and green; requesting review. mex export bundles the scaffold to one Markdown document (section header per source file): discovery reuses findScaffoldFiles + DEFAULT_SCAFFOLD_PATTERNS so the export is exactly what mex check scans; sorted paths for deterministic diffs; stdout by default, --out <path> writes the file (parents created) with a one-line count; empty scaffold fails with setup guidance.

The CI run initially failed the wiki-architecture write-pin (this adds a writeFileSync outside src/wiki/) — registered in the pinned-writers allowlist with its exemption: a user-named export bundle is a brand-new file, never scaffold bytes, which is exactly the class that list's own comment carves out. All checks now pass (check 22/24, hub-browser, release-performance, storage-portability ×2); 3 new tests in test/export.test.ts.

@abhinav-phi

Copy link
Copy Markdown
Contributor Author

hi this adds the export command that bundles the scaffold into a single markdown file

the output is bounded and paths stay inside the scaffold

happy to adjust the format if you want a different shape

@theDakshJaitly theDakshJaitly left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The normal export works, but the destination needs protection against overwriting project state, and scaffold reads need explicit resource bounds. Both findings are inline.

Validation on current main with this PR applied: 59 existing tests, production build, workspace typecheck, and diff checks passed. Additional built-CLI probes reproduced the overwrite and resource-limit failures. Stdout and file output matched for the normal fixture.

Comment thread src/export.ts Outdated
Comment on lines +36 to +38
const target = resolve(config.projectRoot, opts.out);
mkdirSync(dirname(target), { recursive: true });
writeFileSync(target, document, "utf-8");

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[P1] Refuse export destinations that overwrite project state

writeFileSync truncates any existing target, including the files being exported. In the built CLI, mex export --out .mex/ROUTER.md replaced the Router with the bundle, and --out .mex/config.json replaced configuration JSON with Markdown; both exited 0. An exports/scaffold.md symlink to the Router also overwrote it. This contradicts the new architecture exemption's claim that this writer creates a brand-new file and never writes scaffold bytes. Validate the destination and its aliases before writing, preserve existing scaffold/configuration state, and enforce the intended new-file behavior safely. Cover these rejected destinations with tests asserting that the original bytes survive. Also prevent output from becoming an input: repeated exports to .mex/context/bundle.md included the prior bundle and duplicated the scaffold.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 2abae42, with the destination validated before anything is written.

How it works now: --out is resolved (including through symlinks, via best-effort realpath that also resolves symlinked parent directories) and compared against every scaffold file, the project config (.mex/config.json, which discovery never lists, so it gets an explicit guard), and their realpath aliases. A collision throws before any write, so the original bytes always survive. An existing target carrying the bundle marker (# mex scaffold export) is treated as a previous output rather than project state: it is excluded from its own inputs and may be overwritten, which also fixes the repeat-export duplication (second run is now byte-identical to the first).

Tests in test/export.test.ts prove original bytes survive for all three reported cases: --out .mex/ROUTER.md, --out .mex/config.json, and a symlink alias. One environment note: the file-symlink fixture itself needs symlink privilege, so it errors with EPERM on my Windows box; the same alias-detection path is additionally covered by a directory-junction variant that runs everywhere, and the symlink case will run on Linux CI. The architecture test (pinned writers, guard table) still passes since no write call sites were added.

Comment thread src/export.ts
Comment on lines +29 to +33
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");

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[P2] Bound scaffold reads before retaining the full export

findScaffoldFiles provides discovery and deduplication, not file-count or byte limits. This loop reads every file into memory, retains the whole corpus, then allocates the joined document before either output mode can write anything. Large scaffold content can therefore terminate Node instead of producing a controlled error. In a deliberately constrained 64 MiB V8-heap diagnostic, the small fixture passed but two 40 MiB source files caused a fatal heap error during ReadFileUtf8; this is not a claim about Node's default heap threshold. MEX's bounded-input/output and retained-state rules require explicit file/count/aggregate limits checked before allocation, with a clear refusal and boundary tests. If larger exports are supported, use a bounded streaming design instead of retaining the full document.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 2abae42 with explicit limits checked before any content is retained: at most 1000 files, 1 MiB per file (via stat, never read first), and 8 MiB aggregate, each with a clear refusal error naming the limit.

Boundary coverage in test/export.test.ts: count 1000 passes while 1001 refuses (and writes nothing), per-file exactly 1 MiB passes while 1 MiB + 1 byte refuses, and aggregate exactly 8 MiB passes while 9 MiB refuses. Refusals are verified to leave no output file behind. I did not go the streaming route since the refusal path keeps the implementation (single joined document for both stdout and --out) intact; the limits sit far above any realistic hand-written scaffold while refusing far below heap-exhaustion territory.

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.
@abhinav-phi

Copy link
Copy Markdown
Contributor Author

@theDakshJaitly polite ping for re-review on feat/export-command at 2abae42. P1/P2 are addressed in 2abae42. CI triage for run 35329123117: all green except release-performance finalize (job 105553777521, 15s); release-performance-attempt-1 (105549104146, 8m7s) and attempt-2 (105551416158, 8m24s) both passed. Finalize failed on confirmed budget_exceeded (e.g. maintenanceMs small wiki_refresh 581ms vs 186 budget, small wiki_rebuild 1192ms vs 181, large wiki_rebuild 1749ms vs 617). I tried gh run rerun 35329123117 --failed but got Must have admin rights, so could you rerun the failed finalize or advise? Thanks!

@theDakshJaitly

Copy link
Copy Markdown
Collaborator

@theDakshJaitly polite ping for re-review on feat/export-command at 2abae42. P1/P2 are addressed in 2abae42. CI triage for run 35329123117: all green except release-performance finalize (job 105553777521, 15s); release-performance-attempt-1 (105549104146, 8m7s) and attempt-2 (105551416158, 8m24s) both passed. Finalize failed on confirmed budget_exceeded (e.g. maintenanceMs small wiki_refresh 581ms vs 186 budget, small wiki_rebuild 1192ms vs 181, large wiki_rebuild 1749ms vs 617). I tried gh run rerun 35329123117 --failed but got Must have admin rights, so could you rerun the failed finalize or advise? Thanks!

Alright yes I'll look into it asap and also re-review

@theDakshJaitly theDakshJaitly left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-reviewed 2abae42 on current main. The original Router/configuration/symlink cases now preserve their bytes, repeated exports no longer include themselves, and the original oversized fixture is refused cleanly. The 70 existing focused tests, production build, workspace typecheck, and diff check pass. Two remaining gaps are documented inline: other existing project state can still be overwritten, and the byte limits do not bound the actual reads.

CI follow-up: I inspected the retained reports from run 35329123117. Both green measurement jobs produced valid reports with confirmation_required; they did not pass the performance budgets. The final gate confirms five Wiki timing failures across distinct runners: small/medium Wiki refresh and rebuild, plus large Wiki rebuild. For example, small Wiki refresh measured 780.673 ms and 581.305 ms against a 186 ms budget. The finalizer only evaluates those saved reports, so rerunning that job alone would reuse the same failing evidence. After the code fixes, a meaningful retry needs fresh measurement jobs and a clean final gate on the final head. These reports alone do not establish that the export change caused the Wiki slowdown.

Comment thread src/export.ts
Comment on lines +171 to +175
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)) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[P1] Reject existing non-export destinations before writing

The new guard fixes the reported Router/configuration/symlink examples, but its protected set contains only discovered Markdown and config.json. Other existing project state still falls through to writeFileSync: the built CLI accepted --out .mex/events/decisions.jsonl, exited 0, and replaced valid event history with the bundle. An existing README was also replaced. A hard link at exports/scaffold.md to the Router bypassed the realpath comparisons and overwrote the Router itself. Please refuse existing non-export destinations and preserve MEX-owned state regardless of the scanner's file list, with creation/replacement that cannot silently truncate an alias or a changed target. Keep any intended prior-export replacement exception narrowly scoped. Add preservation tests for event history and hard-linked source files.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in b3c6de5 - any pre-existing path at --out that is not a previous export bundle (event history, README, hardlink/symlink/FIFO/dir) is now refused before anything is read or written. Verified with new regression tests (decisions-style state, README, hardlink-to-Router, directory) plus built-CLI probes with bytes intact. Please re-check when convenient.

Comment thread src/export.ts Outdated
Comment on lines +139 to +141
for (const file of files) {
const relativePath = toPosix(relative(config.scaffoldRoot, file));
bundle.push(`## ${relativePath}`, "", readFileSync(file, "utf-8").trimEnd(), "");

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[P2] Bound the actual file reads rather than only the earlier stat

The size preflight and this unrestricted read reopen each path separately. If a writer grows or replaces a file between them, the checked size no longer bounds the allocation. In a deterministic CLI probe, a preload hook grew a real source to 9 MiB immediately before this read, after the real stat calls returned; no stat result was falsified. Export still exited 0 and wrote a 9,437,267-byte bundle, exceeding both the 1 MiB per-file and 8 MiB aggregate limits. A FIFO at .mex/context/live.md also passes the size check with size 0 and hangs in the read; the probe required a timeout kill. Please reject non-regular inputs without blocking, read through a verified descriptor with actual byte ceilings and change checks, and accumulate actual bounded byte counts. Add coverage for source growth/replacement and non-regular inputs.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in b3c6de5 - the stat-then-read split is gone. Each file is opened O_NONBLOCK and read through its own descriptor with the per-file cap enforced on bytes actually transferred (fstat must report a regular file, so FIFOs are refused instead of hanging; the open pins the inode). Aggregate cap is enforced on actual bytes. New + existing bound tests pass (16/17 locally, the 1 failure being the pre-existing Windows symlink-privilege EPERM in old test setup). Please re-check when convenient.

@abhinav-phi

Copy link
Copy Markdown
Contributor Author

Addressed both follow-ups in b3c6de5, verified with unit tests (16/17 locally - the 1 failure is the pre-existing Windows file-symlink privilege EPERM in the old symlink-alias test setup, unrelated) plus built-CLI probes:

P1 (existing non-export destinations): the guard no longer protects only scaffold+config. Any pre-existing path at --out (event history, README, hardlink/symlink/FIFO/dir) is refused before anything is read or written, unless it carries the bundle marker (previous export). New tests: decisions.jsonl-style state, README, hardlink-to-Router (same inode, invisible to realpath), existing directory. Built-CLI probes confirmed: decisions.jsonl, README, and hardlink-to-Router all refused with original bytes intact; normal and repeat exports still work.

P2 (bound actual reads): removed the stat-then-read split. Each file is opened O_NONBLOCK and read through its own descriptor with the per-file cap enforced on bytes actually transferred (fstat must report a regular file, so FIFOs are refused instead of hanging; the open pins the inode, so post-discovery growth/replacement cannot escape the cap). Aggregate cap is enforced on actual bytes before the document is allocated.

Writes are atomic: new files use exclusive wx create (EEXIST becomes the same refusal), previous-bundle overwrites re-verify the marker on the write descriptor before truncating.

CI note: the failing release-performance Finalize only re-evaluates the saved Wiki-timing reports from run 35329123117 (small/medium refresh+rebuild, large rebuild over budget) - rerunning it alone reuses that evidence. After this fix lands, the measurement jobs need a fresh run plus a clean final gate on the final head; those Wiki numbers do not implicate the export change by themselves.

@theDakshJaitly please re-review when you get a chance. Thanks!

@abhinav-phi

Copy link
Copy Markdown
Contributor Author

CI update on 1632584 (run 35453570839): check (22+24), hub-browser, storage-portability (macos+windows), and both perf measurement attempts are all green - including the fixed architecture-inventory test. The only red is the release-performance Finalize gate, and only on Wiki/graph/browser budgets: medium wiki_rebuild 359ms vs 301, small wiki_refresh 288ms vs 186, small wiki_rebuild 434ms vs 181, graph_refresh RSS 481MB vs 470MB, browserHeap home 6.76MB vs 4.93MB (fresh measurements, both attempts). This change touches only src/export.ts + export/architecture tests, which have no path to the wiki-rebuild/graph/browser hot paths, so I do not see a mechanism by which it could move those numbers - and main-branch CI is green today. @theDakshJaitly - does this perf gate block merge of the export change, or can it be waived/tracked separately as a budgets-vs-runners issue? Happy to do anything that would actually implicate this diff.

@theDakshJaitly theDakshJaitly left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The earlier overwrite and input-read findings are now resolved in the regression probes. The 73 existing focused tests, production build, workspace typecheck, and diff check pass. Please fix the two remaining output-handling P2 issues below before approval: incomplete writes currently report success, and a FIFO destination can hang during the previous-bundle check.

On the performance question: the final report for run 35453570839 has one blocking violation, medium Wiki rebuild. The independent attempts measured 1393.517 ms and 359.254 ms against the 301 ms budget. The other raw Wiki/graph/browser crossings mentioned in the update are not additional final blocking violations. The green measurement jobs mean that reports were produced; the final gate is still failed. This evidence alone does not establish that export caused the slowdown, and no waiver is being made in this review. Rerunning only the finalizer would reuse the saved measurements; investigating runner variability needs fresh measurements and a comparable main baseline.

Comment thread src/export.ts
throw error;
}
try {
writeSync(fd, document, 0, "utf-8");

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[P2] Complete partial writes before reporting export success

This single writeSync call ignores the number of bytes actually written; overwritePreviousBundle has the same issue at line 149. A successful call can transfer only a prefix. With the real built CLI on macOS and a child-only RLIMIT_FSIZE=4096, a 20,083-byte export wrote just 4,096 bytes, exited 0, and printed Wrote 2 scaffold file(s) .... Both new-file creation and previous-bundle replacement reproduced this without mocking writes. Use a helper that completes descriptor writes, or loop over the remaining UTF-8 bytes and propagate any failure. Only report success after the entire document is written, and add partial-write coverage for both branches.

Comment thread src/export.ts
Comment on lines +60 to +62
fd = openSync(target, "r");
const prefix = Buffer.alloc(BUNDLE_MARKER.length);
const read = readSync(fd, prefix, 0, prefix.length, 0);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[P2] Refuse non-regular destinations without a blocking marker read

The new nonblocking/type checks protect scaffold inputs, but this destination probe still opens with blocking "r" before the existence refusal. With a real FIFO at scaffold.md, mex export --out scaffold.md waits for a writer in this call instead of refusing the destination; the built-CLI probe had to be killed after three seconds. Open the marker probe nonblocking and verify that its descriptor is a regular file before reading, and make the later previous-bundle descriptor check safe if the destination is replaced by a non-regular object. Add a bounded CLI test for a FIFO output path.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add mex export (bundle scaffold to a single Markdown file)

2 participants