content-lint: reject a cross-locale body link when the same-locale target is built - #202
Conversation
…ame-locale target is built A link can resolve and still be wrong. `resolveInternalLink` finds `/zh-Hans/blog/x/` in an `index.ja.mdx` perfectly real, because it is: the page builds, the link works, and the reader is moved out of the language they were reading in — at which point the language switcher offers to "switch" them to the locale they were already in. Nothing in the gate chain looked at the locale segment at all. The rule is conditional and the condition is the design: it fires only when the same-locale target EXISTS. A deliberate cross-locale link is a real thing (an English-only companion post is the author's only option), and the alternative — a same-locale URL nobody built — is the hard 404 this script already rejects. That is the same rule `scripts/gen-zh-hant.mjs` enforces on the generated side: rewrite `/zh-Hans/` to `/zh-Hant/` only when the Traditional target is provably built. One rule, two enforcement points. "The same-locale target exists" is answered by calling `resolveInternalLink` on the swapped URL, not by re-deriving existence per surface — a second answer to that question would drift exactly where it matters, and produce a cross-locale error pointing at a same-locale URL that 404s. Measured at ca3983b before writing the rule: 335 authored files, 187 internal links carrying a routable locale segment, 28 of them cross-locale — every one a `/en/blog/…` link from de/es/fr/ja/ko/ zh-Hans/zh-Hant to one of the three English-only posts (enterprise-ontology-platform-comparison, ontology-vs-semantic-layer-vs-knowledge-graph, forward-deployed-engineer-trap). All 28 pass: none has a same-locale target. The rule turns nothing in the corpus red. Part of #179 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FeA1nwBz1ohH65dvffUGKr
|
ACCEPT — with the seat's own count and its own ablation. The measurement reproduces exactly. I counted cross-locale links at Same numbers. And the 20 → 28 delta is explained rather than waved at: the same four links now also exist in that post's zh-Hans and zh-Hant copies, so the set of link targets is unchanged. Reporting the delta and its cause is what makes the count trustworthy — a number that quietly disagrees with a prior sweep is worse than no number. Ablation, re-run by the seat. Both legs on
Control on the unmutated tree: 0. That is the discriminating property proven from both sides — the two legs differ in exactly the fact the rule turns on, and nothing else. Union with current Three things in the design that are better than what I ruled. Answering "does the same-locale target exist?" by calling A A link that fails to resolve keeps exactly one finding. Its locale segment is not the interesting fact about it. Two findings for one broken link is how a gate starts getting skimmed. And the error message will not send an author to edit a generated file. The zh-Hant clause pointing at the zh-Hans source and Leg 3 was yours, not mine, and it is the one I would have missed. Linting the leg-1 mutated tree with the pre-merge copy of the script — asserted not to contain Landing on Generated by Claude Code |
Fixes #179
content-lintnow rejects a body link that names a locale the file is not written in — but only when the same-locale target is built. That is the seat ruling's option 2, recorded on the card.The rule, and why it is conditional
A link can resolve and still be wrong.
resolveInternalLink()finds/zh-Hans/blog/x/inside anindex.ja.mdxperfectly real, because it is: the page builds, the link works, and the reader is quietly moved out of the language they were reading in — at which point the language switcher offers to "switch" them to the locale they were already in. Nothing in the gate chain looked at the locale segment at all.The condition is the whole design. A deliberate cross-locale link is a real thing: an English-only companion post is the author's only option, and the alternative — a same-locale URL nobody built — is the hard 404 this script already rejects. So the finding fires only when the same-locale target exists, at which point the link is unambiguously a mistake because the page the reader wanted is right there in their language.
That is the same rule
scripts/gen-zh-hant.mjsenforces on the generated side (#187): it rewrites/zh-Hans/to/zh-Hant/only when the Traditional target is provably built, and withholds the rewrite otherwise, because a manufactured 404 is worse than a cross-locale link. One rule, two enforcement points — the generator repairs the links it can reach, this check catches the hand-written locales (.ja,.ko,.de,.es,.fr) that have no generator to blame. This run ofpnpm buildshows both sides agreeing:✓ zh-Hant: generated 46, kept 0 hand-maintained, 5 with body links pointed at /zh-Hant/, and the check then finds nothing left to report."The same-locale target exists" is answered by calling
resolveInternalLink()on the swapped URL, not by re-deriving existence per surface. A second answer to that question is a second producer of one fact, and it would drift exactly where it matters: the two would disagree about some URL, and the disagreement would surface as a cross-locale error whose suggested repair 404s. Reusing the resolver also means awarnverdict — the noindexed English fallback the glossary and marketing registries serve — does not count as existing. Deliberate and conservative: choosing the real English page over a noindexed English-bodied duplicate is a defensible authoring call, not a mistake.A link that does not resolve at all keeps getting exactly one finding, the accurate one; its locale is not the interesting fact about it.
Error wording
The author reading it has to know which of the two situations they are in, so the message names the file's locale, the link's locale, and the same-locale URL that exists, and says the repair is to change the segment rather than delete the link. Verbatim, from ablation leg 1:
zh-Hant files get one extra clause pointing at the zh-Hans source and
pnpm gen:zh-hant, so a blocking error never names a generated file as the place to edit.Measurement, re-taken at
ca3983bThe card asked for a fresh count rather than trusting #142's sweep of 20, and the corpus had moved (#196, #201, and #189's seven English-only posts). Measured with a standalone script before the rule was written:
All 28 are
/en/blog/…links fromde,es,fr,ja,ko,zh-Hansandzh-Hantcopies ofenterprise-ontology-race-open-vs-closed, pointing at three English-only posts:enterprise-ontology-platform-comparison,ontology-vs-semantic-layer-vs-knowledge-graph,forward-deployed-engineer-trap. Every one passes — the author had no same-locale target to link to. The rule turns nothing in the corpus red, so no content change is in this PR.The count moved from 20 to 28 only because the same four links now also exist in the
zh-Hansandzh-Hantcopies of that post; the set of link targets is unchanged. The English-only count of 7 matches what #189 records.Ablation
Three legs. Mutation proven on disk by anchored
grep -cFbefore each measurement; restore proven bygit hash-objectagainst theHEADblob (an empty hash aborts the run rather than reading as a pass);trap … EXIT INT TERMon absolute paths;git status --porcelainempty afterwards. Fixture:content/blog/ai-agent-workbench/index.ja.mdx, HEAD bloba9762c22cedcfc82ac8664fecc31064f147a6eff.[コンプライアンス統制](/zh-Hans/blog/ai-compliance-control/)to thatjafile (grep -cF= 1) →content lintexits 1,✗ content lint failed (1 blocking issue), naming the link and/ja/blog/ai-compliance-control/.[FDE の罠](/en/blog/forward-deployed-engineer-trap/)to the samejafile (grep -cF= 1) → exit 0,✓ content lint passed (335 files, 44 glossary terms checked), zero findings naming the fixture. This is the leg that matters: PR content(blog): what an FDE actually builds, and the 2026 copycat wave #168's deliberate links stay green.ca3983bcopy of the script (asserted not to containcrossLocaleMiss) → exit 0,✓ content lint passed, zero findings naming the fixture.Gates
All run through the shared verify lock at
015d9ab(git rev-parse --short HEAD), exit captured before any pipe, verdicts quoted from the gate's own output. Working tree clean afterwards.pnpm content:lint✓ content lint passed (335 files, 44 glossary terms checked)—command-exit 0pnpm content:lint --published✓ content lint passed (335 files, 44 glossary terms checked)—command-exit 0pnpm checkResult (135 files): 0 errors, 0 warnings, 0 hints—command-exit 0pnpm build[build] 867 page(s) built in 61.85s+✓ content lint --dist passed (474 built blog pages checked)—command-exit 0pnpm seo:smokeSEO smoke test passed (866 HTML pages checked)—command-exit 0pnpm buildends withnode scripts/content-lint.mjs --dist(#201); that leg is green above.pnpm gen:zh-hantinside the build left the tree unchanged.🤖 Generated with Claude Code
https://claude.ai/code/session_01FeA1nwBz1ohH65dvffUGKr
Generated by Claude Code