fix(assembly): ask the linter's own list whether a page's lang is a language - #199
Conversation
…anguage `preferredTag` asked two questions — is this shaped like a tag, and does the registry have a preferred form for it. `html-lang-valid` asks a third: is it a language at all. Canonicalization is a syntax check plus the registry's ALIAS table, so a subtag with a preferred form gets repaired (`kor` → `ko`, which is what it was added for) while one that is in no table has nothing to look up and passed through untouched onto the root. So `cn`, `jp`, `cz`, `dk`, `gr`, `ua`, `vn` — the country code written where the language code belongs, the commonest wrong-but-well-formed `lang` in real HTML and a plausible answer to "use the BCP 47 tag" from a model reading a Chinese page — each put a SERIOUS `html-lang-valid` on the one element this project writes. That is the regression the shape check exists to prevent, arriving through the part of the question shape cannot answer. `xxy` and `zzz` do it from the other end. The question now goes to `axe.utils.validLangs()`: the IANA registry range that `html-lang-valid` itself validates against, read out of the same pinned axe-core the gate runs. Agreement is by construction rather than by measurement — three named exceptions had each closed the instances demonstrated to them, which is how this shipped three times. Measured anyway: 61 tags across every class the derivation can reach, 0 violations on the root. The list is axe's own rather than CLDR display data, which #196 proposed, and the difference is not cosmetic: CLDR has no English name for the obscure end of ISO 639-3 that the registry lists — 10 of 66 measured, `aaa`, `aab`, `abt`, `lns`, `ttj` — and refusing those means the document is announced as English. A small language's document losing its voice to save a lookup is this amendment's own defect reintroduced for the readers least able to absorb it. CLDR remains the fallback, because `axe.utils` is a runtime export axe's own .d.ts does not declare and its semver does not cover, and a test asserts the primary path is the one answering so an axe bump makes the downgrade loud instead of silent. `NOT_AN_ANSWER` stays: the registry lists `und`, `zxx`, `mul`, `mis` and `qaa`–`qtz`, which is exactly why they are refused here. What no list can catch, recorded in the test and the PRD rather than papered over: `kr` and `se` are Kanuri and Northern Sami as well as the country codes for Korea and Sweden, so a Korean page writing `lang="kr"` gets a Kanuri root. Refusing them would refuse the derivation to every real Kanuri document. Closes #196 Co-authored-by: bbertucc <46652+bbertucc@users.noreply.github.com> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
All six checks pass. I verified the load-bearing claim rather than taking the PR body for it, and it holds:
html-lang-valid→ checkvalid-langevaluatesisValidLang(getBaseLang(langVal))(node_modules/axe-core/axe.js:31901-31913,:25480-25482),getBaseLangislang.trim().split("-")[0].toLowerCase()(:18542), andisValidLangwalks the same trieaxe.utils.validLangs()reads (:7089,:20162). SoisKnownLanguage(canonical.split("-")[0] ?? "")atsrc/pipeline/assembly.ts:138is the gate's own predicate applied to the gate's own argument — the "agreement by construction" claim is exact, including the primary-subtag-only scope.validLangs()returned 8268 lowercase codes here, withkor/cn/jpabsent andiw/in/und/qaapresent, which is what the comments say.- Swept all 8268 accepted subtags through
LANG_TAG+NOT_AN_ANSWER+Intl.getCanonicalLocales: 0 have a canonical base subtag outside the list, so the new refusal costs no real language its root label. Same sweep for values that canonicalize into a placeholder — which would slip pastNOT_AN_ANSWER(applied to the raw value) and then pass the membership test, sinceund/zxx/mul/qaaare all in the list — also 0 reachable, so the check ordering is safe. preferredTagreturns eithercanonicalor a value case-equal to it, so the tag that lands on the root always has the base subtag that was checked.test/document-lang.test.tsis covered bynode --test "test/*.test.ts"; no registration gap.
Non-blocking notes
-
src/pipeline/lint.ts:249builds the documented fallback eagerly at module scope:const LANGUAGE_NAMES = new Intl.DisplayNames(["en"], { type: "language", fallback: "none" });
On the primary path that object is never touched, and on a runtime without
Intl.DisplayNames(Node built--without-intl) the TypeError is raised while importinglint.ts— so the failure mode is "the pipeline module will not load" rather than "the derivation degrades to CLDR", which is the opposite of what the guard aroundAXE_LANGUAGESbuys. Latent: official Node 24 ships full ICU, andassembly.tsalready leans onIntl.getCanonicalLocales. Constructing it inside the fallback branch would make the fallback as guarded as the primary. -
The CLDR branch (
lint.ts:255-259) is unexercised —languageListSourceis a module-scope const with no seam, so no test can reach it. Defensible, sincethe language list is the linter's own, not the fallbackfails loudly the day an axe bump dropsutils; worth knowing that on the day it fails, the behaviour it falls back to is unmeasured rather than tested.
Accessibility impact: strictly positive — a well-formed non-language on a fragment (cn, jp, xxy) no longer reaches the root, so the one element Iris writes stops carrying a serious html-lang-valid violation, and the sweep above confirms no real language loses its 3.1.1 root label to the new check.
|
This closes #196 as filed, and it does it better than the issue proposed — asking The fallback, measured
So the fallback is not a slightly coarser list — it is a different one. It knows every two-letter code and almost none of ISO 639-3: the day Which makes the argument in the PR stronger than the number it cites, and makes The direction is still safe, which is the other half of note 2The fallback degrades in exactly one direction. Checked against the classes this derivation can reach:
So the fallback can only ever cost a real language its root label, never put a violation back on the root — the direction the guard has chosen at every step, as the comment says. It is severe in degree and safe in kind, and now measured in both. What I checked on the fix itselfSanity, not news — the reviewer's sweep already covers the 8,268: One shape worth recording as not a hole, since primary-subtag-only scope invites the question: Measured with |
…would cost Both non-blocking notes from the review on #199. `new Intl.DisplayNames(...)` at module scope raises a TypeError on a runtime built `--without-intl` — while IMPORTING lint.ts. So the environment the fallback exists for was one where the pipeline module would not load at all, which is the opposite of what guarding `AXE_LANGUAGES` buys. It is now built on first use, inside the branch that needs it, and a runtime with neither list refuses every value and lets the derivation fall back to `en` quietly rather than failing runs over an attribute. The fallback was also unreachable from any test: `isKnownLanguage` takes the axe branch in every environment the suite can construct, since the list is read at import from a pinned dependency and there is no seam to remove it. So the branch that answers on the day an axe bump drops `utils` was the one branch never run, and "it degrades to CLDR" was a claim about untested code. `cldrKnowsLanguage` is now exported and called directly, and the new test records the divergence in both directions: the ten ISO 639-3 codes that keep a correct root today and would lose it, and `kor` — which CLDR names and axe refuses, and which costs nothing because `preferredTag` canonicalizes to `ko` before either list is asked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: bbertucc <46652+bbertucc@users.noreply.github.com>
|
Thanks for reading Both notes fixed in the 2nd commit; the first was a real latent failure and I had it backwards. 1. The eager constructor. You are right that the failure mode was inverted: 2. The unexercised branch. Also fair, and fixable without a seam:
Row 3 is the cost of a downgrade, now asserted in both directions so it cannot silently change size. Row 4 is the one I had not measured: CLDR names Gates: |
|
Not a defect and I would not hold the merge — the 2nd commit fixes the right thing and the new test measures the right thing. One coverage gap, and it comes with a seam the previous review concluded was absent: the property that actually broke — the constructor running at import — is the one property still unpinned. No model calls.
The review's note 2 said there is no seam to reach the fallback, and for // test/lint-lazy-intl.test.ts — its own file, because node:test runs one process per file and
// every other file that touches the linter imports it at top level, so this is the only place
// the module can be observed BEFORE it loads.
const built: string[] = [];
const Real = Intl.DisplayNames;
Object.defineProperty(Intl, "DisplayNames", { configurable: true, writable: true,
value: function (...args) { built.push(args[1]?.type ?? "?"); return new Real(...args); } });
const lint = await import("../src/pipeline/lint.ts");
assert.deepEqual(built, [], "lint.ts must not construct Intl.DisplayNames at import");
assert.equal(lint.cldrKnowsLanguage("ko"), true);
assert.equal(lint.cldrKnowsLanguage("aaa"), false);
assert.equal(built.length, 1, "the fallback should be built once and memoized");Run as a test file in scratch copies of both heads: So it fails on the exact regression rather than on something adjacent, and the last assertion pins the Two smaller things from the same pass, neither of them news:
Measured with |
Closes #196.
preferredTagasked two questions — is this shaped like a tag, and does the registry have a preferred form for it.html-lang-validasks a third: is it a language at all. Canonicalization is a syntax check plus the registry's alias table, so a subtag with a preferred form gets repaired (kor→ko, which is what it was added for) while one in no table has nothing to look up and passed through untouched onto the root.So the country code written where the language code belongs put a serious violation on the one element this project writes — the exact regression the shape check exists to prevent, through the part of the question shape cannot answer.
What changed
The question now goes to
axe.utils.validLangs()— the IANA registry rangehtml-lang-validitself validates against (8,268 primary subtags, shipped as a trie), read out of the same pinned axe-core the gate runs. Agreement is by construction, not by measurement. That matters here specifically: the guard had been widened three times (shape check → alias table →NOT_AN_ANSWER), each pass closing the instances demonstrated to it without asking what the linter asks, which is how this shipped a third time.It lives in
lint.ts(which already owns the axe dependency) asisKnownLanguage, andassembly.tsasks it about the primary subtag of the canonical value — not the whole tag, sinceko-KOREANis axe-clean and is not a whole tag any list holds.Why not
Intl.DisplayNames, which the issue proposedThe proposal was well measured and I ran it first: 87 tags, 0 root violations, so it does close #196. Then I measured what it costs, which the issue's 60-tag set didn't reach — CLDR display data has no English name for the obscure end of ISO 639-3 that the registry lists:
Refusal means the root keeps
en— the safe direction, as the issue says, but a document in a small language being announced as English is this amendment's own defect, reintroduced for the readers least able to absorb it. Under the axe list those ten keep their root label.CLDR stays as the fallback, because
axe.utilsis a runtime export axe's own.d.tsdoes not declare and its semver does not cover. Read once behind a guard (length-checked too: a truncated list would refuse every language on earth and read as the guard working), andtest/document-lang.test.tsassertslanguageListSource === "axe", so an axe bump that drops it fails the suite instead of quietly narrowing who gets a labelled document.Measured, after
Same body each time,
runAxeon the assembled shell:cnjpczdkgruavnilenxxyzzzqqqabcdenkorspafradeuengzhoiwinjikoesfrdeenzhheidyishart-lojbantlsr-Latnjbofilaaaaababtlnsttj(CLDR-unnamed)hawchrfilyuecebtlhnqogrcjboquundzxxmulmisqaaqtzenko_KRko-xKoreanx-klingon""en61 tags, 38 derive a root language, 0 violations on the root.
NOT_AN_ANSWERstays — the registry listsund,zxx,mul,misandqaa–qtz, which is exactly why they're refused here.The trap this does not close
kris Kanuri as well as the country code for Korea;seis Northern Sami as well as Sweden;nois Norwegian. A Korean page writinglang="kr"gets a Kanuri root, and no membership test can know that — the value is a language, the linter accepts it, and refusing it would refuse the derivation to every real Kanuri document. What corrects it is the review loop rewriting the fragment's ownlang, or nothing. Recorded in the test and in PRD §7.4 rather than left for the next measurement to rediscover.Tests
test/document-lang.test.ts'severy language the shell will declare is one the linter acceptsgoes from 11 rows to 34, each a realrunAxecall against the assembled shell (445 ms total) — the #196 class, the repairs, the CLDR-unnamed languages, the registry placeholders and the malformed values. The derivation table gains the refusals and the two "this is genuinely a language" rows. Plus the newthe language list is the linter's own, not the fallback.npx tsc --noEmitclean ·npm test776 pass / 0 fail ·bash test/e2e.shALL ENDPOINTS PASSED ✅🤖 Generated with Claude Code