Skip to content

feat(check): add the check command - #119

Merged
willkg merged 14 commits into
mainfrom
check-subcommand
Aug 30, 2026
Merged

feat(check): add the check command#119
willkg merged 14 commits into
mainfrom
check-subcommand

Conversation

@willkg

@willkg willkg commented Aug 30, 2026

Copy link
Copy Markdown
Member

Fixes #42.

Summary

  • Adds markfluence check FILE...: validates markdown against the converter and frontmatter rules with no network access, no credentials, and no writes.
  • Upgrades the converter's link diagnostics to make check worth building: a doc-link that's missing entirely or escapes the documentation root is now Broken (replaces the published element, matching how a broken image already behaves); one that exists but isn't published yet, or whose #fragment matches no heading, warns instead of publishing silently. Every Broken/Warning message — images, links, table-cell colors — is now prefixed with its source line.
  • frontmatter.Parse now reports a clear error for an unterminated frontmatter block instead of silently misreading the whole file as body.
  • check --show-html prints the converted storage HTML (indented by nesting depth) and attachment list, for debugging what a file would publish without publishing it; the same payload is a debug field under --json.
  • Bumps R1 (report-unresolved-references) to Holds in docs/guarantees.md, scoped to the two reference kinds markfluence actually attempts to resolve.
  • Updates README and CLAUDE.md accordingly, and fixes two pieces of pre-existing documentation drift in CLAUDE.md noticed along the way.
  • Addressed a /code-review pass on this branch: fixed a docs overclaim, a redundant ok/status field, duplicated message templates, and a redundant regex evaluation; filed check warns on a same-page anchor in an unpublished file #118 for a pre-existing (not introduced here) limitation check now surfaces more loudly.
  • Ran a focused /security-review: no findings.

Test plan

  • make check (vet, fmt-check, test, build, lint) passes
  • New regression fixtures cover each link-severity bucket (not found, outside root, unpublished sibling, fragment miss) plus existing image/table cases with line numbers
  • cmd/check has its own end-to-end tests (clean/warnings/broken/failed statuses, multi-root batches, --show-html, --json schema conformance) and a static-import guard against ever pulling in internal/client
  • Manually exercised markfluence check against real fixtures, human and --json output, including --show-html

willkg added 14 commits August 30, 2026 11:16
Extract's leniency for a "---" that never closes -- falling back to "no
frontmatter, whole file is body" -- hid a common paste mistake completely,
including from every command that calls Parse (create surfaced it as a
confusing "missing title" error instead). Parse now reports
ErrUnterminatedFrontmatter; linkindex.Build skips the offending file the same
way it already skips an unreadable one, so one malformed sibling never blocks
checking or converting an unrelated file.
Index.FileExists answers "does this file exist under root" without regard to
whether it has a page_id yet -- reusing the anchors map, which Build already
populates (even to an empty map) for every walked .md file, unlike pages,
which only gets an entry once a file is published. This is what lets
convert's upcoming link-severity split tell "missing entirely" apart from
"exists but not published yet".
resolveDocKey now reports whether a link/anchor destination's resolved path
escapes the documentation root -- a lexical check, since the index build
side needs none (an escaping key can never be in it) but the query side had
no way to tell that guaranteed miss apart from a genuine "not found".

A link that escapes root is now Broken: rewriteDocLink reports "LINK BROKEN:
... (outside the documentation root)" and renderLink replaces the whole
element -- tags and visible text alike -- with that literal message, the
same way images.go already does for a missing image. This needed a small
per-node flag on storageRenderer (linkBrokenText) since goldmark still
invokes a container node's renderer on the matching leaving call regardless
of WalkSkipChildren on entering, and there is no "</a>" to write in that
case.
A doc link whose target genuinely doesn't exist under root now reports "LINK
BROKEN: ... (not found)" and replaces the published element the same way an
escaping or missing image already does, rather than the generic "link not
resolved" warning every unresolved link used to get regardless of why.
FileExists is what makes this distinguishable from "exists but has no
page_id yet", which stays a warning -- that's the normal state of every page
in a tree that hasn't been published, not a defect.

Updated the two existing regression fixtures whose "unknown" link pointed at
a genuinely nonexistent file, and added a new one (link-not-yet-published)
so the still-a-warning path -- an existing, unpublished sibling -- has
coverage of its own; nothing previously exercised it directly.
Both anchor branches in rewriteHref used to no-op silently on a miss -- a
same-page #fragment or a cross-file path.md#fragment that named no real
heading published with no word about it at all. Now both warn "anchor not
found: ...". The cross-file branch gates on FileExists so a target that
doesn't exist at all isn't double-reported: rewriteDocLink already covers
that case as Broken on its own.

