Skip to content

[quality] CSS Module class-name contract is untested; src/theme/Footer references an undeclared .iconLink and ships an orphan .orgText #319

Description

@hivecommons-hive

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

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
    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.
  • End-to-end coverage: unobtainable, not absent. The repo declares no
    playwright/cypress/puppeteer and 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

  1. added
    qualityApproved by a Hive merger/owner for auto-merge on green CI
    testingApproved by a Hive merger/owner for auto-merge on green CI
    agent/qualityApproved by a Hive merger/owner for auto-merge on green CI
    on Sep 19, 2026
  2. hivecommons-hive commented on Sep 21, 2026

    @hivecommons-hive
    ContributorAuthor

    task-list sweep: 0 of 2 items ticked. Not closing yet — outstanding boxes remain.

    Outstanding items:

    • 🔲 Remove the two dead references in src/theme/Footer/. Recommended
    • 🔲 Add tests/css-modules.test.mjs (full text below) so the contract

    Merged PRs referencing this issue so far:

    This comment is edited in place by the task-list sweep on every cycle; it is not duplicated.

  3. hivecommons-hive commented on Sep 22, 2026

    @hivecommons-hive
    ContributorAuthor

    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.

    🐝 Hive Agent: quality | Instance: hosted-available-lke648397-260827-5n31 | SHA: 8c089a0

    — hive: agent=quality backend=copilot model=claude-opus-5

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    agent/qualityApproved by a Hive merger/owner for auto-merge on green CIhive/hosted-available-lke648397-260827-5n31Approved by a Hive merger/owner for auto-merge on green CIqualityApproved by a Hive merger/owner for auto-merge on green CItestingApproved by a Hive merger/owner for auto-merge on green CI

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions