diff --git a/docs/superpowers/plans/2026-09-12-1.0.0-acceptance-protocol.md b/docs/superpowers/plans/2026-09-12-1.0.0-acceptance-protocol.md new file mode 100644 index 0000000..6f94e0f --- /dev/null +++ b/docs/superpowers/plans/2026-09-12-1.0.0-acceptance-protocol.md @@ -0,0 +1,426 @@ +# 1.0.0 Artifact, Compatibility and Publication Protocol + +This document is the executable acceptance companion to the [release readiness implementation plan](2026-09-12-1.0.0-release-readiness.md). It defines T5, T6, T9 and T10. Read the main plan's execution contract and authorization boundary first. + +All commands run from the repository root unless stated otherwise. Use Node 24. Generated fixtures and exported documents are synthetic; do not substitute private notes. Never mark a native check PASS based on a mock, a non-empty output buffer, or a success notification alone. + +## Evidence layout + +Create a new workspace for each candidate; the shell variable names below are task-specific: + +```bash +release_qa_dir=$(mktemp -d "${TMPDIR:-/tmp}/document-exporter-1.0-qa.XXXXXX") +mkdir -p "$release_qa_dir/logs" "$release_qa_dir/native" "$release_qa_dir/screenshots" +git rev-parse HEAD > "$release_qa_dir/source-commit.txt" +``` + +Record the actual absolute path in `docs/releases/1.0.0/readiness.md`. Do not commit the temporary directory. At handoff, keep the artifacts available until the release is verified; report any temporary-storage lifetime limitation. Store concise sanitized results and hashes in the committed evidence record, not just temporary links. + +Each acceptance row records: + +```json +{ + "case": "A01", + "status": "NOT RUN", + "sourceCommit": "unmeasured", + "bundleSha256": "unmeasured", + "os": "unmeasured", + "obsidianVersion": "unmeasured", + "readerVersion": "not used", + "fixture": "content.md", + "settings": { "expandEmbeds": true, "copyAttachments": true, "overwriteExisting": false }, + "artifactPaths": [], + "evidencePaths": [], + "observations": "No execution has been recorded.", + "nextAction": "Run the specified case." +} +``` + +Replace fields with measurements when executed. The initial values describe a real NOT RUN state, not completion evidence. For each FAIL, include expected versus actual and a minimal reproduction. For each BLOCKED, identify the device/application/access that is missing and exactly which rows depend on it. + +## T5.1 — Implement the deterministic fixture generator + +Create `scripts/create-release-fixtures.mjs` using this complete source. It uses only built-in Node modules. It refuses a non-empty destination, does not overwrite existing fixture files, and creates valid PNGs rather than using arbitrary bytes as images. + +```js +import fs from "node:fs"; +import path from "node:path"; +import { deflateSync } from "node:zlib"; +import { createHash } from "node:crypto"; + +const destination = process.argv[2]; +if (!destination || process.argv.length !== 3) { + throw new Error("Usage: node scripts/create-release-fixtures.mjs "); +} +const root = path.resolve(destination); +if (fs.existsSync(root) && (!fs.statSync(root).isDirectory() || fs.readdirSync(root).length)) { + throw new Error(`Refusing non-empty or non-directory destination: ${root}`); +} +fs.mkdirSync(root, { recursive: true }); +const manifest = []; +function put(name, data) { + const target = path.join(root, name); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, data, { flag: "wx" }); + manifest.push({ + path: name, + sha256: createHash("sha256").update(data).digest("hex"), + }); +} +function crc32(bytes) { + let crc = 0xffffffff; + for (const byte of bytes) { + crc ^= byte; + for (let bit = 0; bit < 8; bit++) { + crc = (crc >>> 1) ^ ((crc & 1) ? 0xedb88320 : 0); + } + } + return (crc ^ 0xffffffff) >>> 0; +} +function chunk(type, data) { + const label = Buffer.from(type, "ascii"); + const length = Buffer.alloc(4); + length.writeUInt32BE(data.length); + const checksum = Buffer.alloc(4); + checksum.writeUInt32BE(crc32(Buffer.concat([label, data]))); + return Buffer.concat([length, label, data, checksum]); +} +function png(width, height, rgb) { + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(width, 0); + ihdr.writeUInt32BE(height, 4); + ihdr[8] = 8; + ihdr[9] = 2; + const pixels = Buffer.alloc(height * (1 + width * 3)); + for (let y = 0; y < height; y++) { + const row = y * (1 + width * 3); + for (let x = 0; x < width; x++) { + const offset = row + 1 + x * 3; + pixels[offset] = rgb[0]; + pixels[offset + 1] = rgb[1]; + pixels[offset + 2] = rgb[2]; + } + } + return Buffer.concat([ + Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), + chunk("IHDR", ihdr), + chunk("IDAT", deflateSync(pixels)), + chunk("IEND", Buffer.alloc(0)), + ]); +} + +put("images/landscape.png", png(640, 240, [210, 30, 30])); +put("images/portrait.png", png(240, 640, [30, 30, 210])); +put("collision/a/img.png", png(160, 100, [210, 30, 30])); +put("collision/b/img.png", png(160, 100, [30, 30, 210])); +put("collision/a/A.md", "# Collision A\n\nRed image:\n\n![[img.png]]\n"); +put("collision/b/B.md", "# Collision B\n\nBlue image:\n\n![[img.png]]\n"); +put("content.md", [ + "---", "title: Release acceptance", "---", "# Release acceptance", "", + "BEGIN-CONTENT 中文导出 😀 café", "", + "## Heading two", "### Heading three", "#### Heading four", + "##### Heading five", "###### Heading six", "", + "**Bold** and *italic* and `inline code`.", "", + "1. Ordered one", "2. Ordered two", "", "- Bullet one", "- Bullet two", "", + "| Name | Value |", "| --- | --- |", "| Alpha | 123 |", "| 中文 | 456 |", "", + "```ts", "const sentinel = 'CODE-CONTENT';", "```", "", + "[External link](https://example.com/)", "", + "![Landscape](images/landscape.png)", "", + "![Portrait](images/portrait.png)", "", "END-CONTENT", "", +].join("\n")); +put("folder/index.md", "# Folder index\n\n[[nested/part]]\n\n![[nested/part]]\n\nEND-INDEX\n"); +put("folder/nested/part.md", "# Nested part\n\nEMBED-SENTINEL\n\n![Local](../../images/landscape.png)\n\n[[../index]]\n"); +put("folder/nested/third.md", "# Third\n\nTHIRD-SENTINEL\n\n[[part]]\n"); +put("heading-host.md", "# Heading host\n\nBefore\n\n![[heading-source#Wanted]]\n\nAfter\n"); +put("heading-source.md", "# Source\n\n## Wanted\n\nWANTED-SENTINEL\n\n## Excluded\n\nEXCLUDED-SENTINEL\n"); +put("adjacency.md", "![[images/landscape.png]]\n## After image\n\nAFTER-IMAGE-SENTINEL\n"); +put("export-report.md", "# Preserve this document\n\nREPORT-DOCUMENT-SENTINEL\n\n[[MissingReportTarget]]\n"); +put("failure/missing.md", "# Missing references\n\n![[NoSuchImage.png]]\n\n[[NoSuchNote]]\n"); +put("failure/cycle-a.md", "# Cycle A\n\n![[cycle-b]]\n"); +put("failure/cycle-b.md", "# Cycle B\n\n![[cycle-a]]\n"); +put("limitations.md", [ + "# Documented limitations", "", "![[heading-source#^absent-block]]", "", + "```dataview", 'LIST FROM "folder"', "```", "", + "- [ ] Task item", "", "> [!note] Callout", "> CALLOUT-SENTINEL", "", + "$$x^2 + y^2 = z^2$$", "", "```mermaid", "graph LR", "A-->B", "```", "", +].join("\n")); +put("long.md", "# Long document\n\n" + Array.from({ length: 120 }, (_, i) => + `## Section ${i + 1}\n\nPAGE-SENTINEL-${i + 1} 中文 long document.\n\n` + + "| Column A | Column B |\n| --- | --- |\n| Left | Right |\n\n" +).join("")); +for (let i = 1; i <= 501; i++) { + const name = String(i).padStart(3, "0"); + put(`bulk/note-${name}.md`, `# Bulk ${name}\n\nBULK-SENTINEL-${name}\n`); +} +fs.writeFileSync(path.join(root, "fixture-manifest.json"), JSON.stringify(manifest, null, 2) + "\n", { flag: "wx" }); +process.stdout.write(`Created ${manifest.length} synthetic fixture files in ${root}\n`); +``` + +Run and verify: + +```bash +node scripts/create-release-fixtures.mjs "$release_qa_dir/fixtures" +node scripts/create-release-fixtures.mjs "$release_qa_dir/fixtures" +``` + +The first run succeeds. The second MUST fail with a refusal and must not alter fixture hashes. This is the generator's negative check. It is not a test failure to bypass. Validate generated PNGs with an image reader before native acceptance. + +Do not export the entire fixture root as one normal-use case: `bulk/`, intentional failures and documented limitations are separate cases. For the same-name collision test, verify Obsidian resolves `img.png` to the file adjacent to each source note before exporting. + +## T5.2 — Headless contract suite + +Create `src/export/ReleaseArtifacts.test.ts`; extend test support only as required by these concrete cases. Normal `npm test` must exercise these assertions without persisting artifacts. + +Use the actual `ExportPlanBuilder`, `ExportRunner`, collector, rewriter, renderer and writer. A fixture memory vault may replace Obsidian's vault, but do not replace output functions with resolved success spies. For HTML, label results as basic/fallback renderer evidence. No PDF success claim is possible here. + +Required assertions: + +| Case | Exact checks | +|---|---| +| `content.md` → Markdown | BEGIN/END markers, table text, code text, local image references; exported image bytes equal source | +| `content.md` → HTML fallback | Parse with jsdom; no missing BEGIN/END markers; table rows have Alpha/123 and 中文/456; images resolve to produced assets | +| `content.md` → DOCX | ZIP entries readable; `word/document.xml` parses as XML without parsererror; all text markers and table cells present; image relationship targets exist; hyperlink target is https://example.com/ | +| `content.md` → EPUB | container points to package; package/nav/chapter XHTML parse without errors; spine targets resolve; markers present; packaged image bytes equal source; no app:// references | +| `folder/` → Markdown and HTML batch | All three primary files exist under preserved nested paths; relative local links resolve; shared attachment references point to actual bytes | +| Collision A followed by B | First document text and image hash unchanged; second document references the blue image in its actual output root | +| `export-report.md` → Markdown with warnings | Primary retains REPORT-DOCUMENT-SENTINEL; report is a distinct existing file | +| Missing attachment and cancellation injections | Structured result matches T3; no false completed result for failed required writes | + +DOCX/EPUB test packages can be read with the existing `src/formats/testZip.ts`. If a binary extractor is needed, factor its existing local-header walking into a byte-returning helper and preserve the existing text wrapper; don't add a second ZIP implementation. For independent artifact acceptance, use a real ZIP/XML utility as described below, not only the project's own reader. + +Optional artifact persistence contract: + +```ts +const artifactRoot = process.env.RELEASE_ARTIFACT_DIR; +// Only this test file persists outputs. Create artifactRoot exclusively in +// beforeAll if absent; reject a non-empty existing directory. Write each +// validated artifact with exclusive-create semantics and an index.json. +// Ordinary CI without the variable does not write any artifacts to disk. +``` + +Persist one directory per case/format, preserving the whole output tree and its assets. The test index records original fixture identifiers, headless rendering path, settings, result status and output SHA-256. Tests may generate fixture notes in memory using the same marker text instead of invoking the 501-file generator on every CI run. + +```bash +RELEASE_ARTIFACT_DIR="$release_qa_dir/headless" npx vitest run src/export/ReleaseArtifacts.test.ts +``` + +Expected: all assertions pass; four supported headless formats have actual outputs and an index. PDF must be absent from this headless-success list. + +## T6.1 — Prepare native environments + +Use `/Users/Roger/my-vault` for authorized local Obsidian development/testing. Put synthetic input under a uniquely named folder such as `release-acceptance-1.0-`, and output in a separate uniquely named folder so folder export cannot accidentally include previous exports. Never overwrite unrelated vault notes or settings. For other hosts/devices use a dedicated test vault with the same synthetic files. + +Before altering the installed plugin: + +1. Determine the actual vault and plugin path through Obsidian configuration/UI. Do not assume the path in an old note is current. +2. Inspect whether `document-exporter` is a symlink. If it points to a different checkout, a local build is not an installed candidate. +3. Back up the plugin and `data.json` outside every vault's `plugins/` directory, preserving symlink information. Do not create a second same-id manifest within `plugins/`. +4. Disable the plugin while replacing assets. If its directory is a symlink, record and back up the link itself outside `plugins/`, then replace the link with an independent test directory at the same plugin path; do not write the test assets through a symlink into another checkout. Preserve the test settings separately, install the three built/downloaded assets in the independent directory, re-enable/reload, and verify the displayed version. At restoration, remove only the test-owned directory and restore the original link; verify its target is unchanged. For an ordinary existing directory, back it up outside `plugins/` and use the same independent-directory procedure. +5. Record SHA-256 for the actual installed files, OS, Obsidian version and installer/Electron details available in its debug information. + +Do not downgrade a user's active Obsidian installation or install additional applications without applicable authorization. An isolated historical install or another test machine is acceptable. When UI automation is available, use supported computer-use tools; otherwise describe precisely which environment is missing. Missing automation or hardware is BLOCKED, not a successful native test. + +## T6.2 — Native cases + +For each case, open the export dialog through the specified entry, select the input and output, review the summary, export, then inspect the actual result. Use default theme and plugin enabled for the baseline. Add custom-theme or plugin interaction checks only where a claimed capability or reproduced issue makes them relevant. + +| ID | Input / interaction | Settings and formats | Required outcome | +|---|---|---|---| +| A01 | Current `content.md`; editor context menu | Five formats; expand=true, copy=true, overwrite=false | Text/table/code markers survive; red landscape and blue portrait not swapped or distorted; DOCX/EPUB open; PDF readable | +| A02 | Right-click `folder/` | Five formats as batch | Three primary outputs; nested paths retained; Markdown/HTML inter-note links work; no promise of merged book/document | +| A03 | Ribbon/command → Selected files: index and third | Markdown, HTML, then one binary format | Exactly selected primary outputs; link to excluded part follows documented behavior; output summary correct | +| A04 | A then B then A again to identical destination | Markdown and HTML; overwrite=false | All earlier primary and attachment hashes unchanged; new run points to correct image; feedback identifies actual destination | +| A05 | Export A, change its synthetic image, re-export | Markdown; overwrite=true | Explicit overwrite works; source remains untouched; unrelated documents not deleted | +| A06 | `export-report.md`, then `failure/missing.md` | Markdown; copy=true | Primary sentinel preserved; distinct report; missing-reference diagnostics visible; no silently clobbered report | +| A07 | `heading-host.md`, `adjacency.md`, cycle-a | PDF and HTML; expansion/copy combinations true/true, true/false, false/true, false/false | Wanted heading only when expanded; image/header boundaries preserved; no infinite cycle; disabling copy does not remove local PDF image metadata | +| A08 | `bulk/` during export | Markdown and a native PDF batch; cancel once progress is visible | Cancelled message, accurate completed count; completed outputs kept; potentially incomplete outputs identified; retry with overwrite=false preserves prior results | +| A09 | `long.md` | PDF, DOCX | First/middle/last sentinel present; no blank-only result, clipped body text or overlapping rows; PDF pages inspected; long images/table pagination usable | +| A10 | Local synthetic remote-resource probe described below | HTML and PDF | Record actual loopback resource requests during rendering/opening; privacy wording matches behavior; local-only fixture needs no remote image request | +| A11 | `limitations.md` | Five formats | Record actual degradation for callouts, math, Mermaid, task lists, block refs and Dataview; preserve documented exclusions, do not silently promise native parity | +| A12 | `bulk/` 100 files, then 501 files | Markdown and HTML; one full run each | Correct file counts; UI remains usable; >500 warning observed; record elapsed time and memory if measurable; no arbitrary timing threshold | + +A07 is not a promise that unsupported block references work. The heading cache test must run in actual Obsidian. For cycle fixtures, an explicit warning or documented bounded fallback is expected; unbounded recursion/freeze is a failure. + +### A10: local network observation without external services + +Use a loopback-only server with a logging request handler. Create the following in `$release_qa_dir/remote-probe.cjs`, substituting no private content: + +```js +const http = require("node:http"); +const fs = require("node:fs"); +const png = fs.readFileSync(process.argv[2]); +http.createServer((request, response) => { + process.stdout.write(JSON.stringify({ method: request.method, url: request.url }) + "\n"); + response.writeHead(200, { "Content-Type": "image/png", "Cache-Control": "no-store" }); + response.end(png); +}).listen(0, "127.0.0.1", function () { + process.stdout.write(`PORT=${this.address().port}\n`); +}); +``` + +Run it in a managed terminal session: + +```bash +node "$release_qa_dir/remote-probe.cjs" "$release_qa_dir/fixtures/images/landscape.png" +``` + +Read the emitted port. Create a synthetic note with `![Probe](http://127.0.0.1:/probe.png?case=A10)` using the actual emitted port. Record requests separately when previewing, exporting, and opening exported HTML. Close/restart the server for each phase if necessary so old preview requests aren't mistaken for export traffic. Use a unique query parameter per phase to avoid cache ambiguity. End the managed server session after testing. + +One loopback request proves an unconditional "no network requests" promise is too broad. No loopback request does not prove no network access across every plugin/renderer; document only observed behavior and source-backed boundaries. Do not use an external tracking endpoint or private image URL. + +## T6.3 — Inspect actual documents + +**PDF:** generate through this plugin in native Obsidian, never through a separate HTML printer. Open in a PDF viewer, inspect every page of A01 and first/middle/last plus boundary pages of A09. Use PDF extraction/rendering tools available on the host to check page count and sentinel text; missing tools do not prevent a real viewer check but that limitation must be recorded. Capture representative screenshots, including any defect. A PDF byte-length threshold alone is insufficient. + +**DOCX:** inspect the ZIP with an independent tool; for example `unzip -t "$docx_file"` after assigning `docx_file` to an actual emitted DOCX path. Parse OOXML and verify relationships, then open in Word or LibreOffice. No repair/corrupt-file dialog is allowed. Record exact reader/version. Word-only compatibility claims require a Word run; LibreOffice alone supports only the tested-reader claim. Table and hyperlink verification must test content/function, not just XML element presence. + +**EPUB:** obtain the official EPUBCheck distribution and retain its adjacent `lib/` directory. Record checker version and Java runtime. Use the official command-line interface, including strict warning handling: + +```bash +java -version +java -jar "$EPUBCHECK_JAR" --version +java -jar "$EPUBCHECK_JAR" --failonwarnings --json "$epub_report" "$epub_file" +``` + +Set `EPUBCHECK_JAR` to the installed JAR path, `epub_file` to each actual emitted EPUB, and `epub_report` to a unique report under `$release_qa_dir/logs`. These are required tool/input paths, not assumed bundled dependencies. The official [EPUBCheck CLI documentation](https://www.w3.org/publishing/epubcheck/docs/cli/) documents `--failonwarnings` and JSON output. At execution time verify the official distribution/runtime requirements before installing. Do not remove `--failonwarnings` to manufacture a pass. Then inspect navigation, images and chapter text in a real EPUB reader and record its version. + +**HTML/Markdown:** copy the entire output tree, including assets, to a new directory outside the vault. Open HTML in a real browser and inspect image loads; click relative links between exported notes. Parse local Markdown destinations and confirm target existence/content. Check no required local dependency points back into the original vault. External links remain links and need not be fetched. EPUB's omitted cross-note links are a separate documented limit, not a failed HTML test. + +## T6.4 — Minimum platform matrix + +This is a bounded matrix, not the Cartesian product of every OS, app version and format: + +| Environment | Required rows | +|---|---| +| Current macOS + current stable Obsidian | Full A01-A12, clean install and 0.7.4 upgrade | +| Desktop Obsidian 1.4.0 on one supported isolated OS | Enable plugin, open/save settings, A01 representative five formats, A04 | +| Current Windows + current stable Obsidian | Enable/settings, PDF A01/A09, system directory with spaces/non-ASCII, A04; ordinary drive path | +| Current Linux + current stable Obsidian | Enable/settings, PDF A01/A09, system directory export, A04 | +| Current iOS Obsidian | Enable/settings, A01 for DOCX/EPUB/Markdown/HTML, vault output accessible, PDF absent in dialog/settings | +| Current Android Obsidian | Same four-format and PDF-exclusion smoke as iOS | + +UNC paths have unit coverage; add an actual UNC case if the product explicitly documents network-share support, otherwise record it as not natively certified. Do not fabricate an available share. + +For each platform, test stored defaultProfile=pdf falls back on mobile, reload persists settings, and no desktop-only external-folder picker is offered on mobile. Verify the fallback display and the actual export format agree. + +Current stable versions are measured at execution time from installed apps/official sources, not hardcoded in this plan. Never install a beta merely to satisfy "current". If the 1.4.0 run fails because an API is unavailable, either add a focused compatibility fallback or propose a supported-minimum change with evidence. Platform removal or minimum-version increases require explicit decision, followed by docs/version-map updates and rerunning the revised matrix. + +## T9 — Installation and upgrade + +Use a dedicated test setup. Never keep a backup with the same plugin id inside `.obsidian/plugins/`. + +1. Download actual 0.7.4 assets into a fresh directory: + +```bash +mkdir -p "$release_qa_dir/release-0.7.4" +gh release download 0.7.4 --dir "$release_qa_dir/release-0.7.4" --pattern main.js --pattern manifest.json --pattern styles.css +``` + +2. Install those assets, enable the plugin and set a distinctive vault-relative output folder, HTML default, expand=false, copy=true, overwrite=false. Export the synthetic A image case and retain hashes of its source and output. Save the test `data.json` outside the plugin directory. +3. Disable the plugin, replace only the three assets with the final candidate, re-enable/reload, verify the version and settings persist. The version bump alone must not reset settings. +4. Export B to the same requested folder. A's previous output/image must stay unchanged. The new result reports its actual output folder. Run one PDF and one EPUB smoke on desktop; run the four non-PDF smoke cases on mobile as applicable. +5. In a separate clean test installation with no old data.json, verify defaults, commands, settings, export dialog and five-format desktop smoke. Default mobile export must be supported. +6. Compare all synthetic source hashes before/after. No source modification is allowed. Record installed asset hashes, original settings and observed settings; do not commit user data.json. +7. Restore the previously installed development plugin/setup when the tests end, unless the current session explicitly intends to keep the tested candidate installed. Record what was restored. Preserve failure artifacts until diagnosis is complete. + +When preparing 1.0.0 from earlier candidate code, follow the main plan's version bump and final gates. The earlier native run is valid only for identical executable code/assets; rerun affected cases if they differ. + +## T10 — PR, tag and shipped assets + +This section requires publication authorization in the execution session. If only local implementation was authorized, stop after the reviewed local candidate and report exactly that state; do not ask for redundant permission for already authorized actions. + +### PR and exact commit + +1. Create/push a branch and PR only if authorized. Use a prepared body file for `gh pr create --body-file ...`; describe behavior, tests, platform evidence and limitations in normal English engineering prose. +2. Resolve the actual PR number from tool output. Run `gh pr checks ` and inspect `gh pr view --json state,mergeable,headRefOid,url`. Treat pending/failed checks as not passed. Merge only when authorized and required checks pass. +3. After merge, preserve unrelated local changes and update clean main: + +```bash +git switch main +git fetch origin main --tags +git pull --ff-only origin main +git status --short --branch +git rev-parse HEAD +``` + +Compare HEAD with the merge commit and candidate implementation. If main contains new executable changes, rerun affected acceptance; do not blindly tag the newest main. Confirm tag `1.0.0` is absent locally and remotely: + +```bash +git tag --list 1.0.0 +git ls-remote --tags origin refs/tags/1.0.0 +``` + +If it already exists, inspect its target and release state; do not force-move or recreate it. An existing valid publication should be verified, not duplicated. + +4. On the verified main commit: + +```bash +RELEASE_TAG=1.0.0 npm run check:version +node --test scripts/check-version.test.mjs +npm run lint:obsidian-warnings +npm run build +npm test +git tag -a 1.0.0 -m "Release 1.0.0" +git push origin 1.0.0 +``` + +Do not use a `v` prefix. The existing workflow owns release creation; do not execute `gh release create` locally. + +### Workflow and artifacts + +5. Resolve the exact tag-triggered run: + +```bash +gh run list --workflow release.yml --limit 10 --json databaseId,headSha,status,conclusion,url +``` + +Match its `headSha` to the verified tag commit. Inspect logs on failure; do not create a manual substitute release. Wait for that run using `gh run watch --exit-status` in a managed tool session, reporting meaningful progress without repeated unchanged polling. + +6. Inspect/download final assets: + +```bash +gh release view 1.0.0 --json tagName,isDraft,isPrerelease,publishedAt,url,assets +mkdir -p "$release_qa_dir/published-1.0.0" +gh release download 1.0.0 --dir "$release_qa_dir/published-1.0.0" --pattern main.js --pattern manifest.json --pattern styles.css +``` + +Expected: non-draft, non-prerelease, the three requested files uploaded, manifest version 1.0.0 and approved minimum version. Inspect actual content, not only API asset names. + +7. Compute hashes portably: + +```bash +node --input-type=module - "$release_qa_dir/published-1.0.0" <<'NODE' +import fs from "node:fs"; +import path from "node:path"; +import { createHash } from "node:crypto"; +const root = process.argv[2]; +for (const name of ["main.js", "manifest.json", "styles.css"]) { + const data = fs.readFileSync(path.join(root, name)); + console.log(name, data.length, createHash("sha256").update(data).digest("hex")); +} +const manifest = JSON.parse(fs.readFileSync(path.join(root, "manifest.json"), "utf8")); +if (manifest.version !== "1.0.0") throw new Error("Wrong published manifest version"); +NODE +``` + +Compare each hash with the installed/tested candidate and the GitHub digest when available. If build bytes differ, investigate toolchain/source differences and retest the published bundle; do not equate matching manifest versions with identical binaries. + +8. Verify provenance for each downloaded file with the available GitHub CLI: + +```bash +gh attestation verify "$release_qa_dir/published-1.0.0/main.js" --repo rogerdigital/document-exporter +gh attestation verify "$release_qa_dir/published-1.0.0/manifest.json" --repo rogerdigital/document-exporter +gh attestation verify "$release_qa_dir/published-1.0.0/styles.css" --repo rogerdigital/document-exporter +``` + +If the CLI lacks attestation support or access is blocked, report that evidence gate rather than pretending success. Inspect the available official CLI help before changing syntax. + +9. Repeat a clean install from the downloaded assets, plus A01 representative exports and A04. When published hashes match the fully tested candidate, the entire platform matrix need not be rerun; installation verification still does. If hashes differ, establish the cause and rerun affected rows before marking the release verified. +10. Update readiness with release URL, tag SHA, workflow URL, assets/hashes, provenance result and shipped install evidence. If a final evidence-only commit is needed after release, submit it through a separate documentation PR; do not move the release tag. +11. Perform branch cleanup only if authorized and only after merged state is proven. Verify remote absence with `git ls-remote --heads origin `; never delete unrelated/unmerged branches. Restore the intended local main/test-vault state. + +## Acceptance decision + +Before publication, all required integrity, outcomes, artifact, platform, documentation, workflow and upgrade rows must be PASS. A deferred feature listed in the main plan is not a blocker. A missing device is a blocked required row until tested or the user explicitly changes the support scope; it is not grounds for silently lowering the bar. + +After publication, three correct assets, successful tag workflow, provenance and installation from downloaded files are required to mark RELEASED AND VERIFIED. Stop cleanup and report a failed publication check with its next action if any fails. diff --git a/docs/superpowers/plans/2026-09-12-1.0.0-release-readiness.md b/docs/superpowers/plans/2026-09-12-1.0.0-release-readiness.md new file mode 100644 index 0000000..30a2c31 --- /dev/null +++ b/docs/superpowers/plans/2026-09-12-1.0.0-release-readiness.md @@ -0,0 +1,647 @@ +# 1.0.0 Release Readiness Implementation Plan + +> **For agentic workers:** Use `superpowers:executing-plans` to execute this plan task by task. Independent acceptance research may run in parallel; changes to the runner and writer must be integrated sequentially. Track work with the checkboxes below and preserve evidence for every completed gate. + +**Goal:** Release the existing five export formats as 1.0.0 after protecting previous exports, making incomplete outcomes explicit, and proving real artifact and platform compatibility. + +**Architecture:** Keep the existing resolver → plan → assembler → attachment collector → link rewriter → renderer → writer pipeline. Isolate exports when overwrite is disabled, enforce that policy at writes, and introduce structured run outcomes without a transaction engine. Keep automated contract tests separate from native-application acceptance. + +**Tech Stack:** TypeScript, Obsidian API, esbuild, Vitest, Node.js 24, GitHub Actions; real Obsidian/Electron and document readers for acceptance. + +--- + +## Execution contract + +This is the authoritative 1.0.0 work list. Historical unchecked plans are background, not additional scope. Read `CLAUDE.md` before execution. Paths below are repository-relative to `/Users/Roger/Code/personal/document-exporter`; resolve them against the actual checkout if using a worktree. + +The companion [acceptance protocol](2026-09-12-1.0.0-acceptance-protocol.md) defines the fixtures, environment matrix, evidence and publication steps. Both documents are required inputs. + +The request that created this document authorizes planning only. When later instructed to execute the plan, complete implementation and available validation without asking about routine choices. Make local logical commits when execution includes commit authorization. Push, PR creation, merge and release publication follow the authorization in that execution session; this document itself does not authorize external publication. Never publish with a required gate marked BLOCKED or FAIL. + +If a device, application, credential or approval is unavailable, mark only that dependent gate BLOCKED with the exact missing capability and next action. Continue independent work. Do not label the entire plan complete, change platform promises, or waive acceptance because access is missing. Do not claim that mocks prove native rendering. Do not remove or disable functionality merely to pass acceptance. + +Use English for code, test descriptions, commits, PRs and release notes. Use conventional commit messages without attribution trailers. Do not commit private vault content, credentials, generated bundles or large acceptance outputs. + +## Baseline and scope + +Audit snapshot: 2026-09-12, local `main` at `d5af272380f7c4bdf7c72869065f389701d7eb06`, version 0.7.4. Version check, lint, build and 360 tests passed. GitHub showed no open issues, successful verify/release runs and three uploaded 0.7.4 assets. Refresh all of this before execution; these are not permanent release guarantees. + +Confirmed blocker: two sequential single-note Markdown exports with overwrite disabled and different `img.png` sources both succeed; the second replaces the first export's `assets/img.png`. The source notes themselves are not changed in that reproduction. The current check only considers the primary output file (`ExportRunner.resolveEffectivePlan`), while the collector's name registry is per run and the writer modifies existing attachment files. + +Other confirmed behaviors: cancellation is encoded as a success boolean, previously collected warnings can be lost on early return, and cancellation after rendering can leave a primary document with incomplete attachments. Native artifact quality and the advertised platform range remain acceptance questions, not proven failures. + +In scope: output integrity, outcome reporting, regression fixtures, artifact/platform acceptance, truthful documentation, version/release checks and upgrade validation. + +Out of scope: new formats, Canvas, Dataview execution, block-reference expansion, mobile PDF, localization, templates, a CLI product, a generic transaction/rollback service, broad Markdown parser rewrites, dependency upgrades unrelated to a demonstrated blocker. EPUB's documented attachment/link limitations may remain. + +## Decisions fixed for implementation + +1. **Directory isolation:** with overwrite disabled, reuse a single-file output root only if it does not exist. An existing file or directory at that root relocates the whole export to the existing timestamp/suffix convention. This deliberately also relocates an empty existing directory. Batch export keeps its current target-leaf relocation policy. Explain the behavior in settings/help; do not add another setting. +2. **Write defense:** the runner passes the overwrite policy to `OutputWriter`. With overwrite disabled, no existing text or binary file may be modified, even if it appears after plan resolution. External writes use exclusive create; vault writes use create without the modify branch. A conflict produces an explicit error, not a silent overwrite. This is not a promise of cross-process atomic vault transactions. +3. **Report safety:** choose a report name that cannot collide with any planned primary output or an existing file/directory. A note named `export-report.md` must survive its own warnings report. Reports never overwrite prior reports, including with overwrite enabled. +4. **Outcomes:** distinguish `completed`, `partial`, `cancelled`, and `failed`. Count completed files only after their required writes succeed; retain warnings and list potentially incomplete primary paths. Preserve files on cancellation/failure and tell the user what remains. Do not delete uncertain or pre-existing output automatically. +5. **Scope of compatibility:** retain the current advertised minimum version and platforms until tested. A proven incompatibility gets a focused fix. Changing minimum supported version or dropping a platform requires a documented product decision; unavailable hardware alone is not evidence for such a change. + +Alternatives intentionally rejected: pre-scanning every renderer to predict exact output bytes/paths adds substantial coupling; treating each document and all attachments as a transaction requires rollback semantics for user files. Directory isolation plus write-time refusal solves the reproduced defect with a smaller surface. + +## Checklist and dependency order + +- [ ] 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. +- [ ] T3 — Define structured outcomes and preserve partial results. +- [ ] T4 — Present accurate completion/cancellation/failure messages. +- [ ] T5 — Add reproducible artifact fixtures and automated contract checks. +- [ ] T6 — Execute native artifact and compatibility acceptance. +- [ ] T7 — Align docs, settings and metadata with verified behavior. +- [ ] T8 — Strengthen version checks and tag release verification. +- [ ] T9 — Validate upgrade and the final 1.0.0 candidate. +- [ ] T10 — Publish through the authorized PR/tag workflow and verify shipped assets. + +Order: T0 → T1 → T2 → T3 → T4 → T5 → T6 → T7 → T9 → T10. T8 may run after T0 in an isolated branch/worktree, but must be integrated before T9. Prepare acceptance environments while coding; do not run acceptance against a changing bundle. Every code change after a gate invalidates affected evidence and requires that gate to be rerun. + +## T0 — Refresh baseline and create evidence record + +**Read:** `CLAUDE.md`, `package.json`, `manifest.json`, `versions.json`, `.github/workflows/{ci,release}.yml`, the two plan documents. + +**Create:** `docs/releases/1.0.0/readiness.md`. + +- [ ] Inspect state before any checkout, install or build: + +```bash +git status --short --branch +git rev-parse HEAD +git log -5 --oneline +node --version +npm --version +git remote -v +``` + +Do not discard existing changes. If checkout contains unrelated edits, use an isolated worktree following repository guidance. If working directly in a clean checkout, use a branch such as `fix/1.0-export-integrity`, not protected main. Confirm whether the test vault plugin is symlinked to this checkout before building: a build can update its live plugin. + +- [ ] Refresh remote state read-only when available: + +```bash +gh issue list --state open --limit 100 --json number,title,url +gh pr list --state open --json number,title,url,headRefName +gh release view --json tagName,publishedAt,url,assets +gh run list --limit 10 --json name,conclusion,headSha,url +``` + +Paginate if there are 100 issues; classify current issues against this plan. Reuse relevant existing work instead of overwriting it. Record network/authentication limitations without inventing remote results. + +- [ ] Run the baseline on the repository's Node 24 runtime. Use `npm ci` if the lockfile installation is not current, then: + +```bash +npm run check:version +npm run lint:obsidian-warnings +npm run build +npm test +``` + +Expected: all exit 0. A different test count after upstream changes is valid; record actual output, not an assumed 360. Diagnose failures before implementation. + +- [ ] Initialize the evidence record with this schema: + +```markdown +# 1.0.0 Readiness Record + +Release decision: NOT READY +Source commit: record the verified git rev-parse HEAD output +Runtime: record actual Node/npm/OS/Obsidian versions + +| Gate | Status | Source commit / artifact SHA-256 | Evidence | Remaining action | +|---|---|---|---|---| +| Baseline | NOT RUN | Unmeasured | No run recorded | Execute T0 | +| Output integrity | NOT RUN | Unmeasured | No run recorded | Execute T1-T2 | +| 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 | +| Platforms | NOT RUN | Unmeasured | No run recorded | Execute T6 | +| Documentation | NOT RUN | Unmeasured | No review recorded | Execute T7 | +| Release gate | NOT RUN | Unmeasured | No run recorded | Execute T8 | +| Upgrade / candidate | NOT RUN | Unmeasured | No run recorded | Execute T9 | +| Published assets | NOT RUN | Unmeasured | Not published | Execute T10 | +``` + +Replace observations as work occurs. Allowed states: NOT RUN, PASS, FAIL, BLOCKED. Each FAIL/BLOCKED row needs a concrete reproduction or missing capability and a next action. A link to a local artifact is evidence only while that artifact remains available; preserve checksums and sanitized summaries in the repository. + +## T1 — Persistent fixture and corruption regression + +**Create:** `src/test-support/memory-vault.ts`, `src/export/ExportIntegrity.test.ts`. + +**Read:** `src/__mocks__/obsidian.ts`, `ExportRunner.test.ts`, `OutputWriter.test.ts`, `ExportPlan.ts`. + +The existing runner mocks do not persist writes, so a spy-only test cannot detect corruption across two runs. Build one small reusable test adapter with real mock `TFile` instances, text/binary storage, and folder visibility. It is test support, not a production adapter. + +- [ ] Implement this fixture contract: + +```ts +import { TFile } from "obsidian"; + +export type StoredContent = string | ArrayBuffer; + +// Required return shape. The implementation uses Map +// plus Map; all created children update parent folders. +export interface MemoryVaultFixture { + app: import("obsidian").App; + putText(path: string, text: string): TFile; + putBinary(path: string, bytes: Uint8Array): TFile; + text(path: string): string; + bytes(path: string): Uint8Array; + paths(): string[]; +} +``` + +`createMemoryVault()` returns that shape. Required vault methods: `getAbstractFileByPath`, `read`, `readBinary`, `createFolder`, `create`, `modify`, `createBinary`, `modifyBinary`, `getMarkdownFiles`. Creation rejects existing paths. Modification rejects missing/wrong-kind paths. Clone binary input/output to prevent aliasing. Missing reads throw. Metadata `getFileCache` returns frontmatter/embeds/links for the fixture's literal wiki links; `getFirstLinkpathDest` resolves exact vault paths and source-relative paths. Use actual mock `TFile` construction because production format/writer code still uses `instanceof`. Keep parsing limited to explicit fixture syntax; this adapter is not a replacement for Obsidian metadata. For heading/block acceptance use native Obsidian rather than pretending to emulate its cache fully. + +- [ ] Add this failing integration test; it imports the real runner, collector, rewriter and writer, and must not mock their write methods: + +```ts +import { describe, expect, it } from "vitest"; +import { ExportRunner } from "@/export/ExportRunner"; +import { ExportPlanBuilder } from "@/export/ExportPlan"; +import { DEFAULT_SETTINGS } from "@/types"; +import { createMemoryVault } from "@/test-support/memory-vault"; + +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 = { + ...DEFAULT_SETTINGS, + expandEmbeds: false, + copyAttachments: true, + overwriteExisting: false, + }; + const run = (path: string, name: string) => { + const plan = new ExportPlanBuilder( + fixture.app, { type: "current-file", path }, + "markdown-bundle", "exports", name, + ).setInputFiles([path]).build(); + return new ExportRunner(fixture.app).run(plan, 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"); +}); +``` + +The single-byte attachments intentionally test byte preservation only. Real PNGs are required for artifact acceptance in T5/T6. + +- [ ] Run `npx vitest run src/export/ExportIntegrity.test.ts`. Before T2 the assertion must fail on `[2]` versus `[1]`, not on missing mock methods or imports. Record that failure. Do not leave the committed branch intentionally red; commit fixture plus the passing fix together in T2. + +## T2 — Protect all outputs + +**Modify:** `src/export/ExportRunner.ts`, `src/export/OutputWriter.ts`, `src/export/ExportRunner.test.ts`, `src/export/OutputWriter.test.ts`. + +**Extend:** `src/export/ExportIntegrity.test.ts`. + +- [ ] Add policy to the writer constructor while preserving defaults for direct callers: + +```ts +constructor(app: App, private readonly overwriteExisting = true) { + this.app = app; +} +``` + +The runner constructs `new OutputWriter(this.app, settings.overwriteExisting)`. Default true preserves existing direct-renderer and writer call behavior; all product export runs explicitly pass the user's setting. + +- [ ] Replace the single-file conflict predicate in `resolveEffectivePlan`: + +```ts +if (plan.source.type === "current-file") { + if (!writer.pathExists(plan.outputRoot)) return plan; + const candidateRoot = this.nextAvailablePath( + writer.timestampedFolder(plan.outputRoot), writer, + ); + return relocatePlan(plan, candidateRoot); +} +``` + +Keep `overwriteExisting` early return and batch relocation. Keep `relocatePlan` before construction of `outputPathMap`, attachment paths and link rewriting. Update the old test that explicitly requires reusing an existing directory: the new expected behavior is intentional. Update external mocks to model root existence, not only the primary file. + +- [ ] Apply the overwrite rule to all three writer paths. External text write uses: + +```ts +fs.writeFileSync(filePath, content, { + encoding: "utf-8", + flag: this.overwriteExisting ? "w" : "wx", +}); +``` + +External binary and attachment writes use the same flag with a Uint8Array. Vault `writeText`/`writeBinary` reject an existing destination when overwrite is false, then use `create`/`createBinary`; only overwrite=true can use modify. Route `copyBinaryFile` through `writeBinary` after reading its source to avoid a separate policy implementation. A missing or non-file source must throw `Attachment source not found: ` instead of silently succeeding. Update its existing skip test to require this error. + +- [ ] Add report-path allocation before writing a report. Keep a private helper on the runner; use the complete planned output list even when some files failed: + +```ts +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; +} +``` + +Use a separate `new OutputWriter(this.app, false)` for the report even if the main export allows overwrite. If a report destination appears after allocation, fail its exclusive write and surface the warning; never overwrite it or recursively try to report that failure. Case-insensitive reservation is deliberately conservative for case-insensitive filesystems. + +- [ ] Add byte/content assertions for this regression matrix: + +| Input / event | Required assertion | +|---|---| +| Two runs, different primary names, same attachment basename | Previous primary and image bytes unchanged; new root contains second image | +| Existing empty single-file root | Relocated according to the fixed policy | +| File at requested root; timestamp name and suffix already exist | A free suffix is chosen; no existing path modified | +| Batch selected files and folder source | Primary paths, local links, assets and report share relocated batch leaf | +| Primary output named `export-report.md` plus unresolved link warning | Primary content preserved; report has a nonconflicting suffix | +| File/folder named `export-report-2.md` | Allocation skips it | +| Existing report with overwrite enabled | Old report preserved; new report uses a fresh name | +| Destination appears after plan resolution | Writer refuses modification; original bytes preserved | +| overwrite=true, ordinary primary/image output | Explicit overwrite still works | +| Missing attachment between collection and copy | Error becomes a visible incomplete result in T3 | + +Use temporary directories plus `vi.resetModules`/`vi.stubGlobal("window", { require })` and dynamic imports for the external-fs tests: `OutputWriter` reads `window.require` at module initialization. Use `createRequire(import.meta.url)` from `node:module` for the test's require. Restore globals/modules and remove only that test's temporary directory in `finally`. Never test writes against actual user files. + +- [ ] Run: + +```bash +npx vitest run src/export/ExportIntegrity.test.ts src/export/OutputWriter.test.ts src/export/ExportRunner.test.ts src/export/ExportPlan.test.ts src/export/LinkRewriter.test.ts +npm run lint:obsidian-warnings +npm run build +``` + +Expected: green, reproduced overwrite gone, no link relocation regressions. Commit unit: `fix: preserve existing export documents and assets`. + +## T3 — Structured outcomes and incomplete-output accounting + +**Create:** `src/export/ExportOutcome.ts`, `src/export/ExportOutcome.test.ts`. + +**Modify:** `src/export/ExportRunner.ts`, `src/export/ExportRunner.test.ts`, `src/export/ExportIntegrity.test.ts`. + +- [ ] Define the result contract in `ExportOutcome.ts`, import it into the runner, and re-export `ExportResult` from the runner for existing imports: + +```ts +export type ExportStatus = "completed" | "partial" | "cancelled" | "failed"; + +export interface ExportResult { + status: ExportStatus; + success: boolean; // true only for completed; UI switches on status + outputRoot: string; // actual batch leaf or relocated single-file root + totalFiles: number; // original requested input count + completedFiles: number; // fully processed primary files + completedPaths: string[]; + incompletePaths: string[]; // outputs that may exist but are not complete + warnings: string[]; + errors: string[]; + reportPath?: string; +} + +export function resolveExportStatus( + cancelled: boolean, completed: number, total: number, hasFailure: boolean, +): ExportStatus { + if (cancelled) return "cancelled"; + if (completed === total && total > 0 && !hasFailure) return "completed"; + return completed > 0 ? "partial" : "failed"; +} +``` + +Warnings such as unsupported embeds or a rendering fallback are not automatically failures. Failed required copies/writes and missing requested inputs are failures. A file with a missing required copied attachment is not complete even when its primary exists. A renderer-reported limitation stays a warning unless a native acceptance test proves a promised required output is absent. Do not parse arbitrary warning prose to infer state. + +- [ ] Add the pure state table first: + +```ts +it.each([ + [false, 2, 2, false, "completed"], + [false, 1, 2, true, "partial"], + [false, 0, 2, true, "failed"], + [true, 0, 2, false, "cancelled"], + [true, 1, 2, false, "cancelled"], + [true, 2, 2, false, "cancelled"], + [false, 0, 0, false, "failed"], + [false, 2, 2, true, "partial"], +] as const)("resolves outcome %s/%i/%i/%s", (cancelled, done, total, failure, expected) => { + expect(resolveExportStatus(cancelled, done, total, failure)).toBe(expected); +}); +``` + +- [ ] Replace scattered early-return objects with one finalization path. Preserve the existing pipeline body; do not reorganize rendering modules. Use a run-local state: + +```ts +const completedPaths: string[] = []; +const incompletePaths = new Set(); +const errors: string[] = []; +let hasFailure = false; +let reportPath: string | undefined; +``` + +Precise transition rules: + +1. Preflight platform/external-path/no-input errors return `failed`, no outputs and no report folder creation. Count requested inputs using `plan.inputFiles.length`; do not silently remove a missing requested input and shrink total. Add an error naming each missing input, process the remaining valid files, and finish partial/failed as appropriate. +2. After `onFileStart` and every asynchronous pipeline stage, check cancellation before advancing. Cancellation breaks the loop and finalizes; it never overwrites prior diagnostics. +3. Immediately before invoking a renderer, add its primary path to `incompletePaths`. The renderer can fail after partially writing; do not claim that this path definitely exists. Store actual `assetsRoot` in result.outputRoot for batch results. +4. Catch assembly, collection, folder creation, rendering and writing errors inside the run. Preserve previous warnings and completed paths, record `Export failed for : `, then stop this run. Do not launch retries implicitly. +5. On required attachment-copy failure, add an error, retain this primary in `incompletePaths`, and continue other copies/files so usable output is preserved. Add a path to `copiedAttachments` only AFTER successful copy. A failed shared attachment must be retried for a later source that needs it, rather than incorrectly skipped as already copied. +6. Only after required writes succeed and cancellation has not interrupted the file, remove its primary from `incompletePaths`, append to `completedPaths`, and call `onFileComplete`. Preserve `onFileStart`'s original index; make its completion callback first argument the completed count minus one so the existing progress bar cannot imply all files succeeded after a skipped failure. +7. After cancellation/error/normal completion, write a nonconflicting report if diagnostics exist AND this run entered its output-writing stage AND its actual output directory exists. This includes overwrite=true runs that reuse an existing directory. Track entry into the output stage explicitly; preflight failures must not create report directories. Report status, requested/completed counts, completed paths, possibly incomplete paths, warnings and errors. Preserve all arrays; do not replace them with a lone cancellation warning. +8. Report write failure appends `Could not write export report: ` as a warning, leaves `reportPath` absent, and does not erase primary outcomes or recursively write another report. A successful document with only report failure can remain completed-with-warning. +9. Resolve status using the pure helper. `success` is exactly `status === "completed"`. Never infer completion merely from the presence of any output file. Never auto-delete outputs to make counts look correct. + +Final result construction is: + +```ts +const status = resolveExportStatus( + this.cancelled, completedPaths.length, plan.inputFiles.length, hasFailure, +); +return { + status, success: status === "completed", outputRoot: assetsRoot, + totalFiles: plan.inputFiles.length, + completedFiles: completedPaths.length, + completedPaths, incompletePaths: [...incompletePaths], + warnings: allWarnings, errors, + ...(reportPath ? { reportPath } : {}), +}; +``` + +- [ ] Add integration tests with deterministic callbacks/spies, not timers: + +| Injection | Expected result and preserved data | +|---|---| +| Cancel during first assembling phase | cancelled, completed=0, no primary write | +| Cancel in first `onFileComplete` of a three-file batch | cancelled, completed=1/3, first primary and diagnostics preserved | +| Cancel in attachment-copy phase after renderer wrote | cancelled, incomplete primary listed; no claim that it is fully exported | +| Throw reading the second source | partial, first completed path retained; both prior warnings and new error present | +| PDF renderer rejects | failed, completed=0; potential primary listed; no false success | +| One shared attachment copy fails once then succeeds | first primary incomplete; later file attempts copy again and may complete | +| One input disappears before run | total remains original count; missing input named in errors | +| Warning report write fails | successful documents retained; report failure visible in warnings | +| Only an unresolved-link warning | completed with warning, not failed | + +Example assertion after cancelling on the second copy phase: + +```ts +expect(result).toMatchObject({ status: "cancelled", success: false, completedFiles: 0 }); +expect(result.incompletePaths).toContain(expectedPrimaryPath); +expect(result.warnings).toContain(priorWarning); +expect(fixture.text(expectedPrimaryPath)).toContain("assets/"); +``` + +Here `expectedPrimaryPath` is the actual plan output path and `priorWarning` is the unresolved-link warning injected by that test fixture; define both within the test rather than relying on suite-global state. + +- [ ] Run `npx vitest run src/export/ExportOutcome.test.ts src/export/ExportRunner.test.ts src/export/ExportIntegrity.test.ts`, then build. Update previous cancellation tests to the new explicit contract instead of retaining obsolete success semantics. Commit unit: `fix: retain accurate partial and cancelled export results`. + +## T4 — Accurate user feedback + +**Create:** `src/ui/ExportResultMessage.ts`, `src/ui/ExportResultMessage.test.ts`. + +**Modify:** `src/main.ts`. + +- [ ] Extract the result-to-message mapping into a pure helper instead of adding a broad DOM test harness for `main.ts`: + +```ts +import type { ExportResult } from "@/export/ExportOutcome"; + +export function exportResultMessage(result: ExportResult): string { + const labels = { + completed: "Export complete", + partial: "Export partially complete", + cancelled: "Export cancelled", + failed: "Export failed", + } as const; + const pieces = [ + `${labels[result.status]}: ${result.completedFiles}/${result.totalFiles} file(s) complete`, + result.outputRoot, + ]; + if (result.incompletePaths.length) { + pieces.push(`${result.incompletePaths.length} output(s) may be incomplete`); + } + const firstDiagnostic = result.errors[0] ?? result.warnings[0]; + if (firstDiagnostic) pieces.push(firstDiagnostic); + if (result.reportPath) pieces.push(`Details: ${result.reportPath}`); + if (result.status !== "completed") { + pieces.push("Existing output was kept. Retry with overwrite off to create a separate export."); + } + return pieces.join(" — "); +} +``` + +Use `progress.finish(exportResultMessage(exportResult))` in `executeExport` in place of the boolean branch. Keep unexpected outer exceptions visible. Remove only imports rendered unused; `ProgressNotice` behavior need not change. + +- [ ] Test each status, all-zero cancelled case, partial with report, errors versus warnings, and the retry hint. Example: + +```ts +expect(exportResultMessage({ + status: "cancelled", success: false, outputRoot: "exports/notes", + totalFiles: 3, completedFiles: 1, completedPaths: ["exports/notes/a.md"], + incompletePaths: [], warnings: [], errors: [], +})).toContain("Export cancelled: 1/3 file(s) complete"); +``` + +Assert cancelled messages never begin `Export complete` or `Export failed`. Verify progress after one failed attachment does not reach 100% simply because the final input was visited. + +- [ ] Run `npx vitest run src/ui/ExportResultMessage.test.ts src/ui/ProgressNotice.test.ts src/export/ExportRunner.test.ts`, lint and build. Commit unit: `fix: distinguish export completion cancellation and failure`. + +## T5 — Reproducible artifacts and automated contract checks + +**Create:** `scripts/create-release-fixtures.mjs`, `src/export/ReleaseArtifacts.test.ts`. + +**Extend:** `src/test-support/memory-vault.ts` only for concrete fixture needs. + +Follow the full fixture specification and commands in the companion acceptance protocol. The fixture generator is explicit-path, refuses non-empty destinations, and creates only synthetic data. It is a developer utility, not a user-facing CLI feature. + +- [ ] Implement the generator exactly to the documented filenames/content requirements. Record source-image hashes; make A's and B's same-basename images visually and byte-wise different. +- [ ] Add a real pipeline suite for Markdown, HTML fallback, DOCX and EPUB; no mocked renderer/writer success. Use the persistent fixture for collision tests and real output buffers for package checks. +- [ ] Check actual Markdown/HTML link destinations and attachment bytes; parse DOCX/EPUB XML from generated ZIPs, assert XML validity, relationships, table cells, text and image references. Reuse `readStoredZipEntry` for stored ZIP text and the installed jsdom XML parser. Reject `` results. Do not accept "buffer nonempty" as a format assertion. +- [ ] Provide optional artifact persistence from this test file through `RELEASE_ARTIFACT_DIR`. Refuse to write outside the explicitly supplied new/empty directory. Keep the suite runnable with no environment variable and no filesystem artifacts in ordinary CI. Save relative filenames and a JSON index identifying `headless-fallback` rendering, settings, source case, warnings/errors and SHA-256 of each saved artifact. Do not persist PDFs from a mocked print pipeline. +- [ ] Run the exact generation/validation sequence in the acceptance protocol. Commit unit: `test: add reproducible release artifact coverage`. + +## T6 — Native artifacts and platform acceptance + +**Update:** `docs/releases/1.0.0/readiness.md` with evidence. + +- [ ] Execute the native artifact cases A01-A12 and platform matrix in the companion protocol against a fixed candidate build. +- [ ] Inspect content, image identity, link portability, pagination and reader compatibility. Record PASS only after the produced files are inspected. Compare original notes and exported contents, not only screenshots of an export completion notice. +- [ ] Run the two-run same-image-name case through the actual export dialog as well as automated tests. +- [ ] For any supported-case failure, save a minimal synthetic reproduction, add a failing targeted test where a stable boundary exists, implement the narrow fix, then rerun affected artifact and platform rows. Do not implement deferred features to resolve expected degradations. +- [ ] If a named format standard rejects an artifact, record the actual validator error and fix the producer. Do not suppress validator errors or switch to an unverified validator version solely for a green result. + +Commit unit for sanitized evidence: `docs: record release artifact and compatibility validation`. Record each separately discovered code fix in its own conventional commit with associated regression tests. + +## T7 — Align documentation and metadata + +**Modify:** `README.md`, `CLAUDE.md`, `manifest.json`, `package.json`, `src/settings/settings-tab.ts`. + +**Create:** `docs/releases/1.0.0/release-notes.md`. + +- [ ] Add EPUB to both package and plugin descriptions using this exact description: + +```text +Export notes, folders, and selected Markdown files to PDF, Word, EPUB, Markdown bundles, and HTML. +``` + +Update the architecture documentation: Markdown batch creates one file per note, HTML creates one HTML per note, and EPUB is a shipped renderer. Remove obsolete combined-only descriptions. + +- [ ] Replace the overwrite setting description and README explanation with: + +```text +Replace existing export files when enabled. When disabled and the destination already exists, the export uses a new timestamped folder for its documents, attachments and report. +``` + +Clarify that "destination" means the selected root for a single note and the batch leaf for folder/selected-file exports, including an existing empty destination. No source folder or previous export is moved; only the planned export destination changes. + +- [ ] Add a format capability table derived from A01-A12 evidence. Columns: format, rendering path, local images, non-image attachments, links, platform and verified limitations. State that native rendering is used for desktop PDF and in-app HTML, while DOCX/EPUB use format-specific/basic conversion. Do not promise exact visual parity. Preserve the existing excluded-feature list unless implementation evidence changes it. +- [ ] Verify external-resource behavior with A10 before changing the privacy paragraph. For the current architecture, use the following scoped text if the test confirms it: + +```text +Document Exporter does not upload notes or collect telemetry. Export processing runs locally. Notes and exported HTML may reference remote resources, such as images; Obsidian or the application opening the exported document may request those resources. Fully offline exports require local attachments. +``` + +This task changes documentation, not the network policy or the ability to display remote images. If actual behavior differs, state the verified behavior rather than adding an unsolicited blocking feature. + +- [ ] Write 1.0.0 notes covering output protection, explicit partial/cancelled results, supported formats/platforms, limitations and the destination-layout change. Include verified upgrade behavior. Do not claim new support or guaranteed compatibility without a PASS row. +- [ ] Run `git diff --check`, version check and lint (settings text is TypeScript). Review README examples against actual filenames produced by A01-A03. Commit unit: `docs: define the supported 1.0 export contract`. + +## T8 — Version consistency and release gates + +**Modify:** `scripts/check-version.mjs`, `.github/workflows/release.yml`, `CLAUDE.md` release guidance. + +**Create:** `scripts/check-version.test.mjs`. + +**Modify:** `.github/workflows/ci.yml` to run the script's regression tests. + +- [ ] Extend the existing version check to validate the lockfile and minimum-version mapping. Keep historical `versions.json` entries intact: + +```js +const lock = JSON.parse(fs.readFileSync("package-lock.json", "utf8")); +if (lock.version !== expected || lock.packages?.[""]?.version !== expected) { + errors.push(`package-lock.json root versions must equal ${expected}`); +} +if (versions[expected] !== manifest.minAppVersion) { + errors.push(`versions.json[${expected}] must equal manifest.minAppVersion`); +} +``` + +Place these checks before the existing `errors.length` exit. Preserve `RELEASE_TAG` validation. Do not impose a new version grammar that would break legitimate development snapshots. + +- [ ] Test the real script in a temporary working directory using Node's built-in test runner. Write minimal JSON fixtures and `spawnSync(process.execPath, [absoluteScriptPath], { cwd: fixtureDir, env })`; clone env and explicitly delete inherited RELEASE_TAG unless that case sets one. Assert exit code and error substrings for: valid metadata, package/manifest drift, missing versions entry, mismatched minimum version, lockfile root version drift, missing lockfile package root version and wrong release tag. Remove only fixture-owned temp directories in finally. Run: + +```bash +node --test scripts/check-version.test.mjs +npm run check:version +``` + +- [ ] Add `node --test scripts/check-version.test.mjs` to CI before `check:version`. In the release workflow, after `npm ci` and before attestation/publication, execute the same script test and the following full gate: + +```yaml + - name: Test version validation + run: node --test scripts/check-version.test.mjs + + - name: Verify version metadata + env: + RELEASE_TAG: ${{ github.ref_name }} + run: npm run check:version + + - name: Lint + run: npm run lint:obsidian-warnings + + - name: Build + run: npm run build + + - name: Test + run: npm test +``` + +Replace overlapping existing steps rather than duplicating them. Preserve attestation for all three files, Node 24 and tag-driven release creation. Keep native device tests out of ordinary headless CI; they are candidate acceptance evidence. + +- [ ] Ensure release creation uses the reviewed notes for 1.0.0. Keep future releases working by selecting `docs/releases//release-notes.md` when present and otherwise retaining `--generate-notes`. Do not create releases in local verification. The release shell fragment is: + +```bash +tag="${GITHUB_REF#refs/tags/}" +notes="docs/releases/$tag/release-notes.md" +if [ -f "$notes" ]; then + gh release create "$tag" --title="$tag" --notes-file "$notes" main.js manifest.json styles.css +else + gh release create "$tag" --title="$tag" --generate-notes main.js manifest.json styles.css +fi +``` + +- [ ] Locally run script tests, version check, lint, build and all tests; review YAML indentation and existing trigger/permissions. When a PR is authorized, require CI to verify the workflow changes. Commit unit: `ci: verify release metadata and tests before publication`. + +## T9 — Upgrade and final candidate + +**Modify late in this task:** `package.json`, `package-lock.json`, `manifest.json`, `versions.json`. + +**Update:** `docs/releases/1.0.0/readiness.md`, `docs/releases/1.0.0/release-notes.md`. + +- [ ] Complete T1-T8, including the initial candidate artifact/platform rows, before bumping the release version. T9's final 1.0.0 installation rows are executed after the bump and are not a prerequisite for the bump itself. If a required environment is unavailable, continue available tasks but do not produce a release-ready claim. +- [ ] Exercise both clean installation and upgrade from actual 0.7.4 assets using the companion procedure. Verify settings survive, no source notes change, and prior exports are protected. Do not mistake copying a development source tree for installing shipped assets. +- [ ] Bump to 1.0.0 without creating a tag: + +```bash +npm version 1.0.0 --no-git-tag-version +npm run check:version +git diff -- package.json package-lock.json manifest.json versions.json +``` + +`npm version` runs `version-bump.mjs` via the repository's version lifecycle, which also stages manifest/versions; inspect both staged and unstaged changes. If execution starts already at 1.0.0, verify consistency instead of rerunning the bump. Never remove historical version mappings. The 1.0.0 mapping must equal the actually approved `manifest.minAppVersion`. + +- [ ] Run the final gate with `RELEASE_TAG=1.0.0 npm run check:version`, script tests, lint, build and all tests. Verify an intentionally wrong tag fails without modifying files: + +```bash +RELEASE_TAG=wrong-tag npm run check:version +``` + +Expected: nonzero and a tag mismatch; record this as an intentional negative check, not a release failure. + +- [ ] Hash `main.js`, `manifest.json` and `styles.css`; repeat clean-install/upgrade smoke on this exact versioned build. If executable bytes changed since T6, rerun affected native tests. Record source commit plus bundle hashes; a later docs-only evidence commit can reference the prior executable commit without pretending it tested a future commit hash. +- [ ] Review all gate evidence. Set `Release decision: READY TO PUBLISH` only if T1-T9 required gates pass and there is no unresolved source-data corruption, output corruption, unreadable promised-format artifact, false completion or broken supported-platform installation. + +Commit unit: `chore: prepare version 1.0.0`. Use a release PR if publication is authorized; otherwise hand off the reviewed local change set and readiness report. Do not add new features during this candidate phase. + +## T10 — Authorized publication and shipped verification + +Execute only when publication is authorized and the pre-publication gates are PASS. Follow the precise branch/PR/tag/asset procedure in the companion protocol. + +- [ ] Merge through the protected-main PR workflow; inspect actual CI conclusion for the exact head. +- [ ] Tag the verified main commit as `1.0.0` and push the tag once. Never invoke local `gh release create`; CI owns release creation. +- [ ] Verify the release workflow, three asset names, version metadata and downloaded SHA-256 values against the tested candidate. Investigate differences; do not automatically treat them as harmless. +- [ ] Install the downloaded artifacts into the designated test setup, run the final smoke cases and verify attestation for each asset. +- [ ] Record release URL, source SHA, asset hashes and final install evidence. Only then mark `Release decision: RELEASED AND VERIFIED` and check T10. +- [ ] If asset verification fails after publication, report the concrete issue, stop cleanup and follow the approved patch-release response. Do not silently retag or replace the release with unrelated bytes. + +## Completion and handoff format + +Implementation-only completion and release completion are separate. End an execution report with: + +1. Tasks completed and exact commits. +2. Test commands and actual results, including expected negative checks. +3. Native/platform matrix PASS/FAIL/BLOCKED counts, with evidence location. +4. Remaining blockers and the exact next action; no generic "manual testing recommended" statement. +5. Publication state: not authorized / ready / published but verification pending / released and verified. + +This plan is complete when T0-T10 are checked with evidence. Code changes alone can satisfy T1-T5/T8, but cannot satisfy T6/T9/T10 without their environment and artifact checks. + +## Planning validation record + +On 2026-09-12, the two documents were checked for task coverage, internal links, balanced code fences and whitespace. The companion's fixture-generator code was extracted and executed in a new temporary directory: it created 520 synthetic files; all recorded hashes matched; PNG decompressed dimensions matched their headers; a second run correctly refused the non-empty destination without changing its manifest. JavaScript code blocks passed syntax checks. An execution review corrected report eligibility for reused output directories, the final-candidate prerequisite order and symlink-safe test installation. + +These checks validate the plan and fixture recipe only. No T0-T10 implementation, native compatibility run, version bump, commit, PR or publication was performed while writing this plan.