Added a regression fixture covering both branches; nothing existing
exercised an anchor miss at all.
nodeLine walks a node to its first descendant *ast.Text (the same Segment
substrate nodeText already reads) and converts that byte offset to a
1-indexed line, since neither *ast.Link nor *ast.Image carries its own
position -- goldmark's parser never calls SetLines on either. Wired into
every existing message site (images.go's four Broken and two Warnings,
tables.go's cell-background warning) and the link sites added over the last
few commits, via a new storageRenderer.linePrefix helper.

lineOffset on storageRenderer corrects for the fact that goldmark parses
md.Body, which Extract has already stripped of its frontmatter block -- every
position it reports is relative to the body, not the file a reader would
open and count lines in. Without it, a document with frontmatter reports a
line number that is wrong by exactly the frontmatter's own line count.

Regenerated every regression golden this touches (every one of them, since
the change lands on pre-existing messages too, not just the ones from the
last few commits) and updated the README's example messages to match.
Closes #42. Validates one or more markdown FILEs against the converter and
frontmatter rules with no network access and no credentials: it never
imports internal/client (guarded by a static import check in
check_test.go), never writes to disk, and never contacts Confluence.

Frontmatter validation: an unparseable/unterminated block (via
frontmatter.Parse's new error), an invalid page_width, a present-but-
non-numeric page_id. Deliberately not checked: whether page_id/space/parent
are present at all -- check cannot know the caller's intended operation, and
a file legitimately has none of these before its first create.

Reuses update/create's project.Cache/linkindex.Cache pattern to build the
root/index pair MdToConfluence needs, against hardcoded baseURL/spaceKey
(the regression suite's own defaults) rather than flags, since neither
affects what Broken/Warnings report.

--show-html adds the converted storage HTML and attachment list to a
result that reached the converter: indented by nesting depth in human
output (a per-line indent, not a whitespace-normalizing reformat, since the
renderer already breaks lines at every structural boundary) and as a
`debug: {html, attachments} | null` field in --json, where html stays
compact/unindented to match what update/create would literally publish.

Schema: added "check" to the command enum, an if/then branch, and the
checkResult/checkSummary/checkAttachment $defs.
The dedicated diagnostic R1's own Partial note named as missing now exists:
a doc-link that's missing entirely or escapes the documentation root is
Broken and replaces the published element; one that exists but isn't
published yet, or whose fragment matches no heading, warns; every message
carries its source line; and check adds the "audit without publishing" half
with no network access or writes.

Scoped explicitly to the two reference kinds markfluence actually attempts
to resolve -- doc-links and images. A relative link to a non-.md, non-image
local file (e.g. a PDF) is never a resolution attempt at all, so it sits
outside R1's claim rather than inside it unmet.
New "Validating markdown locally" usage group, a ### check section (what it
checks, the Broken-fails/Warnings-don't rule, --show-html), and updates to
the --json section's status-verb and per-target notes plus a new bullet on
check's broken-status ok:false-with-no-error/code shape and the debug field.
CLAUDE.md gains a cmd/check/ bullet; the internal/convert writeup gains the
link-severity split, the linkBrokenText output-replacement mechanism, and
the nodeLine/linePrefix/lineOffset line-number machinery; internal/frontmatter's
writeup notes Parse's new error return. Also corrected two pieces of
pre-existing drift the same paragraph already carried: MdToConfluence's
signature was still shown pre-026 (missing root/index), and links.go's
description still referenced a "docKey" that no longer exists and claimed
unresolved-link failure is silent, which #42 is what makes untrue.
- docs/guarantees.md: R1's "every message carries the source line" overclaimed
  relative to nodeLine's own documented ok=false case (an empty link/image
  alt has no *ast.Text to walk to) -- scoped the wording to match.
- frontmatter.go: Parse and Extract each ran frontmatterRE separately; Parse
  now reuses the one match. Documented and pinned (a new test) the one
  known false positive this lexical check can't avoid -- a document opening
  with a bare "---" thematic break and nothing that closes it reads the same
  as unterminated frontmatter.
- cmd/check: checkResult.ok was a stored bool kept in sync with status by
  hand at four call sites; it's now computed from status, so there's one
  thing to get right instead of two that could silently disagree.
- internal/convert/links.go: the "anchor not found" and "LINK BROKEN: ...
  (reason)" messages were each built from an identical literal in two
  separate branches; extracted into warnAnchorNotFound/reportLinkBroken so a
  future reword can't drift between the two copies.
Neither check --help nor README.md said what the warning's TARGET actually
refers to, or named its second, easy-to-misread cause: a same-page anchor
(#heading) is internally treated as a link to the current file, so it hits
this exact warning too when the current file itself has no page_id yet --
reading as though the file names itself as an unresolved target, when it's
just this file before its first publish. Added to both places, and to the
"Links to sibling .md files" section's own bullet list.
@willkg
willkg merged commit 32f646c into main Aug 30, 2026
1 check passed
@willkg
willkg deleted the check-subcommand branch August 30, 2026 22:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add an offline check command (convert + validate a file, no network)

1 participant