Skip to content

Commit c7421bf

Browse files
committed
fix(diff): seven from the code review, two of them wrong answers
A unified-diff label was recognised by its prefix, but a removed body line is "-" plus its content -- so "--json is a flag" arrives as "---json is a flag" and was read as a file label: left out of added/removed and coloured as one. Markdown thematic breaks and flag-heavy prose hit it constantly. A label is now recognised by position and nothing else (bodyLines), and the test that asserted the old behaviour is replaced rather than adjusted, since it is what let the collision through. labelDifference's warnings were appended only when it returned a row, and a labels: value markfluence refuses returns none -- so a file that cannot be published, whose labels also disagreed, reported nothing at all and exited 0. Warnings now travel whether or not there is a row, and labels.Declared's own case-repair warnings travel with them: a repaired label is compared as the repaired name, which otherwise looks like agreement with a file saying something else. Five smaller ones. With no markfluence.yaml anywhere, Discover falls back to the starting directory, so a root-relative label was the bare base name and dropped the docs/ a reader typed; reportPath gates on a real marker now. An empty page body is compared rather than refused -- a folder 404s before that point, so the reachable case is the empty page create leaves for a body-less file. The frontmatter heading no longer says "differs" on a run that exits 0 with only uncomparable rows. pagewidth.Read's explicit flag is reported, so an unset page is not quoted as having said "narrow". And CLAUDE.md said patch -p1 where the default direction needs -R.
1 parent c3a3320 commit c7421bf

6 files changed

