Skip to content

stray-const: the constant declared where it happens to be used - #66

Draft
zmaril wants to merge 3 commits into
mainfrom
claude/sj-const-files-2g6g2t
Draft

zmaril wants to merge 3 commits into
mainfrom
claude/sj-const-files-2g6g2t

Conversation

@zmaril

@zmaril zmaril commented Aug 30, 2026 •

Copy link
Copy Markdown
Contributor

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 say 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; const-files is theme-files for constants.

stray-const = true
const-files = ["src/consts.rs", "src/env.rs"]

Or --stray-const. Enabling it with no const-files is refused rather than obeyed: every constant would be a finding with nowhere to move it, which is a configuration nobody means.

The analysis is beamte's

The first cut of this rule matched declaration syntax with a per-language table of regexes, here in straitjacket. That was wrong, and the second commit replaces it.

That table is a parser written badly. The entire content of the rule is the difference between declaring a name and using one, and text cannot tell those apart without knowing each language's declaration grammar — so every language added meant another pattern to get subtly wrong, and each one had to re-answer where a string literal ends and where a comment starts.

So the analysis moved to beamte, which owns what a construct is: PowderworksCode/beamte#23 adds const-declaration, which asks the node vocabulary instead — a _binding that is neither a _directive nor a _parameter, outside any _callable. One form, every grammar, no table to drift.

What remains in this PR is everything beamte refuses to do: fetch a grammar, parse, choose a severity, and name the files constants belong in. That last one is the policy half and stays policy — beamte reports every declaration it finds, const-files decides which are licensed.

What the change costs and buys

regex (first commit) beamte (this PR)
languages 18 10, the ones treebank publishes a grammar for
first scan no network downloads a grammar, then cached
n > MAX_SIZE flagged not a declaration
TOKEN_VAR=… in publish.sh missed found
commented out / in a string needed a masking pass free

The lost eight languages are the honest price: a language going quiet here is now a grammar problem rather than a missing table entry, which is at least a problem with one fix. Both costs are why the rule is off by default twice over — a scan that reaches the network because the tool was upgraded is not a surprise anyone should get for free, and where constants belong is a decision no default can make.

rules/comments.rs reverts to main. The code/comment masking it grew existed only to keep the regex off commented-out code; a parse does that for nothing. pack::cached moves out of test_quality.rs into pack.rs, so both beamte hosts share one grammar cache rather than each keeping a private copy.

Two decisions that keep it a rule rather than a nuisance

  • Declarations, not uses. Referencing MAX_SIZE is the entire point of having it; flagging uses would make the rule unsatisfiable. This is the one the regex could not actually deliver.
  • At least two words joined by an underscore. PI, OK, HTTP, a Go export and a C header guard are all single all-caps words — flagging them would bury the constants among them.

One caveat worth knowing: an enum member spelled in screaming snake is a binding outside a callable, so it reads as a declaration. That is the rule working as specified rather than a bug, and straitjacket-allow covers the cases where it isn't wanted.

Dogfooding

Run against this repository with const-files = ["src/config.rs"]: 24 findings across 43 files — 13 Rust, 6 shell, 5 TypeScript. MINIMUM_PACK_ABI, WASI_BADF, HEADER_LINE_LIMIT, LINE_MARKER, FILE_MARKER, STRUCTURED_CODE and the rest. Every one is a real declaration.

The four above the regex version's twenty are shell reassignments in publish.sh, which no tree can distinguish from declarations — shell has no separate declaration form. Spot-checked as correctly not flagged: use crate::language::{…, STRUCTURED_CODE} (a _directive), and has_facet(&STRUCTURED_CODE) (a use, carrying no binding role at all).

Running the tree version over site/ is also what caught a real bug in beamte before it shipped: TypeScript nests variable_declarator inside lexical_declaration and both carry _binding, so mdxFiles(DOCS_DIR) came back as a declaration. Fixed upstream by reporting only the innermost binding.

Verification

cargo fmt --check, cargo clippy --workspace --all-targets, 93 workspace tests (5 unit + 12 integration for this rule, covering 8 language cases), the 96 site docs-vs-manifest tests, the musl release build, vale on the prose, and straitjacket clean on itself.

The integration tests fetch real packs, for the reason tests/pack_host.rs gives at length: a test that passes because it found no grammar is worse than no test.

Blocked on the same thing as #63

gate will fail at scripts/publish.sh --dry-run until beamte is on crates.io. cargo publish ignores [patch.crates-io] by design, so the dry run resolves beamte = "0.3" against the registry for real, where only 0.1.0 exists. The detail is on #63: beamte's v0.2.0 release run hit a 403 from an under-scoped crates.io token, which is a beamte settings fix rather than anything either PR can carry.

The patch table here pins a commit, not a branch — a branch name stops resolving the moment it is merged and deleted, which is exactly how #63's CI broke under a PR that had not changed.

Merge order: beamte#23 → beamte 0.3 published → drop the patch table here.

Independent of #63 in code — the two share no source and can merge in either order. They are complementary: env-vars governs where the environment may be read, this governs where constants may be declared, and a repository's env module is naturally named in both lists.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WrSzGnURZoupdEfVdwk9pg

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 say which
are still true, and the same one gets made twice under two names.
const-files names where they live and every declaration outside is an
error. It is theme-files for constants.

