Skip to content

fix(create): convert in preflight, so a document defect creates nothing - #134

Merged
willkg merged 6 commits into
mainfrom
create-preflight-conversion
Sep 6, 2026
Merged

fix(create): convert in preflight, so a document defect creates nothing#134
willkg merged 6 commits into
mainfrom
create-preflight-conversion

Conversation

@willkg

@willkg willkg commented Sep 6, 2026

Copy link
Copy Markdown
Member

Fixes #127.

The bug

create is three-phase: preflight validates every file (any failure aborts the batch), reserve creates a content-less page per file and writes its page_id into the frontmatter, publish converts the markdown and fills each page in. MdToConfluence was only called in publish, so a defect knowable purely from the files on disk surfaced after a page had already been created and the file edited.

Concretely, with #59's base-name attachment naming: doc.md referencing both arch/diagram.png and deploy/diagram.png wants one attachment named diagram.png for two assets, which the converter refuses. The author was left with a content-less page, a page_id they did not ask for, and a re-run that refused with "a page already exists at page_id" — recoverable only by deleting the page or hand-editing the frontmatter. markfluence check diagnoses this with no client and no network, which is what makes it worth fixing: everything the diagnosis needs is resolved by the end of preflight, and preflight simply was not asking.

The fix

Preflight converts each file and keeps nothing but the error, so the batch aborts with nothing created and nothing written.

Publish cannot reuse the result, which was #127's open question. Reserve seeds the batch's page ids into the shared link index in between (index.SetPage), and rewriteDocLink puts entry.PageID straight into the built URL — so a preflight conversion renders "link not resolved" for every in-set sibling link, and publishing that would reintroduce the ordering dependency _plans/026 removed. The cost is one extra local conversion per file, over bytes already in memory. Preflight's Broken/Warnings are discarded for the same reason.

The error survives the phase boundary, for a narrower reason than "the converter ignores the index". Whether renderImage runs at all does depend on it: renderLink skips a broken link's children, and Broken comes from index.FileExists. What holds is that reserve only ever calls SetPage, which writes idx.pages alone — nothing there can raise an error or change one's text — while FileExists/Anchor read idx.anchors, fixed at Build time and identical in both phases. A change making SetPage also mark a file as existing would break this, so it is pinned by a test rather than left to a comment.

The conversion runs last in resolveFile, after every server check, so the page_id-first error precedence is untouched. The cost, stated plainly: a file that cannot convert still makes those API calls before being told.

Reporting

Phase 1 now rejects a file for more than one reason, so the code travels on the failure struct instead of being hardcoded by abort(): a conversion failure reports CONVERT, the code it already reported from publish. validationFailure constructs the failures that have no error value behind them, so nothing sets code by hand — abortedResult emits whatever is there, and "" is not in the schema's enum. No schema change: the code enum is global and already contains both.

The abort line reads failed preflight rather than failed validation, since it now covers more than validation.

S7

New guarantee S7 no-partial-create, status Partial: a file that create fails to publish leaves no page behind. Partial rather than Holds, and the three residuals are named rather than glossed — a server or network failure while publishing; an unreadable attachment (SyncAttachments opens every asset to checksum and upload it, which the converter never does, so an image that Lstat'd fine can still fail); and an unwritable frontmatter file, since reserveOne writes after CreatePage and leaves a stub whose id is not persisted. Closing any of them would mean deleting a page, which S4 does not authorise on its own.

Behavior change

--dry-run on a defective file now aborts the batch instead of printing a per-file CONVERT failure. Forced by the design — a dry run has to predict a real run, and a real run aborts — but it means one bad file no longer previews the rest. Noted in the README, pointing at check for linting files independently.

Verification

make check passes. I disabled the preflight conversion and confirmed all three new cmd/create tests fail against the old behavior, with the bug visible in the output: bad.md got a page created and a page_id written into its frontmatter. The internal/convert invariant test asserts both halves — the error is identical across an unseeded and a seeded index, and the HTML differs — so it cannot pass vacuously.

A code review of the branch found three of my comments overstated what the change guarantees; those are corrected in the last two commits, and the S7 residuals above are the substance of it. It also found that a preflight *client.HTTPError still reports VALIDATION rather than AUTH — pre-existing, left alone here because it changes the code on failures this issue is not about, and filed as #133 with the rule to copy (cmd/fix's locateCode) and the trap to avoid (CodeFor alone answers NETWORK for any non-HTTPError).

Design and rationale: _plans/034_create-preflight-conversion.md.

The invariant create's preflight conversion will rest on. Its verdict is
only as good as phase 3's because neither NameCollisionError nor
goldmark's own failure reads the index -- and the page emphatically does
depend on it, which is why a preflight result cannot be reused.

Both halves are asserted, since "the errors match" proves nothing if the
seeding never mattered.
A conversion failure surfaced in the publish phase, after reserve had
already created a page and written its id into the file -- leaving a
content-less page, a page_id nobody asked for, and a re-run that failed
with "a page already exists at page_id" instead. Fixes #127.

Preflight converts every file and keeps nothing but the error. The result
cannot be reused by publish: reserve seeds the batch's page ids into the
shared link index in between, so an in-set link renders unresolved in
preflight and resolves in publish. The error is the same either way,
because no error path reads the index.

Phase 1 now rejects a file for more than one reason, so the code travels
on the failure instead of being hardcoded by abort(): a conversion
failure reports CONVERT, the code it already reported from publish. The
abort line says "failed preflight" rather than "failed validation" to
match.
The strongest safety property create has, written down now that #127
closed the part of it that was knowable from disk. Partial rather than
Holds: a publish-phase server or network failure still leaves the
reserved stub behind, which _plans/026 accepted deliberately, and
closing that would mean deleting a page -- a change S4 does not
authorise on its own.
- publishOne can fail locally, not only remotely: SyncAttachments opens
  every asset to checksum and upload it, which the converter never does.
  So can reserveOne's frontmatter write, which runs after CreatePage and
  leaves a stub whose id is *not* persisted -- the one case update
  cannot pick up. S7 now names all three residuals instead of one.
- "No error path reads the index" was stronger than what holds. Whether
  renderImage runs does depend on it (renderLink skips a broken link's
  children, and Broken comes from FileExists). What holds is that
  reserve only calls SetPage, which writes idx.pages alone, while
  FileExists/Anchor read idx.anchors, fixed at Build time.
- The end-to-end test's comment credited the fake's unexpected-request
  guard, which cannot fire: a revert fails at MdToConfluence, before
  SyncAttachments.

Also: validationFailure replaces the two bare failure literals so
nothing sets code by hand; --help says "checked" to match the abort
line; the README notes that one bad file aborts a --dry-run preview of
the whole batch.
_plans/034's Out of scope now cites #133, with the trap the fix has to
avoid: CodeFor alone answers NETWORK for any non-HTTPError.
@willkg
willkg merged commit 8536ac6 into main Sep 6, 2026
1 check passed
@willkg
willkg deleted the create-preflight-conversion branch September 6, 2026 22:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

create: a document defect can leave a stub page behind

1 participant