Skip to content

Authoring rules

Repo-local rules live at .sextant/rules/<name>.md. The file is markdown with YAML frontmatter — same shape as the built-in rules embedded in crates/sextant-rules/rules/builtin/.

The body of the file becomes user-facing documentation: it’s what explain_rule and sextant rules explain print, and it’s what the LLM evaluator (if any) uses as its prompt.

---
id: project.no-todo # required, dotted, globally unique
name: "No TODO comments" # required, human-readable
description: "One-liner." # required, shown in `rules list`
severity: warn # info | warn | error
category: style # complexity | size | duplication |
# tests | reliability | style |
# security | docs | { custom: "<name>" }
scope: file # diff | file | repo
languages: [rust, python] # omit = all languages
evaluator: # see below
type: regex
pattern: '\bTODO\b'
enabled: true # default true
overrides: [] # rule ids this rule disables
tags: [style, todo]
---
FieldRequiredNotes
idyesDotted, globally unique. Convention: <source>.<category>.<short-name>.
nameyesHuman-readable.
descriptionyesOne-liner shown in rules list.
severityyesinfo, warn, or error.
categoryyesBuilt-in enum or { custom: "<name>" }.
scopeyesdiff, file, or repo.
languagesnoWhitelist. Empty = all languages.
evaluatoryesregex, ast, or llm for repo rules. See below.
enablednoDefault true. Set false for a stub overriding a built-in.
overridesnoRule ids this rule replaces.
tagsnoFree-form labels.
evaluator:
type: regex
pattern: '\.unwrap\('
FieldRequiredNotes
patternyesStandard Rust regex. Matched per line.
replacementnoRegex-crate replacement template. When set, each match emits a unified-diff patch rewriting the line.

Cheapest authoring option. The regex runs against each line of each file in scope; every match is one finding pointing at that line.

Runs a tree-sitter query against the file’s parse tree. Pick this when you need to distinguish a keyword in a type position from the same keyword in a comment or string, or when you need to scope a match by ancestor node kind.

evaluator:
type: ast
query: '((predefined_type) @t (#eq? @t "any"))'
capture: t
message: "no `any` allowed"
not_under: [catch_clause]
FieldRequiredNotes
queryyesTree-sitter query S-expression, compiled per language listed in languages:.
capturenoCapture name to anchor the finding line. Defaults to the first capture.
messagenoOverride message. Defaults to <rule.name>: matched <snippet>.
not_undernoDrop a match if any ancestor’s node kind is in this list.

ast rules require at least one language in languages:. The same query string is compiled once per listed language, so you can target both typescript and tsx (which share grammar) with one file.

evaluator:
type: llm
model: claude-sonnet-4-6 # optional; falls back to [judge].model
max_tokens: 1024 # optional
temperature: 0.0 # optional

The rule body is the prompt. Placeholders {{path}}, {{code}}, and {{rule.id}} get substituted at evaluation time. Output is constrained via tool-use to well-typed Findings — no JSON parsing failures.

Requires [judge] in .sextant/config.toml and the corresponding API key in env. See Configuration → judge.

Reserved for built-in rules. Don’t use this in repo-local rules.

  1. Validate: sextant rules check .sextant/rules/<name>.md — catches YAML errors and missing fields without fully loading the rule.
  2. Confirm load: sextant rules list | grep <id> — should show the rule with source: repo.
  3. Try it: sextant grade and look for findings.
  4. Read it back: sextant rules explain <id> — verify the body formats well.
  • The body is shown verbatim by explain_rule. Treat it as user- facing documentation: explain why the rule exists and how to fix a finding.
  • Calibrate severity. error should block real bugs; warn is “fix when convenient”; info is informational.
  • For LLM rules, write the prompt to ask for concrete findings tied to specific lines. Vague prompts produce vague output.

Repo-local rules with the same id as a built-in replace it. To turn one off entirely, ship a stub:

---
id: builtin.size.fn-length
name: "(disabled)"
description: "x"
severity: info
category: size
enabled: false
evaluator: { type: regex, pattern: "(?!)" }
---

The (?!) regex never matches, the stub is enabled: false, and the built-in is replaced by an inert rule.

---
id: project.no-unwrap
name: "No unwrap()"
description: "Forbid .unwrap() in production code."
severity: warn
category: reliability
scope: file
languages: [rust]
evaluator:
type: regex
pattern: '\.unwrap\('
tags: [rust, panics]
---
# No unwrap()
`.unwrap()` panics on `None` / `Err`. In production code that's
almost always a bug — the program crashes instead of handling the
error path.
## Fixing a finding
- Use `?` to propagate errors up.
- Use `.expect("reason")` if the panic is genuinely unreachable, with
a comment explaining why.
- Use pattern matching to handle both arms explicitly.
---
id: project.api-surface-comments
name: "Public API needs a comment"
description: "Public functions / types should have a doc-comment explaining intent."
severity: info
category: docs
scope: file
languages: [rust]
evaluator:
type: llm
model: claude-sonnet-4-6
---
# Public API needs a comment
You are reviewing `{{path}}` for missing documentation on the public
API surface.
Look for `pub fn`, `pub struct`, `pub enum`, and `pub trait` items
that lack a `///` or `//!` doc-comment immediately above them.
Internal items (`pub(crate)`, `pub(super)`) are out of scope.
For each violation, return a finding pointing at the line of the
public item with severity `info` and a message naming the item and
suggesting a one-line description.
```code
{{code}}

If you want to distribute a rule set — to other repos, other teams, or the internet — bundle the markdown files into a rule pack. A pack adds a pack.toml manifest, lives under .sextant/rules/vendor/<name>/ once installed, and ships hash-locked so consumers can’t silently disable individual rules.