Rules
The built-in rules are intentionally generic — no framework or
single-language assumptions — so the same binary works across all your repos.
Everything is on by default; you ratchet down with --skip. (The one exception
is no-comments, an opt-in mode.) Each rule only looks
at the file types where it makes sense (e.g. color ignores .json,
inline-svg only scans component sources).
Every rule is lexical. Straitjacket reads files and applies patterns; it does not parse, resolve types, or follow calls. That is why it runs on any repository without setup, and it is also the limit of what it can tell you.
Run straitjacket --list-rules to see them with their one-line descriptions.
That output comes from the binary, so it is the authority if this page and your
installed version ever disagree.
The rules#
| rule | default | flags |
|---|---|---|
emoji |
on | emoji glyphs in code, comments, strings, and Markdown (a reliable LLM tell). Color emoji, VS16-presented glyphs, and flag sequences — but not text symbols like © ™ ✓, arrows, dashes, or the geometric star. |
color |
on | hardcoded color literals — hex (#1e1e1e) and CSS color functions (rgb(), rgba(), hsl(), hwb(), lab(), lch(), oklab(), oklch(), color()). Use a theme token / CSS variable instead. Files listed in theme-files are allowed to define them. |
inline-svg |
on | hand-rolled inline <svg> in component code — extract it into a named, reusable icon. |
inline-font |
on | inline font-family literal stacks — a quoted font or a multi-family list (Inter, sans-serif). A token reference (fontFamily: MONO), a CSS variable, or a bare generic (monospace) is fine. Define the font once and reference a token. |
motion |
on | ad-hoc transition / animation / @keyframes — centralize motion so it can be tuned or disabled. |
file-size |
on | files longer than the line budget (default 1500) — sprawling single files are a common LLM tell. Tune with --max-lines, disable with --max-lines 0 or --skip file-size. Exempt path prefixes with file-size-exclude. |
deep-nesting |
on | lines indented past the nesting budget (default 8) — deeply nested logic is hard to follow. Depth is read off leading indentation (which is canonical when a formatter is enforced), so it's language-agnostic and needs no parser. Runs on programming-language sources only (not markup/data). Tune with --max-nesting, disable with --max-nesting 0 or --skip deep-nesting, or exempt code that legitimately nests deeper with an allow marker. |
stray-todo |
on | deferred-work markers left in comments — TODO, TBD, FIXME, WIP. Do the work now, or record it in an issue the repository tracks. Exempt path prefixes with todo-exclude. |
unused-marker |
on | a suppression marker that did not suppress anything — the finding it was written for is gone, so the marker is stale. Turn it off with --no-fail-on-unused-markers. |
no-comments |
opt-in | every comment, in every language it knows (//, /* */, #, --, <!-- -->). See no-comments mode below. |
stray-const |
opt-in | SCREAMING_SNAKE_CASE constants declared outside the files named by const-files — a limit, a path, a key or a magic number named where it happens to be used rather than where the program keeps its decisions. Parses with a treebank grammar, so it can tell a declaration from a use. See stray constants below. |
test-quality |
opt-in | tests that weaken what they prove — currently a loop or a conditional in a test body. Parses the file with a treebank grammar, so it reads a test the way the language writes one: #[test], @Test, it(...), TEST(...), test "...". See test quality below. |
deep-nesting and embedded DSLs#
deep-nesting reads depth from leading indentation and deliberately does not
tokenize the language — that's what keeps it a single, language-agnostic pass. The
trade-off: a multi-line string literal that embeds an indented DSL — YAML or
JSON in a Python """…""", an HTML/SQL heredoc, a template literal — is counted as
if that indentation were code nesting, so a deep enough block can trip the rule even
though the surrounding code is flat.
This is by design, not a bug to detect around: reliably knowing "am I inside a string literal?" needs a per-language parser, which the rule exists to avoid. Suppress the false positive with a marker instead.
For a file that carries a lot of embedded DSL — test fixtures, template modules — a file-scoped marker is cleanest, because it's a real comment outside any string and doesn't touch the literal's contents:
# straitjacket-allow-file:deep-nesting — this module is embedded YAML fixtures
A line-scoped marker also works — but it has to sit on the line the finding points at, which for embedded content is inside the string literal, so it becomes part of that text (usually fine as a DSL comment, but check it doesn't change what the fixture means):
CI = """\
jobs:
build:
steps:
- run: deep | embedded | yaml # straitjacket-allow:deep-nesting
"""
See Suppression markers for the full syntax.
emoji and test fixtures#
emoji is lexical and does not know a string literal from a comment, so a test
that deliberately exercises Unicode — remove_extension("💖.txt") — is flagged
like any other emoji in source. That is the rule working as specified, not a
misfire, but it is the most common first-run surprise on a repository that
handles text. A file-scoped marker is the right answer for a fixture module:
// straitjacket-allow-file:emoji — these fixtures test Unicode handling
test quality#
Rules about what a test proves, rather than how the code is written. They come from beamte, which implements them against the treebank node vocabulary; straitjacket runs them and reports what comes back.
Today that is one rule, test-logic: a loop or a conditional in a test body,
restating Testing on the Toilet: Don't Put Logic in
Tests.
A test is a concrete input/output pair, so state the values directly rather
than computing them, and split the cases into separate tests.
straitjacket --test-quality # beside the nine default rules
straitjacket --only test-quality # on its own
or in straitjacket.toml:
only = ["test-quality"]
test-rules = ["test-logic"] # optional: unset runs every rule beamte has
It is opt-in because it reaches the network. The grammar for a language is downloaded the first time a test file in that language is scanned, verified against a sha256 in treebank's manifest, and cached content-addressed after that. Grammars are fetched per language and only once a file has already looked like a test, so a Python repository never downloads the Java grammar. If a grammar cannot be fetched, the file is reported as not read rather than passing quietly — a checker that goes silent when its parser is missing reads exactly like a clean suite.
Languages#
Ten, being the ones treebank publishes a grammar for: Python, Ruby, Rust, Java, TypeScript, JavaScript, C, C++, Shell and Zig. A language with no grammar is not scanned by this rule at all.
Test detection is per language, because no two mark a test the same way:
| shape | looks like | languages |
|---|---|---|
| a name | def test_adds, void test_adds() |
Python, Ruby, Shell, C |
| an attribute | #[test], @Test |
Rust, Java |
| an invocation taking a body | it("adds", ...), TEST(Suite, Adds) |
TypeScript, JavaScript, Ruby, C, C++ |
| a declaration of its own | test "adds" { ... } |
Zig |
Rust and Zig keep tests inside ordinary source files, so files are not prefiltered by path alone.
A suite is not a test: a loop in describe(...) is generating cases, not
computing an expectation, and is left alone. In Ruby and JavaScript, iterating
with a block — users.each do |u|, users.forEach(...) — counts as a loop,
since that is the form those languages actually use.
no-comments mode#
Where — you guessed it — no comments are allowed. The no-comments rule flags
every comment: line, block, doc comments, pragmas, all of them. The position
is the maximalist one: a comment is a place where the code stopped speaking for
itself, and LLMs narrate relentlessly (// increment the counter). If it
matters, say it in the code; if it's history, say it in the commit message.
It's the one rule that is off by default — the rest of the rule set runs at its max and you ratchet down, but comments are ordinary in most codebases, so this one is a mode you opt into:
straitjacket --no-comments # the mode, alongside every other rule
straitjacket --only no-comments # just show me the comments (implies the mode)
or check it into straitjacket.toml with
no-comments = true.
A leading file header is permitted — ordinary comments are allowed in the first 10 lines, before code begins — and documentation comments are allowed wherever they feed language documentation tooling, including rustdoc and JSDoc.
It knows the comment syntax of the common extensions — C-family (//, /* */),
hash languages (#: Python, Ruby, shell, YAML, TOML), SQL (--), CSS
(/* */), and HTML/Vue/Svelte (<!-- -->) — and tracks string literals, so a
// in a URL or a # inside a quoted value isn't a comment. A block comment
reports once, at its opening delimiter.
Two things that look like comments are never flagged:
- Shebang lines (
#!/usr/bin/env bash) — interpreter directives, not commentary. - Suppression markers — a comment carrying
straitjacket-allow[-file]is the escape hatch at work. That's also what keeps the escape hatch usable for every other rule while the mode is on; a marker that suppresses nothing is still caught byunused-marker.
The scanner is deterministic, not a per-language parser, so an exotic literal (a
regex literal, a heredoc) can fool it — suppress with a
marker as usual, or grandfather a file
with straitjacket-allow-file:no-comments.
Severity and exit code#
The report distinguishes error and warning findings, and warnings are
tagged (warn) in the output. Every built-in rule shipped today reports at
error level, so a normal run's summary always reads 0 warning(s); the
distinction exists for rules that report advisory findings.
The process exits 1 when there's any error-level finding — so CI fails — and
0 when the scan is clean or found only warnings. It exits 2 on a
configuration or operational failure, which is a different thing from a finding
and should not be mistaken for one. Override with --no-fail to report
findings and still exit 0, which is the shape for a first run against an
existing repository.
Defaults at a glance#
| setting | default | flag |
|---|---|---|
| file-size line budget | 1500 | --max-lines |
| deep-nesting depth budget | 8 | --max-nesting |
| no-comments mode | off | --no-comments |
scan .json |
off | --include-json |
respect .gitignore |
on | --no-ignore |
| fail on unused markers | on | --no-fail-on-unused-markers |
| fail on findings | on | --no-fail |
stray constants#
A constant is a decision the program has made: a limit, a retry count, a path,
a key, a magic number somebody named. Scattered across the tree those decisions
cannot be read as a set, nobody can tell which are still true, and the same one
gets made twice under two names. stray-const reports a
SCREAMING_SNAKE_CASE declaration anywhere but the files you designate:
straitjacket --stray-const
or in straitjacket.toml:
stray-const = true
const-files = ["src/consts.rs", "src/env.rs"]
const-files is theme-files for constants: a declaration inside one is what
the rule is asking for, and everywhere else is an error. Enabling the rule
without naming a file is refused rather than obeyed — every constant would be
a finding with nowhere to move it, which is a configuration nobody means.
Declarations, not uses. MAX_SIZE mentioned in an expression is the whole
point of having a constant; only the line that introduces the name is a
finding. A rule that flagged uses could not be satisfied.
That distinction is the reason this rule parses. Telling a declaration from a
use is a question about the tree, and answering it from text needs a table of
declaration keywords per language — a parser written badly, which is what the
first version of this rule was. The analysis is
beamte's const-declaration,
which asks the node vocabulary instead:
| shape | what the tree says | verdict |
|---|---|---|
MAX_SIZE = 3 |
a binding | declared |
const MAX_SIZE: u8 = 3 |
a binding | declared |
from settings import MAX_SIZE |
a binding, and a directive | imported, not declared |
def f(MAX_SIZE) |
a binding, and a parameter | a parameter, not a constant |
n > MAX_SIZE |
neither | a use |
A name bound inside a function is a local — it cannot be moved to another file, so it is not reported. Because the rule reads the vocabulary rather than any language's syntax, there is no per-language table to drift: a constant is recognised the same way in every grammar.
It is opt-in for two reasons, either enough alone. The grammar for a language is downloaded the first time a file in that language is scanned, so the rule reaches the network; and it has nothing to say until you name the files constants belong in, which is a decision no default can make. A file whose grammar cannot be fetched is reported as not read rather than passing quietly.
What counts as a constant#
A name of at least two words joined by underscores — MAX_SIZE,
DEFAULT_PATH, API_BASE_URL. A single all-caps word is deliberately left
alone: PI, OK, HTTP, a Go export, a C header guard and a type parameter
are all spelled that way, and flagging them would bury the constants among
them.
Ten languages, being the ones treebank publishes a grammar for: Python, Ruby, Rust, Java, TypeScript, JavaScript, C, C++, Shell and Zig.
One miss is worth naming. An enum member written as an assignment in a class
body — Python's RED_ONE = 1 inside class Colour(Enum) — is a binding
outside any function and is reported, though it cannot be moved either.
Telling an enum from a class needs its base class, which is a fact about a
library rather than about the tree; name those files in const-files, or
suppress with a marker.