Deliberately not a parser. A rule that has to tell one expression from
another needs a tree, which is why test-quality fetches a grammar. This
one asks whether a line *declares* a screaming-snake name, and
declaration syntax answers that alone -- so it covers all eighteen
languages straitjacket calls structured code rather than the nine with
a pack, and never reaches the network. It is opt-in only because
designating the files is a decision no default can make, and enabling
it with no const-files is refused rather than obeyed: every constant
would be a finding with nowhere to move it.

Three decisions that keep it a rule rather than a nuisance.
Declarations, not uses -- referencing a constant is the point of having
one, and flagging that would make the rule unsatisfiable. At least two
words joined by an underscore, because PI, OK, HTTP, a Go export and a
C header guard are all single all-caps words and flagging them would
bury the constants among them. And a bare NAME = value counts as a
declaration only where the language spells it that way -- in Python,
Ruby and Shell only at the left margin, which is what keeps every enum
member and every shell local from being a finding.

To read code rather than text, rules/comments.rs now yields the
comments and a code-only view from one traversal. Masking preserves
byte offsets, so a column in the masked line is the column in the real
one, and a declaration commented out or quoted inside a string is not
one. One lexer answers where the code stops and the prose starts;
answering it in two places is how the two answers come to disagree.

Run against this repository it reports twenty constants across Rust,
TypeScript and shell -- every one a real declaration, with imports,
uses and indented shell locals correctly left alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WrSzGnURZoupdEfVdwk9pg
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 30, 2026 •

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
straitjacket 6da1335 Commit Preview URL

Branch Preview URL
Aug 30 2026, 08:42 PM

claude added 2 commits August 30, 2026 20:21
The first cut matched declaration syntax with a per-language table of
keyword patterns. That table is a parser written badly: it cannot tell
`MAX_SIZE` in `const MAX_SIZE = 3` from `MAX_SIZE` in `n > MAX_SIZE`
without knowing each language's declaration grammar, and every language
added means another pattern to get subtly wrong.

The question is a tree question, so it moves to beamte, which owns what
a construct *is*. `const-declaration` asks the node vocabulary instead:
a `_binding` that is neither an import nor a parameter, outside any
callable, whose bound name is SCREAMING_SNAKE. That needs no language
table at all -- only which pack serves which grammar.

What is left here is what beamte refuses to do: fetch a grammar, parse,
pick a severity, and name the files constants belong in. The last of
those is the policy half, and it stays policy: beamte reports every
declaration and `const-files` decides which are licensed.

Costs the change accepts: ten languages instead of eighteen, and a
grammar download on first scan, which is why the rule is off by default
twice over. Gains: no false positive on a use, and shell reassignment
in `publish.sh` now reads as the declaration it is.

`comments.rs` reverts to main -- the masking it grew existed only to
keep the regex off commented-out code, and a parse does that for free.
`pack::cached` moves out of `test_quality` so both hosts share one
cache rather than each keeping a private one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WrSzGnURZoupdEfVdwk9pg
A `[patch.crates-io]` entry naming a branch stops resolving the moment
that branch is merged and deleted, and the failure lands on whoever
pushes next rather than on whoever merged. That is how #63's CI broke:
beamte's branch went away under a PR that had not changed, and cargo
could not resolve the dependency before a single check ran.

A merged commit stays reachable, so the rev survives the merge. The
table is temporary either way -- `beamte = "0.3"` above it is already
written for the registry, and this whole block goes when 0.3 publishes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WrSzGnURZoupdEfVdwk9pg

zmaril commented Aug 30, 2026

Copy link
Copy Markdown
Contributor Author

gate is blocked upstream, not by this diff

Eleven of twelve checks pass on 6da1335, the musl release build among them. gate fails on its last step, scripts/publish.sh --dry-run:

error: failed to prepare local package for uploading
Caused by:
  failed to select a version for the requirement `beamte = "^0.3"`
  candidate versions found which didn't match: 0.1.0
  location searched: crates.io index

The [patch.crates-io] table is what lets cargo build and cargo test resolve beamte, and they do. But cargo publish ignores [patch] by design — a published crate cannot carry a patch table — so the dry run resolves beamte = "0.3" against the registry for real, where only 0.1.0 exists.

Not a flake, and not worth a re-run: the same failure is deterministic across three different head commits (00326d5 and 6da1335 here, 7ae7b97 on #63) and names a dependency version that either exists on crates.io or does not.

What it needs

Two things, in order, and neither is a change to this branch:

  1. PowderworksCode/beamte#23 merges and 0.3.0 publishes. That PR is the const-declaration rule this one hosts; it is currently 10/10 green.
  2. beamte's crates.io publishing gets fixed first. v0.2.0 was tagged and its release ran, but the crate job failed with 403 Forbidden: this token does not have the required permissions to perform this action at the upload step — the stored bootstrap token can create a crate but not publish further versions of it. The full diagnosis and both fixes are on #63. Until that is sorted, no beamte version can reach the registry, so gate stays red on both PRs.

Once 0.3.0 is published, the fix here is to delete the [patch.crates-io] table — beamte = "0.3" above it is already written for the registry — and gate goes green with no other change. I'm watching this PR and will push that as soon as the crate lands.

One thing already done to keep this from getting worse: the patch pins a commit rather than a branch (2aa372ec). A branch pin stops resolving the moment the branch is merged and deleted, which is how #63's CI broke under a PR that had not changed.


Generated by Claude Code

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants