Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,43 @@

## [Unreleased]

### Added

- `stray-const`, an opt-in rule that reports `SCREAMING_SNAKE_CASE` constants
**declared** outside the files named by `const-files`. A constant is a
decision the program has made -- a limit, a path, a key, a magic number
somebody named -- and scattered across the tree those decisions cannot be
read as a set, so the same one gets made twice under two names.
`const-files` is `theme-files` for constants: a declaration inside one is
what the rule asks for, everywhere else is an error. Enable with
`stray-const = true` or `--stray-const`; enabling it without naming a file
is refused, because every constant would be a finding with nowhere to move

Check warning on line 19 in CHANGELOG.md

View workflow job for this annotation

GitHub Actions / vale

[vale] reported by reviewdog 🐶 [write-good.Passive] 'is refused' may be passive voice. Use active voice if you can. Raw Output: {"message":"[write-good.Passive] 'is refused' may be passive voice. Use active voice if you can.","location":{"path":"CHANGELOG.md","range":{"start":{"line":19,"column":3},"end":{"line":19,"column":13}}},"severity":"WARNING","code":{"value":"write-good.Passive"}}
it.

The analysis is [beamte](https://github.com/PowderworksCode/beamte)'s
`const-declaration`, and it parses rather than matching text because the
whole content of the rule is the difference between declaring a name and
using one. Text cannot tell those apart without a table of declaration
keywords per language; the node vocabulary answers it in one form for every
grammar, so an import binds a name without declaring it, a parameter is not
a constant, a function's locals cannot be moved to another file, and a use

Check warning on line 28 in CHANGELOG.md

View workflow job for this annotation

GitHub Actions / vale

[vale] reported by reviewdog 🐶 [write-good.Passive] 'be moved' may be passive voice. Use active voice if you can. Raw Output: {"message":"[write-good.Passive] 'be moved' may be passive voice. Use active voice if you can.","location":{"path":"CHANGELOG.md","range":{"start":{"line":28,"column":42},"end":{"line":28,"column":50}}},"severity":"WARNING","code":{"value":"write-good.Passive"}}
is not a binding at all. Ten languages, the ones treebank publishes a
grammar for. Opt-in twice over: the first scan of a language downloads its
grammar, and the rule has nothing to say until the files are named.

Check warning on line 31 in CHANGELOG.md

View workflow job for this annotation

GitHub Actions / vale

[vale] reported by reviewdog 🐶 [write-good.Passive] 'are named' may be passive voice. Use active voice if you can. Raw Output: {"message":"[write-good.Passive] 'are named' may be passive voice. Use active voice if you can.","location":{"path":"CHANGELOG.md","range":{"start":{"line":31,"column":60},"end":{"line":31,"column":69}}},"severity":"WARNING","code":{"value":"write-good.Passive"}}

### Changed

- The pack cache moves to `src/pack.rs`, shared, so two rules meeting the same
language in one scan JIT its grammar once between them rather than each
keeping a copy.
- `test-quality` renders instructions for the test-scoped rules only. beamte's
catalogue now holds rules it does not run, and advertising one would promise
a check `test-quality` never makes.
- beamte 0.3: `Rule::property` and `Rule::citation` are `Option`, so a rule may
state a structural fact rather than restate a published argument.
`severity_of` maps a rule with no test property to a warning, since it makes
no claim that mapping is about.

## [0.2.0] - 2026-08-30

### Added
Expand Down
21 changes: 10 additions & 11 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 13 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ wasmer = { version = "7", default-features = false, features = ["sys", "cranelif
# `pure` selects the Rust implementation instead, and naming blake3 here is what
# unifies that feature across the whole graph.
blake3 = { version = "1", default-features = false, features = ["pure"] }
beamte = "0.1"
beamte = "0.3"
clap = { version = "4", features = ["derive"] }
ignore = "0.4"
inventory = "0.3"
Expand Down Expand Up @@ -64,3 +64,15 @@ let_underscore_must_use = "deny"
[profile.release]
lto = true
strip = true

# beamte 0.3 (`const-declaration`, and `Rule::property`/`citation` as
# `Option`) is not on crates.io yet. Until it is, the requirement above
# resolves through this patch to the commit that carries it.
#
# A commit rather than a branch, because a branch name stops resolving the
# moment the branch is merged and deleted, which is how #63 broke: the
# dependency vanished under a PR that had not changed. A merged commit stays
# reachable. Drop this table the moment 0.3 is published -- the requirement
# above is already written for the registry.
[patch.crates-io]
beamte = { git = "https://github.com/PowderworksCode/beamte", rev = "2aa372ecec972e9b3535885c4615968d6a0878bc" }
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,12 @@ src/theme/button.css:12:14 [color] #ff6600
straitjacket: 1 error(s), 0 warning(s) across 84 file(s); 0 suppressed
```

Eleven rules ship; nine run at the first invocation. The other two you opt
into: `no-comments`, and `test-quality`, which reads your tests the way the
Twelve rules ship; nine run at the first invocation. The other three you opt
into: `no-comments`; `stray-const`, which reports `SCREAMING_SNAKE_CASE`
constants *declared* anywhere but the files you designate as their home, so
the decisions a program has made can be read as a set rather than hunted for
— it parses, because telling a declaration from a use is a question about the
tree; and `test-quality`, which reads your tests the way the
language writes them — `#[test]`, `@Test`, `it(...)`, `TEST(...)`,
`test "..."` — and flags the ones that weaken what they prove, such as a loop
or a conditional in a test body. It parses with a
Expand Down
2 changes: 1 addition & 1 deletion site/content/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ straitjacket
```

With no arguments, Straitjacket scans the current directory, honoring your
`.gitignore`. Nine of the eleven rules are on by default — it runs near its
`.gitignore`. Nine of the twelve rules are on by default — it runs near its
max and you ratchet down later. The other two are modes you opt into:
`no-comments`, and `test-quality`, which parses your tests with a downloaded
grammar.
Expand Down
2 changes: 1 addition & 1 deletion site/content/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ Run `straitjacket` at the root of any project. It honors your `.gitignore`,
prints one line per finding as `path:line:col [rule] matched`, and exits
non-zero on any error — so CI fails the moment slop lands.

Nine of the eleven rules are on at the first run, so the strictest
Nine of the twelve rules are on at the first run, so the strictest
Straitjacket gets by default takes no configuration to reach. What you
disagree with, you turn off — `--skip` for a run, `straitjacket.toml` for
good, `straitjacket-allow` on the one line you meant. The other two you opt
Expand Down
2 changes: 2 additions & 0 deletions site/content/reference/config-file.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ Every key mirrors a [CLI flag](/reference/cli) one-for-one, in
| `test-rules` | list of [test rule](/reference/rules#test-quality) ids; unset runs all | — |
| `test-quality` | boolean | `--test-quality` ([test quality](/reference/rules#test-quality)) |
| `no-comments` | boolean | `--no-comments` ([no-comments mode](/reference/rules#no-comments-mode)) |
| `stray-const` | boolean | `--stray-const` ([stray constants](/reference/rules#stray-constants)) |
| `const-files` | list of files constants are declared in — required when `stray-const` is on | — |
| `include-json` | boolean | `--include-json` |
| `no-ignore` | boolean | `--no-ignore` |
| `no-fail` | boolean | `--no-fail` |
Expand Down
74 changes: 74 additions & 0 deletions site/content/reference/rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
| `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](#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](#stray-constants) below. |

Check warning on line 36 in site/content/reference/rules.md

View workflow job for this annotation

GitHub Actions / vale

[vale] reported by reviewdog 🐶 [write-good.Passive] 'be used' may be passive voice. Use active voice if you can. Raw Output: {"message":"[write-good.Passive] 'be used' may be passive voice. Use active voice if you can.","location":{"path":"site/content/reference/rules.md","range":{"start":{"line":36,"column":190},"end":{"line":36,"column":197}}},"severity":"WARNING","code":{"value":"write-good.Passive"}}
| `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](#test-quality) below. |

### `deep-nesting` and embedded DSLs
Expand Down Expand Up @@ -211,3 +212,76 @@
| 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:

```sh
straitjacket --stray-const
```

or in [`straitjacket.toml`](/reference/config-file):

```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](https://github.com/PowderworksCode/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.
5 changes: 5 additions & 0 deletions site/content/rules.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@
"summary": "ordinary comment outside the leading file header",
"default_enabled": false
},
{
"id": "stray-const",
"summary": "a constant is declared outside the designated constant files",
"default_enabled": false
},
{
"id": "stray-todo",
"summary": "deferred-work marker left in a comment",
Expand Down
17 changes: 17 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,13 @@ pub struct FileConfig {
pub theme_files: Option<Vec<String>>,
pub max_nesting: Option<usize>,
pub no_comments: Option<bool>,
/// Turn on `stray-const`, which reports SCREAMING_SNAKE_CASE constants
/// declared outside the files named by `const-files`.
pub stray_const: Option<bool>,
/// The files constants are declared in. `theme-files` for constants: a
/// declaration inside one is what the rule asks for, everywhere else it
/// is a finding.
pub const_files: Option<Vec<String>>,
pub test_quality: Option<bool>,
pub include_json: Option<bool>,
pub no_ignore: Option<bool>,
Expand Down Expand Up @@ -55,6 +62,8 @@ pub struct Settings {
pub max_nesting: usize,
pub no_comments: bool,
pub test_quality: bool,
pub stray_const: bool,
pub const_files: Vec<PathBuf>,
pub include_json: bool,
pub no_ignore: bool,
pub no_fail: bool,
Expand All @@ -77,6 +86,8 @@ impl Default for Settings {
max_nesting: DEFAULT_MAX_NESTING,
no_comments: false,
test_quality: false,
stray_const: false,
const_files: Vec::new(),
include_json: false,
no_ignore: false,
no_fail: false,
Expand Down Expand Up @@ -126,6 +137,12 @@ impl Settings {
if let Some(value) = file.no_comments {
self.no_comments = value;
}
if let Some(value) = file.stray_const {
self.stray_const = value;
}
if let Some(paths) = file.const_files {
self.const_files = paths.into_iter().map(PathBuf::from).collect();
}
if let Some(value) = file.test_quality {
self.test_quality = value;
}
Expand Down
4 changes: 4 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ struct Cli {

#[arg(long, help = "Enable the opt-in `no-comments` rule")]
no_comments: bool,

#[arg(long, help = "Enable the opt-in `stray-const` rule")]
stray_const: bool,
#[arg(long, help = "Enable the opt-in `test-quality` rule")]
test_quality: bool,

Expand Down Expand Up @@ -333,6 +336,7 @@ fn resolve(cli: &Cli) -> anyhow::Result<Settings> {
settings.max_nesting = value;
}
settings.no_comments |= cli.no_comments;
settings.stray_const |= cli.stray_const;
settings.test_quality |= cli.test_quality;
settings.include_json |= cli.include_json;
settings.no_ignore |= cli.no_ignore;
Expand Down
Loading
Loading