linkrefs docs claim harness check reports dangling links, but no link check exists #195
Labels
No labels
bug
enhancement
needs-triage
ready-for-agent
ready-for-human
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Cloonar/deckle#195
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
harness checkreports every internal link whose target does not exist, so the best-effort link rewrite's documented safety net becomes real, and the one rule deciding whether a stored link resolves gets one owner that the check,GET /api/link/resolveand publish's draft-link check all call.Current behavior
internal/linkrefs/doc.go("Best-effort, not refusal") and thewalkMarkdowncomment ininternal/linkrefs/linkrefs.gosay a link the rewrite skipped is reported byharness checkas dangling.internal/checkcmdresolves no link of any shape. Reproduced on a freshharness initsite: a page whose body holds a markdown link to/renamed-away/and an inline anchor to/about/#c-nouid(the page exists, the uid does not), and whose front matter holdsrelated: /gone/, gets no link finding — only the "not canonical" warning for hand spacing. Sincee4d8138(ADR 0017) nothing records a renamed page's old URL, so a skipped rewrite is a silent 404 on the live site.Link resolution is evaluated twice today, in the server only:
handleLinkResolve(internal/server/linktargets.go) matches the target against the default-language tree's permalinks and looks a#c-<uid>fragment up on the page at any depth (for a non-default language: on the translation file first, then the source);disabledLinkTargets(internal/server/publish.go) matches permalinks and, additionally, a content-path spelling (posts/b/index.md) that predates the stored-link vocabulary and that nothing writes. Resolve reports a bundle sub-path (/news/first/photo.jpg) asfound: false, althoughlinkrefs/doc.goand themigrate-siteskill ("a link to a bundle file is the page URL plus the filename, never relative") name it as a link shape.Desired behavior
harness checkwalks every page and translation file undercontent/— draft pages included — and, for every destinationlinkrefs.Destinationsyields that is non-empty, oflinkrefs.Pagekind and root-relative (starts with/), reports a warning in areacontenton the file holding the link when the target does not resolve.linkrefs.SplitTargettarget is a page's permalink in the default-language tree; or that permalink plus a sub-path naming an existing file in that page's bundle directory; and, when the tail carries a#c-<uid>fragment, a shortcode with that uid exists at any depth on the page (for a link in a translation file: on the translation, else on the source — the lookup resolve makes today). Any other fragment, and a query, is display detail and never checked. A draft page is a valid target.#anchors,/media/…links, relative destinations (../about/,about.md),ref/relrefshortcodes, empty values, plain text params, or thealiaseskey. The check produces no false positives: a text param is yielded whole byDestinationsand must never warn.internal/serverandinternal/checkcmd;handleLinkResolve,disabledLinkTargetsand the check call it. ConsequentlyGET /api/link/resolveanswersfound: truefor a bundle sub-path whose file exists, and publish's content-path match is gone. Publish's draft-link warning still fires for a link to a draft page.linkrefscomments name the finding exactly as it exists.docs/harness.md"Check" lists the warning.scaffold/site/AGENTS.md"Verifying a change" and theverify-siteskill step 1 name it and say what to do about it (fix the link, or confirm the target was meant to go). Themigrate-siteskill's "after each section,harness checklists …" sentence includes it.docs/api.mdGET /api/link/resolverecords the bundle sub-path answer.Decisions
Settled in the triage grilling on 2026-10-15; implement, do not reopen.
checkcmdwould add. Where it lives (pagetree, which already builds the tree, or a new package abovelinkrefsandpagetree) is the implementer's call;linkrefscannot own it, since it must stay belowmediain the import graph and must not importpagetree.#c-<uid>element anchors and bundle sub-paths — the documented stored-link vocabulary./media/…links are not checked here: no media reference is checked byharness checktoday, and that check has its own owner (media.ResolvePath); it is #300.found: true— today'sfound: falsefor a documented link shape is a defect of its own; a flag that makes only the check accept them would put two behaviours behind one rule.Page-kind values before resolution, and relative destinations stay silent — a second rule to tell a relative link from a text param is not worth a false positive; relative destinations are resolved by nothing today.docs/harness.md's severity rule), and a page whose link cannot be verified must not fail the check any more than it blocks a rename.Key interfaces
linkrefs.Destinations,linkrefs.Classify,linkrefs.SplitTarget— the one scan and the reading of a candidate; reuse, never add a second scan or regex.pagetree.Build,pagetree.Node.FindByPermalink,Node.Path(the bundle directory is derivable from it) — the page lookup behind the owner.checkcmd.Finding,checker.warnfwithAreaContent— the finding shape;Countsand the--jsonshape stay as they are.handleLinkResolveanddisabledLinkTargets— become callers of the owner;linkResolveResponseis unchanged in shape.internal/checkcmd/check.go(content()already walks and parses every.mdfile;addresses()already builds the tree per language),internal/server/linktargets.go,internal/server/publish.go,internal/pagetree.Acceptance criteria
contentwarnings on the linking file, each naming its destination and what is missing; the same site with the links fixed is clean;harness checkexits 0 either way.#anchor, a/media/…link, a relative destination, an empty link param and a plain text param produce no finding.GET /api/link/resolveand the check agree on every case above, includingfound: truefor a bundle sub-path; publish's draft-link warning still fires for a link to a draft page and no longer matches a content-path spelling.internal/serverorinternal/checkcmd, and no comment says a function mirrors or must stay in sync with another.docs/harness.md"Check",scaffold/site/AGENTS.md"Verifying a change", theverify-siteandmigrate-siteskills,docs/api.mdand the twolinkrefscomments say what the engine now does; the drift tests (docs/docs_test.go,scaffold/skills_test.go,internal/element/agentsdoc_test.go, the route test ininternal/server) pass.How to verify
go test ./internal/checkcmd/ ./internal/linkrefs/ ./internal/server/ ./internal/pagetree/ ./docs/ ./scaffold/ ./internal/element/andgo vet ./...(building./...needsweb/dist; the package tests do not).internal/checkcmd:scaffoldSite, writecontent/about/_index.mdand a page with the three links above,Run(Options{SiteDir: dir})— before the change the report holds only the "not canonical" warning, after it holds the three link warnings.harness check --site <site>on a site after aharness-driven rename whose rewrite skipped a page (an unparsable page linking to the renamed one, fixed by hand afterwards) prints the dangling link.Assumptions to check
Destinationsalready reaches every link position the issue lists (front-matter values at any nesting, block-shortcode children) — readnodeDestinationsanddeepStringsbefore relying on it.ce/href.htmland the render-link hook.pagetree.Buildon the default language is the right tree for a link found in a translation file (resolve assumes so; publish assumes so) and that building it once per check run is enough.Out of scope
/media/…links or image params against the library — #300.ref/relrefshortcodes.--jsonshape ofharness checkbeyond the new finding rows.Blocked by
None - can start immediately
Original report
PR #193 (commit
e4d8138) removed every engine-written alias. The link-rewrite package's docs were updated to say that a link the best-effort rewrite skipped is caught later byharness check:internal/linkrefs/doc.go("Best-effort, not refusal"): a link that was not rewritten is reported byharness checkas dangling rather than left to fail the whole operation.internal/linkrefs/linkrefs.go,walkMarkdowndoc:harness checkreports the dangling link.internal/checkcmdhas no link check at all: it never resolves markdown destinations,hrefvalues or link-typed params against the page tree. Before #193 the alias a rename recorded was the safety net for a skipped rewrite; now there is none, and the docs point at a check that does not exist.Desired behavior, in order of preference: add the check (every internal link whose target is no page, element anchor or bundle file of the site, as a warning, with the
verify-siteskill and AGENTS.md updated), or reword both comments so they do not promise a check.Related: #193 concern 2 of the land-pr verdict; #194 (no engine-written aliases).
Triage Notes
What we've established so far:
harness initsite, a page whose body links to/renamed-away/(markdown) and/about/#c-nouid(inline<a href>, page exists, uid does not) and whose front matter holdsrelated: /gone/gets one finding fromharness check: the "not canonical" warning for my hand spacing. No link finding.internal/checkcmd/check.gocontains no link code at all; the only "dangling" it knows is sidecar names.e4d8138(PR #193), the commit that removed every engine-written alias. ADR 0017 records that decision; its "Consequences" lean on the same non-existent safety net. So the docs are not stale — they promised a check that was never built.checkcmdcan call. "Does this stored link resolve" is evaluated today byGET /api/link/resolve(handleLinkResolveininternal/server/linktargets.go:linkrefs.Classify→linkrefs.SplitTarget→FindByPermalinkon the default-language page tree →#c-<uid>shortcode lookup at any depth), and a second time by publish's draft-link check (disabledLinkTargetsininternal/server/publish.go, which also accepts a content-path spelling such asposts/b/index.md). A third evaluation insidecheckcmdis exactly what CLAUDE.md "Every rule has exactly one owner" forbids: adding the check means moving the resolution rule to a package the server andcheckcmdboth import and making all three callers use it.linkrefsimports onlycontent,pagetreedoes not importlinkrefs, so the owner can live inpagetreeor in a new package above both; that choice is the agent's.linkrefs.Destinationsyields candidates, not links: every string shortcode param and every front-matter string is yielded whole, so a plain text param ("Hello world") and an empty optional link param both come out asPagekind. Publish gets away with it by looking candidates up in a map of draft targets; a dangling check must filter first, or every text param becomes a warning. Relative destinations andref/relrefshortcodes are not resolved by anything today (publish documents that as its limitation).linkrefs/doc.go, and themigrate-siteskill: "a link to a bundle file is the page URL plus the filename, never relative"), but/api/link/resolvereports themfound: falsetoday because the match is the exact permalink. Whatever owner the check calls has to accept them, and then resolve does too..out-of-scope/(the directory does not exist).docs/harness.md"Check" (the warnings list),scaffold/site/AGENTS.md"Verifying a change" (the paragraph listing what the check reports), theverify-siteskill step 1 (same list, and what to do with the new warning), themigrate-siteskill (the "after each section,harness checklists …" sentence, where a dangling-link warning is most useful),docs/api.mdGET /api/link/resolveif bundle sub-paths becomefound: true, and the twolinkrefscomments so they name the finding precisely. No ADR: a check is not hard to reverse. If only the docs are fixed, just the two comments and one sentence in ADR 0017's consequences.Recommendation:
bug, and option 1 (add the check). ADR 0017 removed the alias safety net on the premise that this check exists; without it a rewrite the best-effort pass skips is silent until a visitor hits a 404 on the live site. The resolution rule exists already, so the work is relocating it and walkingcontent/with it, not designing anything new.Decisions for you (a short reply settles them; my recommended answer first):
#c-<uid>element anchors and bundle sub-paths — the issue's list, which islinkrefs' documented vocabulary./media/…links stay out: neither image params nor media links are checked byharness checktoday, so a media check is its own issue, not a side effect of this one.found: truein/api/link/resolvetoo. Recommend yes: one owner means resolve inherits the rule, and today'sfound: falsefor a documented link shape is a defect of its own. The alternative is to strike bundle sub-paths from the link shapes inlinkrefs/doc.goandmigrate-site.posts/b/index.md) from publish's draft check rather than teaching the owner a spelling nothing writes and resolve reports as dangling. Commit68857e1added it without a stated reason; the agent should confirm there is none before dropping it.warning, areacontent, on the file holding the link (a translation file for a translation), one per distinct destination per file, for every non-empty root-relative destination ofPagekind whose target is no page, whose#c-<uid>names no element on that page, or whose sub-path is no file of that page's bundle. Relative destinations, bare#anchors, external URLs and empty values draw nothing — no false positives, as the best-effort contract already promises. A link to a draft page is not dangling (publish warns about those).Draft brief (option 1, with the recommended answers above)
Replaces the issue body once the decisions are confirmed.
Summary
harness checkreports every internal link whose target does not exist, so the best-effort link rewrite's documented safety net is real, and the one rule deciding whether a stored link resolves has one owner that the check,/api/link/resolveand publish's draft-link check all call.Current behavior
internal/linkrefs/doc.go("Best-effort, not refusal") and thewalkMarkdowncomment ininternal/linkrefs/linkrefs.gosay a link the rewrite skipped is reported byharness checkas dangling.internal/checkcmdresolves no link of any shape. Reproduced on a scaffolded site: a markdown link to/renamed-away/, an inline anchor to/about/#c-nouidand a front-matter value/gone/produce no finding. Sincee4d8138(ADR 0017) nothing else records a renamed page's old URL, so a skipped rewrite is a silent 404 on the live site.Link resolution is evaluated twice today:
handleLinkResolve(internal/server/linktargets.go) matches the target against the default-language tree's permalinks and looks the#c-<uid>up on the page;disabledLinkTargets(internal/server/publish.go) matches permalinks and, additionally, a content-path spelling. Resolve reports a bundle sub-path (/news/first/photo.jpg) as not found althoughlinkrefs/doc.goand themigrate-siteskill name it as a link shape.Desired behavior
harness checkwalks every page and translation file undercontent/and, for every destinationlinkrefs.Destinationsyields that is non-empty, oflinkrefs.Pagekind and root-relative (starts with/), reports a warning in areacontenton the file holding the link when the target does not resolve. A target resolves when itsSplitTargettarget is a page's permalink in the default-language tree; or that permalink plus a sub-path naming an existing file in that page's bundle directory; and, when the tail carries a#c-<uid>fragment, a shortcode with that uid exists at any depth on the page (for a link in a translation file: on the translation, else on the source, as resolve does today). Any other fragment or a query is display detail and never checked.#anchors,/media/…links, relative destinations,ref/relrefshortcodes, empty values or thealiaseskey — the check must not produce false positives.internal/serverandinternal/checkcmd, andhandleLinkResolve,disabledLinkTargetsand the check call it./api/link/resolvetherefore answersfound: truefor a bundle sub-path whose file exists. Publish's content-path match is dropped unless the agent finds a stated reason for it.linkrefscomments name the finding exactly as it exists;docs/harness.md"Check" lists the warning;scaffold/site/AGENTS.md"Verifying a change" and theverify-siteskill name it and say what to do about it (fix the link, or confirm the target was meant to go); themigrate-siteskill's post-section check sentence lists it;docs/api.mdGET /api/link/resolverecords the bundle sub-path answer.Decisions
pagetree, which already builds the tree, or a new package abovelinkrefsandpagetree) is the agent's call;linkrefscannot own it because it must stay belowmediaand must not importpagetree.docs/harness.md's severity rule), and a page whose link cannot be verified must not blockharness checkany more than it blocks a rename.Page-kind values before resolution —Destinationsyields every string param whole; without the filter every text param is a finding./media/…links are not checked here — no media reference is checked byharness checktoday; that is a separate check with its own owner (media.ResolvePath).Key interfaces
linkrefs.Destinations,linkrefs.Classify,linkrefs.SplitTarget— the scan and the reading of a candidate; reuse, do not add a second scan.pagetree.Node.FindByPermalink,pagetree.Build— the page lookup; the bundle directory of a node is derivable fromNode.Path.checkcmd.Finding/checker.warnfwithAreaContent— the finding shape;Countsstays as is.handleLinkResolveanddisabledLinkTargets— become callers of the owner.internal/checkcmd/check.go(content()already walks every.mdfile and parses it;addresses()already builds the tree per language),internal/server/linktargets.go,internal/server/publish.go.Acceptance criteria
contentwarnings on the linking file, each naming the destination; the same site with the links fixed is clean.#anchor, a/media/…link, a relative destination, an empty link param and a plain text param produce no finding./api/link/resolveand the check agree on every case above, includingfound: truefor a bundle sub-path; publish's draft-link warning still fires for a link to a draft page.internal/serverorinternal/checkcmd(no "same as" comment either).docs/harness.md"Check",scaffold/site/AGENTS.md"Verifying a change",verify-siteandmigrate-siteskills,docs/api.mdand the twolinkrefscomments say what the engine now does; the drift tests (docs/docs_test.go,scaffold/skills_test.go, the route test ininternal/server) pass.How to verify
go test ./internal/checkcmd/ ./internal/linkrefs/ ./internal/server/ ./internal/pagetree/ ./docs/ ./scaffold/andgo vet ./...harness check --site <scaffolded site>with the three links above; before the change it prints only "not canonical", after it prints three link warnings and exits 0.Assumptions to check
Destinationsalready reaches every link position the issue lists (front-matter values at any nesting, block-shortcode children) — readnodeDestinations/deepStringsbefore relying on it.posts/b/index.md) — grep the web client and the themes before dropping publish's match.Out of scope
/media/…links or image params against the library (a separate issue).ref/relrefshortcodes.Blocked by
None - can start immediately
Grilled with the maintainer on 2026-10-15: every decision in the triage notes above landed on the recommended answer (add the check; pages, element anchors and bundle sub-paths; resolve follows the owner; drop publish's content-path match; relative destinations silent; draft pages checked; warning; one issue). The body is now the agent brief. Media references are a separate check: #300.