Custom rules
Build a local Critiq rule by starting from an existing rule and spec pair, narrowing the match shape, and proving behavior with fixture-backed tests.
#When to write a custom rule
Add a custom rule when the OSS catalog does not capture a repository policy you care about. The highest-signal rules usually encode a stable architecture boundary, a repeated review issue, or a house policy that generic checks cannot express precisely.
Keep the scope narrow. A good first rule should describe one concrete code pattern and one clear review outcome rather than trying to encode an entire style guide in a single document.
#Repository layout
Keep local rules under .critiq/rules/ beside the repo config so the rule authoring loop stays in the same repository as the code it governs.
.critiq/
config.yaml
rules/
ts.team.no-legacy-feature-flag.rule.yaml
ts.team.no-legacy-feature-flag.spec.yaml#Authoring workflow
Start from an existing local rule or from a maintained example in the separate critiq-rules repository. Copy a matching .rule.yaml and .spec.yaml pair into .critiq/rules/, then rename the rule ID and rewrite the human-facing text.
That approach matters because the first problem in rule authoring is almost never syntax. It is picking a rule shape that already matches the kind of AST condition you need. Starting from a nearby example lets you iterate on intent instead of rediscovering the document contract.
#Rule document shape
A rule document needs five top-level sections: apiVersion, kind, metadata, scope, match, and emit. In practical terms they answer these questions: what is this rule, where does it apply, what code shape should Critiq detect, and what finding should it emit.
apiVersion: critiq.dev/v1alpha1
kind: Rule
metadata:
id: ts.team.no-legacy-feature-flag
title: Avoid legacy feature flag helper
summary: New code should not depend on the legacy flag API.
scope:
languages:
- typescript
paths:
include:
- "src/**"
match:
node:
kind: CallExpression
where:
- path: callee.text
equals: isLegacyFlagEnabled
emit:
finding:
category: maintainability
severity: medium
confidence: high
message:
title: Avoid legacy feature flag helper
summary: Use the new flag client instead of isLegacyFlagEnabled.metadata should read like a review surface, not like an implementation note. The title and summary should be understandable to someone reading the finding in a pull request without needing to open the rule file first.
#Designing the match
The match block is the core of the rule. Simple rules often start with a single node and a small set of where checks. More specific rules usually combine conditions with all, any, not, or ancestor so the rule stays precise and does not drift into false positives.
The design target is predictability. If the match shape is too broad, the rule becomes noisy and teams disable it. If it is too narrow, it misses the pattern it was written to protect. Start with the smallest match that catches the intended example, then add conditions only when a failing spec proves you need them.
#Writing specs
Every rule should have a paired .spec.yaml. Start with one invalid source fixture that must emit a finding and one valid fixture that must stay clean. That gives you the fastest feedback loop for verifying both detection and non-detection.
Keep the fixtures small. A rule spec should isolate the pattern under test so you can tell whether a failure comes from the rule logic rather than from unrelated syntax or surrounding code noise.
#Validate and test
Run validation, explanation, and tests every time you change the rule or its fixtures.
npx critiq rules validate ".critiq/rules/*.rule.yaml"
npx critiq rules explain .critiq/rules/ts.team.no-legacy-feature-flag.rule.yaml
npx critiq rules test ".critiq/rules/*.spec.yaml"Use rules validate to catch contract and semantic issues, rules explain to inspect how Critiq interpreted the rule, and rules test to prove the fixtures behave the way you intended.
#Starter examples
ts.logging.no-console-logis a good starting point for a simplenode.whererule.ts.logging.no-console-erroris useful when you neednotplusancestor.ts.config.no-process-env-outside-configis a good shape for path scoping.
Pick the closest existing rule by language and pattern shape. That is a better starting point than a blank file.