Code-quality tests

Tests that read the source code and fail on patterns you have banned

When the linter does not have the rule you want

Biome and ESLint cover the common rules. Every codebase also has its own: "don't call process.cwd() in tests", "don't put HTML in JavaScript strings", "don't mutate objects". Writing a linter plugin for each is heavy. Writing a test is not.

The chobble-template keeps 42 of these in test/unit/code-quality/. Each one reads the source files, looks for a pattern, and fails with the file and line of every violation. They run with the rest of the suite, so they need no extra tooling.

Every check, grouped

All 42 tests in the folder, grouped by what they protect. Each name links to the test file, which explains the rule in its header.

Hiding errors

Test What it bans
or-fallbacks x || [], x || "" and other || defaults that hide missing data. Defaults belong early in the data chain.
nullish-coalescing ?? anywhere outside the collection builders and shared packages, where defaults are set once
data-fallbacks item.data.title ?? "" style fallbacks on collection data, which is already typed
try-catch-usage try/catch outside the allow-list in the exceptions file

Mutation and state

Test What it bans
let-usage let, and "mutable const": const x = [], {}, new Set() or new Map()
array-push .push(). Build a new array with map, filter or reduce.
object-mutation obj[key] = value assignments

Size and structure

Test What it bans
function-length Functions over 30 lines (20 preferred)
single-use-functions Unexported functions called exactly once, which add indirection without reuse
duplicate-methods The same function name defined in more than one file, a sign that two files should share one helper
test-only-exports Exports from src/ and packages/ that only tests import
memoize-inside-function memoize() inside a function body, which makes a new, empty cache on every call
nested-array-lookup .find() or .filter() inside another loop's callback: O(n×m) work where an index would be O(n+m)
short-circuit-order arr.includes(x) && id === y. Put the cheap equality check first.

Naming, style and comments

Test What it bans
aliasing const alias = fn, const alias = obj.prop: a second name for the same thing
destructuring const { foo } = bar. Write bar.foo so the source of each value stays visible.
naming-conventions camelCase names longer than four words
then-usage .then() chains. Use async/await.
relative-paths ../ imports, and over-broad #src/ aliases, where a specific alias exists
inline-type-annotations JSDoc @type comments inside function bodies
comment-limits More than five lines of inline comments in a file, beyond the header. Agents over-comment.
commented-code Code left in comments
todo-fixme-comments TODO and FIXME, which belong in issues
backwards-compat Any mention of "backwards compatibility", which in an internal codebase means a shim nobody needs

Templates, CSS and the site

Test What it bans
html-in-js HTML built in JavaScript strings instead of templates
url-construction Hard-coded collection paths such as /products/ in URLs. The paths come from the strings file, so a site can rename them.
unused-classes Classes in the HTML that no stylesheet or script uses
unused-images Image files that no page uses
design-system-scoping SCSS rules outside the .design-system { } scope
template-selectors An element ID in selectors.js that no HTML template contains
unregistered-collections collections.foo in a template when no foo collection is registered
data-exports Data files that mix named and default exports, which breaks Eleventy
block-markdown-rendering A content block that renders a plain-text field as Markdown, or a Markdown field without the prose styles

Generated files

Test What it bans
pages-yml-freshness A committed CMS config, .pages.yml, that no longer matches what its generator produces
pages-yml-reference-names A collection entry the CMS refers to that has no name field
type-generation-freshness Generated TypeScript types that have drifted from .pages.yml
lockfile Any lockfile except bun.lock

Tests

Test What it bans
test-quality Vague test names (test1, works), names with more than one "and", assertions without a message, set-then-assert tautologies, and async tests that never await
mock-module-usage Bare mock.module(), which leaks into other test files. Use the mockModule helper, which restores the module.
dom-mocking globalThis.document and new DOM(), when the test DOM is already global

The checkers themselves

Test What it bans
code-scanner Unit tests for the shared scanner that every check above uses
test-hygiene Empty file lists. A check that scans no files passes, and nobody notices.

The same idea in tickets

The tickets repository keeps its version in one file, test/integration/code-quality.test.ts. It scans the real src/ and test/ trees and expects zero violations. The detectors live in test/scripts/code-quality/, and each one has its own fixture tests that prove it fires on bad code and stays quiet on good code. These are all ten rules:

Rule What it bans
No in-memory state Module-level Map or Set in src/. An edge function can run on many isolates, so state there is not shared. Six caches are allowed, each with a comment.
DB writes go through the client getDb().execute or .batch outside the database client. A raw write skips cache invalidation.
Numbered parameters SQL text that spells a ?N token by hand. Each value binds once through the numbered binder.
No aliasing A module-level second name for an existing function or value
No module-level let Mutable module state. Use const with once() or lazyRef().
No .then() Promise chains. Use async/await.
No ../ imports Relative parent imports. Use a # alias.
No test-only exports Exports from src/ that only tests use. See Dead code.
No redundant constant arguments A function that every caller passes the same constant. Use a default parameter or a constant instead.
No duplicate type shapes Two differently named types with the same members. Make them one shared type.

The shape of one check

Each test file starts with a comment that says what it detects and why, then uses a shared scanner from the toolkit package. The header of single-use-functions.test.js is typical:

/**
 * Detects unexported functions that are only called once.
 *
 * Single-use unexported functions are candidates for inlining into
 * their caller, as they add indirection without reuse benefit. This
 * is a warning, not an error, since some single-use functions are
 * intentionally named for clarity.
 */

The scanner (packages/js-toolkit/code-quality/) handles reading files, matching patterns, skipping comments, and reporting. A new check is mostly a regular expression and an explanation.

Exceptions live in one file, and only shrink

Every grandfathered violation lives in one file, test/code-quality/code-quality-exceptions.js, as file:line or whole-file entries with a comment explaining each one. The file opens with a banner telling every reader, human or agent, that the only valid change is a deletion. Several checks also fail on a stale exception, one that no longer matches a violation, so fixed code cannot leave a loophole behind for the next change.

Checks that check the checks

The tickets repository takes this one step further: its precommit tooling is itself under test in test/scripts/, with 100% coverage. A check that silently stops matching is worse than no check, because everyone believes it is still working.