Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/releases/1.0.0/readiness.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ Runtime: Node v24.16.0, npm 11.13.0, macOS 27.0 (26A428, arm64); Obsidian versio
| Gate | Status | Source commit / artifact SHA-256 | Evidence | Remaining action |
|---|---|---|---|---|
| Baseline | PASS | 2915687ebd656444054387fa474e0393bf420d7b | 2026-09-15 local run: `npm run check:version` ("Version metadata is consistent: 0.7.4"), `npm run lint:obsidian-warnings`, `npm run build`, `npm test` (20 files, 360 tests passed) — all exit 0. Remote read-only: 0 open issues, 0 open PRs; release 0.7.4 (published 2026-08-26) with `main.js`, `manifest.json`, `styles.css`; latest `verify` runs green including HEAD 2915687. No vault plugin symlink present, so builds cannot update a live plugin. | T0 complete |
| Output integrity | NOT RUN | Unmeasured | No run recorded | Execute T1-T2 |
| Output integrity | PASS | this branch's `fix: preserve existing export documents and assets` commit | T1 reproduced the overwrite corruption as required (expected `[1]`, received `[2]` in `src/export/ExportIntegrity.test.ts`). T2 added directory isolation, exclusive writes (`wx` external / create-only vault), report-name protection and a 10-case regression matrix. 2026-09-16: five export suites 145/145, full suite 378/378, lint and build exit 0. | T6 reruns the two-run case through the native export dialog |
| Outcomes | NOT RUN | Unmeasured | No run recorded | Execute T3-T4 |
| Headless artifacts | NOT RUN | Unmeasured | No run recorded | Execute T5 |
| Native artifacts | NOT RUN | Unmeasured | No run recorded | Execute T6 |
Expand Down
4 changes: 2 additions & 2 deletions docs/superpowers/plans/2026-09-12-1.0.0-release-readiness.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,8 @@ Alternatives intentionally rejected: pre-scanning every renderer to predict exac
## Checklist and dependency order

- [x] T0 — Refresh baseline and create evidence record.
- [ ] T1 — Add persistent in-memory vault fixture and reproduce output corruption.
- [ ] T2 — Implement directory isolation, exclusive writes and report-name protection.
- [x] T1 — Add persistent in-memory vault fixture and reproduce output corruption.
- [x] T2 — Implement directory isolation, exclusive writes and report-name protection.
- [ ] T3 — Define structured outcomes and preserve partial results.
- [ ] T4 — Present accurate completion/cancellation/failure messages.
- [ ] T5 — Add reproducible artifact fixtures and automated contract checks.
Expand Down
2 changes: 2 additions & 0 deletions eslint.config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ export default tseslint.config(
// Tests hand-polyfill Obsidian DOM globals in jsdom; the rule's
// activeWindow.createDiv() suggestion does not type-check there.
"obsidianmd/prefer-create-el": "off",
// Tests run under vitest in Node and are never bundled for mobile.
"obsidianmd/no-nodejs-modules": "off",
},
},
globalIgnores([
Expand Down
232 changes: 232 additions & 0 deletions src/export/ExportIntegrity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
import { describe, expect, it, vi, afterEach } from "vitest";
import { ExportRunner } from "@/export/ExportRunner";
import { ExportPlanBuilder } from "@/export/ExportPlan";
import { OutputWriter } from "@/export/OutputWriter";
import { DEFAULT_SETTINGS, ExportSettings } from "@/types";
import { createMemoryVault } from "@/test-support/memory-vault";

// Real runner, collector, rewriter and writer with a persistent in-memory
// vault: writes survive across runs so cross-run corruption is observable.
// Only timestampSuffix is stubbed (for deterministic relocation paths); no
// write method is ever mocked here.
const TIMESTAMP = "2026-09-16T00-00-00";

function integritySettings(overrides: Partial<ExportSettings> = {}): ExportSettings {
return {
...DEFAULT_SETTINGS,
expandEmbeds: false,
copyAttachments: true,
overwriteExisting: false,
...overrides,
};
}

function singleFilePlan(
fixture: ReturnType<typeof createMemoryVault>,
path: string,
name: string,
) {
return new ExportPlanBuilder(
fixture.app, { type: "current-file", path },
"markdown-bundle", "exports", name,
).setInputFiles([path]).build();
}

afterEach(() => {
vi.restoreAllMocks();
});

describe("export integrity", () => {
it("preserves earlier attachments across sequential single-note exports", async () => {
const fixture = createMemoryVault();
fixture.putText("a/A.md", "![[a/img.png]]");
fixture.putText("b/B.md", "![[b/img.png]]");
fixture.putBinary("a/img.png", new Uint8Array([1]));
fixture.putBinary("b/img.png", new Uint8Array([2]));
const settings = integritySettings();
const run = (path: string, name: string) =>
new ExportRunner(fixture.app).run(singleFilePlan(fixture, path, name), settings);
const first = await run("a/A.md", "A");
const original = fixture.text("exports/A.md");
const second = await run("b/B.md", "B");
expect(first.success).toBe(true);
expect(second.success).toBe(true);
expect(fixture.text("exports/A.md")).toBe(original);
expect(Array.from(fixture.bytes("exports/assets/img.png"))).toEqual([1]);
expect(second.outputRoot).not.toBe(first.outputRoot);
expect(Array.from(fixture.bytes(`${second.outputRoot}/assets/img.png`))).toEqual([2]);
expect(fixture.text(`${second.outputRoot}/B.md`)).toContain("assets/img.png");
});

it("relocates when the single-file output root is an existing empty directory", async () => {
vi.spyOn(OutputWriter.prototype, "timestampSuffix").mockReturnValue(TIMESTAMP);
const fixture = createMemoryVault();
fixture.putText("a/A.md", "content");
await fixture.app.vault.createFolder("exports");

const result = await new ExportRunner(fixture.app)
.run(singleFilePlan(fixture, "a/A.md", "A"), integritySettings());

expect(result.success).toBe(true);
expect(result.outputRoot).toBe(`exports-${TIMESTAMP}`);
expect(fixture.text(`exports-${TIMESTAMP}/A.md`)).toContain("A");
expect(fixture.paths()).not.toContain("exports/A.md");
});

it("relocates past occupied root and existing timestamp/suffix candidates without modifying them", async () => {
vi.spyOn(OutputWriter.prototype, "timestampSuffix").mockReturnValue(TIMESTAMP);
const fixture = createMemoryVault();
fixture.putText("a/A.md", "content");
fixture.putText("exports", "occupied file at root");
fixture.putText(`exports-${TIMESTAMP}`, "taken");
fixture.putText(`exports-${TIMESTAMP}-2`, "taken");

const result = await new ExportRunner(fixture.app)
.run(singleFilePlan(fixture, "a/A.md", "A"), integritySettings());

expect(result.success).toBe(true);
expect(result.outputRoot).toBe(`exports-${TIMESTAMP}-3`);
expect(fixture.text(`exports-${TIMESTAMP}-3/A.md`)).toContain("A");
expect(fixture.text("exports")).toBe("occupied file at root");
expect(fixture.text(`exports-${TIMESTAMP}`)).toBe("taken");
expect(fixture.text(`exports-${TIMESTAMP}-2`)).toBe("taken");
});

it("keeps primaries, links, assets and report on the relocated batch leaf", async () => {
vi.spyOn(OutputWriter.prototype, "timestampSuffix").mockReturnValue(TIMESTAMP);
const fixture = createMemoryVault();
fixture.putText("notes/A.md", "![[notes/img.png]] See [[B]] and [[missing]]");
fixture.putText("notes/B.md", "B content");
fixture.putBinary("notes/img.png", new Uint8Array([7]));
await fixture.app.vault.createFolder("exports/notes");

const plan = new ExportPlanBuilder(
fixture.app,
{ type: "folder", path: "notes", recursive: true },
"markdown-bundle", "exports", "index", "notes",
).setInputFiles(["notes/A.md", "notes/B.md"]).build();
const result = await new ExportRunner(fixture.app).run(plan, integritySettings());

const leaf = `exports/notes-${TIMESTAMP}`;
expect(result.success).toBe(true);
expect(fixture.text(`${leaf}/A.md`)).toContain("[B](B.md)");
expect(fixture.text(`${leaf}/A.md`)).toContain("assets/img.png");
expect(fixture.text(`${leaf}/B.md`)).toContain("B content");
expect(Array.from(fixture.bytes(`${leaf}/assets/img.png`))).toEqual([7]);
expect(fixture.text(`${leaf}/export-report.md`)).toContain("Unresolved link: missing");
expect(fixture.paths()).not.toContain("exports/notes/A.md");
});

it("keeps a primary named export-report.md and moves the report to a fresh name", async () => {
const fixture = createMemoryVault();
fixture.putText("a/A.md", "Link to [[missing]]");

const result = await new ExportRunner(fixture.app)
.run(singleFilePlan(fixture, "a/A.md", "export-report"), integritySettings());

expect(result.success).toBe(true);
const primary = fixture.text("exports/export-report.md");
expect(primary).toContain("# A");
expect(primary).not.toContain("Unresolved link");
expect(fixture.text("exports/export-report-2.md")).toContain("Unresolved link: missing");
});

it("skips an existing export-report-2.md when allocating the report", async () => {
const fixture = createMemoryVault();
fixture.putText("a/A.md", "Link to [[missing]]");
fixture.putText("exports/export-report-2.md", "previous report");

const result = await new ExportRunner(fixture.app)
.run(
singleFilePlan(fixture, "a/A.md", "export-report"),
integritySettings({ overwriteExisting: true }),
);

expect(result.success).toBe(true);
expect(fixture.text("exports/export-report.md")).toContain("# A");
expect(fixture.text("exports/export-report-2.md")).toBe("previous report");
expect(fixture.text("exports/export-report-3.md")).toContain("Unresolved link: missing");
});

it("never overwrites a prior report even with overwrite enabled", async () => {
const fixture = createMemoryVault();
fixture.putText("a/A.md", "Link to [[missing]]");
fixture.putText("exports/export-report.md", "OLD REPORT");

const result = await new ExportRunner(fixture.app)
.run(
singleFilePlan(fixture, "a/A.md", "A"),
integritySettings({ overwriteExisting: true }),
);

expect(result.success).toBe(true);
expect(fixture.text("exports/export-report.md")).toBe("OLD REPORT");
expect(fixture.text("exports/export-report-2.md")).toContain("Unresolved link: missing");
});

it("refuses to modify a destination that appears after plan resolution", async () => {
const fixture = createMemoryVault();
fixture.putText("a/A.md", "content");

const result = await new ExportRunner(fixture.app).run(
singleFilePlan(fixture, "a/A.md", "A"),
integritySettings(),
{
onFileStart: () => {},
onFileComplete: () => {},
onPhase: (phase) => {
if (phase === "Assembling document") {
void fixture.app.vault.create("exports/A.md", "PRE-EXISTING");
}
},
},
);

expect(result.success).toBe(false);
expect(result.warnings[0]).toContain("Output already exists: exports/A.md");
expect(fixture.text("exports/A.md")).toBe("PRE-EXISTING");
});

it("still overwrites primary and attachment output when overwrite is enabled", async () => {
const fixture = createMemoryVault();
fixture.putText("a/A.md", "![[a/img.png]]");
fixture.putBinary("a/img.png", new Uint8Array([1]));
const settings = integritySettings({ overwriteExisting: true });
const runner = new ExportRunner(fixture.app);

await runner.run(singleFilePlan(fixture, "a/A.md", "A"), settings);
expect(Array.from(fixture.bytes("exports/assets/img.png"))).toEqual([1]);

fixture.remove("a/img.png");
fixture.putBinary("a/img.png", new Uint8Array([9]));
const second = await runner.run(singleFilePlan(fixture, "a/A.md", "A"), settings);

expect(second.success).toBe(true);
expect(second.outputRoot).toBe("exports");
expect(Array.from(fixture.bytes("exports/assets/img.png"))).toEqual([9]);
});

it("surfaces a missing attachment as a failed copy warning", async () => {
const fixture = createMemoryVault();
fixture.putText("a/A.md", "![[a/img.png]]");
fixture.putBinary("a/img.png", new Uint8Array([1]));

const result = await new ExportRunner(fixture.app).run(
singleFilePlan(fixture, "a/A.md", "A"),
integritySettings(),
{
onFileStart: () => {},
onFileComplete: () => {},
onPhase: (phase) => {
if (phase === "Copying attachments") {
fixture.remove("a/img.png");
}
},
},
);

expect(result.success).toBe(true);
expect(result.warnings).toContain("Failed to copy attachment: a/img.png");
expect(fixture.text("exports/export-report.md")).toContain("Failed to copy attachment");
});
});
13 changes: 9 additions & 4 deletions src/export/ExportRunner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -339,22 +339,27 @@ describe("ExportRunner", () => {
});

describe("output collisions", () => {
it("keeps the original root when the directory exists but the target file does not", async () => {
it("relocates when the single-file output root exists even if the target file does not", async () => {
const app = createPathAwareMockApp(["note.md"], ["exports"]);
const runner = new ExportRunner(app as never);
vi.spyOn(OutputWriter.prototype, "timestampSuffix").mockReturnValue("2026-07-29");
const writeSpy = vi.spyOn(OutputWriter.prototype, "writeText")
.mockResolvedValue(undefined);

const result = await runner.run(makePlan(["note.md"]), defaultSettings());

expect(result.outputRoot).toBe("exports");
expect(result.outputRoot).toBe("exports-2026-07-29");
expect(writeSpy).toHaveBeenCalledWith(
"exports-2026-07-29/note.md",
expect.any(String),
);
expect(writeSpy).not.toHaveBeenCalledWith(
"exports/note.md",
expect.any(String),
);
});

it("relocates the output file when the target already exists", async () => {
it("relocates when only the target file exists", async () => {
const app = createPathAwareMockApp(
["note.md"],
["exports"],
Expand Down Expand Up @@ -462,7 +467,7 @@ describe("ExportRunner", () => {
outputFiles: ["/tmp/exports/note.md"],
};
vi.spyOn(OutputWriter.prototype, "pathExists")
.mockImplementation((path) => path === "/tmp/exports/note.md");
.mockImplementation((path) => path === "/tmp/exports");
vi.spyOn(OutputWriter.prototype, "timestampSuffix").mockReturnValue("2026-07-29");
vi.spyOn(OutputWriter.prototype, "ensureFolder").mockResolvedValue(undefined);
const writeSpy = vi.spyOn(OutputWriter.prototype, "writeText")
Expand Down
49 changes: 39 additions & 10 deletions src/export/ExportRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ export class ExportRunner {
settings: ExportSettings,
callbacks?: ExportProgressCallbacks,
): Promise<ExportResult> {
const writer = new OutputWriter(this.app);
const writer = new OutputWriter(this.app, settings.overwriteExisting);
const allWarnings: string[] = [];
this.cancelled = false;

Expand Down Expand Up @@ -260,16 +260,23 @@ export class ExportRunner {
callbacks?.onFileComplete(i, files.length);
}

// Write export report
// Write export report. Reports never overwrite anything, including
// prior reports, even when the main export allows overwrite.
if (allWarnings.length > 0) {
const report = allWarnings
.map((w, i) => `${i + 1}. ${w}`)
.join("\n");

await writer.writeText(
`${assetsRoot}/export-report.md`,
`# Export Warnings\n\n${report}\n`,
);
const reportWriter = new OutputWriter(this.app, false);
try {
await reportWriter.writeText(
this.reportPath(assetsRoot, effectivePlan, reportWriter),
`# Export Warnings\n\n${report}\n`,
);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
allWarnings.push(`Could not write export report: ${msg}`);
}
}

return {
Expand Down Expand Up @@ -317,11 +324,12 @@ export class ExportRunner {
if (settings.overwriteExisting) return plan;

if (plan.source.type === "current-file") {
const targetPath = plan.outputFiles[0];
if (!targetPath || !writer.pathExists(targetPath)) return plan;
// Reuse the single-file output root only if nothing occupies it;
// an existing file or directory — even an empty one — relocates the
// whole export so previous outputs keep their attachments.
if (!writer.pathExists(plan.outputRoot)) return plan;
const candidateRoot = this.nextAvailablePath(
writer.timestampedFolder(plan.outputRoot),
writer,
writer.timestampedFolder(plan.outputRoot), writer,
);
return relocatePlan(plan, candidateRoot);
}
Expand Down Expand Up @@ -351,4 +359,25 @@ export class ExportRunner {
}
return available;
}

// Pick a report path that cannot collide with any planned primary output
// or an existing file/directory. Case-insensitive reservation is
// deliberately conservative for case-insensitive filesystems.
private reportPath(root: string, plan: ExportPlan, writer: OutputWriter): string {
const reserved = new Set(plan.outputFiles.map((path) => path.toLowerCase()));
let sequence = 1;
let candidate = `${root}/export-report.md`;
const conflicts = (path: string) => {
const key = path.toLowerCase();
return writer.pathExists(path) || [...reserved].some(
(other) => other === key || other.startsWith(`${key}/`)
|| key.startsWith(`${other}/`),
);
};
while (conflicts(candidate)) {
sequence++;
candidate = `${root}/export-report-${sequence}.md`;
}
return candidate;
}
}
Loading