From e47688b7c911da5c49ae498500bfd5494e4e097a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 13:46:14 +0000 Subject: [PATCH 1/2] env-vars: the environment read outside the declared edge An environment variable read mid-file is configuration no signature admits to. beamte 0.2's env-read finds them structurally -- an invocation or access of the language's environment surface, so a mention in a comment or a string is not a finding -- and this rule runs it over every file straitjacket can parse, in the nine languages the surface table covers. Shell is deliberately not among them: $VAR is the language's own variable model. env-files names the files that ARE the configuration edge, where reads are licensed -- theme-files for the environment. Everywhere else a read is an error, not a property mapping: the rule is opt-in, and a repository that turned it on wants the read stopped, not mentioned. Enable with env-vars = true or --env-vars; opt-in for test-quality's reason, that the first scan of a language downloads its grammar. 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; the finding and not-read formatting move to rules/beamte_findings.rs for the same reason. test-quality now reads beamte's new Rule::scope and holds file-scoped rules out of its default selection -- one read in a test file is one finding under one key -- and test-rules rejects a file-scoped rule by name, pointing at env-vars. beamte 0.2 is not on crates.io yet, so the requirement resolves through a [patch.crates-io] git entry carrying a drop-me note. Until the release, cargo publish --dry-run fails at manifest preparation -- beamte ^0.2 has no registry candidate -- which is the release ordering, not a defect here: publish beamte 0.2, delete the patch table, and the gate is whole again. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WrSzGnURZoupdEfVdwk9pg --- CHANGELOG.md | 16 ++ Cargo.lock | 21 +- Cargo.toml | 9 +- README.md | 7 +- site/content/getting-started.md | 2 +- site/content/index.md | 2 +- site/content/reference/config-file.md | 2 + site/content/reference/rules.md | 52 +++- site/content/rules.json | 5 + src/config.rs | 17 ++ src/main.rs | 4 + src/pack.rs | 41 ++++ src/rules/beamte_findings.rs | 83 +++++++ src/rules/env_vars.rs | 326 ++++++++++++++++++++++++++ src/rules/mod.rs | 30 ++- src/rules/test_quality.rs | 162 +++---------- src/scanner.rs | 1 + tests/env_vars.rs | 213 +++++++++++++++++ 18 files changed, 844 insertions(+), 149 deletions(-) create mode 100644 src/rules/beamte_findings.rs create mode 100644 src/rules/env_vars.rs create mode 100644 tests/env_vars.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 4943deb..5b7a127 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,22 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Added +- `env-vars`, an opt-in rule that reports code reading the process environment + where nothing declares it -- `std::env::var`, `os.environ`, `process.env`, + `ENV[...]`, `System.getenv`, `getenv` -- in the nine languages beamte's + `env-read` covers. An ambient read is configuration no signature admits to, + and no small test of that code can stay hermetic (*Test Sizes*, 2010-12-13). + `env-files` names the files that *are* the configuration edge, where reads + are licensed -- `theme-files` for the environment; everywhere else a read is + an error. Enable with `env-vars = true` or `--env-vars`. Opt-in for + `test-quality`'s reason: the first scan of a language downloads its grammar. + Shell is deliberately not covered, `$VAR` being the language's own variable + model, and Rust's compile-time `env!` is not a finding -- the build declares + those variables, which is the announced channel the rule steers reads toward. +- `test-rules` now rejects a file-scoped beamte rule by name, pointing at + `env-vars` instead: listing `env-read` there would run it over test files + alone while looking like it ran everywhere. + - [Update Straitjacket](https://straitjacket.dev/guides/updating), a guide for the thing every installed tool eventually needs and this one never documented: re-run the installer, roll back with `STRAITJACKET_VERSION`, bump the Action diff --git a/Cargo.lock b/Cargo.lock index e9ed121..fa34491 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -151,9 +151,8 @@ checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" [[package]] name = "beamte" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d369373b6cfa7fd9d2e8c8716ff29a31b111aabdb3ecaf26d07d60b218df553b" +version = "0.2.0" +source = "git+https://github.com/PowderworksCode/beamte?branch=claude%2Fsloth-env-vars-config-2g6g2t#f948a16610774a82d3d041d8023ef95f16e80c2f" dependencies = [ "treebank", ] @@ -405,7 +404,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -880,7 +879,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -1570,7 +1569,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -1960,7 +1959,7 @@ dependencies = [ "bitflags", "libc", "mach2 0.4.3", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2047,7 +2046,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2113,7 +2112,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2528,7 +2527,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3114,7 +3113,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index f9795f2..78e6427 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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.2" clap = { version = "4", features = ["derive"] } ignore = "0.4" inventory = "0.3" @@ -64,3 +64,10 @@ let_underscore_must_use = "deny" [profile.release] lto = true strip = true + +# beamte 0.2 (`env-read`, `Rule::scope`) is not on crates.io yet. Until it is, +# the requirement above resolves through this patch to the branch that carries +# it. Drop this table the moment 0.2 is published: the requirement is already +# written for the registry, so deleting these three lines is the whole change. +[patch.crates-io] +beamte = { git = "https://github.com/PowderworksCode/beamte", branch = "claude/sloth-env-vars-config-2g6g2t" } diff --git a/README.md b/README.md index 9bd5fce..b134ec3 100644 --- a/README.md +++ b/README.md @@ -27,8 +27,11 @@ 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`; `env-vars`, which flags code reading the process +environment where nothing declares it (`std::env::var`, `os.environ`, +`process.env`) outside the files you designate as the configuration edge; 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 diff --git a/site/content/getting-started.md b/site/content/getting-started.md index 10849d0..5b5b957 100644 --- a/site/content/getting-started.md +++ b/site/content/getting-started.md @@ -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. diff --git a/site/content/index.md b/site/content/index.md index 11efb30..8409ffd 100644 --- a/site/content/index.md +++ b/site/content/index.md @@ -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 diff --git a/site/content/reference/config-file.md b/site/content/reference/config-file.md index 1e9c7b5..acb116e 100644 --- a/site/content/reference/config-file.md +++ b/site/content/reference/config-file.md @@ -54,6 +54,8 @@ Every key mirrors a [CLI flag](/reference/cli) one-for-one, in | `theme-files` | list of paths allowed to define colors | — | | `test-rules` | list of [test rule](/reference/rules#test-quality) ids; unset runs all | — | | `no-comments` | boolean | `--no-comments` ([no-comments mode](/reference/rules#no-comments-mode)) | +| `env-vars` | boolean | `--env-vars` ([environment variables](/reference/rules#environment-variables)) | +| `env-files` | list of files licensed to read the process environment — the declared [configuration edge](/reference/rules#environment-variables) | — | | `include-json` | boolean | `--include-json` | | `no-ignore` | boolean | `--no-ignore` | | `no-fail` | boolean | `--no-fail` | diff --git a/site/content/reference/rules.md b/site/content/reference/rules.md index f9f1151..8076dc3 100644 --- a/site/content/reference/rules.md +++ b/site/content/reference/rules.md @@ -34,6 +34,7 @@ installed version ever disagree. | `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. | | `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. | +| `env-vars` | **opt-in** | code that reads the process environment where nothing declares it — `std::env::var`, `os.environ`, `process.env`, `ENV[...]`, `System.getenv`, `getenv`. Files listed in `env-files` are the declared configuration edge and are allowed to read it. See [environment variables](#environment-variables) below. | ### `deep-nesting` and embedded DSLs @@ -105,7 +106,7 @@ or in [`straitjacket.toml`](/reference/config-file): ```toml only = ["test-quality"] -test-rules = ["test-logic"] # optional: unset runs every rule beamte has +test-rules = ["test-logic"] # optional: unset runs every test rule beamte has ``` **It is opt-in because it reaches the network.** The grammar for a language is @@ -210,3 +211,52 @@ existing repository. | respect `.gitignore` | on | `--no-ignore` | | fail on unused markers | on | `--no-fail-on-unused-markers` | | fail on findings | on | `--no-fail` | + +## environment variables + +An environment variable read in the middle of ordinary code is configuration +no signature admits to: the function behaves differently on two machines and +nothing in its declaration says why. `env-vars` reports every such read — +`std::env::var` in Rust, `os.environ` and `os.getenv` in Python, `process.env` +in TypeScript and JavaScript, `ENV[...]` in Ruby, `System.getenv` and +`System.getProperty` in Java, `getenv` in C and C++, `std.process.getEnvVarOwned` +in Zig. The finding is [beamte](https://github.com/PowderworksCode/beamte)'s +`env-read`, restating [Test +Sizes](https://testing.googleblog.com/2010/12/test-sizes.html): a small test +may not touch system properties, and a component that reads the environment +mid-body forces that violation on every small test that executes it. + +```sh +straitjacket --env-vars +``` + +or in [`straitjacket.toml`](/reference/config-file): + +```toml +env-vars = true +env-files = ["src/config.rs"] # the declared configuration edge +``` + +`env-files` names the files that *are* the configuration edge — the one module +that reads the environment and hands values on as arguments. Reads there are +licensed; reads anywhere else are errors. It is `theme-files` for the +environment: designate the edge instead of papering readers over with markers. +The exception with a story — a genuinely per-invocation override — takes a +[suppression marker](/reference/suppression-markers), which must carry one. + +**It is opt-in because it reaches the network**, exactly as `test-quality` is: +the grammar for a language is downloaded the first time a file in that language +carries an environment-shaped token, verified and cached content-addressed +after that. A file whose grammar cannot be fetched is reported as **not read** +rather than passing quietly. + +Files are prefiltered by cheap substrings (`env::var`, `environ`, +`process.env`), so a file that cannot contain a read is never parsed and the +rule stays affordable over a whole repository. + +Nine languages: Python, Ruby, Rust, Java, TypeScript, JavaScript, C, C++ and +Zig. Shell is deliberately not among them — `$VAR` is the language's own +variable model, and flagging every expansion would be flagging the language. +Compile-time reads are not findings either: Rust's `env!` resolves when the +build runs, against variables the build declares, which is the announced +channel this rule steers reads toward. diff --git a/site/content/rules.json b/site/content/rules.json index 273c4cd..9a9fbb1 100644 --- a/site/content/rules.json +++ b/site/content/rules.json @@ -17,6 +17,11 @@ "summary": "emoji glyph in source; use a text label or named icon", "default_enabled": true }, + { + "id": "env-vars", + "summary": "code reads the process environment outside the declared edge", + "default_enabled": false + }, { "id": "file-size", "summary": "file exceeds the configured line budget", diff --git a/src/config.rs b/src/config.rs index ee9a900..502aac4 100644 --- a/src/config.rs +++ b/src/config.rs @@ -29,6 +29,13 @@ pub struct FileConfig { /// Which of beamte's test-quality rules to run. Unset means all of them, /// including any beamte adds later. pub test_rules: Option>, + /// Turn on `env-vars`, which reads every parseable file for environment + /// reads outside the declared edge. Opt-in for `test-quality`'s reason: + /// the first run downloads a grammar. + pub env_vars: Option, + /// The files licensed to read the process environment: the declared + /// configuration edge. `theme-files` for the environment. + pub env_files: Option>, /// Sections that configured rules Straitjacket no longer has. They are /// accepted by the parser only so that [`reject_removed_sections`] can /// name the rule that went away. @@ -58,6 +65,8 @@ pub struct Settings { pub no_fail: bool, pub fail_on_unused_markers: bool, pub test_rules: Vec, + pub env_vars: bool, + pub env_files: Vec, } impl Default for Settings { @@ -79,6 +88,8 @@ impl Default for Settings { no_fail: false, fail_on_unused_markers: true, test_rules: Vec::new(), + env_vars: false, + env_files: Vec::new(), } } } @@ -105,6 +116,12 @@ impl Settings { if let Some(test_rules) = file.test_rules { self.test_rules = test_rules; } + if let Some(value) = file.env_vars { + self.env_vars = value; + } + if let Some(paths) = file.env_files { + self.env_files = paths.into_iter().map(PathBuf::from).collect(); + } if let Some(value) = file.max_lines { self.max_lines = value; } diff --git a/src/main.rs b/src/main.rs index 385a159..b2edcd7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -53,6 +53,9 @@ struct Cli { #[arg(long, help = "Enable the opt-in `no-comments` rule")] no_comments: bool, + #[arg(long, help = "Enable the opt-in `env-vars` rule")] + env_vars: bool, + #[arg(long, help = "Scan JSON files, which are skipped by default")] include_json: bool, @@ -331,6 +334,7 @@ fn resolve(cli: &Cli) -> anyhow::Result { settings.max_nesting = value; } settings.no_comments |= cli.no_comments; + settings.env_vars |= cli.env_vars; settings.include_json |= cli.include_json; settings.no_ignore |= cli.no_ignore; settings.no_fail |= cli.no_fail; diff --git a/src/pack.rs b/src/pack.rs index b7fb8a3..ca1b42d 100644 --- a/src/pack.rs +++ b/src/pack.rs @@ -9,8 +9,10 @@ //! `tb_*` ABI, statically linked, importing only WASI. It is not linked C, so //! it cannot break the musl cross-build the release depends on. +use std::cell::RefCell; use std::collections::HashMap; use std::path::Path; +use std::rc::Rc; use std::sync::Mutex; use anyhow::{Context, Result, bail}; @@ -399,6 +401,45 @@ impl Loaded { } } +thread_local! { + /// Loaded packs, and the reasons for the ones that would not load. Shared + /// by every rule that parses, so two rules meeting the same language in + /// one scan JIT its grammar once between them. + /// + /// A `FileRule` must be `Send + Sync` and a wasmer `Store` is neither, so + /// the packs cannot live in a rule. They live beside the rules instead, + /// which costs nothing today -- the walk in `src/walk.rs` is a single + /// sequential iterator -- and stays correct rather than unsound if that + /// ever changes. A parallel walk would pay one JIT per thread per grammar. + /// + /// The failure is cached with the same weight as the success: a machine + /// with no network pays one failed fetch, not one per file. + static CACHED: RefCell, String>>> = + RefCell::new(HashMap::new()); +} + +/// The pack for a grammar, fetched once and then reused. +/// +/// Fetched per language, and only once a rule has already decided a file is +/// worth parsing, so a Python repository never downloads the Java grammar. +pub fn cached(grammar: &'static str) -> std::result::Result, String> { + CACHED.with_borrow_mut(|packs| { + packs + .entry(grammar) + .or_insert_with(|| { + acquire(grammar) + .map(Rc::new) + .map_err(|error| format!("{error:#}")) + }) + .clone() + }) +} + +fn acquire(grammar: &'static str) -> Result { + let bytes = treebank::fetch::fetch_bytes(grammar)?; + Pack::from_bytes(&bytes, &format!("the treebank {grammar} pack")) +} + fn intern(table: &mut Vec, ids: &mut HashMap, value: String) -> u32 { if let Some(id) = ids.get(&value) { return *id; diff --git a/src/rules/beamte_findings.rs b/src/rules/beamte_findings.rs new file mode 100644 index 0000000..c77f91f --- /dev/null +++ b/src/rules/beamte_findings.rs @@ -0,0 +1,83 @@ +//! How a beamte finding reads once straitjacket owns it. +//! +//! Two rules host beamte -- `test-quality` over test files, `env-vars` over +//! every file -- and a finding must read the same way whichever door it came +//! through. One place formats them, so the two cannot drift. + +use crate::finding::{EvidenceStep, Finding, Location, Severity}; +use crate::rule::{Candidate, RuleKey}; + +/// A beamte finding as a straitjacket candidate. +/// +/// The beamte rule is named in the message rather than in the key, because +/// straitjacket registers one rule for a family of them and `test-logic` +/// would be a key nothing in its manifest declares. Beamte's DESIGN.md §6.3 +/// puts citing the post on the host: it turns an argument with a linter into +/// a much shorter argument with Titus Winters. +pub fn candidate( + key: RuleKey, + severity: Severity, + path: &str, + text: &str, + finding: beamte::Finding, +) -> Candidate { + let line = finding.span.line; + let column = finding.span.column; + Candidate::line(Finding { + rule: key, + severity, + location: Location::point(path, line, column), + matched: matched_text(text, line), + message: format!("{}: {}", finding.rule, finding.message), + help: help_of(&finding), + related: Vec::new(), + evidence: finding + .evidence + .into_iter() + .map(|step| EvidenceStep { + location: Location::point(path, step.span.line, step.span.column), + message: step.message, + }) + .collect(), + }) +} + +/// A file that could not be checked, said out loud. +/// +/// Beamte's DESIGN.md §7.3: a file that was not read is reported as unread, +/// never as clean. Returning nothing would mean a failed pack fetch reads +/// exactly like a file with nothing wrong in it. +pub fn not_read(key: RuleKey, path: &str, message: String, help: Option) -> Candidate { + Candidate::file(Finding { + rule: key, + severity: Severity::Warning, + location: Location::point(path, 1, 1), + matched: String::new(), + message, + help, + related: Vec::new(), + evidence: Vec::new(), + }) +} + +fn help_of(finding: &beamte::Finding) -> Option { + let citation = beamte::rule(finding.rule.as_str()).map(|rule| rule.citation); + match (&finding.help, citation) { + (Some(help), Some(citation)) => { + Some(format!("{help} — {} ({})", citation.title, citation.url)) + } + (Some(help), None) => Some(help.clone()), + (None, Some(citation)) => Some(format!("{} ({})", citation.title, citation.url)), + (None, None) => None, + } +} + +/// The line a finding sits on, trimmed, for the `matched` field every other +/// rule fills in from its own regex. +fn matched_text(text: &str, line: usize) -> String { + text.lines() + .nth(line.saturating_sub(1)) + .unwrap_or_default() + .trim() + .to_string() +} diff --git a/src/rules/env_vars.rs b/src/rules/env_vars.rs new file mode 100644 index 0000000..5273d59 --- /dev/null +++ b/src/rules/env_vars.rs @@ -0,0 +1,326 @@ +//! Environment reads outside the declared configuration edge, found by +//! beamte's `env-read` rule over a treebank pack. +//! +//! The finding is beamte's: an environment variable read mid-file is an input +//! no signature admits to, and no small test of that code can stay hermetic +//! (*Test Sizes*, 2010-12-13). This file owns everything beamte refuses to: +//! getting a grammar, parsing, deciding a severity, and — the part that is +//! policy about a repository rather than a fact about a tree — naming the +//! files that *are* the configuration edge. `env-files` in +//! `straitjacket.toml` names them, and a read inside one is licensed rather +//! than reported. +//! +//! Same door as `test-quality`, different files: that rule reads what looks +//! like a test, this one reads everything it can parse, which is what +//! beamte's `Scope::File` on the rule means. Both go through `inspect_with`, +//! so the two cannot come to format a finding differently. +//! +//! A finding here is an error rather than a mapping of beamte's property: +//! the rule is opt-in, and a repository that turned it on wants the read +//! stopped, not mentioned. The licensed edge is `env-files`; the exception +//! with a story is a suppression marker, which must carry one. + +use beamte::node::Unit; +use beamte::{RuleId, Selection}; + +use crate::Settings; +use crate::finding::Severity; +use crate::language::LanguageProfile; +use crate::rule::{Candidate, FileRule, RuleDescriptor, RuleKey, SourceFile}; +use crate::rules::{RuleRegistration, beamte_findings}; + +pub const KEY: RuleKey = RuleKey::new("env-vars"); + +/// Off unless a configuration asks for it, for `test-quality`'s reason: the +/// first run downloads a grammar, and a scan that reaches the network because +/// the tool was upgraded is not a surprise anyone should get for free. +const DEFAULT_ENABLED: bool = false; + +/// A language this rule can read. +/// +/// The list is beamte's — `env_read::covers` — and the entries here add what +/// only a host knows: which pack serves the grammar and which cheap +/// substrings mean a file is worth parsing at all. A file with no marker +/// cannot contain a read the surface table would match, so it is skipped +/// before any parse, which is what makes running over *every* source file +/// affordable. +/// +/// Shell is deliberately absent, agreeing with beamte: `$VAR` is the +/// language's own variable model, and flagging every expansion would be +/// flagging the language. +struct Supported { + /// Straitjacket's own language id, from `src/language.rs`. + id: &'static str, + pack: &'static str, + model: &'static str, + markers: &'static [&'static str], +} + +const SUPPORTED: &[Supported] = &[ + Supported { + id: "c", + pack: "c", + model: "c", + markers: &["getenv", "_dupenv_s"], + }, + Supported { + id: "cpp", + pack: "cpp", + model: "cpp", + markers: &["getenv", "_dupenv_s"], + }, + Supported { + id: "java", + pack: "java", + model: "java", + markers: &["System.getenv", "System.getProperty"], + }, + Supported { + id: "javascript", + pack: "typescript", + model: "javascript", + markers: &["process.env", "import.meta.env", "Deno.env"], + }, + Supported { + id: "python", + pack: "python", + model: "python", + markers: &["environ", "getenv"], + }, + Supported { + id: "ruby", + pack: "ruby", + model: "ruby", + markers: &["ENV"], + }, + Supported { + id: "rust", + pack: "rust", + model: "rust", + markers: &["env::var", "env::vars"], + }, + Supported { + id: "typescript", + pack: "typescript", + model: "typescript", + markers: &["process.env", "import.meta.env", "Deno.env"], + }, + Supported { + id: "zig", + pack: "zig", + model: "zig", + markers: &["getenv", "getEnvVar", "getEnvMap", "hasEnvVar"], + }, +]; + +fn supported(language: &LanguageProfile) -> Option<&'static Supported> { + SUPPORTED.iter().find(|entry| entry.id == language.id) +} + +pub struct EnvVarsRule { + /// The files licensed to read the environment: the declared configuration + /// edge. Everything else is reported. Same matching as + /// `file-size-exclude`, so one notion of "this path" covers both. + allow: Vec, + /// `Only(env-read)`: the one file-scoped rule beamte has today. Computed + /// from the catalogue rather than named, so a second file-scoped rule + /// lights up here the way a test-scoped one lights up `test-quality`. + only: Vec, +} + +impl EnvVarsRule { + pub fn new(allow: Vec) -> Self { + let only = beamte::catalogue() + .iter() + .filter(|rule| rule.scope == beamte::Scope::File) + .map(|rule| rule.id) + .collect(); + Self { allow, only } + } + + fn licensed(&self, path: &str) -> bool { + let path = std::path::Path::new(path); + self.allow.iter().any(|allowed| { + path == allowed + || path.starts_with(allowed) + || allowed.is_relative() && path.is_absolute() && path.ends_with(allowed) + }) + } +} + +fn build(settings: &Settings) -> Box { + Box::new(EnvVarsRule::new(settings.env_files.clone())) +} + +fn instruction(settings: &Settings) -> String { + let rules: Vec = beamte::catalogue() + .iter() + .filter(|rule| rule.scope == beamte::Scope::File) + .map(|rule| format!("{} ({})", rule.instruction, rule.citation.title)) + .collect(); + let edge = if settings.env_files.is_empty() { + "No file is currently declared as that edge; name one with `env-files` \ + in straitjacket.toml." + .to_string() + } else { + format!( + "The declared edge is: {}.", + settings + .env_files + .iter() + .map(|path| path.display().to_string()) + .collect::>() + .join(", ") + ) + }; + format!("{} {edge}", rules.join(" ")) +} + +inventory::submit! { + RuleRegistration { + key: KEY, + factory: Some(build), + instruction, + } +} + +impl FileRule for EnvVarsRule { + fn descriptor(&self) -> RuleDescriptor { + RuleDescriptor { + id: KEY, + summary: "code reads the process environment outside the declared edge", + default_enabled: DEFAULT_ENABLED, + } + } + + fn applies_to(&self, language: &LanguageProfile) -> bool { + supported(language).is_some() + } + + fn check(&self, file: SourceFile<'_>, candidates: &mut Vec) { + let Some(entry) = supported(file.language) else { + return; + }; + if self.licensed(file.path) { + return; + } + if !entry.markers.iter().any(|m| file.text.contains(m)) { + return; + } + let Some(model) = beamte::TestModel::for_language(entry.model) else { + return; + }; + + let pack = match crate::pack::cached(entry.pack) { + Ok(pack) => pack, + Err(reason) => { + candidates.push(beamte_findings::not_read( + KEY, + file.path, + format!( + "not read: the {} grammar could not be loaded, so this file was \ + not checked for environment reads ({reason})", + entry.pack + ), + Some( + "Packs are downloaded once and cached. Check network access, or \ + skip `env-vars` if this environment is offline by design." + .to_string(), + ), + )); + return; + } + }; + + let tree = match pack.parse(file.text) { + Ok(tree) => tree, + Err(error) => { + candidates.push(beamte_findings::not_read( + KEY, + file.path, + format!( + "not read: this file did not parse as {} ({error:#})", + entry.model + ), + None, + )); + return; + } + }; + + let unit = Unit::new(file.path, tree.source(), tree.root()); + for finding in beamte::inspect_with(&unit, &model, Selection::Only(&self.only)) { + candidates.push(beamte_findings::candidate( + KEY, + Severity::Error, + file.path, + file.text, + finding, + )); + } + } +} + +#[cfg(test)] +mod tests { + use super::{EnvVarsRule, KEY, SUPPORTED}; + + #[test] + fn every_supported_language_is_one_beamte_covers() { + for entry in SUPPORTED { + assert!( + beamte::rules::env_read::covers(entry.model), + "{} maps to model {}, which beamte's env-read does not cover", + entry.id, + entry.model + ); + } + assert_eq!( + SUPPORTED.len(), + beamte::rules::env_read::LANGUAGES.len(), + "beamte covers a language this rule does not offer, or the reverse" + ); + } + + #[test] + fn every_supported_language_is_one_straitjacket_knows() { + for entry in SUPPORTED { + assert!( + crate::language::language_profile(entry.id).is_some(), + "{} is not a language straitjacket has a profile for", + entry.id + ); + } + } + + /// A language with no markers is a language whose every file parses; a + /// language with wrong markers is worse, a prefilter that skips files + /// the rule would have flagged. Emptiness is checkable here; fidelity + /// is what `tests/env_vars.rs` checks with real reads. + #[test] + fn every_marker_would_survive_its_own_surface() { + for entry in SUPPORTED { + assert!( + !entry.markers.is_empty(), + "{} has no markers, so every file of it parses", + entry.id + ); + } + } + + #[test] + fn a_licensed_file_is_licensed_however_the_walk_spells_it() { + let rule = EnvVarsRule::new(vec!["src/config.rs".into(), "tools/".into()]); + + assert!(rule.licensed("src/config.rs")); + assert!(rule.licensed("/repo/src/config.rs")); + assert!(rule.licensed("tools/release.py")); + assert!(!rule.licensed("src/main.rs")); + assert!(!rule.licensed("src/config.rs.bak")); + } + + #[test] + fn the_key_is_the_one_the_registry_carries() { + assert_eq!(KEY.as_str(), "env-vars"); + } +} diff --git a/src/rules/mod.rs b/src/rules/mod.rs index be07e34..54dc011 100644 --- a/src/rules/mod.rs +++ b/src/rules/mod.rs @@ -1,7 +1,9 @@ +mod beamte_findings; mod color; mod comments; mod deep_nesting; mod emoji; +mod env_vars; mod file_size; mod inline_font; mod inline_svg; @@ -135,16 +137,31 @@ pub fn resolve(names: &[String]) -> anyhow::Result> { /// /// Straitjacket carries one rule key for all of them, so these names never /// reach [`resolve`] and would otherwise be accepted silently -- a typo in -/// `test-rules` would quietly turn a rule off rather than say so. +/// `test-rules` would quietly turn a rule off rather than say so. A rule +/// whose beamte scope is `File` is rejected by name too: it runs under +/// `env-vars`, over every file, and listing it here would run it over test +/// files alone while looking like it ran. pub fn resolve_test_rules(names: &[String]) -> anyhow::Result<()> { - let unknown: Vec<&str> = names - .iter() - .filter(|name| beamte::rule(name).is_none()) - .map(|name| name.as_str()) - .collect(); + let mut unknown = Vec::new(); + let mut misfiled = Vec::new(); + for name in names { + match beamte::rule(name) { + None => unknown.push(name.as_str()), + Some(rule) if rule.scope == beamte::Scope::File => misfiled.push(name.as_str()), + Some(_) => {} + } + } + if !misfiled.is_empty() { + bail!( + "{} runs over every file, not only tests: enable `env-vars` instead \ + of naming it in `test-rules`.", + misfiled.join(", ") + ); + } if !unknown.is_empty() { let known: Vec<&str> = beamte::catalogue() .iter() + .filter(|rule| rule.scope == beamte::Scope::Tests) .map(|rule| rule.id.as_str()) .collect(); bail!( @@ -156,5 +173,6 @@ pub fn resolve_test_rules(names: &[String]) -> anyhow::Result<()> { Ok(()) } +pub use env_vars::KEY as ENV_VARS; pub use no_comments::KEY as NO_COMMENTS; pub use unused_marker::{KEY as UNUSED_MARKER, descriptor as unused_marker_descriptor}; diff --git a/src/rules/test_quality.rs b/src/rules/test_quality.rs index 1a805e4..d752751 100644 --- a/src/rules/test_quality.rs +++ b/src/rules/test_quality.rs @@ -6,29 +6,27 @@ //! findings through suppression and reporting. Beamte's DESIGN.md §6.1 splits //! the concerns line by line and this is the straitjacket column. //! -//! So there is exactly one call into it -- `inspect_with` -- and adding a rule -//! to beamte lights it up here with no change to this file. The alternative, -//! a module per rule, is how the previous generation of these rules -//! fragmented until each one knew a little about parsing and none of them -//! agreed. +//! So there is exactly one call into it -- `inspect_with` -- and adding a +//! test-scoped rule to beamte lights it up here with no change to this file. +//! The alternative, a module per rule, is how the previous generation of +//! these rules fragmented until each one knew a little about parsing and none +//! of them agreed. A rule whose beamte scope is `File` is the one exception: +//! it reads every file rather than only the ones that look like tests, so it +//! runs under `env-vars` instead, and the default selection here holds it +//! out. //! //! Which rules run is configuration, because a project that wants one rule //! and a project that dislikes one rule are both real. `test-rules` in //! `straitjacket.toml` names them; unset means all of them. -use std::cell::RefCell; -use std::collections::HashMap; -use std::rc::Rc; - use beamte::node::Unit; use beamte::{Property, RuleId, Selection}; use crate::Settings; -use crate::finding::{EvidenceStep, Finding, Location, Severity}; +use crate::finding::Severity; use crate::language::LanguageProfile; -use crate::pack::Pack; use crate::rule::{Candidate, FileRule, RuleDescriptor, RuleKey, SourceFile}; -use crate::rules::RuleRegistration; +use crate::rules::{RuleRegistration, beamte_findings}; const KEY: RuleKey = RuleKey::new("test-quality"); @@ -133,58 +131,30 @@ fn path_names_a_test(path: &str) -> bool { lowered.contains("test") || lowered.contains("spec") } -thread_local! { - /// Loaded packs, and the reasons for the ones that would not load. - /// - /// A `FileRule` must be `Send + Sync` and a wasmer `Store` is neither, so - /// the packs cannot live in the rule. They live beside it instead, which - /// costs nothing today -- the walk in `src/walk.rs` is a single sequential - /// iterator -- and stays correct rather than unsound if that ever changes. - /// A parallel walk would pay one JIT per thread per grammar. - /// - /// The failure is cached with the same weight as the success: a machine - /// with no network pays one failed fetch, not one per file. - static PACKS: RefCell, String>>> = - RefCell::new(HashMap::new()); -} - -/// The pack for a grammar, fetched once and then reused. -/// -/// Fetched per language, and only once a file of that language has already -/// looked like a test, so a Python repository never downloads the Java -/// grammar. -fn pack(grammar: &'static str) -> Result, String> { - PACKS.with_borrow_mut(|packs| { - packs - .entry(grammar) - .or_insert_with(|| { - acquire(grammar) - .map(Rc::new) - .map_err(|error| format!("{error:#}")) - }) - .clone() - }) -} - -fn acquire(grammar: &'static str) -> anyhow::Result { - let bytes = treebank::fetch::fetch_bytes(grammar)?; - Pack::from_bytes(&bytes, &format!("the treebank {grammar} pack")) -} - pub struct TestQualityRule { - /// Empty means every rule beamte has, which is also what it will mean - /// after beamte grows one. + /// Empty means every test-scoped rule beamte has, which is also what it + /// will mean after beamte grows one. only: Vec, + /// The file-scoped rules, held out of the default selection: they are the + /// `env-vars` rule's to run, over every file rather than only the ones + /// that look like tests, and running them here as well would report each + /// read in a test file twice under two keys. + file_scoped: Vec, } impl TestQualityRule { pub fn new(only: Vec) -> Self { - Self { only } + let file_scoped = beamte::catalogue() + .iter() + .filter(|rule| rule.scope == beamte::Scope::File) + .map(|rule| rule.id) + .collect(); + Self { only, file_scoped } } fn selection(&self) -> Selection<'_> { if self.only.is_empty() { - Selection::All + Selection::Except(&self.file_scoped) } else { Selection::Only(&self.only) } @@ -208,6 +178,9 @@ fn build(settings: &Settings) -> Box { fn instruction(_settings: &Settings) -> String { let mut sentences = Vec::new(); for rule in beamte::catalogue() { + if rule.scope != beamte::Scope::Tests { + continue; + } sentences.push(format!("{} ({})", rule.instruction, rule.citation.title)); } sentences.join(" ") @@ -259,7 +232,7 @@ impl FileRule for TestQualityRule { return; }; - let pack = match pack(entry.pack) { + let pack = match crate::pack::cached(entry.pack) { Ok(pack) => pack, Err(reason) => { candidates.push(not_read( @@ -296,82 +269,19 @@ impl FileRule for TestQualityRule { let unit = Unit::new(file.path, tree.source(), tree.root()); for finding in beamte::inspect_with(&unit, &model, self.selection()) { - let line = finding.span.line; - let column = finding.span.column; - candidates.push(Candidate::line(Finding { - rule: KEY, - severity: severity_of(finding.property), - location: Location::point(file.path, line, column), - matched: matched_text(file.text, line), - message: message_of(&finding), - help: help_of(&finding), - related: Vec::new(), - evidence: finding - .evidence - .into_iter() - .map(|step| EvidenceStep { - location: Location::point(file.path, step.span.line, step.span.column), - message: step.message, - }) - .collect(), - })); + candidates.push(beamte_findings::candidate( + KEY, + severity_of(finding.property), + file.path, + file.text, + finding, + )); } } } -/// A file that could not be checked, said out loud. -/// -/// Beamte's DESIGN.md §7.3: a file that was not read is reported as unread, -/// never as clean. Returning nothing here would mean a failed pack fetch -/// reads exactly like a suite with nothing wrong in it, which is the failure -/// this whole rule exists to stop making. fn not_read(path: &str, message: String, help: Option) -> Candidate { - Candidate::file(Finding { - rule: KEY, - severity: Severity::Warning, - location: Location::point(path, 1, 1), - matched: String::new(), - message, - help, - related: Vec::new(), - evidence: Vec::new(), - }) -} - -/// How a beamte finding reads once straitjacket owns it. -/// -/// The beamte rule is named in the message rather than in the key, because -/// straitjacket registers one rule for all of them and `test-logic` would be -/// a key nothing in its manifest declares. -fn message_of(finding: &beamte::Finding) -> String { - format!("{}: {}", finding.rule, finding.message) -} - -/// The fix, and the post the rule was issued under. -/// -/// Beamte's DESIGN.md §6.3 puts citing the post on the host: it turns an -/// argument with a linter into a much shorter argument with Titus Winters, -/// and makes the rule set auditable rather than one person's taste. -fn help_of(finding: &beamte::Finding) -> Option { - let citation = beamte::rule(finding.rule.as_str()).map(|rule| rule.citation); - match (&finding.help, citation) { - (Some(help), Some(citation)) => { - Some(format!("{help} — {} ({})", citation.title, citation.url)) - } - (Some(help), None) => Some(help.clone()), - (None, Some(citation)) => Some(format!("{} ({})", citation.title, citation.url)), - (None, None) => None, - } -} - -/// The line a finding sits on, trimmed, for the `matched` field every other -/// rule fills in from its own regex. -fn matched_text(text: &str, line: usize) -> String { - text.lines() - .nth(line.saturating_sub(1)) - .unwrap_or_default() - .trim() - .to_string() + beamte_findings::not_read(KEY, path, message, help) } #[cfg(test)] diff --git a/src/scanner.rs b/src/scanner.rs index a0a9d66..b8330cc 100644 --- a/src/scanner.rs +++ b/src/scanner.rs @@ -52,6 +52,7 @@ impl Scanner { let mut enabled = if only.is_empty() { descriptor.default_enabled || (descriptor.id == rules::NO_COMMENTS && settings.no_comments) + || (descriptor.id == rules::ENV_VARS && settings.env_vars) } else { only.contains(&descriptor.id) }; diff --git a/tests/env_vars.rs b/tests/env_vars.rs new file mode 100644 index 0000000..b967a75 --- /dev/null +++ b/tests/env_vars.rs @@ -0,0 +1,213 @@ +//! `env-vars` against every language beamte's `env-read` covers. +//! +//! The point of this file is breadth, exactly as `tests/test_quality.rs`: the +//! rule reads an environment read the way each language writes one -- +//! `std::env::var` in Rust, `os.environ` in Python, `process.env` in +//! TypeScript, `ENV[...]` in Ruby, `System.getenv` in Java, `getenv` in C -- +//! and one case per language is what keeps a whole language from going +//! silent quietly. +//! +//! These 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. + +use straitjacket::config::Settings; +use straitjacket::finding::Severity; +use straitjacket::scanner::Scanner; + +/// A scanner with `env-vars` on, the way a configuration turns it on. +fn scanner(env_files: Vec) -> Scanner { + let settings = Settings { + env_vars: true, + env_files, + ..Settings::default() + }; + Scanner::new(&settings).expect("the scanner builds") +} + +fn findings(path: &str, source: &str) -> Vec { + let extension = path.rsplit('.').next().unwrap_or(""); + scanner(Vec::new()) + .scan(source, path, extension) + .findings + .into_iter() + .map(|finding| { + format!( + "{}:{} {}", + finding.location.line, finding.rule, finding.message + ) + }) + .collect() +} + +/// One environment read per language, written the way that language writes +/// one. Each `source` reads exactly one variable and does nothing else +/// wrong, so exactly one finding is correct in every row. +const CASES: &[(&str, &str, &str)] = &[ + ( + "python", + "src/loader.py", + "def load():\n return os.getenv(\"HOME\")\n", + ), + ( + "rust", + "src/loader.rs", + "fn load() -> Option {\n std::env::var(\"HOME\").ok()\n}\n", + ), + ( + "typescript", + "src/loader.ts", + "export function load(): string | undefined {\n return process.env.HOME;\n}\n", + ), + ( + "javascript", + "src/loader.js", + "function load() {\n return process.env.HOME;\n}\n", + ), + ( + "ruby", + "lib/loader.rb", + "def load_home\n ENV[\"HOME\"]\nend\n", + ), + ( + "java", + "src/Loader.java", + "class Loader {\n String load() {\n return System.getenv(\"HOME\");\n }\n}\n", + ), + ( + "c", + "src/loader.c", + "#include \nconst char *load(void) {\n return getenv(\"HOME\");\n}\n", + ), + ( + "cpp", + "src/loader.cc", + "#include \nconst char *load() {\n return std::getenv(\"HOME\");\n}\n", + ), + ( + "zig", + "src/loader.zig", + "const std = @import(\"std\");\nfn load() ?[]const u8 {\n return std.posix.getenv(\"HOME\");\n}\n", + ), +]; + +#[test] +fn every_language_reports_its_environment_read() { + for (language, path, source) in CASES { + let found = findings(path, source); + assert_eq!( + found.len(), + 1, + "{language}: expected exactly one finding in {path}, got {found:?}" + ); + assert!( + found[0].contains("env-read"), + "{language}: the finding should carry beamte's rule id, got {found:?}" + ); + assert!( + found[0].contains("HOME"), + "{language}: the finding should name the variable, got {found:?}" + ); + } +} + +#[test] +fn a_read_is_an_error_and_cites_the_post() { + let result = scanner(Vec::new()).scan( + "fn load() -> Option {\n std::env::var(\"HOME\").ok()\n}\n", + "src/loader.rs", + "rs", + ); + + assert_eq!(result.findings.len(), 1); + assert_eq!(result.findings[0].severity, Severity::Error); + let help = result.findings[0].help.as_deref().unwrap_or_default(); + assert!( + help.contains("testing.googleblog.com"), + "the help should cite the post, got: {help}" + ); +} + +#[test] +fn the_declared_edge_is_licensed_and_everything_else_is_not() { + let scanner = scanner(vec!["src/config.rs".into()]); + let source = "fn load() -> Option {\n std::env::var(\"HOME\").ok()\n}\n"; + + let licensed = scanner.scan(source, "src/config.rs", "rs"); + assert_eq!( + licensed.findings, + Vec::new(), + "the declared edge may read the environment" + ); + + let unlicensed = scanner.scan(source, "src/main.rs", "rs"); + assert_eq!(unlicensed.findings.len(), 1); +} + +/// The read sits inside a test. `env-vars` reports it -- a test that reads +/// the environment is exactly the non-hermetic test the citation describes -- +/// and `test-quality` does not, because the file-scoped rule is env-vars' to +/// run and one read should not become two findings. +/// +/// The read is a plain call rather than a macro argument: a Rust macro's +/// arguments are lexed as a token tree, not parsed, so a read inside +/// `assert!(...)` carries no invocation node for beamte to match -- a miss +/// beamte's rule documents by name. +#[test] +fn a_test_file_is_read_by_env_vars_and_not_by_test_quality() { + let source = "#[test]\nfn test_home() {\n let home = std::env::var(\"HOME\");\n home.unwrap();\n}\n"; + + let by_env = findings("tests/home.rs", source); + assert_eq!(by_env.len(), 1, "env-vars reads test files too: {by_env:?}"); + + let settings = Settings { + only: vec!["test-quality".to_string()], + ..Settings::default() + }; + let by_tests = + Scanner::new(&settings) + .expect("the scanner builds") + .scan(source, "tests/home.rs", "rs"); + assert_eq!( + by_tests.findings, + Vec::new(), + "test-quality holds the file-scoped rule out of its default selection" + ); +} + +#[test] +fn a_shell_file_is_not_this_rules_to_read() { + let found = findings("scripts/release.sh", "#!/bin/sh\necho \"$HOME\"\n"); + assert_eq!( + found, + Vec::::new(), + "every expansion in shell is an environment read; flagging the \ + language is not a rule" + ); +} + +#[test] +fn naming_the_file_scoped_rule_in_test_rules_is_refused_with_directions() { + let settings = Settings { + test_rules: vec!["env-read".to_string()], + ..Settings::default() + }; + + let error = match Scanner::new(&settings) { + Ok(_) => panic!("env-read is not a test rule and should be refused"), + Err(error) => error.to_string(), + }; + assert!( + error.contains("env-vars"), + "the error should point at the rule that runs it: {error}" + ); +} + +#[test] +fn a_mention_in_a_comment_is_not_a_read() { + let found = findings( + "src/loader.rs", + "// std::env::var(\"HOME\") would be wrong here\nfn load() {}\n", + ); + assert_eq!(found, Vec::::new()); +} From 7ae7b9734e88d0f0b8e3d356bb574d0d1d9bc131 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 20:04:34 +0000 Subject: [PATCH 2/2] Point the beamte patch at a branch that still exists beamte#21 merged and GitHub deleted the feature branch with it, so the [patch.crates-io] entry named a ref that is gone. A patch cargo cannot resolve fails the whole job at dependency resolution -- before fmt, clippy or a single test -- which is a worse failure than the publish dry-run this branch already expects. The default branch carries the merged env-read, so the patch points there and needs no ref to chase. It comes out entirely once 0.2.0 is on crates.io; the requirement above it is already written for the registry. Cargo.lock also picks up a windows-sys 0.59 -> 0.61 bump for several Windows-only transitive dependencies. That is the resolver on a newer toolchain rather than anything this change asks for, and it is left as cargo wrote it: reverting the references by hand would orphan the 0.61 entry and break --locked. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WrSzGnURZoupdEfVdwk9pg --- Cargo.lock | 18 +++++++++--------- Cargo.toml | 13 ++++++++----- 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e272f82..76cdae5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -152,7 +152,7 @@ checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" [[package]] name = "beamte" version = "0.2.0" -source = "git+https://github.com/PowderworksCode/beamte?branch=claude%2Fsloth-env-vars-config-2g6g2t#f948a16610774a82d3d041d8023ef95f16e80c2f" +source = "git+https://github.com/PowderworksCode/beamte#5025a62d797b5f869584d86694022400f68ba694" dependencies = [ "treebank", ] @@ -404,7 +404,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -879,7 +879,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1569,7 +1569,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1959,7 +1959,7 @@ dependencies = [ "bitflags", "libc", "mach2 0.4.3", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2046,7 +2046,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2112,7 +2112,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2527,7 +2527,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3113,7 +3113,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index a743256..8c795c1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -65,9 +65,12 @@ let_underscore_must_use = "deny" lto = true strip = true -# beamte 0.2 (`env-read`, `Rule::scope`) is not on crates.io yet. Until it is, -# the requirement above resolves through this patch to the branch that carries -# it. Drop this table the moment 0.2 is published: the requirement is already -# written for the registry, so deleting these three lines is the whole change. +# beamte 0.2 (`env-read`, `Rule::scope`) is merged but not yet on crates.io. +# Until it is, the requirement above resolves through this patch to beamte's +# default branch, which carries it. Deliberately not a branch pin: the feature +# branch was deleted when it merged, and a patch naming a branch that no longer +# exists fails dependency resolution before a single check runs. Drop this +# table the moment 0.2 is published -- the requirement is already written for +# the registry, so deleting these lines is the whole change. [patch.crates-io] -beamte = { git = "https://github.com/PowderworksCode/beamte", branch = "claude/sloth-env-vars-config-2g6g2t" } +beamte = { git = "https://github.com/PowderworksCode/beamte" }