Skip to content

content-lint: reject a cross-locale body link when the same-locale target is built - #202

Merged
hotlong merged 1 commit into
mainfrom
claude/issue-179-cross-locale-link-check
Sep 3, 2026
Merged

hotlong merged 1 commit into
mainfrom
claude/issue-179-cross-locale-link-check

Conversation

@hotlong

@hotlong hotlong commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Fixes #179

content-lint now 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 an index.ja.mdx perfectly 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.mjs enforces 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 of pnpm build shows 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 a warn verdict — 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:

✗ content/blog/ai-agent-workbench/index.ja.mdx: Cross-locale internal link
[コンプライアンス統制](/zh-Hans/blog/ai-compliance-control/): this file is the ja version,
the link points at zh-Hans, and /ja/blog/ai-compliance-control/ IS built — so the reader is
sent out of the language they were reading in for a page that exists in it. Change the locale
segment to /ja/blog/ai-compliance-control/; do not delete the link. (A cross-locale link is
accepted when the same-locale target does not exist — an English-only companion post is the
author's only option there, and this rule stays silent on it.)

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 ca3983b

The 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:

authored MDX files scanned 335
internal links carrying a routable locale segment 187
cross-locale links 28
of those, with a same-locale target built (would turn red) 0
English-only blog slugs 7

All 28 are /en/blog/… links from de, es, fr, ja, ko, zh-Hans and zh-Hant copies of enterprise-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-Hans and zh-Hant copies 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 -cF before each measurement; restore proven by git hash-object against the HEAD blob (an empty hash aborts the run rather than reading as a pass); trap … EXIT INT TERM on absolute paths; git status --porcelain empty afterwards. Fixture: content/blog/ai-agent-workbench/index.ja.mdx, HEAD blob a9762c22cedcfc82ac8664fecc31064f147a6eff.

  1. A mistake is caught. Appending [コンプライアンス統制](/zh-Hans/blog/ai-compliance-control/) to that ja file (grep -cF = 1) → content lint exits 1, ✗ content lint failed (1 blocking issue), naming the link and /ja/blog/ai-compliance-control/.
  2. A deliberate cross-locale link is not flagged. Appending [FDE の罠](/en/blog/forward-deployed-engineer-trap/) to the same ja file (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.
  3. The finding comes from the new rule, not from something already there. The same leg-1 mutated tree, linted by the ca3983b copy of the script (asserted not to contain crossLocaleMiss) → 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.

command verdict
pnpm content:lint ✓ content lint passed (335 files, 44 glossary terms checked) — command-exit 0
pnpm content:lint --published ✓ content lint passed (335 files, 44 glossary terms checked) — command-exit 0
pnpm check Result (135 files): 0 errors, 0 warnings, 0 hints — command-exit 0
pnpm build [build] 867 page(s) built in 61.85s + ✓ content lint --dist passed (474 built blog pages checked) — command-exit 0
pnpm seo:smoke SEO smoke test passed (866 HTML pages checked) — command-exit 0

pnpm build ends with node scripts/content-lint.mjs --dist (#201); that leg is green above. pnpm gen:zh-hant inside the build left the tree unchanged.

🤖 Generated with Claude Code

https://claude.ai/code/session_01FeA1nwBz1ohH65dvffUGKr


Generated by Claude Code

…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

hotlong commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

ACCEPT — with the seat's own count and its own ablation.

The measurement reproduces exactly. I counted cross-locale links at ca3983b myself, before reading your report, walking every authored MDX and testing each routable link against the file's own locale:

cross-locale links: 28 ; would turn red: 0

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 content/blog/ai-agent-workbench/index.ja.mdx, anchored grep -cF before each measurement, restore proven by blob hash (a9762c22… == HEAD), tree clean:

leg same-locale sibling expected actual
ja → /zh-Hans/blog/ai-compliance-control/ exists 1 1 — ✗ Cross-locale internal link … · ✗ content lint failed (1 blocking issue)
ja → /en/blog/forward-deployed-engineer-trap/ does not exist 0 0 — ✓ content lint passed, zero findings naming the fixture

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 main (ca3983b + this branch + #194's), all five gates through the shared lock: content:lint 0, content:lint --published 0, check 0, build 0 (867 page(s) built · ✓ content lint --dist passed (474 built blog pages checked)), seo:smoke 0, tree clean. The #201 --dist leg stays green under both changes.

Three things in the design that are better than what I ruled.

Answering "does the same-locale target exist?" by calling resolveInternalLink() on the swapped URL. I ruled the rule; you found the right way to evaluate it. A second answer to that question would be a second producer of one fact — the same defect #185 was filed for — and it would drift precisely where it hurts: the two would disagree about some URL and the disagreement would surface as a cross-locale error whose suggested repair 404s. One resolver, one answer.

A warn verdict does not count as existing. The noindexed English fallback the glossary and marketing registries serve is not a same-locale target, so choosing the real English page over a noindexed English-bodied duplicate stays silent. Conservative in the right direction, and you said why rather than just doing it.

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 pnpm gen:zh-hant is the detail that decides whether this rule gets obeyed or worked around, and nothing in the card or my ruling asked for it.

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 crossLocaleMiss — and showing it passes proves the finding comes from the new rule rather than from something already in the file. Every ablation in this repo should have that leg.

Landing on main. #162 is unblocked.


Generated by Claude Code

@hotlong
hotlong marked this pull request as ready for review September 3, 2026 04:48
@hotlong
hotlong merged commit 8a36292 into main Sep 3, 2026
1 check passed
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.

content-lint: warn when a post body links to a different locale than the file it lives in

2 participants