- All lint rules live in
src/rules - Each rule is self-contained:
index.tsfor rule source,index.test.tsfor tests andREADME.mdfor the rule documentation - Rule presets live in
src/configs
- Before committing, run the linter:
npm run lint - This project uses ESM only, do not use CommonJS
- Create a new folder under
src/rules/<rule-name>/with all three of the following files (none are optional):index.ts— the rule implementationindex.test.ts— tests for the ruleREADME.md— documentation (see Rule README guidelines)
- Register the rule in
src/index.ts(import + plugins array) at the correct alphabetical position and updatesrc/index.test.ts(expected names array) accordingly - Add the rule to all applicable configuration presets in
src/configs/— thesrc/configs/recommended.test.tsasserts that every exported rule appears inrecommended.ts, so omitting it will fail the test suite - Add the rule to the corresponding preset configuration rules list in root
README.md - Use PostCSS API's as much as possible. Only if goals cannot be achieved reach for
@projectwallace/css-parser - Only use
@projectwallace/css-parsermethodsparse_value(),parse_selector(),parse_selector_list()orparse_atrule_prelude(). Other parsing methods SHOULD NOT be necessary. - If the rule should allow users to exclude specific values, add a secondary
ignoreoption (see theignoreoption pattern below).
This project follows the stylelint contributor guide for rules for message wording, error positioning, README structure, and test conventions. Refer to that guide before inventing new patterns; the sections below document conventions we follow and any project-specific additions.
Every rule's README.md must follow this structure exactly:
# Rule name
One-sentence description.
<!-- prettier-ignore -->
```css
selector or declaration {}
/* ↑
* what this arrow points at */One paragraph explaining what is measured and why it matters.
type signature
Description of each component (e.g. for [a, b, c] tuples: what a, b, c mean).
Given: <example option value>
the following are considered violations:
/* violating CSS */The following patterns are not considered violations:
/* passing CSS */
Rules:
- Wrap all CSS blocks with `<!-- prettier-ignore -->` to prevent reformatting
- Show at least one violation and one passing pattern
- Document all options including their type signature
## The `ignore` option pattern
When a rule should let users exclude specific values from being checked, name the secondary option `ignore`. It accepts `Array<string | RegExp>`. Both exact string matches and regular expressions must be supported.
**Naming:** always `ignore`, never `allowList`, `ignoreValues`, `ignoreProperties`, or any other variant.
**Validation** — import `ignore_option_validators` from `src/utils/option-validators.ts` and pass it to `validateOptions`:
```ts
import { is_allowed, ignore_option_validators } from '../../utils/option-validators.js'
// inside validateOptions:
{
actual: secondaryOptions,
possible: { ignore: ignore_option_validators },
optional: true,
}
Checking — import is_allowed from the same utility and call it with the value and the option:
const ignore = secondaryOptions?.ignore ?? []
if (!is_allowed(value, ignore)) {
/* count or report */
}lint() helper — rules with a primary option use a shared async helper so each test only passes the CSS and options:
async function lint(code: string, primaryOption: unknown, secondaryOptions?: unknown) {
const config = {
plugins: [plugin],
rules: {
[rule_name]:
secondaryOptions !== undefined ? [primaryOption, secondaryOptions] : primaryOption,
},
}
const {
results: [result],
} = await stylelint.lint({ code, config })
return result
}Section headers — group tests with 75-dash divider comments:
// ---------------------------------------------------------------------------
// No violation
// ---------------------------------------------------------------------------Test naming — use the following prefixes consistently:
should not run when …— for invalid/disabled config (option validation)should not error when …— for valid CSS that passes the ruleshould error when …— for CSS that triggers a violation
Assertions — always check both errored and warnings together. Use toStrictEqual([]) for empty warnings. Exception: invalid-option tests (the should not run when … group) only assert expect(errored).toBe(true) — no warnings check, since the count and content of validation errors are stylelint internals.
ignore option tests — whenever a rule supports the ignore secondary option, cover both a plain string match and a RegExp pattern in the tests.
test.each — use test.each([]) instead of individual test() calls when testing multiple similar inputs (e.g. a list of CSS keywords or property values). This keeps test files concise.
Import the appropriate validator from src/utils/option-validators.ts and pass it directly to possible. No secondary guard is needed.
| Validator | Accepts | Use for |
|---|---|---|
is_valid_positive_integer |
integer > 0 | counts that must be at least 1 |
is_valid_non_negative_integer |
integer >= 0 | counts that may be 0 (to disable) |
is_valid_ratio |
float 0–1 | percentage/ratio options |
import { is_valid_positive_integer } from '../../utils/option-validators.js'
// inside validateOptions:
{ actual: primaryOption, possible: [is_valid_positive_integer] }Passing an out-of-range value (e.g. -1 or 1.5 to a count rule) is a breaking error — stylelint reports errored: true rather than silently skipping the rule.
Set ruleFunction.primaryOptionArray = true so stylelint treats the array as the primary option rather than a secondary-option wrapper. Validate with a custom function:
function is_valid_specificity(v: unknown): boolean {
return (
Array.isArray(v) &&
v.length === 3 &&
v.every((n: unknown) => typeof n === 'number' && Number.isInteger(n) && n >= 0)
)
}Use possible: is_valid_specificity inside validateOptions.
import { getSpecificity, compareSpecificity } from '@projectwallace/css-analyzer/selectors'
// compareSpecificity returns > 0 when first arg has higher specificity than second
if (compareSpecificity(actual, max) > 0) {
// violation
}For per-selector checks, parse first then call getSpecificity on each individual selector text:
import { parse_selector_list } from '@projectwallace/css-parser/parse-selector'
const selector_list = parse_selector_list(rule.selector)
for (const selector of selector_list) {
const specificities = getSpecificity(selector.text)
const specificity = specificities[0] as [number, number, number]
// compare specificity here
}Always pass index/endIndex (or word) to utils.report so the error highlights only the offending token, not the whole PostCSS node. The correct approach depends on which node type you're reporting on.
parse_selector_list returns nodes with .start/.end as character offsets within the selector string. Since a Rule node's source starts with the selector, these map directly to index/endIndex:
const selector_list = parse_selector_list(rule.selector)
for (const selector of selector_list) {
if (/* violation */) {
utils.report({
node: rule,
index: selector.start,
endIndex: selector.end,
// ...
})
}
}For a specific token within a selector (e.g. a prefixed pseudo-element), use the token node's .start/.end — they are also relative to rule.selector:
walk(selector, (node) => {
if (node.is_vendor_prefixed) {
utils.report({ node: rule, index: node.start, endIndex: node.end /* ... */ })
}
})Use word: decl.prop to highlight just the property name. Use word: node.text to highlight a specific parsed value token:
// Property violation
utils.report({ node: declaration, word: declaration.prop /* ... */ })
// Value token violation (node from parse_value / walk)
utils.report({ node: declaration, word: node.text /* ... */ })Use word: at_rule.name to highlight just the at-rule name (after @):
utils.report({ node: at_rule, word: at_rule.name /* ... */ })Nodes returned by parse_atrule_prelude have .start/.end relative to at_rule.params. To convert to an offset within the AtRule node source (which starts at @), add the params prefix length:
const params_offset = 1 + at_rule.name.length + (at_rule.raws.afterName ?? ' ').length
utils.report({
node: at_rule,
index: params_offset + parsed_node.start,
endIndex: params_offset + parsed_node.end,
// ...
})Violation tests must assert column and endColumn to prove the error focuses on the right token rather than the whole node:
expect(warnings[0]).toMatchObject({
column: 5, // start of the offending token
endColumn: 22, // end of the offending token, not the end of the whole rule
// ...
})- Create a new file under
src/configs/<config-name>.ts - Document the config in the Usage section of
README.md