Skip to content
Merged
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
18 changes: 18 additions & 0 deletions .serena/memories/core.md
Original file line number Diff line number Diff line change
Expand Up @@ -1403,6 +1403,24 @@ judge_fingerprint`, its own domain tag), so a caller can reference content it
exactly what this surface could rebuild. `Module` holds no `source` field and
hand-writes `Debug`, so a policy body has nowhere to live past compilation
(rule 4).
- `pattern.rs` — the `[[pattern]]` table (CLOUD-885): named regular expressions a
policy module references by id, never writes inline. **The lever is cost, not
prohibition** — "do not regex things that are not regular" is a judgement and
rule 3 refuses a gate over one, but _where a pattern lives_ is decidable. So a
regex costs a config row and an id while the same question over an
already-parsed document costs a field access, and the cheap path becomes the
correct one without a translator reasoning about regularity. Consumer-owned for
`verbs`' stated reason (rule 1): a tracker-key expression is a consumer
identifier. Two consequences fall out rather than being designed in —
duplication becomes _unwritable_ (measured: one concept, 19 spellings across 17
shell programs), and the inventory becomes reviewable data (§11). `policy.rs`
closes it at both ends: an inline literal is refused, and so is a reference no
row declares, because that resolves to UNDEFINED and Rego reads undefined as
"does not hold" — a silent gate. The same failure by deletion is
`trust::WeakeningKind::PatternRemoved`. Two defects here were found by an AST
probe rather than a reading: a backtick literal is a `RawString` node and not a
`String`, and a REFERENCE contains a literal (`patterns["x"]` is a `RefBrack`
indexed by a string), so a naive sweep refuses the sanctioned form.
- `provision.rs` — the `[[provision]]` manifest (CLOUD-90): pinned tools fetched
and cached out of tree. §9's check/fix pair — `provision status` (read) is
freshness, `provision apply [-n]` (write) is the fix. **The provisioned binary
Expand Down
33 changes: 33 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,39 @@ gix = { version = "0.86", default-features = false, features = [
# code, in the harder place to review, to avoid a feature that costs a lockfile
# edge. The first tree-scoped preset was written string-only and hit the wall on
# the second rule, which is when this was reconsidered.
#
# `semver` IS NOT HERE, AND THAT IS THIS ROW'S OWN RULE APPLIED TO ITSELF. It
# was added beside `regex` on the reasoning that version-comparison gates will
# want it — and no module in the tree calls it. A feature nothing calls is
# `input.tree.tracked` documented and never built (CLOUD-845): it reads as a
# capability, it is checked by nothing, and the first consumer discovers whether
# it actually works. So it lands with its first consumer rather than ahead of
# one. The measurement is recorded on CLOUD-885 either way — `semver` is
# `dep:semver` alone and cleared both gates — so re-adding it costs a line, not
# a re-derivation.
#
# `glob` IS REFUSED BY CONSTRUCTION, not by preference, and stating it here is
# what stops it being re-litigated as an oversight: `glob = ["dep:globset"]`, and
# `globset` is one of the nine names `evaluator-closure-check` decides on. The
# gate that defends the security claim already answers this. A module needing
# path matching takes it from the row's `sources` glob (CLOUD-850), resolved by
# the engine outside the module — which is the better shape anyway, since the
# engine bounds acquisition by declaration.
#
# `time` IS DEFERRED, AND THE BLOCKER IS UPSTREAM'S DECLARATION rather than this
# pin (CLOUD-885). `time = ["dep:chrono", "dep:chrono-tz"]` and regorus declares
# both with `default-features = true` — every published version carrying the
# feature does, checked against the index. chrono's default `clock` reaches
# `iana-time-zone`, which on Apple targets reaches `core-foundation-sys`, which
# `macos-link-check`'s `FRAMEWORK_CRATES` names; enabling it fails that gate at
# exit 1 on exactly that crate. Cargo's feature unification is ADDITIVE, so a
# direct `chrono = { default-features = false }` here would not subtract what
# regorus requested — this is not fixable from our manifest. CLOUD-885 carries
# the re-open predicate as a command.
#
# `semver` IS NOT HERE, on the same rule (CLOUD-885): no module calls it, and a
# feature nothing calls is `input.tree.tracked` documented and never built
# (CLOUD-845). It lands with its first consumer.
regorus = { version = "0.11", default-features = false, features = [
"ast",
"std",
Expand Down
17 changes: 17 additions & 0 deletions batten.toml
Original file line number Diff line number Diff line change
Expand Up @@ -1657,6 +1657,23 @@ no_fix_reason = "an IO crate reaching the evaluator is closed where it was enabl
#
# Mediated-call scoped, because the predicate is over a command line. The other
# four families of that guard stay in bash and its header says which and why.
# The short flag cluster `git commit` accepts a message source in — `-m`, `-am`,
# `-F`, `-C`, `-c` — as a shape rather than a literal (CLOUD-885).
#
# DECLARED HERE RATHER THAN IN THE MODULE, and this row is the mechanism proving
# itself on consumer #1. An expression is a consumer fact: written into
# `policy/run-shape.rego` it would be a pattern with no home, invisible to
# `config show`, and the second module wanting the same shape would re-spell it.
# The engine refuses an inline regex at load, so this row is not a convention —
# it is the only way to write the predicate at all.
#
# The anchor is the predicate. `contains` over the flag's tail cannot say
# "letters", so `-x=mfoo` carried an `m` and read as naming a message source,
# letting through a commit that still blocks on $EDITOR.
[[pattern]]
id = "short-message-flag-cluster"
regex = "^-[A-Za-z]*[mFCc]"

[[rule]]
id = "commit-message-obtainable"
kind = "policy"
Expand Down
20 changes: 20 additions & 0 deletions crates/batten/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,18 @@ pub struct Config {
/// lookup are [`crate::verbs`].
#[serde(default, rename = "verb", skip_serializing_if = "Vec::is_empty")]
pub verbs: Vec<crate::verbs::MutatingVerb>,
/// The named-regex table (CLOUD-885): every expression a policy module may
/// apply, declared once and referenced by id.
///
/// Consumer-specific by nature for exactly [`Config::verbs`]'s reason — a
/// tracker-key expression is a consumer identifier, so it lives here and
/// never in the crate (non-negotiable rule 1). A module reaches it at
/// `data.batten.patterns["<id>"]`; writing one inline is refused at load,
/// which is what prices a one-off pattern against a field access over an
/// already-parsed document. The type and its validation are
/// [`crate::pattern`].
#[serde(default, rename = "pattern", skip_serializing_if = "Vec::is_empty")]
pub patterns: Vec<crate::pattern::NamedPattern>,
/// The per-path-class redirect table (CLOUD-280): what to run instead,
/// keyed by what is protected rather than by the verb reaching for it.
///
Expand Down Expand Up @@ -616,6 +628,12 @@ fn parse_ungated(text: &str, source: &str) -> Result<Config> {
// too: `batten.local.toml` may add verb rows, and a raise-only override that
// adds an inert one has still written something that cannot mean anything.
crate::verbs::validate(&config.verbs)?;
// The named-regex table, at parse for the identical reason (CLOUD-885): a
// malformed expression is a config fault, and refusing it here means
// `config lint` and `doctor` catch it rather than a mediated call
// discovering it at adjudication, which is the worst time and the wrong exit
// class (house style §8).
crate::pattern::validate(&config.patterns)?;
crate::redirect::validate(&config.redirects)?;
// And the marker table, for the identical reason in the identical shape
// (CLOUD-253). Both tables arrived in one commit; CLOUD-242 wired one of
Expand Down Expand Up @@ -766,6 +784,7 @@ impl Config {
strictness: None,
fail_on_warning: None,
rules: Vec::new(),
patterns: Vec::new(),
scope: Vec::new(),
protected: Vec::new(),
unlanded: Vec::new(),
Expand Down Expand Up @@ -1029,6 +1048,7 @@ mod tests {
/// [`parse_ungated`] that does it. Deleting a call fails the test below.
const VALIDATED_AT_LOAD: &[(&str, &str)] = &[
("verbs", "crate::verbs::validate("),
("patterns", "crate::pattern::validate("),
("redirects", "crate::redirect::validate("),
("markers", "crate::markers::validate("),
// The LOCATED form (CLOUD-773): the loaders hold the config text, so a
Expand Down
18 changes: 17 additions & 1 deletion crates/batten/src/hook.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2037,6 +2037,19 @@ impl Policy {
// still is at `batten check`/`enforce`, which is where a tree rule
// is evaluated and where `verify` and CI both reach it — so the
// module is still refused before it can matter, one surface over.
//
// AND THE MODULE CHECKS DO NOT RUN HERE EITHER (CLOUD-885), which is
// the same finding reached from the other side. Narrowing the rows
// above removes the tree modules; this removes the AST walk over the
// ones that remain. `check_no_inline_regex` and
// `check_tree_paths_are_emittable` read a module through
// `get_ast_as_json`, which serialises every rule of every module —
// and their answer is a property of the module TEXT, so it is fixed
// for the life of the load and identical on every surface. CI
// measured re-deriving it per call at `wired` 14.03 ms -> 22.98 ms
// (1.638x). Both are the same rule: the mediated call loads and
// decides; a config fault is reported where config faults are
// reported.
bundles: crate::policy::load(
root,
&resolved
Expand All @@ -2045,6 +2058,8 @@ impl Policy {
.filter(|rule| rule.scope == RuleScope::MediatedCall)
.cloned()
.collect::<Vec<Rule>>(),
&resolved.patterns,
crate::policy::ModuleChecks::SkipOnHotPath,
reference,
)?,
})
Expand Down Expand Up @@ -5318,7 +5333,8 @@ mod tests {
Policy {
harness: Harness::ExitCode,
facts: Vec::new(),
bundles: crate::policy::load(&dir, &[row], None).expect("load"),
bundles: crate::policy::load(&dir, &[row], &[], crate::policy::ModuleChecks::Run, None)
.expect("load"),
shapes: Vec::new(),
fail_on_warning: false,
verbs: Vec::new(),
Expand Down
36 changes: 31 additions & 5 deletions crates/batten/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ pub mod lint;
pub mod markers;
pub mod output;
pub mod outputs;
pub mod pattern;
pub mod policy;
pub mod provision;
pub mod receipt;
Expand Down Expand Up @@ -440,7 +441,7 @@ fn run_baseline(
) -> Result<ExitCode> {
let root = anchor();
let config = resolve::resolve(&root, overrides)?;
let scan = rules::run_static(&config.rules, &config.provisions, &root)?;
let scan = rules::run_static(&config.rules, &config.provisions, &config.patterns, &root)?;

if prune {
let Some(existing) = baseline::load(&root)? else {
Expand Down Expand Up @@ -776,7 +777,12 @@ fn run_state_record(overrides: &Overrides, mode: Mode, err: &mut dyn Write) -> R
// included (CLOUD-97 never once evaluated in this repository for exactly
// that reason). Withholding is honest here because `record` below folds
// `not_evaluated` into the store, where a withheld rule's findings HOLD.
let scan = rules::run_recorded(&config.rules, &config.provisions, Path::new("."))?;
let scan = rules::run_recorded(
&config.rules,
&config.provisions,
&config.patterns,
Path::new("."),
)?;
if !scan.not_evaluated.is_empty() {
// Never silent: a rule that did not look must say so, or a clean-looking
// record is the false green. The COUNT carries that on the default rung
Expand Down Expand Up @@ -1353,7 +1359,13 @@ impl SuiteReport {
fn run_policy_test(json: bool, overrides: &Overrides, out: &mut dyn Write) -> Result<ExitCode> {
let root = Path::new(".");
let config = resolve::resolve(root, overrides)?;
let bundles = policy::load(root, &config.rules, overrides.config_from.as_deref())?;
let bundles = policy::load(
root,
&config.rules,
&config.patterns,
policy::ModuleChecks::Run,
overrides.config_from.as_deref(),
)?;
// The same walk the tree engine hoists, so a suite's input carries the same
// `tracked` a real `check` would hand the bundle (CLOUD-845). Resolved once
// here rather than per row, for the reason `rules::run` gives.
Expand Down Expand Up @@ -3767,12 +3779,26 @@ fn apply_baseline(
Ok(kept)
}

/// One of the two rule-running surfaces, as [`run_rules`] takes it.
///
/// Named rather than written inline because the pattern table joined the
/// argument list (CLOUD-885) and a four-argument fn pointer is past what
/// `clippy::type_complexity` will read. The alias is also the clearer spelling:
/// the three tables and the root are what a runner needs, and saying so once
/// beats repeating it at both call sites.
type RuleRunner = fn(
&[rules::Rule],
&[provision::Provision],
&[pattern::NamedPattern],
&Path,
) -> Result<rules::Scan>;

fn run_rules(
out: &mut dyn Write,
err: &mut dyn Write,
mode: Mode,
overrides: &Overrides,
runner: fn(&[rules::Rule], &[provision::Provision], &Path) -> Result<rules::Scan>,
runner: RuleRunner,
surface: Surface,
json: bool,
) -> Result<ExitCode> {
Expand Down Expand Up @@ -3801,7 +3827,7 @@ fn run_rules(
// store's resolve pass fail-closed (CLOUD-81), and the enforce surface now
// journals (CLOUD-529), so dropping it here would let a rule that never
// looked resolve every finding it covers.
let scan = runner(&config.rules, &config.provisions, &root)?;
let scan = runner(&config.rules, &config.provisions, &config.patterns, &root)?;
let mut findings = scan.findings.clone();

// Declared budgets are gates, evaluated here rather than only under `policy
Expand Down
10 changes: 9 additions & 1 deletion crates/batten/src/lint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -462,7 +462,15 @@ pub fn run(dir: &Path, base_ref: Option<&str>, today: crate::waiver::Date) -> Re
// reports nothing about a set it never saw.
let bundles = config::parse(&text, &path.display().to_string())
.ok()
.map(|config| crate::policy::load(dir, &config.rules, base_ref))
.map(|config| {
crate::policy::load(
dir,
&config.rules,
&config.patterns,
crate::policy::ModuleChecks::Run,
base_ref,
)
})
.and_then(std::result::Result::ok)
.unwrap_or_default();
smells(
Expand Down
Loading