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.