Lines changed: 253 additions & 49 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ Module `github.com/mozilla/markfluence` (`go 1.25`). `main.go` is a shim to `cmd
6363
- `cmd/root.go` — the cobra root: `--url`/`--username`/`--debug`/`--no-color` persistent flags, version from `internal/buildinfo`, and registration of every subcommand. `Execute()` prints cobra-generated errors (bad args/flags) but not `ui.ErrSilent`, which marks a failure a command already reported.
6464
- `cmd/{update,create,check,diff,info,read,export,children,find,search}/` — one package per command (each exports `Cmd`), orchestrating the `internal` packages and `internal/ui` output. `create` is three-phase and transactional (preflight all, reserve parents-first in topological order, then publish); `check` is read-only on both the server and disk and touches neither, since it never constructs one (see its own bullet below). `create` accepts a **page or a folder** as `parent`: `checkParentInSpace` asks the page route, then `/folders/{id}`, and finding nothing as a page proves nothing until both have been asked — that single missing fallback was #68, since Confluence itself accepts a folder `parentId` with no other accommodation. It also validates a frontmatter `page_id` **first** (before space/parent/title lookups: most specific error, three fewer API calls) and treats all three outcomes as failures — non-numeric, resolves to nothing, or already taken. The middle one is the bug from #18: publishing anyway would create a second page and overwrite the id, so an id that can't be explained is never "create a new page". Both "already exists" errors (page_id taken, title clash) link the page in the way; the `pageIDFailure` typed error carries `page_id`/`url` so `--json` reports them as fields, which is the only case where a failed result names a page. Preflight also **converts every file and throws the page away**, keeping only the error (#127/S7): a defect the converter refuses is a property of the file on disk, so asking before the reserve phase is what keeps it from leaving a content-less page and a `page_id` the author has to undo by hand. The result cannot be reused by `publishOne` — reserve seeds the batch's ids into the shared link index in between, so an in-set link renders unresolved in preflight and resolves in publish, and phase 1's `Broken`/`Warnings` are discarded for that reason. The *error* is identical across the two, 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` is decided by `FileExists`), but reserve only calls `SetPage`, which writes `idx.pages` alone — nothing there can raise an error or change one's text, and `FileExists`/`Anchor` read `idx.anchors`, fixed at `Build` time. Making `SetPage` also mark a file as existing would break it; pinned by `TestErrorDoesNotDependOnTheIndex`. It is called **last**, after every server check, so `page_id`-first precedence is untouched, and its failure carries `CodeConvert` on the `failure` struct rather than `abort()`'s old hardcoded `VALIDATION`.
6565
- `cmd/check/` — `check` (#42): validate one or more markdown FILEs against the converter and frontmatter rules with **no network access, no credentials, and no writes** — the first command whose `run()` never constructs a `client.ConfluenceClient` (`root.go`'s `PersistentPreRunE` doesn't force one into existence either, so nothing upstream requires it). It builds `root`/`index` per file exactly like `update`/`create` (`internal/project.Cache`/`internal/linkindex.Cache`), against hardcoded `baseURL`/`spaceKey` (the regression suite's own `https://wiki.example.net`/`ENG`) rather than flags — both are read only to build a rewritten doc-link's *text*, and nothing in `Broken`/`Warnings` reads either, so hardcoding them costs nothing and makes `check` byte-identical across machines. Frontmatter validation is deliberately narrow: an unparseable/unterminated block (`frontmatter.ErrUnterminatedFrontmatter`, plus everything real YAML now refuses — a nested value, a `|` block, a duplicate key, a tab indent, a reserved indicator), an invalid `page_width` (`pagewidth.Declared`), a present-but-non-numeric `page_id` (`pageref.IsDigits`), and a **present-but-empty `title`** — never whether `page_id`/`space`/`parent` are set at all, since `check` cannot know whether the caller is about to `create` or `update`, and a false positive there is worse than a miss. `title` is the one exception to that reasoning and only in its present-but-empty form: `create` and `update` both reject it, so no verb makes it valid and there is no false positive to have. An *absent* title stays unreported, since `update` accepts one and keeps the live page's title. A `broken` result is `ok: false` with `error`/`code` both left `null`: unlike every other failure, `broken`/`warnings` already say everything there is to say, so `code: VALIDATION` is reserved for `status: failed` (a file that never reached the converter at all). `--show-html` surfaces `ConfluencePage.HTML`/`Attachments` — nothing else in the CLI ever prints either — as `debug: {html, attachments} | null`; `html` stays compact/unindented in `--json` (matching what `update`/`create` would literally publish) while human output indents it by nesting depth (`indentHTML`, a per-line indent based on tag-open/close counting, not a whitespace-normalizing reformat, since the renderer already breaks lines at every structural boundary and reformatting within a line could alter meaningful inline text).
66-
- `cmd/diff/` — `diff` (#154): what differs between one file and its page, fetching the page and rendering it to markdown the way `export` does. Writes nothing, to disk or to Confluence. **One file**, deliberately the opposite of `update`/`check` — a diff over a tree is output nobody reads, and #148's `status` is the tree-wide view. Four things decide its shape. **The output is two things on two streams**: stdout carries the body as a unified diff and *nothing else*, so `diff FILE > my.diff` is a patch `patch -p1` applies, while stderr carries the frontmatter half as a per-field report (`frontmatterReport`). The split is not presentational — a value difference is a one-line fact rather than a hunk, and only a report can carry the **provenance** (`pagemeta.Resolved.Origin`, via `sourceLabel`) that makes "the title differs" say which file to edit; keeping stdout to one document is `children --space`'s hint rule with a stronger case. **The frontmatter block is shared between the two diffed documents byte for byte** rather than diffed (`documents`), which is what makes hunk line numbers file-relative rather than body-relative (a `patch` "succeeded at 23 (offset 18 lines)" fudge otherwise), keeps the local side the file's *exact bytes* so a patch can apply to it — never normalized, so a missing final newline stays the `\ No newline at end of file` marker unified diff has for it — and means no patch can rewrite a `page_id`; the author's blank line after the delimiter belongs to that shared prefix, or a file with two diffs against a rendered side with one. **Only fields the file declares are compared**, because those are the ones publishing asserts (an absent `labels`/`page_width` leaves the page's alone per L9, an absent `title` keeps the live one), which is the single rule that removes most of what #154 accepted as unavoidable frontmatter noise; a page-side read that *fails* is reported `comparable: false` and does **not** count as a difference, since `read`/`export`'s omit-on-failure would here claim publishing adds a label that may already be there. `parent` is compared as a **resolved id**, not a spelling — a file from an export tree names its parent by relative `.md` path — and `page_width` consults the project file's own setting too, since that is a real declaration (#100) `update` acts on, labelled distinctly (`markfluence.yaml (project default)`) because it is a different place in the file than a `pages:` entry. And **exit codes are `diff(1)`'s**: `0` identical, `1` differs, `2` *any* trouble, including the operational failures every other command reports as `1`. A deliberate departure from `docs/json-output.md`'s contract, and the reason the command is worth having in CI; `--reverse` swaps the sides so the patch applies Confluence→file without `patch -R`. Colour goes through `internal/ui`'s `DiffAdded`/`DiffRemoved`/`DiffHunk` applied line by line to the library's own output (`renderDiff`) rather than re-rendering its structured hunks, since the `@@ -a,b +c,d` arithmetic is exactly what a library was chosen to get right; the styler is a parameter for `cmd/search`'s reason — lipgloss emits nothing when stdout is not a terminal, so a test wired to the real style proves nothing.
66+
- `cmd/diff/` — `diff` (#154): what differs between one file and its page, fetching the page and rendering it to markdown the way `export` does. Writes nothing, to disk or to Confluence. **One file**, deliberately the opposite of `update`/`check` — a diff over a tree is output nobody reads, and #148's `status` is the tree-wide view. Four things decide its shape. **The output is two things on two streams**: stdout carries the body as a unified diff and *nothing else*, so `diff FILE > my.diff` is a patch — applied with `patch -R -p1`, since the file on disk is the `+++` side, or a plain `patch -p1` from `diff --reverse` — while stderr carries the frontmatter half as a per-field report (`frontmatterReport`). The split is not presentational — a value difference is a one-line fact rather than a hunk, and only a report can carry the **provenance** (`pagemeta.Resolved.Origin`, via `sourceLabel`) that makes "the title differs" say which file to edit; keeping stdout to one document is `children --space`'s hint rule with a stronger case. **The frontmatter block is shared between the two diffed documents byte for byte** rather than diffed (`documents`), which is what makes hunk line numbers file-relative rather than body-relative (a `patch` "succeeded at 23 (offset 18 lines)" fudge otherwise), keeps the local side the file's *exact bytes* so a patch can apply to it — never normalized, so a missing final newline stays the `\ No newline at end of file` marker unified diff has for it — and means no patch can rewrite a `page_id`; the author's blank line after the delimiter belongs to that shared prefix, or a file with two diffs against a rendered side with one. A **label is recognised by its position and never by its prefix** (`bodyLines`/`headerLines`): a removed body line is `-` plus its content, so `--json is a flag` arrives as `---json is a flag` and a prefix test read it as a file label — dropping it from the counts and colouring it as one, which markdown thematic breaks and flag-heavy documentation hit constantly. An **empty page body is compared as an empty body** rather than refused the way `read` refuses one: a folder cannot reach that point (v2 answers a folder id with 404), so the reachable case is a genuinely empty page — what `create` leaves for a body-less file — and the whole local file is simply an addition. **Only fields the file declares are compared**, because those are the ones publishing asserts (an absent `labels`/`page_width` leaves the page's alone per L9, an absent `title` keeps the live one), which is the single rule that removes most of what #154 accepted as unavoidable frontmatter noise; a page-side read that *fails* is reported `comparable: false` and does **not** count as a difference, since `read`/`export`'s omit-on-failure would here claim publishing adds a label that may already be there. `parent` is compared as a **resolved id**, not a spelling — a file from an export tree names its parent by relative `.md` path — and `page_width` consults the project file's own setting too, since that is a real declaration (#100) `update` acts on, labelled distinctly (`markfluence.yaml (project default)`) because it is a different place in the file than a `pages:` entry. And **exit codes are `diff(1)`'s**: `0` identical, `1` differs, `2` *any* trouble, including the operational failures every other command reports as `1`. A deliberate departure from `docs/json-output.md`'s contract, and the reason the command is worth having in CI; `--reverse` swaps the sides so the patch applies Confluence→file without `patch -R`. The diff labels are root-relative, **except when no `markfluence.yaml` exists anywhere**: `project.Discover` then falls back to the starting directory, which here is the file's own, so a root-relative path would be the bare base name and drop the `docs/` a reader typed — `reportPath` gates on `root.File != ""` and uses the path as typed otherwise. Colour goes through `internal/ui`'s `DiffAdded`/`DiffRemoved`/`DiffHunk` applied line by line to the library's own output (`renderDiff`) rather than re-rendering its structured hunks, since the `@@ -a,b +c,d` arithmetic is exactly what a library was chosen to get right; the styler is a parameter for `cmd/search`'s reason — lipgloss emits nothing when stdout is not a terminal, so a test wired to the real style proves nothing.
6767
- `cmd/export/` — `export`: `pagedoc` for the body, `attachfile` for the attachments. **`--depth` exports a subtree** (`0` default / a number / `all`), **`--space KEY` a whole space** (requiring an explicit `--depth`, since the default would export nothing and defaulting to `all` would make a typo walk a whole space), and a **folder** may be the target — a folder and a space have no file of their own, so their children become the top level, which is why `layout`'s `rootRef` carries the id children hang off *separately* from whether anything is written for it: the walk's top-level nodes report a folder as their parent but report nothing for a space, and conflating the two placed every page at the destination root. `layout` owns every path an export writes (mirrored hierarchy: `<slug>.md` plus a `<slug>/` for children and unrecorded attachments, a folder as a bare directory) and the `-<id>` suffix for a group of siblings that slug the same — applied to *every* member so a filename never depends on walk order, and disambiguating rather than refusing because a space nobody can retitle would otherwise be unexportable over a punctuation variant (an exported filename is ergonomic; identity is `page_id`, per L8). `parent:` is a relative path to the parent's own `.md` so the tree publishes into fresh pages, except for the export root and a page whose parent is a folder, which keep an id. Two things the layout buys that are easy to miss: page directories are unique, which is what makes page-scoped attachment placement collision-free — so `pagedoc.Placement` must carry the *disambiguated* directory (`AttachmentDirFor`), or two colliding siblings silently share one attachment file, which no checksum catches because a native attachment has none. `destClaims` reserves every page's destination before any attachment is written, since a recorded `path=` is server data that can name a page's own file and a parent's attachments are written before its children exist — otherwise the attachment lands first and the page is reported `skipped (exists)`. A page already on disk skips its *render* but not its attachment pass, so a retry resumes a run that died mid-download. `markfluence.yaml` is planted at `dest` for a multi-page export **before the first page**, because a partial tree with no marker republishes every shared asset as `IMAGE BROKEN`. Markdown only. An attachment with a recorded `path=` lands there; one without is page-scoped. Still no `--attachments-dir`, but for a different reason than before the naming change: moving an asset no longer renames its attachment, so it is no longer unsafe — it would simply reintroduce the collision a base name has to refuse, since two pages' `diagram.png` cannot share a directory. Only referenced attachments are exported, found by scanning raw storage for `ri:filename` (not just `ac:image`, which is all the converter special-cases, so a link target or a macro-internal reference would otherwise be dropped). A reference with no attachment is a warning, not a failure.
6868
- `cmd/children/` — `children`: list the pages and folders under a page or folder, via `internal/pagetree`. `--depth` is a **string** vocabulary (a positive number or `all`, default `1`), not an int: `all` is not a number, and `0` is refused rather than read as "unlimited" the way it is elsewhere, because silently walking a whole space for someone who meant "none" is worse than an error that names `all`. Empty is a success: `No children.` and exit 0. **`--space KEY` lists a whole space instead of a page** (#98), which makes `PAGE` optional — exactly one of the two, checked before credentials. Depth 1 is then the space's **root pages**, not the homepage's children: a space can have several roots (`create` with a null parent makes one), so seeding the walk from `homepageId` would drop a root and its whole subtree, and there is no root-level *folder* to miss because a folder created with no parent lands under the homepage ([docs/confluence/spaces.md](docs/confluence/spaces.md)). A root row's `parent_id` is `null` — the one place `childrenResult` needs `stringOrNull` for it — since a space is not a node. The key is resolved through `ResolveSpaceID` before the walk even though the v1 route it feeds takes a key: an unknown key must fail as a typo (exit 2) the way it does for `find`/`search`, and the v1 route reports one as a 404, which is also what a rejected credential looks like. Because a space's top level is usually one row, human output adds a `--depth` reminder when `--depth` was left at its default — on **stderr**, via `ui.Hint`, so the table stays pipeable; `--json` never sees it. A failing space walk likewise reports an `errorObject` on stderr rather than a `results[0]` failure, since `SingleOpFailure.page_id` would otherwise carry a space key.
6969
- `cmd/find/` — `find`: resolve a title to the ids carrying it, via `client.FindByTitle`. A title is the one handle `internal/pageref` cannot resolve. It reports **current pages, archived pages, and folders**, which takes two requests because no single API sees all three — and the three-way split is the thing to keep straight before touching it ([docs/confluence/search.md](docs/confluence/search.md)). An **archived** page is reported, with a `status` column, because it is absent from the page tree yet still reserves its title; a **folder** is reported because a folder id is a legitimate `parent`, but a folder reserves nothing, so a folder row must never be treated as a naming conflict. `--space` is a space **key**, and an unknown one is a hard error rather than an empty result — CQL answers an unknown key with zero rows, which reads exactly like "no such page". Either half failing fails the whole command: a partial answer reads as "nothing found", and the caller's next move on that is to create a duplicate. Empty is a success: `No matches found.` and exit 0. Its operational failure is an `errorObject` on stderr rather than a `results[0]` entry — there is no page id to name — which it shares with `search` and with `children --space`, and nothing else.

0 commit comments

Comments
 (0)