You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[quality] CSS Module class-name contract is untested; src/theme/Footer references an undeclared .iconLink and ships an orphan .orgText #319
Nothing in the test suite reads CSS Module class names. A CSS Module exports
only the classes its stylesheet declares, so styles.somethingUndeclared
evaluates to undefined and React renders the element with no class
attribute at all. The build succeeds, onBrokenLinks says nothing, and the
element silently loses its styling. src/ has 9 stylesheet importers and 167 class references behind that unguarded invariant.
The gap has already let drift accumulate, in src/theme/Footer/ specifically:
1. styles.iconLink is referenced but never declared. src/theme/Footer/index.js:131 sets className={styles.iconLink} on each of
the six social-link anchors. src/theme/Footer/styles.module.css declares no .iconLink rule, so every one of those anchors renders as <a title="..." href="..."> with no class.
To be precise about severity: this is not currently a visible regression.
The anchors still get their padding from the descendant selector .socialIcons a (css:61) and the icons their fill from .socialIcons svg
(css:65). The reference is dead weight that reads as if it styles something.
The hazard is latent, not realised.
2. .orgText is declared but referenced nowhere. src/theme/Footer/styles.module.css:26-29 defines .orgText; no importer
mentions it. Dead rule.
All 8src/components/*/styles.module.css stylesheets are clean in both
directions. The drift is isolated to src/theme/Footer/.
Why existing gates miss it
docusaurus build resolves the stylesheet, not the names read off it; an
undeclared key is plain undefined property access on a JS object.
tests/validate-button-contrast.test.mjs is the only test that reads any
CSS, and it reads src/css/custom.css for --cncf-button-background hex
values. It never touches a *.module.css.
npm run check:format / check:spelling / check:markdown do not model
cross-file name resolution.
One PR closes this. It has a production half and a test half; the test is red
until the production half lands, so they must go together.
Remove the two dead references in src/theme/Footer/. Recommended
fix is pure deletion — 6 lines, zero visual delta, because the anchors
are already styled through .socialIcons a:
The alternative, if a maintainer would rather keep the class as the
styling hook, is to declare `.iconLink { padding: 17px; }` and replace
the `.socialIcons a` descendant selector with it. That is a behaviour-
preserving refactor rather than a deletion; either choice satisfies the
test. `.orgText` should be deleted regardless.
Add tests/css-modules.test.mjs (full text below) so the contract
holds in both directions from here on.
Verified locally: against main the test file fails exactly twice — once on styles.iconLink, once on .orgText — and with the diff above applied all 4
assertions pass. The file is already prettier --check clean.
importassertfrom'node:assert/strict';import{readFileSync,readdirSync,existsSync}from'node:fs';import{dirname,join,relative,resolve}from'node:path';import{fileURLToPath}from'node:url';importtestfrom'node:test';// CSS Modules export only the class names declared in the stylesheet, so a// reference to an undeclared class evaluates to `undefined` and renders as a// missing `class` attribute. Nothing in the build fails, nothing in the test// suite reads these names, and the element simply loses its styling.constroot=fileURLToPath(newURL('..',import.meta.url));constsrcDir=join(root,'src');functionwalk(dir){constout=[];for(constentryofreaddirSync(dir,{withFileTypes: true})){constfull=join(dir,entry.name);if(entry.isDirectory())out.push(...walk(full));elseif(/\.(js|jsx|ts|tsx)$/.test(entry.name))out.push(full);}returnout;}// Strips comments so commented-out code never counts as a reference and a// commented-out rule never counts as a declaration.functionstripComments(source){returnsource.replace(/\/\*[\s\S]*?\*\//g,'');}// Collects `import styles from './styles.module.css'` bindings, keyed by the// local identifier, so a file that renames the default import is still read.functioncssImports(source,fromFile){constimports=newMap();constpattern=/import\s+([A-Za-z_$][\w$]*)\s+from\s+['"]([^'"]+\.module\.css)['"]/g;for(const[,binding,specifier]ofsource.matchAll(pattern)){imports.set(binding,resolve(dirname(fromFile),specifier));}returnimports;}// Import specifiers contain the substring `styles.module`, which would// otherwise be read as a reference to a class named `module`.functionwithoutImports(source){returnsource.replace(/^\s*import\s[\s\S]*?from\s+['"][^'"]+['"];?/gm,'');}functionreferencedClasses(source,binding){constnames=newSet();source=withoutImports(source);constdotted=newRegExp(`\\b${binding}\\.([A-Za-z_$][\\w$-]*)`,'g');for(const[,name]ofsource.matchAll(dotted))names.add(name);constbracketed=newRegExp(`\\b${binding}\\[\\s*['"\`]([\\w-]+)['"\`]\\s*\\]`,'g',);for(const[,name]ofsource.matchAll(bracketed))names.add(name);returnnames;}// True when a file indexes the stylesheet with a computed key, in which case// the referenced set cannot be known statically and the forward assertion// would be unsound for that file.functionhasDynamicAccess(source,binding){returnnewRegExp(`\\b${binding}\\[(?!\\s*['"\`][\\w-]+['"\`]\\s*\\])`).test(source,);}functiondeclaredClasses(css){returnnewSet([...stripComments(css).matchAll(/\.(-?[A-Za-z_][\w-]*)/g)].map((match)=>match[1],),);}// The first class in a selector is the rule's entry point. `.socialIcons svg`// and `.button:hover` are reachable through `socialIcons` and `button`, so// only the leading class decides whether a rule is reachable from JS at all.functionentryPointClasses(css){constnames=newSet();constbody=stripComments(css);// Selector lists precede a `{` and follow `}`, `{` or the start of file.for(const[,,selectorList]ofbody.matchAll(/(^|[{}])([^{}]*)\{/g)){for(constselectorofselectorList.split(',')){consttrimmed=selector.trim();if(!trimmed||trimmed.startsWith('@'))continue;constleading=trimmed.match(/^\.(-?[A-Za-z_][\w-]*)/);if(leading)names.add(leading[1]);}}returnnames;}constmodules=[];for(constfileofwalk(srcDir)){constsource=stripComments(readFileSync(file,'utf8'));for(const[binding,cssPath]ofcssImports(source,file)){modules.push({ file, binding, cssPath, source });}}test('at least one CSS Module import is discovered',()=>{assert.ok(modules.length>0,'found no `*.module.css` imports under src/; the extraction is vacuous',);});test('every imported *.module.css file exists on disk',()=>{for(const{ file, cssPath }ofmodules){assert.ok(existsSync(cssPath),`${relative(root,file)} imports ${relative(root,cssPath)}, which does not exist`,);}});test('every referenced CSS Module class is declared in its stylesheet',()=>{letchecked=0;for(const{ file, binding, cssPath, source }ofmodules){if(hasDynamicAccess(source,binding))continue;constdeclared=declaredClasses(readFileSync(cssPath,'utf8'));for(constnameofreferencedClasses(source,binding)){checked+=1;assert.ok(declared.has(name),`${relative(root,file)} uses \`${binding}.${name}\`, but `+`${relative(root,cssPath)} declares no \`.${name}\` rule; `+'the class attribute renders as undefined',);}}assert.ok(checked>0,'no class references were checked');});test('every CSS Module rule is reachable from the code that imports it',()=>{constbyStylesheet=newMap();for(const{ binding, cssPath, source }ofmodules){if(!byStylesheet.has(cssPath))byStylesheet.set(cssPath,newSet());constused=byStylesheet.get(cssPath);for(constnameofreferencedClasses(source,binding))used.add(name);}for(const[cssPath,used]ofbyStylesheet){constentryPoints=entryPointClasses(readFileSync(cssPath,'utf8'));assert.ok(entryPoints.size>0,`${relative(root,cssPath)} yielded no selectors; the extraction is vacuous`,);constorphans=[...entryPoints].filter((name)=>!used.has(name)).sort();assert.deepEqual(orphans,[],`${relative(root,cssPath)} declares rules no importer references: `+orphans.map((name)=>`.${name}`).join(', '),);}});
On vacuity
Two of the four assertions exist only to stop the other two from passing
silently, and that was not theoretical: the first draft of the selector
extractor destructured the wrong capture group, returned an empty set, and the
orphan assertion went green against a stylesheet it had not actually read. The entryPoints.size > 0 and checked > 0 guards are what caught it.
Why there is no PR attached
The fix is production code — src/theme/Footer/index.js and its stylesheet —
and the quality lane's mandate is testing changes only (new tests, fixtures,
CI/coverage config), so this lane cannot push it. Shipping the test alone would
merge a red suite, and narrowing the test to skip src/theme/ would be writing
the assertion around the one violation it was built to find.
This therefore needs a human or an agent whose lane can touch src/ to land
both halves in one PR. That is a permission ceiling, not a judgement call
about whether the change is worth making.
Evidence
Revision 00b44df, node v26.8.2, run locally 2026-09-19.
Unit coverage: node --test --experimental-test-coverage -> 55 tests, 55
pass. No existing test file reads a *.module.css; the only CSS-reading test
is tests/validate-button-contrast.test.mjs against src/css/custom.css.
With tests/css-modules.test.mjs added: node --test -> 59 tests, 57 pass,
2 fail, both in src/theme/Footer/. With the diff above also applied: 59
tests, 59 pass.
Counts: 9 *.module.css importers under src/, 167 distinct class
references, 1 undeclared (iconLink), 1 orphan rule (orgText).
Edge cases checked and absent from this repo: no :global(...), no composes:, no computed styles[expr] access. The test degrades safely if
any appear — hasDynamicAccess skips a file that starts indexing
dynamically, rather than reporting false failures.
Impact: medium — no visible regression today, but 167 class references across
9 stylesheets have no guard, and the failure mode is a silently unstyled
element that ships green
Effort: low — a 6-line deletion plus one test-only file, no new dependencies
Resolved by merged PR #371 (commit ab4ce2c, test: cover the CSS Module class-name resolution contract), which added tests/css-modules.test.mjs. The PR referenced this issue without a closing keyword, so it stayed open — closing now as done.
Finding
Nothing in the test suite reads CSS Module class names. A CSS Module exports
only the classes its stylesheet declares, so
styles.somethingUndeclaredevaluates to
undefinedand React renders the element with noclassattribute at all. The build succeeds,
onBrokenLinkssays nothing, and theelement silently loses its styling.
src/has 9 stylesheet importers and167 class references behind that unguarded invariant.
The gap has already let drift accumulate, in
src/theme/Footer/specifically:1.
styles.iconLinkis referenced but never declared.src/theme/Footer/index.js:131setsclassName={styles.iconLink}on each ofthe six social-link anchors.
src/theme/Footer/styles.module.cssdeclares no.iconLinkrule, so every one of those anchors renders as<a title="..." href="...">with no class.To be precise about severity: this is not currently a visible regression.
The anchors still get their padding from the descendant selector
.socialIcons a(css:61) and the icons their fill from.socialIcons svg(css:65). The reference is dead weight that reads as if it styles something.
The hazard is latent, not realised.
2.
.orgTextis declared but referenced nowhere.src/theme/Footer/styles.module.css:26-29defines.orgText; no importermentions it. Dead rule.
All 8
src/components/*/styles.module.cssstylesheets are clean in bothdirections. The drift is isolated to
src/theme/Footer/.Why existing gates miss it
docusaurus buildresolves the stylesheet, not the names read off it; anundeclared key is plain
undefinedproperty access on a JS object.tests/validate-button-contrast.test.mjsis the only test that reads anyCSS, and it reads
src/css/custom.cssfor--cncf-button-backgroundhexvalues. It never touches a
*.module.css.npm run check:format/check:spelling/check:markdowndo not modelcross-file name resolution.
src/theme/Footer/**or readsCSS Module class names. [quality] src/css/custom.css <-> static/fonts/ contract is untested; 27 italic faces (764K) shipped but never declared #317 covers
src/css/custom.cssurl()refs andstatic/fonts/—custom.cssis a global stylesheet, not a module, and isnot scanned here. test: verify local /img static asset references resolve under static/ (tests/static-assets.test.mjs) #289 covers
/imgstatic asset paths. [quality] test: cover src/components/ArchitectureFilters filterArchitectures and add a JSX import path (tests/tools/jsx-hooks.mjs + tests/helpers-jsx.mjs + tests/architecture-filters.test.mjs) #229 coversfilterArchitectures. test: cover useFocusTrap scroll lock, tab cycling and focus restore #268 coversuseFocusTrap. [architect] refactor: split MemberDirectory into focused sub-modules #118 refactorsMemberDirectory— the test below followsimport ... from '*.module.css'statements rather than assuming one
index.jsper directory, so it keepsworking after that split.
Recommendation
One PR closes this. It has a production half and a test half; the test is red
until the production half lands, so they must go together.
src/theme/Footer/. Recommendedfix is pure deletion — 6 lines, zero visual delta, because the anchors
are already styled through
.socialIcons a:tests/css-modules.test.mjs(full text below) so the contractholds in both directions from here on.
Verified locally: against
mainthe test file fails exactly twice — once onstyles.iconLink, once on.orgText— and with the diff above applied all 4assertions pass. The file is already
prettier --checkclean.tests/css-modules.test.mjs(verified, prettier-clean)On vacuity
Two of the four assertions exist only to stop the other two from passing
silently, and that was not theoretical: the first draft of the selector
extractor destructured the wrong capture group, returned an empty set, and the
orphan assertion went green against a stylesheet it had not actually read. The
entryPoints.size > 0andchecked > 0guards are what caught it.Why there is no PR attached
The fix is production code —
src/theme/Footer/index.jsand its stylesheet —and the quality lane's mandate is testing changes only (new tests, fixtures,
CI/coverage config), so this lane cannot push it. Shipping the test alone would
merge a red suite, and narrowing the test to skip
src/theme/would be writingthe assertion around the one violation it was built to find.
This therefore needs a human or an agent whose lane can touch
src/to landboth halves in one PR. That is a permission ceiling, not a judgement call
about whether the change is worth making.
Evidence
00b44df,node v26.8.2, run locally 2026-09-19.node --test --experimental-test-coverage-> 55 tests, 55pass. No existing test file reads a
*.module.css; the only CSS-reading testis
tests/validate-button-contrast.test.mjsagainstsrc/css/custom.css.tests/css-modules.test.mjsadded:node --test-> 59 tests, 57 pass,2 fail, both in
src/theme/Footer/. With the diff above also applied: 59tests, 59 pass.
*.module.cssimporters undersrc/, 167 distinct classreferences, 1 undeclared (
iconLink), 1 orphan rule (orgText).:global(...), nocomposes:, no computedstyles[expr]access. The test degrades safely ifany appear —
hasDynamicAccessskips a file that starts indexingdynamically, rather than reporting false failures.
playwright/cypress/puppeteerand CI publishes no coverage artifact(see [quality] CI publishes no coverage evidence, so coverage findings cannot be verified #186), so no claim is made about e2e coverage of this path. A browser
suite is exactly what would catch an unstyled element.
Priority
9 stylesheets have no guard, and the failure mode is a silently unstyled
element that ships green
Filed by quality agent (hold-gated mode)
— hive: agent=quality backend=copilot model=claude-opus-5