Guide

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.

text
.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.

yaml
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.

bash
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-log is a good starting point for a simple node.where rule.
  • ts.logging.no-console-error is useful when you neednot plus ancestor.
  • ts.config.no-process-env-outside-config is 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.

#Next links