Repository navigation
[quality] CSS Module class-name contract is untested; src/theme/Footer references an undeclared .iconLink and ships an orphan .orgText #319
Description
Finding
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 8 src/components/*/styles.module.css stylesheets are clean in both
directions. The drift is isolated to src/theme/Footer/.
Why existing gates miss it
docusaurus buildresolves the stylesheet, not the names read off it; an
undeclared key is plainundefinedproperty access on a JS object.tests/validate-button-contrast.test.mjsis the only test that reads any
CSS, and it readssrc/css/custom.cssfor--cncf-button-backgroundhex
values. It never touches a*.module.css.npm run check:format/check:spelling/check:markdowndo not model
cross-file name resolution.- Disjointness from open PRs: no open PR touches
src/theme/Footer/**or reads
CSS Module class names. [quality] src/css/custom.css <-> static/fonts/ contract is untested; 27 italic faces (764K) shipped but never declared #317 coverssrc/css/custom.cssurl()refs and
static/fonts/—custom.cssis a global stylesheet, not a module, and is
not 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 covers
filterArchitectures. test: cover useFocusTrap scroll lock, tab cycling and focus restore #268 coversuseFocusTrap. [architect] refactor: split MemberDirectory into focused sub-modules #118 refactors
MemberDirectory— the test below followsimport ... from '*.module.css'
statements rather than assuming oneindex.jsper directory, so it keeps
working 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.
- 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:
--- a/src/theme/Footer/index.js
+++ b/src/theme/Footer/index.js
@@ -128,7 +128,6 @@ export default function Footer() {
<a
key={name}
href={href}
- className={styles.iconLink}
title={name}
target="_blank"
rel="noopener noreferrer"
--- a/src/theme/Footer/styles.module.css
+++ b/src/theme/Footer/styles.module.css
@@ -23,11 +23,6 @@
height: auto;
}
-.orgText {
- font-size: 0.75rem;
- line-height: 1.2;
-}
-
.button {
font-size: 16px;
line-height: 16px; 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.
tests/css-modules.test.mjs (verified, prettier-clean)
import assert from 'node:assert/strict';
import { readFileSync, readdirSync, existsSync } from 'node:fs';
import { dirname, join, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import test from '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.
const root = fileURLToPath(new URL('..', import.meta.url));
const srcDir = join(root, 'src');
function walk(dir) {
const out = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) out.push(...walk(full));
else if (/\.(js|jsx|ts|tsx)$/.test(entry.name)) out.push(full);
}
return out;
}
// Strips comments so commented-out code never counts as a reference and a
// commented-out rule never counts as a declaration.
function stripComments(source) {
return source.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.
function cssImports(source, fromFile) {
const imports = new Map();
const pattern =
/import\s+([A-Za-z_$][\w$]*)\s+from\s+['"]([^'"]+\.module\.css)['"]/g;
for (const [, binding, specifier] of source.matchAll(pattern)) {
imports.set(binding, resolve(dirname(fromFile), specifier));
}
return imports;
}
// Import specifiers contain the substring `styles.module`, which would
// otherwise be read as a reference to a class named `module`.
function withoutImports(source) {
return source.replace(/^\s*import\s[\s\S]*?from\s+['"][^'"]+['"];?/gm, '');
}
function referencedClasses(source, binding) {
const names = new Set();
source = withoutImports(source);
const dotted = new RegExp(`\\b${binding}\\.([A-Za-z_$][\\w$-]*)`, 'g');
for (const [, name] of source.matchAll(dotted)) names.add(name);
const bracketed = new RegExp(
`\\b${binding}\\[\\s*['"\`]([\\w-]+)['"\`]\\s*\\]`,
'g',
);
for (const [, name] of source.matchAll(bracketed)) names.add(name);
return names;
}
// 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.
function hasDynamicAccess(source, binding) {
return new RegExp(`\\b${binding}\\[(?!\\s*['"\`][\\w-]+['"\`]\\s*\\])`).test(
source,
);
}
function declaredClasses(css) {
return new Set(
[...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.
function entryPointClasses(css) {
const names = new Set();
const body = stripComments(css);
// Selector lists precede a `{` and follow `}`, `{` or the start of file.
for (const [, , selectorList] of body.matchAll(/(^|[{}])([^{}]*)\{/g)) {
for (const selector of selectorList.split(',')) {
const trimmed = selector.trim();
if (!trimmed || trimmed.startsWith('@')) continue;
const leading = trimmed.match(/^\.(-?[A-Za-z_][\w-]*)/);
if (leading) names.add(leading[1]);
}
}
return names;
}
const modules = [];
for (const file of walk(srcDir)) {
const source = stripComments(readFileSync(file, 'utf8'));
for (const [binding, cssPath] of cssImports(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 } of modules) {
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', () => {
let checked = 0;
for (const { file, binding, cssPath, source } of modules) {
if (hasDynamicAccess(source, binding)) continue;
const declared = declaredClasses(readFileSync(cssPath, 'utf8'));
for (const name of referencedClasses(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', () => {
const byStylesheet = new Map();
for (const { binding, cssPath, source } of modules) {
if (!byStylesheet.has(cssPath)) byStylesheet.set(cssPath, new Set());
const used = byStylesheet.get(cssPath);
for (const name of referencedClasses(source, binding)) used.add(name);
}
for (const [cssPath, used] of byStylesheet) {
const entryPoints = entryPointClasses(readFileSync(cssPath, 'utf8'));
assert.ok(
entryPoints.size > 0,
`${relative(root, cssPath)} yielded no selectors; the extraction is vacuous`,
);
const orphans = [...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
istests/validate-button-contrast.test.mjsagainstsrc/css/custom.css. - With
tests/css-modules.test.mjsadded:node --test-> 59 tests, 57 pass,
2 fail, both insrc/theme/Footer/. With the diff above also applied: 59
tests, 59 pass. - Counts: 9
*.module.cssimporters undersrc/, 167 distinct class
references, 1 undeclared (iconLink), 1 orphan rule (orgText). - Edge cases checked and absent from this repo: no
:global(...), no
composes:, no computedstyles[expr]access. The test degrades safely if
any appear —hasDynamicAccessskips a file that starts indexing
dynamically, rather than reporting false failures. - End-to-end coverage: unobtainable, not absent. The repo declares no
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
- 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
Filed by quality agent (hold-gated mode)
— hive: agent=quality backend=copilot model=claude-opus-5
Activity
hivecommons-hive commentedon Sep 21, 2026
task-list sweep: 0 of 2 items ticked. Not closing yet — outstanding boxes remain.
Outstanding items:
src/theme/Footer/. Recommendedtests/css-modules.test.mjs(full text below) so the contractMerged PRs referencing this issue so far:
This comment is edited in place by the task-list sweep on every cycle; it is not duplicated.
hivecommons-hive commentedon Sep 22, 2026
Resolved by merged PR #371 (commit ab4ce2c,
test: cover the CSS Module class-name resolution contract), which addedtests/css-modules.test.mjs. The PR referenced this issue without a closing keyword, so it stayed open — closing now as done.🐝 Hive Agent:
quality| Instance:hosted-available-lke648397-260827-5n31| SHA:8c089a0— hive: agent=quality backend=copilot model=claude-opus-5