From c6aad16edd2d4004900bf3b72a2bb613a8e8b3c7 Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Sun, 30 Aug 2026 21:14:17 -0700 Subject: [PATCH 01/20] fix(preproc): emit preproc documents in sorted order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `bca preproc` serialized `PreprocResults.files` and the three `HashSet` fields of `PreprocFile` straight off hash order, so the document differed byte for byte between runs over an unchanged tree: eight runs over a five-file fixture gave eight distinct hashes. This was the last file destination in the #1244 / #1303 family still nondeterministic, and unlike those two it moves identically at `--jobs 1` — the variation is at the serialization seam, not in worker arrival order. Sort there rather than storing ordered containers. The four fields are public on published structs and `get_macros` publishes `HashSet` as its return type, so a `BTreeMap` / `BTreeSet` swap would be a source-level break reserved for the next major; four `serialize_with` hooks leave the in-memory types, the public field types, and the wire shape untouched and make only the byte order deterministic. Both destinations benefit — one `to_string` feeds `--output` and stdout alike. The document uses two comparators, which is now stated rather than incidental: `files` sorts its `PathBuf` keys component-wise, matching what `metrics --output` and `ops --output` sort their aggregates on, while the `String` name sets sort by bytes. The two disagree for paths such as `a/x.h` and `a-b/x.h`, so the unit test carries that trio in both positions and fails if either hook reaches for the other comparator. Fixes #1304 --- STABILITY.md | 20 +++ .../src/recipes/quality-reports.md | 15 ++ .../tests/output/output_unification.rs | 161 ++++++++++++++++++ src/preproc.rs | 75 ++++++++ src/preproc_tests.rs | 97 +++++++++++ 5 files changed, 368 insertions(+) diff --git a/STABILITY.md b/STABILITY.md index 6e5400d11..6e80601b4 100644 --- a/STABILITY.md +++ b/STABILITY.md @@ -193,6 +193,26 @@ section. detail — the contract is that these trees tear down in constant stack, not the presence of any particular `Drop` body. - `FunctionSpan` in `src/function.rs`. + - `PreprocResults` and `PreprocFile` in `src/preproc.rs` — the + serialization roots of a `bca preproc` document, on `--output` and + on stdout alike (one `to_string` feeds both destinations). Their + fields keep their `HashMap` / `HashSet` types, so *iteration* order + is unspecified as it always was, but since #1304 their `Serialize` + impls emit in sorted order. Before that both came straight off hash + order and the document differed byte for byte between runs over an + unchanged tree. + + The document uses **two** comparators, and a consumer re-deriving + either order needs the right one for the field it is reading. The + `files` map sorts its `PathBuf` keys **component-wise**, the key + `metrics --output` and `ops --output` sort their aggregates on, so + the three documents agree. `direct_includes`, `indirect_includes`, + and `macros` hold `String`s and sort **byte-lexicographically**. + The two disagree wherever a separator interleaves with a byte below + `/`: `a-b/x.h` sorts *after* `a/x.h` as a key and *before* it + inside an include array, even when both name the same file. The + emitted order is part of the contract now — callers may diff or + hash a `preproc` document — and will not change before `3.0`. - **Offender / catalog enums** - `Severity` in `src/output/offenders.rs` (re-exported from the crate root): an ordered severity scale carrying diff --git a/big-code-analysis-book/src/recipes/quality-reports.md b/big-code-analysis-book/src/recipes/quality-reports.md index 42b902bf5..aa185ee41 100644 --- a/big-code-analysis-book/src/recipes/quality-reports.md +++ b/big-code-analysis-book/src/recipes/quality-reports.md @@ -155,6 +155,21 @@ anywhere accurate C/C++ analysis matters. Subcommands that do not consume it (`vcs`, `preproc`, `list-metrics`, `diff-baseline`) reject it as a usage error. +Step 1's document is deterministic: the `files` map is written in +sorted path order and each entry's `direct_includes`, +`indirect_includes`, and `macros` arrays in sorted name order, so two +`bca preproc` runs over an unchanged tree produce byte-identical +output — on stdout as well as through `--output`. Check +`/tmp/preproc.json` into a build cache or diff it between branches +without spurious churn, the same way you can a `metrics --output` or +`ops --output` document. + +Two comparators are in play, which matters only if you re-derive an +order yourself. The `files` keys are paths and sort component-wise, so +`a/x.h` precedes `a-b/x.h`; the include and macro arrays are plain +strings and sort by bytes, where `a-b/x.h` precedes `a/x.h`. Reading +the document needs neither, and `jq` preserves what was written. + ## Analyze only files changed in a PR Pipe a list of changed files into `--paths-from -` to score just the diff --git a/big-code-analysis-cli/tests/output/output_unification.rs b/big-code-analysis-cli/tests/output/output_unification.rs index afc221449..2434a9551 100644 --- a/big-code-analysis-cli/tests/output/output_unification.rs +++ b/big-code-analysis-cli/tests/output/output_unification.rs @@ -17,6 +17,10 @@ //! - #1303: the same for the *streaming* stdout destination, which //! cannot sort after the fact — its documents are held in a reorder //! buffer and released in walk order. +//! - #1304: `preproc --output` closes the same family. Its document is +//! not an aggregate of per-file records but one `HashMap` of +//! `HashSet`s, so neither of the two fixes above reaches it; the +//! ordering is imposed at the library's serialization seam instead. use assert_cmd::Command; use predicates::prelude::*; @@ -767,3 +771,160 @@ fn output_dir_tree_is_identical_across_runs() { "--output-dir must write an identical tree on every run" ); } + +/// The C fixture `preproc` runs over, in creation order. Neither +/// sorted nor reverse-sorted, so an emission that merely preserved the +/// walk's arrival order could not match the sorted expectation either. +/// `x.c` includes `a.h` and `c.h`, and `c.h` includes `b.h`, so `x.c`'s +/// transitive closure spans every file — which is what makes its +/// `indirect_includes` a four-element set worth asserting on. +const PREPROC_FIXTURE: [(&str, &str); 4] = [ + ( + "x.c", + "#include \"a.h\"\n#include \"c.h\"\nint main(void){return 0;}\n", + ), + ("c.h", "#include \"b.h\"\n#define MACRO_D 4\n"), + ("a.h", "#define MACRO_A 1\n#define MACRO_B 2\n"), + ("b.h", "#define MACRO_C 3\n"), +]; + +/// Write [`PREPROC_FIXTURE`] under `dir` and return its file paths in +/// the order the document's `files` keys must carry. +/// +/// Sorted as `PathBuf`s, not as strings, for the reason +/// [`write_ordering_fixture`] documents — the keys really are compared +/// that way. The `indirect_includes` array is `String`s and sorts by +/// bytes instead, an order this flat single-directory fixture cannot +/// tell apart from the component-wise one. That distinction is pinned +/// where it can be built exactly, in +/// `preproc::tests::serialization_emits_files_and_name_sets_in_sorted_order`; +/// here the concern is end-to-end wiring, so one list serves both. +fn write_preproc_fixture(dir: &std::path::Path) -> Vec { + let mut expected = Vec::with_capacity(PREPROC_FIXTURE.len()); + for (name, body) in PREPROC_FIXTURE { + let file = dir.join(name); + std::fs::write(&file, body).unwrap(); + expected.push(file); + } + expected.sort_unstable(); + expected +} + +/// The path rendered as it appears in the document — a JSON string +/// literal, quotes and escapes included. +fn json_string(path: &std::path::Path) -> String { + serde_json::to_string(path.to_str().unwrap()).unwrap() +} + +/// Run `preproc --output ` over `dir` at `--jobs 8` and return +/// the emitted document as raw text. +/// +/// Raw text, not a parsed `serde_json::Value`: this workspace +/// deliberately leaves `serde_json`'s `preserve_order` feature off (the +/// root `Cargo.toml` says why), so a parsed map re-sorts its own keys +/// and every key-order assertion over one is vacuously true. +/// +/// `--no-config` rather than the `cli_in` cwd anchor the sibling tests +/// use: `preproc` renders a key relative to the working directory when +/// the file sits under it, so anchoring the cwd at the fixture would +/// make every key `./a.h` and put the assertions on a rendering +/// convention instead of on the order. Run from the inherited cwd the +/// keys are the absolute fixture paths, and `--no-config` supplies the +/// hermeticity the anchor would have. +fn preproc_document(dir: &std::path::Path, out: &std::path::Path) -> String { + cli() + .args([ + "preproc", + "--no-config", + "--paths", + dir.to_str().unwrap(), + "--output", + out.to_str().unwrap(), + "--jobs", + "8", + ]) + .assert() + .success(); + + std::fs::read_to_string(out).expect("the preproc document is UTF-8") +} + +/// The text opening a `PreprocFile`'s nested include closure. +const INDIRECT_KEY: &str = "\"indirect_includes\":["; + +/// `preproc --output` emits its `files` map in sorted path order, and +/// each file's include/macro sets in sorted order within that (#1304). +/// +/// Both containers are hashed (`HashMap` holding +/// `HashSet`s), so before the fix this document was a fresh +/// permutation on nearly every run: eight runs over a five-file tree +/// gave eight distinct hashes. The `indirect_includes` assertion is +/// separate on purpose — it is the nested container, and a fix that +/// ordered only the top-level map would leave it moving. Perturbing +/// each half of production alone fails both, measured. +#[test] +fn preproc_output_map_and_include_sets_are_sorted() { + let dir = TempDir::new().unwrap(); + let expected = write_preproc_fixture(dir.path()); + let out = dir.path().join("pp.json"); + + let doc = preproc_document(dir.path(), &out); + + // Where each entry's key sits in the raw document. Sorted-path + // order is the order they were looked up in, so the positions must + // come out strictly increasing. + let at: Vec = expected + .iter() + .map(|path| { + doc.find(&format!("{}:{{", json_string(path))) + .unwrap_or_else(|| { + panic!("document must carry an entry for {}: {doc}", path.display()) + }) + }) + .collect(); + // `windows(2)` over fewer than two entries yields nothing and makes + // the assertion below vacuously true, so pin the count first. + assert_eq!( + at.len(), + PREPROC_FIXTURE.len(), + "every fixture file must have an entry to order" + ); + assert!( + at.windows(2).all(|pair| pair[0] < pair[1]), + "the files map must be emitted in sorted path order: {doc}" + ); + + // `x.c` reaches every fixture file, so its closure is the whole + // sorted path list. + let x_c = format!("{}:{{", json_string(&dir.path().join("x.c"))); + let entry = doc.split_once(&x_c).expect("x.c has an entry").1; + let array = entry + .split_once(INDIRECT_KEY) + .expect("x.c's entry carries an indirect_includes array") + .1; + let emitted = array.split_once(']').expect("the array closes").0; + let want: Vec = expected.iter().map(|path| json_string(path)).collect(); + assert_eq!( + emitted, + want.join(","), + "the nested include closure must be emitted sorted too" + ); +} + +/// The whole document is byte-identical across runs over an unchanged +/// tree, which is the property #1304 reports and the one the sibling +/// destinations already hold. +#[test] +fn preproc_output_is_byte_identical_across_runs() { + let dir = TempDir::new().unwrap(); + write_preproc_fixture(dir.path()); + + let runs: Vec = (0..DETERMINISM_RUNS) + .map(|run| preproc_document(dir.path(), &dir.path().join(format!("pp{run}.json")))) + .collect(); + + assert!( + runs.windows(2).all(|pair| pair[0] == pair[1]), + "preproc --output must write the same bytes on every run: {runs:?}" + ); +} diff --git a/src/preproc.rs b/src/preproc.rs index 8d24a3e21..fda3a52a9 100644 --- a/src/preproc.rs +++ b/src/preproc.rs @@ -125,21 +125,96 @@ impl std::fmt::Display for PreprocDiagnostic { #[derive(Debug, Default, Deserialize, Serialize)] pub struct PreprocFile { /// The set of include directives explicitly written in a file + #[serde(serialize_with = "serialize_sorted_names")] pub direct_includes: HashSet, /// The set of include directives implicitly imported in a file /// from other files + #[serde(serialize_with = "serialize_sorted_names")] pub indirect_includes: HashSet, /// The set of macros of a file + #[serde(serialize_with = "serialize_sorted_names")] pub macros: HashSet, } /// Preprocessor data of a series of `C/C++` files. +/// +/// Serializing walks the `files` map in sorted path order and each +/// [`PreprocFile`]'s name sets in sorted order, so the document is +/// byte-identical across runs over unchanged sources and can be diffed +/// or hashed. The fields stay hashed containers, so *iteration* order +/// remains unspecified. #[derive(Debug, Default, Deserialize, Serialize)] pub struct PreprocResults { /// The preprocessor data of each `C/C++` file + #[serde(serialize_with = "serialize_sorted_files")] pub files: HashMap, } +// The four `serialize_with` hooks above exist so `bca preproc --output` +// writes a byte-identical document across runs over an unchanged tree +// (#1304), the way `Ops`' vocabularies (#1091), the `metrics` / `ops` +// aggregates (#1244) and [`fix_includes`]' own diagnostic sequence +// already do. +// +// Sorting at the serialization seam rather than storing ordered +// containers is deliberate. `files` and the three name sets are public +// fields on published structs, and [`get_macros`] publishes +// `HashSet` as its return type, so a `BTreeMap` / `BTreeSet` +// swap would be a source-level break reserved for the next major. This +// keeps the in-memory types, the public field types, and the wire shape +// exactly as they are; only the byte order becomes deterministic. +// +// They also do not belong in [`crate::wire`]: that module exists because +// the *metric* compute types cannot derive `Deserialize` from their +// derived-value projections, so it mirrors them with a parallel struct +// set. These two structs are already their own wire shape and round-trip +// through a plain derive, so a projection would buy a deep clone and a +// second definition of an identical shape. + +/// Serializes a set of names in sorted order. +/// +/// The elements are `String`s, so this is `str`'s byte-lexicographic +/// order — *not* the component-wise order +/// [`serialize_sorted_files`] applies to the map keys, even though an +/// `indirect_includes` entry and a `files` key can name the same file. +/// The two disagree wherever a separator interleaves with a byte below +/// `/` (`a-b/x.h` sorts before `a/x.h` here and after it there). Both +/// are deterministic, which is the contract; a consumer re-deriving +/// either order needs the right comparator for the field it is reading. +/// +/// Set elements are unique, so the order is total and `sort_unstable` +/// has no observable ties to break. +fn serialize_sorted_names( + names: &HashSet, + serializer: S, +) -> Result { + let mut sorted: Vec<&String> = names.iter().collect(); + sorted.sort_unstable(); + serializer.collect_seq(sorted) +} + +/// Serializes the per-file map in sorted path order. +/// +/// Sorts the `PathBuf` keys, so this is `Path`'s **component-wise** +/// order — `a/x.h` before `a-b/x.h`, the reverse of what comparing the +/// rendered strings gives. That matches the key `bca metrics --output` +/// and `bca ops --output` sort their aggregates on, so the three +/// documents agree; it differs from [`serialize_sorted_names`], which +/// has only `String`s to compare. Comparing keys rather than a +/// rendering of them also stays defined for a key this crate could not +/// decode as UTF-8 — such a key fails the serializer either way, but it +/// fails at the same point on every run. +/// +/// Map keys are unique, so the order is total. +fn serialize_sorted_files( + files: &HashMap, + serializer: S, +) -> Result { + let mut sorted: Vec<(&PathBuf, &PreprocFile)> = files.iter().collect(); + sorted.sort_unstable_by_key(|&(path, _)| path); + serializer.collect_map(sorted) +} + impl PreprocFile { /// Builds a new `PreprocFile` whose macro set contains the given /// macro names (and no includes). diff --git a/src/preproc_tests.rs b/src/preproc_tests.rs index b334672a8..9491f5409 100644 --- a/src/preproc_tests.rs +++ b/src/preproc_tests.rs @@ -1072,3 +1072,100 @@ fn preproc_diagnostic_display_propagates_every_formatter_error() { ); } } + +/// The serialized document's `files` map and each file's three name +/// sets come out in sorted order, not in `HashMap` / `HashSet` +/// iteration order (#1304) — and each in the comparator its own type +/// defines. +/// +/// The `a/x.h`, `a-b/x.h`, `a.h/x.h` trio appears twice on purpose, +/// once as map keys and once inside `indirect_includes`. `Path` orders +/// component-wise and `str` orders by bytes, so the same three paths +/// must come out in *opposite* relative orders in the two places: +/// `-` (0x2D) and `.` (0x2E) both sort below `/` (0x2F) as bytes, but +/// as paths the shorter first component `a` wins outright. Nothing else +/// in the suite pins that distinction, and a hook that reached for the +/// wrong comparator would still be perfectly deterministic — the bug +/// #1304 reports would read as fixed. +/// +/// Each hashed container emits a different permutation per process +/// without its hook, so a coincidental pass needs 5! map orders and +/// three 5-element sets' orders to land sorted at once. Read the guard +/// per *attribute* rather than in aggregate, though: the four +/// `serialize_with`s are independent, and dropping exactly one leaves +/// a single 5-element container to get lucky with, i.e. 1 in 120 per +/// run. That is the shape a later edit would take, and it is why the +/// sets carry five names rather than the two the assertion needs. +/// +/// The assertion is against a hand-written literal rather than a +/// re-derived expectation, and against the *raw string* rather than a +/// parsed `serde_json::Value`: this workspace deliberately leaves +/// `serde_json`'s `preserve_order` feature off (see the root +/// `Cargo.toml`), so a parsed map re-sorts its own keys and any +/// key-order assertion over one is vacuous. +#[test] +fn serialization_emits_files_and_name_sets_in_sorted_order() { + let mut results = PreprocResults::default(); + let mut rich = PreprocFile::default(); + // Every insertion order below is neither sorted nor reverse-sorted, + // so an emission that merely preserved (or reversed) insertion + // could not pass either. + for name in ["q.h", "z.h", "a.h", "m.h", "c.h"] { + rich.direct_includes.insert(name.to_owned()); + } + for name in ["a.h/x.h", "w3.h", "a/x.h", "b1.h", "a-b/x.h"] { + rich.indirect_includes.insert(name.to_owned()); + } + for name in ["MM", "ZZ", "AA", "YY", "CC"] { + rich.macros.insert(name.to_owned()); + } + results.files.insert(PathBuf::from("src/z.c"), rich); + for (path, macro_name) in [ + ("a.h/x.h", "H"), + ("hdr/m.h", "M"), + ("a-b/x.h", "B"), + ("a/x.h", "A"), + ] { + results + .files + .insert(PathBuf::from(path), PreprocFile::new_macros(&[macro_name])); + } + + let json = serde_json::to_string(&results).expect("preproc results serialize"); + + assert_eq!( + json, + concat!( + // Keys: component-wise, so `a/` leads and `a-b/` follows. + r#"{"files":{"#, + r#""a/x.h":{"direct_includes":[],"indirect_includes":[],"macros":["A"]},"#, + r#""a-b/x.h":{"direct_includes":[],"indirect_includes":[],"macros":["B"]},"#, + r#""a.h/x.h":{"direct_includes":[],"indirect_includes":[],"macros":["H"]},"#, + r#""hdr/m.h":{"direct_includes":[],"indirect_includes":[],"macros":["M"]},"#, + r#""src/z.c":{"direct_includes":["a.h","c.h","m.h","q.h","z.h"],"#, + // The same three paths as strings: `a-b/` and `a.h/` now + // lead, because `-` and `.` sort below `/`. + r#""indirect_includes":["a-b/x.h","a.h/x.h","a/x.h","b1.h","w3.h"],"#, + r#""macros":["AA","CC","MM","YY","ZZ"]}}}"#, + ), + "every hashed container must serialize in its own sorted order" + ); + + // The sort is serialize-side only, so the document must still read + // back into the same *content*. Set and map equality is the right + // comparison here precisely because this half is about content and + // not order — the order contract is the literal above. + let back: PreprocResults = serde_json::from_str(&json).expect("document round-trips"); + let reread = back + .files + .get(Path::new("src/z.c")) + .expect("the populated entry survives the round trip"); + assert_eq!( + reread.direct_includes, + ["a.h", "c.h", "m.h", "q.h", "z.h"] + .into_iter() + .map(ToOwned::to_owned) + .collect::>() + ); + assert_eq!(back.files.len(), 5, "every entry survives the round trip"); +} From 69a7f787ea914f342b7f952eec6946394779c747 Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Sun, 30 Aug 2026 21:44:57 -0700 Subject: [PATCH 02/20] =?UTF-8?q?fix(getter/bash):=20count=20$"=E2=80=A6"?= =?UTF-8?q?=20once=20in=20Halstead=20N2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `translated_string: $ => seq('$', $.string)` gives the wrapper one required `string` child and no text of its own beyond the `$`, but `BashCode::get_op_type` classified both as operands. Every `$"…"` therefore scored twice in N2 and planted a second n2 entry beside its child: `a=$"x"` reported N2 3 for two operands. The wrapper also defeated the #180 expansion guard. `bash_string_has_expansion` inspects a node's own children, and a `translated_string` has only `$` and `string`, so it never saw the `simple_expansion` one level down and `b=$"$y"` counted both `$"$y"` and `$y` — exactly the double count #180 removed, for the `$"…"` spelling alone. Drop the wrapper arm, as #1351 did for `command_name` in the same match. The `string` is the keeper under grammar-dispatch §6 because it is the node present for every spelling: in ordinary argument position the grammar emits no wrapper at all, just a `$` token and a `string`. Dropping it therefore makes each wrapper-bearing position agree with argument position rather than newly disagree. The wrapper is never childless, so nothing regresses to zero that a plain string did not already — `$"${#}"` and `"${#}"` both score zero, and the new parity test pins that. The duplicated row-driver in the two Bash wrapper tables is extracted into `assert_bash_wrapper_sheds_one_operand`, which re-derives both `_before` columns from the current parse instead of trusting them. Fixes #1358 --- src/getter/bash.rs | 51 +++++++-- src/metrics/halstead.rs | 232 +++++++++++++++++++++++++++++----------- 2 files changed, 214 insertions(+), 69 deletions(-) diff --git a/src/getter/bash.rs b/src/getter/bash.rs index d2bc1d5df..a5211850f 100644 --- a/src/getter/bash.rs +++ b/src/getter/bash.rs @@ -3,9 +3,12 @@ use super::*; -/// Returns whether a Bash string node carries any expansion child that -/// would itself be classified as an operand by [`BashCode::get_op_type`] -/// (`$var`, `${name[…]}`, `$(cmd)`, `$((expr))`). +/// Returns whether a Bash string node carries an expansion child the walk +/// already counts through — `$var`, `${name[…]}`, `$(cmd)`, `$((expr))`. +/// Only `simple_expansion` is itself an operand; for the other three it is +/// the leaves beneath them (`ls` in `$(ls)`, `1` in `$((1))`) that carry the +/// count, and `${#}` carries none at all. Either way the wrapping literal +/// must not be counted on top (#180). #[inline] fn bash_string_has_expansion(node: &Node) -> bool { node.children().any(|c| { @@ -75,10 +78,13 @@ impl Getter for BashCode { // expansion child, those expansions are already walked and // classified as operands; counting the wrapping literal too // would double-count the inner identifiers (issue #180). - // `RawString` is single-quoted and never interpolates, but - // the check is uniform across the four string kinds for - // clarity. - Bash::String | Bash::RawString | Bash::AnsiCString | Bash::TranslatedString => { + // `RawString` is single-quoted and `AnsiCString` is a leaf + // token, so neither can interpolate, but the check is uniform + // across the three string kinds for clarity. + // + // `translated_string` is deliberately absent (#1358) — see the + // note on `command_name` below, which it shares a shape with. + Bash::String | Bash::RawString | Bash::AnsiCString => { if bash_string_has_expansion(node) { HalsteadType::Unknown } else { @@ -107,6 +113,37 @@ impl Getter for BashCode { // position too, so the wrapper was masking that gap for command // names alone. // + // `translated_string` is absent for the same reason (#1358). + // `translated_string: $ => seq('$', $.string)` gives it one + // required `string` child and no text of its own beyond the + // `$`, so `a=$"x"` scored N2 3 for two operands. It also + // defeated the #180 expansion guard: the wrapper's own children + // are `$` and `string`, so `bash_string_has_expansion` never saw + // the `simple_expansion` one level down and `b=$"$y"` counted + // both `$"$y"` and `$y`. + // + // The `string` is the keeper because it is the node present for + // *every* spelling. Wherever the grammar omits the wrapper — in + // ordinary argument position, `echo $"hi"`, which emits a bare + // `$` token and a `string` — the child is still there, so + // dropping the wrapper makes each position that *does* carry one + // (assignment RHS, `case` subject, command name, `for` list, + // array element, redirection target, `[[ ]]` operand, and any + // other the grammar admits) agree with argument position rather + // than newly disagree (grammar-dispatch §6). + // + // The residue is `command_name`'s, one level down: `$"${#}"` + // scores zero, because its inner `string` wraps an operandless + // `expansion`. A plain `"${#}"` already scored zero, so that is + // the parity rather than a new gap, and + // `bash_translated_string_scores_alike_in_both_positions` pins + // both halves of it. + // + // `Checker::is_string` still matches the wrapper, and that is + // not drift: `bca find string` reports nodes, and an + // interpolating `"$y"` is likewise a string the operand walk + // skips. + // // `_concat` (`Bash::Concat`) is absent for a different reason: // it is a hidden zero-width external token the scanner emits // between `concatenation` parts, never a named node, and it diff --git a/src/metrics/halstead.rs b/src/metrics/halstead.rs index 61754b9a0..e35c9a330 100644 --- a/src/metrics/halstead.rs +++ b/src/metrics/halstead.rs @@ -4714,6 +4714,72 @@ f() { ); } + /// Drives one row of a Bash wrapper/leaf double-count table: a grammar + /// wrapper whose entire contribution to `N2` was its single, + /// already-counted child (`command_name`, #1351; `translated_string`, + /// #1358). + /// + /// `expected` is the post-fix `[n1, N1, n2, N2]`. `(n2_before, + /// N2_before)` is what the same source measured while `wrapper` was + /// still classified as an operand, and both columns are *re-derived* + /// from the current parse rather than merely recorded — the arm was the + /// only difference between the two classifications, so: + /// + /// - `N2_before - N2` must equal the number of `wrapper` nodes. That + /// identity *is* the defect: one spurious operand per wrapper. + /// - `n2_before` must equal the post-fix operand vocabulary unioned + /// with the wrapper spellings. It exceeds `n2` wherever the wrapper's + /// whole text is not already an operand in its own right — either + /// because it differs from its single child's (`"$cmd"`, `${cmd}`, + /// `$"ls"`) or because it spans several (`foo$x`, `$(which ls)`, + /// `{1..3}`). + /// + /// A mistyped or stale column therefore fails rather than misinforming + /// the next reader; one did, during review of #1351. + #[track_caller] + fn assert_bash_wrapper_sheds_one_operand( + source: &str, + expected: [u64; 4], + (n2_before, total_before): (u64, u64), + wrapper: Bash, + ) { + let name: &'static str = wrapper.clone().into(); + let kind = wrapper as u16; + let code = source.as_bytes(); + let parser = BashParser::new(code.to_vec(), &PathBuf::from("foo.sh"), None); + let spellings: Vec<&str> = parser + .root() + .preorder() + .filter(|node| node.kind_id() == kind) + .filter_map(|node| node.utf8_text(code)) + .collect(); + assert!( + !spellings.is_empty(), + "row {source:?} parses without a {name}, so it witnesses nothing", + ); + // Phrased as an addition rather than `total_before - expected[3]`: a + // future edit that inverts the two would underflow `u64` and panic + // with a raw overflow message instead of the one below. + assert_eq!( + total_before, + expected[3] + spellings.len() as u64, + "row {source:?} must shed exactly one operand per {name}; \ + recorded N2_before {total_before}", + ); + + let ops = crate::ops::ops_inner(&parser, None).expect("ops walk succeeds"); + let mut vocabulary: HashSet<&str> = ops.operands.iter().map(String::as_str).collect(); + vocabulary.extend(spellings); + assert_eq!( + vocabulary.len() as u64, + n2_before, + "row {source:?}: n2 before the fix is the post-fix vocabulary \ + plus the {name} spellings; got {vocabulary:?}", + ); + + assert_halstead_counts::(source, "foo.sh", expected, source); + } + /// Regression for #1351, the command-name sibling of #695's bare /// `$x`. `command_name` is a pure wrapper: the grammar gives it /// exactly one required child (a `_primary_expression` or a @@ -4726,23 +4792,12 @@ f() { /// every row the command name still contributes at least one operand /// once the trailing `arg` is discounted. /// - /// `n2_before` / `N2_before` are what each row measured with the - /// wrapper arm in place. They are not free-floating prose — the loop - /// re-derives both from the current parse, because the arm was the - /// only difference between the two classifications: - /// - /// - `N2_before - N2` must equal the number of `command_name` nodes. - /// That identity *is* the defect: one spurious operand per command - /// name. - /// - `n2_before` must equal the size of the post-fix operand - /// vocabulary unioned with the `command_name` spellings. It exceeds - /// `n2` wherever the wrapper's whole text is not already an operand - /// in its own right — either because it differs from its single - /// child's (`"$cmd"`, `${cmd}`) or because it spans several - /// (`foo$x`, `$(which ls)`, `{1..3}`). - /// - /// A mistyped or stale column therefore fails rather than misinforming - /// the next reader; one did, during review of this very fix. + /// `n2_before` / `N2_before` are what each row scores with the + /// `command_name` arm restored *against today's tree*, not a historical + /// measurement — the `$"ls" arg` row has since lost a + /// `translated_string` wrapper to #1358 as well. + /// `assert_bash_wrapper_sheds_one_operand` re-derives both from the + /// current parse rather than trusting them. #[test] fn bash_command_name_wrapper_no_double_count() { // (source, [n1, N1, n2, N2], (n2_before, N2_before)) @@ -4762,13 +4817,11 @@ f() { ("'ls' arg\n", [0, 0, 2, 2], (2, 3)), // ansi_c_string ("$'ls' arg\n", [0, 0, 2, 2], (2, 3)), - // translated_string. FIXME(#1358): N2 3 rather than 2 because - // a `translated_string` wraps a `string` and both are - // operands — the same wrapper/leaf shape as this fix, in the - // same match, but reachable from an assignment RHS and a - // `case` subject as well, so it is its own change. This row - // pins today's wrong value; flip it with #1358. - ("$\"ls\" arg\n", [0, 0, 3, 3], (3, 4)), + // translated_string. Two wrappers stacked here — `command_name` + // over `translated_string` — so the pre-#1351 column below is + // measured against a tree #1358 has already thinned to + // `"ls"` + `arg`. + ("$\"ls\" arg\n", [0, 0, 2, 2], (3, 3)), // simple_expansion ("$cmd arg\n", [0, 0, 2, 2], (2, 3)), // brace expansion: counts through its inner `variable_name`, @@ -4786,50 +4839,105 @@ f() { // concatenation ("foo$x arg\n", [0, 0, 3, 3], (4, 4)), ]; - let path = PathBuf::from("foo.sh"); - for (source, expected, (n2_before, total_before)) in cases { - let code = source.as_bytes(); - let parser = BashParser::new(code.to_vec(), &path, None); - let spellings: Vec<&str> = parser - .root() - .preorder() - .filter(|node| node.kind_id() == Bash::CommandName as u16) - .filter_map(|node| node.utf8_text(code)) - .collect(); - assert!( - !spellings.is_empty(), - "row {source:?} parses without a command_name, so it \ - witnesses nothing", - ); - // Phrased as an addition rather than `total_before - - // expected[3]`: a future edit that inverts the two would - // underflow `u64` and panic with a raw overflow message - // instead of the one below. - assert_eq!( - total_before, - expected[3] + spellings.len() as u64, - "row {source:?} must shed exactly one operand per \ - command_name; recorded N2_before {total_before}", - ); - - let ops = crate::ops::ops_inner(&parser, None).expect("ops walk succeeds"); - let mut vocabulary: HashSet<&str> = ops.operands.iter().map(String::as_str).collect(); - vocabulary.extend(spellings); - assert_eq!( - vocabulary.len() as u64, - n2_before, - "row {source:?}: n2 before the fix is the post-fix \ - vocabulary plus the command_name spellings; got \ - {vocabulary:?}", - ); - + for (source, expected, before) in cases { assert!( expected[3] > 1, "row {source:?} must leave the command name at least one \ operand beside `arg`; a zero here means the deleted arm \ was load-bearing for this spelling", ); - assert_halstead_counts::(source, "foo.sh", expected, source); + assert_bash_wrapper_sheds_one_operand(source, expected, before, Bash::CommandName); + } + } + + /// Regression for #1358, the sibling of #1351 in the same `match`. + /// `translated_string: $ => seq('$', $.string)` carries exactly one + /// required `string` child and no text of its own beyond the `$`, so + /// classifying the wrapper *and* letting the walk reach the child + /// counted every `$"…"` twice in `N2`. + /// + /// The rows assert the total, `N2`. `n2` alone cannot witness this + /// double count: the wrapper and its child spell *different* text + /// (`$"x"` against `"x"`), so the pre-fix tree split one operand into + /// two vocabulary entries rather than repeating one — which is why the + /// `n2_before` column is carried too, and checked against the wrapper + /// spellings the row actually parses to. + #[test] + fn bash_translated_string_wrapper_no_double_count() { + // (source, [n1, N1, n2, N2], (n2_before, N2_before)) + let cases: [(&str, [u64; 4], (u64, u64)); 7] = [ + // Assignment RHS. + ("a=$\"x\"\n", [1, 1, 2, 2], (3, 3)), + // Empty translated string. The grammar makes the inner `string` + // required, so the wrapper is never childless — the condition + // grammar-dispatch §6 warns that deletion would regress. + ("a=$\"\"\n", [1, 1, 2, 2], (3, 3)), + // The one spelling the deleted arm *was* load-bearing for: the + // inner `string` wraps an operandless brace `expansion`, so the + // whole `$"…"` now scores zero. That is #1351's residue one + // level down rather than a new gap — a plain `a="${#}"` already + // scored `[1, 1, 1, 1]` — and + // `bash_translated_string_scores_alike_in_both_positions` pins + // that both positions agree on it. + ("a=$\"${#}\"\n", [1, 1, 1, 1], (2, 2)), + // `${!}` is the same residue plus a `!`, which the operator arm + // counts; it must not turn into an operand along the way. + ("a=$\"${!}\"\n", [2, 2, 1, 1], (2, 2)), + // `case` subject. + ("case $\"y\" in *) ;; esac\n", [4, 4, 1, 1], (2, 2)), + // Command-name position, where #1351's wrapper stacks on top of + // this one; the `_before` columns here price the + // `translated_string` alone. + ("$\"ls\" arg\n", [0, 0, 2, 2], (3, 3)), + // Interpolating form. `bash_string_has_expansion` inspects the + // node's own children, and a `translated_string` has only `$` + // and `string`, so it never saw the `simple_expansion` one level + // down: the wrapper reintroduced exactly the double count #180 + // removed, for the `$"…"` spelling alone. Only `$y` and `b` may + // survive. + ("b=$\"$y\"\n", [1, 1, 2, 2], (3, 3)), + ]; + for (source, expected, before) in cases { + assert_bash_wrapper_sheds_one_operand(source, expected, before, Bash::TranslatedString); + } + } + + /// The load-bearing half of #1358's grammar-dispatch §6 argument, which + /// nothing else asserts. In ordinary argument position the grammar emits + /// no `translated_string` at all — just a `$` token and a `string` — so + /// the `string` is the node present for *every* spelling, and dropping + /// the wrapper makes the wrapper-bearing positions agree with argument + /// position rather than newly disagree. If a grammar bump starts + /// emitting the wrapper here, the deletion has to be re-derived. + #[test] + fn bash_translated_string_scores_alike_in_both_positions() { + // `${#}` carries the residue: it contributes no operand of its own, + // so the second row pins that the deletion left *both* positions at + // zero rather than only the wrapper-bearing one. + for (spelling, expected) in [("$\"hi\"", [0, 0, 2, 2]), ("$\"${#}\"", [0, 0, 1, 1])] { + let as_command_name = format!("{spelling} arg\n"); + let as_argument = format!("cmd {spelling}\n"); + let parse = |source: &str| { + BashParser::new(source.as_bytes().to_vec(), &PathBuf::from("foo.sh"), None) + }; + assert!( + !ast_has_kind_id(&parse(&as_argument), Bash::TranslatedString as u16), + "argument position must still parse {spelling} as a bare `$` \ + plus a `string`; a grammar bump that emits the wrapper here \ + invalidates the parity this test rests on", + ); + assert!( + ast_has_kind_id(&parse(&as_command_name), Bash::TranslatedString as u16), + "command-name position must still emit the wrapper, or the \ + pair witnesses nothing", + ); + assert_halstead_counts::( + &as_command_name, + "foo.sh", + expected, + &as_command_name, + ); + assert_halstead_counts::(&as_argument, "foo.sh", expected, &as_argument); } } From 6853f8cc49b0e8c01d8a8af50f32be24fa232ee7 Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Sun, 30 Aug 2026 21:59:03 -0700 Subject: [PATCH 03/20] test(halstead): say what the Bash _before columns prove MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An audit of the #1358 tests measured that the two `_before`-column identities in `assert_bash_wrapper_sheds_one_operand` hold whatever `get_op_type` answers: neutralising the final `assert_halstead_counts` under the pre-fix arm leaves both wrapper tables green. They catch a mistyped column, not a production regression, so the doc no longer claims they are "re-derived from the current parse" as if that were coverage. Also record that only the command-name half of the parity test moves under the pre-fix arm — dropping the argument-position assertions still fails, dropping the command-name ones passes — so the reference half is not mistaken for redundancy later. --- src/metrics/halstead.rs | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/src/metrics/halstead.rs b/src/metrics/halstead.rs index e35c9a330..b87f6ea87 100644 --- a/src/metrics/halstead.rs +++ b/src/metrics/halstead.rs @@ -4720,10 +4720,11 @@ f() { /// #1358). /// /// `expected` is the post-fix `[n1, N1, n2, N2]`. `(n2_before, - /// N2_before)` is what the same source measured while `wrapper` was - /// still classified as an operand, and both columns are *re-derived* - /// from the current parse rather than merely recorded — the arm was the - /// only difference between the two classifications, so: + /// N2_before)` is what the same source scored while `wrapper` was still + /// classified as an operand. Both columns are checked against the + /// *current* parse under the model "one spurious operand per wrapper + /// node" — they are not measured against the old code, which no longer + /// exists to run: /// /// - `N2_before - N2` must equal the number of `wrapper` nodes. That /// identity *is* the defect: one spurious operand per wrapper. @@ -4734,8 +4735,13 @@ f() { /// `$"ls"`) or because it spans several (`foo$x`, `$(which ls)`, /// `{1..3}`). /// - /// A mistyped or stale column therefore fails rather than misinforming - /// the next reader; one did, during review of #1351. + /// So a mistyped or stale column fails rather than misinforming the next + /// reader — one did, during review of #1351 — but neither check can + /// catch a *production* regression, because both hold whatever + /// `get_op_type` answers. `assert_halstead_counts` below is the only + /// assertion here that fails when the wrapper arm comes back; measured + /// by neutralising it under the pre-fix arm, which leaves both tables + /// green. #[track_caller] fn assert_bash_wrapper_sheds_one_operand( source: &str, @@ -4914,6 +4920,13 @@ f() { // `${#}` carries the residue: it contributes no operand of its own, // so the second row pins that the deletion left *both* positions at // zero rather than only the wrapper-bearing one. + // + // Only the command-name half moves under the pre-fix arm; the + // argument-position half is the reference the parity is measured + // against, and `cmd $"…"` is asserted nowhere else. Measured — with + // the argument assertions dropped this test still fails pre-fix, + // with the command-name ones dropped it passes — so do not + // "simplify" it down to the failing half. for (spelling, expected) in [("$\"hi\"", [0, 0, 2, 2]), ("$\"${#}\"", [0, 0, 1, 1])] { let as_command_name = format!("{spelling} arg\n"); let as_argument = format!("cmd {spelling}\n"); From 15ac604de6d921e9ccc947fd96c3eb7cd1fae5dc Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Sun, 30 Aug 2026 22:28:55 -0700 Subject: [PATCH 04/20] fix(getter/ruby): bill a suffixed numeral once tree-sitter-ruby parses a suffixed numeric literal as a wrapper over the numeral it suffixes -- `rational: seq($._int_or_float, immediate('r'))`, and `complex` over an `_int_or_float` or over an aliased `rational` for the `ri` form. `RubyCode::get_op_type` listed `Integer`, `Float`, `Complex` and `Rational` side by side in one plain operand arm, so every level of the nest was billed: `a = 1r; b = 2i; c = 3ri; d = 4` reported n2 11 / N2 12 for its eight operands, with `3ri` counted three times. Suppress a numeral whose parent is a `complex` or a `rational`, the compound-leaf guard of grammar-dispatch section 5. The wrapper is the keeper rather than the leaf, which is the opposite of the resolution #1351 and #1358 took for their Bash wrappers. Section 6's keeper rule -- take the node present for every spelling -- exists to stop a childless variant scoring zero, and neither choice can do that here: both wrappers' numeral child is required, and an unsuffixed `4` carries no wrapper at all. With that hazard absent the tiebreak is operand identity. `1`, `1r`, `1i` and `1ri` are four distinct Ruby constants and only the wrapper's span carries the suffix that says which, so billing the leaf would file all four under the operand text `1` -- n2 5 / N2 8 for eight operands. Every other language here spells the suffix inside a single token, so keeping the wrapper is also what makes Ruby agree with its siblings. The regression test walks the nesting depths separately, because the half-fix that guards the two leaf kinds and not the intermediate `rational` still passes `1r` and `2i` and fails only the `ri` rows. Fixes #1359 --- .bca-baseline.toml | 8 +- src/getter/ruby.rs | 70 +++++++++++++++++ src/metrics/halstead.rs | 161 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 235 insertions(+), 4 deletions(-) diff --git a/.bca-baseline.toml b/.bca-baseline.toml index 723afe43a..0564036f5 100644 --- a/.bca-baseline.toml +++ b/.bca-baseline.toml @@ -807,7 +807,7 @@ value = 5.0 path = "src/getter/bash.rs" qualified = "BashCode::get_op_type" metric = "halstead.effort" -value = 57989.391134957754 +value = 57421.05047463073 [[entry]] path = "src/getter/elixir.rs" @@ -843,7 +843,7 @@ value = 48346.66496041094 path = "src/getter/ruby.rs" qualified = "RubyCode::get_op_type" metric = "halstead.effort" -value = 97661.51169055807 +value = 114672.1239039813 [[entry]] path = "src/metrics/abc/csharp.rs" @@ -1047,7 +1047,7 @@ value = 6.0 path = "src/node.rs" qualified = "Node<'a>" metric = "nom" -value = 34.0 +value = 33.0 [[entry]] path = "src/ops.rs" @@ -1203,7 +1203,7 @@ value = 7.0 path = "src/spaces/compute.rs" qualified = "metrics_inner" metric = "halstead.effort" -value = 120010.12175375846 +value = 119147.7514530567 [[entry]] path = "src/spaces/compute.rs" diff --git a/src/getter/ruby.rs b/src/getter/ruby.rs index 96fc18cd1..cc8d0583b 100644 --- a/src/getter/ruby.rs +++ b/src/getter/ruby.rs @@ -184,6 +184,76 @@ impl Getter for RubyCode { ], ), + // The numeral inside a suffixed numeric literal. At the + // pinned tree-sitter-ruby the two suffix forms are wrappers + // over the numeral they suffix, never leaves: + // + // rational: seq($._int_or_float, token.immediate('r')) + // complex: choice( + // seq($._int_or_float, token.immediate('i')), + // seq(alias($._int_or_float, $.rational), + // token.immediate('ri'))) + // + // so `2i` is a `complex` over an `integer`, and `3ri` a + // `complex` over a `rational` over an `integer` — three + // levels deep, every one of which the plain operand arm + // below billed. `a = 1r; b = 2i; c = 3ri; d = 4` reported + // n2 11 / N2 12 for its eight operands (#1359). + // + // Neither §1 nor §2 adds a kind to name here, and both + // facts are pinned in the test rather than trusted: + // `_int_or_float` is hidden, so `Ruby::IntOrFloat` exists + // in the enum and the parser never emits it, leaving the + // wrapper itself as the numeral's parent; and the `ri` + // form's `alias(…, $.rational)` reuses kind 308 instead of + // minting a suffixed variant, so the middle node is a + // `rational` whose span holds no `r` of its own. + // + // The wrapper is the keeper here, not the leaf. `1`, `1r`, + // `1i` and `1ri` are four distinct Ruby constants; billing + // the leaf would file all four under the operand text `1` + // and erase what makes them different values. Every other + // language here spells the suffix inside a single token + // (`1u32`, `1L`, `1j`), so keeping the wrapper is what + // makes Ruby agree with its siblings. It is the same + // resolution the `Nil` / `Nil2` pair takes below, and the + // opposite of the one `%w[…]` takes above — there the + // wrapper holds operands that stand on their own, here the + // three levels are one constant. + // + // Gated rather than deleted, though nothing regresses to + // zero either way (§6): node-types.json marks both + // wrappers' lone numeral child `"required": true`, and an + // unsuffixed `4` carries no wrapper at all, so the arm + // below still owns it. §6's keeper rule — take the node + // present for *every* spelling — would pick the leaf, but + // that rule exists to stop a childless variant scoring + // zero and neither choice can do that here. With the + // hazard absent the tiebreak is operand identity, which + // only the wrapper's span carries. + // + // Written on the parent per §5, but unlike the regex guard + // above the choice is unobservable: `complex` and + // `rational` admit numeral children and nothing else, so no + // node can have either as an *ancestor* without having it + // as its parent. No Ruby input tells the two spellings + // apart, and no test can pin the difference. + // + // Every kind in both sets is load-bearing. Drop `Rational` + // from the guarded set and the middle node of `3ri` is + // billed again; drop it from the parent set and the leaf + // under a bare `1r` is. Only their *pairing* is + // unobservable — a `rational` directly inside a `rational` + // is not a shape this grammar admits. + R::Integer | R::Float | R::Rational + if matches!( + ancestors.parent(node).map(|p| p.kind_id().into()), + Some(R::Complex | R::Rational) + ) => + { + HalsteadType::Unknown + } + // Operands: identifiers and literals. R::Identifier | R::IdentifierSuffix | R::IdentifierSuffixToken1 | R::Constant | R::ConstantSuffix | R::ConstantSuffixToken1 diff --git a/src/metrics/halstead.rs b/src/metrics/halstead.rs index b87f6ea87..cedde4817 100644 --- a/src/metrics/halstead.rs +++ b/src/metrics/halstead.rs @@ -6976,6 +6976,167 @@ f() { } } + #[test] + fn ruby_suffixed_numeric_literals_bill_one_operand_1359() { + // #1359, the wrapper/leaf double count one arm below #1353's. + // `rational` and `complex` wrap the numeral they suffix, and + // `get_op_type` billed every level: `a = 1r` scored n2 3 / N2 3 + // for two operands, and `c = 3ri` — a `complex` over a + // `rational` over an `integer` — scored n2 3 / N2 4 for the + // same two. + // + // The rows are ordered by nesting depth on purpose, because the + // half-fix is a plausible one to write. Guard the two leaf + // kinds and not the intermediate `rational` — `R::Integer | + // R::Float` where the arm says `R::Integer | R::Float | + // R::Rational` — and the `1r` and `2i` rows still pass; the + // `ri` rows, whose middle `rational` must be suppressed as well + // as the leaf beneath it, are the two that fail (measured: + // `c = 3ri` reports n2 3 / N2 3). + // + // The trailing rows are the ones that must *not* move. `g = 4` + // has no wrapper at all and pins that the guard is scoped to + // the numerals inside one (grammar-dispatch §6 — nothing + // regresses to zero). `"#{4r}"` and `-3r` put the wrapper + // somewhere other than an assignment right-hand side, under a + // node that guards its own children (`Interpolation`, whose + // enclosing `String` is suppressed by the arm above) and under + // one that does not (`unary`); both must still bill the + // wrapper once. They do *not* pin parent-versus-ancestor + // scoping and nothing can: `complex` and `rational` admit + // numeral children only, so an ancestor-scoped guard would + // classify every Ruby input identically. And `1r + 1r` is the + // row where N2 and n2 come apart, so the `N2` column is + // load-bearing on its own rather than tracking `n2`. + // + // The kind assertions are the grammar-dispatch §1 / §2 drift + // marker: a bump that renumbered `complex` or `rational`, or + // stopped nesting them, would leave the counts passing while + // measuring a construct the guard no longer names. `IntOrFloat` + // is the §2 half — the hidden `_int_or_float` the two wrappers + // are written over, which a bump could promote into the + // numeral's parent and silently stop the guard firing. Not + // every kind a row mentions is one the guard names + // (`Interpolation` is context), and `unary` and `binary` are + // deliberately absent: they carry five and three aliased + // variants here, so naming one would pin the alias rather than + // the shape. + for (source, kinds, counts, operands) in [ + ( + "a = 1r\n", + &[Ruby::Rational as u16, Ruby::Integer as u16][..], + [1, 1, 2, 2], + vec!["a", "1r"], + ), + ( + "b = 2i\n", + &[Ruby::Complex as u16, Ruby::Integer as u16][..], + [1, 1, 2, 2], + vec!["b", "2i"], + ), + ( + "c = 3ri\n", + &[ + Ruby::Complex as u16, + Ruby::Rational as u16, + Ruby::Integer as u16, + ][..], + [1, 1, 2, 2], + vec!["c", "3ri"], + ), + ( + "d = 1.5r\n", + &[Ruby::Rational as u16, Ruby::Float as u16][..], + [1, 1, 2, 2], + vec!["d", "1.5r"], + ), + ( + "e = 2.5i\n", + &[Ruby::Complex as u16, Ruby::Float as u16][..], + [1, 1, 2, 2], + vec!["e", "2.5i"], + ), + ( + "f = 1.5ri\n", + &[ + Ruby::Complex as u16, + Ruby::Rational as u16, + Ruby::Float as u16, + ][..], + [1, 1, 2, 2], + vec!["f", "1.5ri"], + ), + ( + "g = 4\n", + &[Ruby::Integer as u16][..], + [1, 1, 2, 2], + vec!["g", "4"], + ), + ( + "h = \"#{4r}\"\n", + &[Ruby::Interpolation as u16, Ruby::Rational as u16][..], + [1, 1, 2, 2], + vec!["h", "4r"], + ), + ( + "i = -3r\n", + &[Ruby::Rational as u16, Ruby::Integer as u16][..], + [2, 2, 2, 2], + vec!["i", "3r"], + ), + ( + "j = 1r + 1r\n", + &[Ruby::Rational as u16, Ruby::Integer as u16][..], + [2, 2, 2, 3], + vec!["j", "1r"], + ), + ] { + let parser = + RubyParser::new(source.as_bytes().to_vec(), &PathBuf::from("foo.rb"), None); + for kind in kinds { + assert!( + ast_has_kind_id(&parser, *kind), + "the kind this row measures is unreachable for `{source}`" + ); + } + assert!( + !ast_has_kind_id(&parser, Ruby::IntOrFloat as u16), + "`_int_or_float` is hidden; a grammar that emits it would \ + sit between the wrapper and the numeral for `{source}`" + ); + + assert_halstead_counts::(source, "foo.rb", counts, source); + assert_ops_operands::(source, "foo.rb", operands.len(), operands); + } + } + + #[test] + fn ruby_numeric_suffixes_stay_distinct_operands_1359() { + // The reason #1359 keeps the *wrapper* and gates the leaf + // rather than dropping the two wrapper kinds from the arm, as + // #1351 and #1358 did for their Bash wrappers. `1`, `1r`, `1i` + // and `1ri` are four distinct Ruby constants — Integer, + // Rational, Complex and Complex-of-Rational — and only the + // wrapper's span carries the suffix that says which. + // + // This is the assertion to watch fail against the leaf-keeping + // alternative: billing the `integer` would file all four under + // the operand text `1`, reporting n2 5 / N2 8 where the source + // plainly has eight distinct operands. The count alone does not + // say that, so the vocabulary is pinned by text. + // + // expected: operator `=` → n1 1, N1 4; four targets and four + // constants, none repeated → n2 = N2 = 8. + let source = "w = 1\nx = 1r\ny = 1i\nz = 1ri\n"; + assert_halstead_counts::(source, "foo.rb", [1, 4, 8, 8], source); + assert_ops_operands::( + source, + "foo.rb", + 8, + vec!["w", "x", "y", "z", "1", "1r", "1i", "1ri"], + ); + } + /// Comprehensive iRules Halstead test exercising every operator family /// classified in `get_op_type`: declaration/control keywords (`proc`, /// `set`, `if`, `return`), structural punctuation (`{}` `[]` `()`), From 9108f0adb06bab54dbff078283abb0ecc4645092 Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Sun, 30 Aug 2026 23:04:14 -0700 Subject: [PATCH 05/20] fix(getter/ruby): stop billing subshell backticks tree-sitter-ruby aliases both ends of a subshell literal to one backtick token: `bca dump` at the pinned =0.23.1 shows all seven spellings -- the backtick pair plus %x with {}, (), [], <>, || and !! -- emitting BQUOTE for the opener and the closer alike. `RubyCode::get_op_type` lists BQUOTE in its operator arm, so `u = `echo hi`` reported n1 2 / N1 3 for a line whose only operation is the assignment. This is the delimiter fabrication #1312 removed for regexes and #1256 for Elixir; `subshell` was in neither sweep. Suppress a BQUOTE whose parent is a `subshell`, the compound-leaf guard of grammar-dispatch section 5. The `subshell` node itself remains the operand, so the literal is not lost -- including the childless `` `` spelling, which still bills its wrapper. Gated rather than deleted, per section 6: a backtick is also a legal Ruby method name, and at this grammar `def `(cmd)` and `s.`("ls")` wrap their BQUOTE in a named `operator` node. Deleting the kind would score that marker zero, which the positive-control test pins. Parent, not ancestor, and here the difference is observable: a backtick method inside a subshell's interpolation keeps `operator` as its parent but gains the `subshell` as an ancestor, so an ancestor scan would swallow it. The three mutants -- no guard, ancestor scan, and deletion from the operator arm -- each fail a different subset of the five new tests. Ruby Halstead operator counts drop for code containing subshell literals. The sweep the issue asked for found no sibling: no other language fabricates a paired-delimiter operator through this shape. Both baselines are refreshed in this commit. The new arm raises `get_op_type`'s halstead.effort past its recorded value, and it is the fourth stuck arm in a match that has been outside `cargo fmt` since before this change. Fixes #1360 --- .bca-baseline.toml | 2 +- .rustfmt-bail-baseline.txt | 2 +- src/getter/ruby.rs | 42 +++++++++++ src/metrics/halstead.rs | 144 +++++++++++++++++++++++++++++++++++++ 4 files changed, 188 insertions(+), 2 deletions(-) diff --git a/.bca-baseline.toml b/.bca-baseline.toml index 0564036f5..bb5aa32cf 100644 --- a/.bca-baseline.toml +++ b/.bca-baseline.toml @@ -843,7 +843,7 @@ value = 48346.66496041094 path = "src/getter/ruby.rs" qualified = "RubyCode::get_op_type" metric = "halstead.effort" -value = 114672.1239039813 +value = 120865.54836941602 [[entry]] path = "src/metrics/abc/csharp.rs" diff --git a/.rustfmt-bail-baseline.txt b/.rustfmt-bail-baseline.txt index aa97d0d3f..5b34e906b 100644 --- a/.rustfmt-bail-baseline.txt +++ b/.rustfmt-bail-baseline.txt @@ -122,7 +122,7 @@ src/getter/mozcpp.rs 5 src/getter/perl.rs 3 src/getter/php.rs 11 src/getter/python.rs 7 -src/getter/ruby.rs 3 +src/getter/ruby.rs 4 src/getter/tcl.rs 6 src/macros/mod.rs 38 src/metrics/abc/elixir.rs 6 diff --git a/src/getter/ruby.rs b/src/getter/ruby.rs index cc8d0583b..f5f448079 100644 --- a/src/getter/ruby.rs +++ b/src/getter/ruby.rs @@ -49,6 +49,48 @@ impl Getter for RubyCode { { HalsteadType::Unknown } + // Subshell delimiter punctuation — the second delimiter + // family of the fabrication the arm above removes for + // regexes (#1360, following #1312). tree-sitter-ruby + // aliases both ends of a subshell literal to a single + // backtick token: `bca dump` at the pinned grammar shows + // all seven spellings — the backtick pair plus %x with + // {}, (), [], <>, || and !! — emitting BQUOTE (kind 103) + // for the opener *and* the closer, so a one-command + // literal reported a backtick operator that is nowhere in + // the source as an operation. The `Subshell` node itself + // is the operand (the string-like arm below), so + // suppressing its delimiters loses nothing, and the + // childless `` `` `` spelling still bills that wrapper. + // + // Gated rather than dropped from the operator arm, per + // grammar-dispatch §6: a backtick is also a legal Ruby + // method name — def and call alike — and at this grammar + // that marker is a BQUOTE wrapped in a named `operator` + // node, so its parent is `Operator` and never `Subshell`. + // Deleting the kind would score the method-name marker + // zero. + // + // Parent, not ancestor, and here the difference is + // observable: a backtick method inside a subshell's `#{…}` + // interpolation still has `Operator` as its parent but the + // `Subshell` as a further ancestor, so an ancestor scan + // would swallow it. That is the input + // `ruby_subshell_guard_is_parent_scoped_not_ancestor_scoped` + // exists to separate — unlike the numeral guard below, + // whose two spellings no Ruby input can tell apart. + // + // One kind, where the regex guard above names two: BQUOTE2 + // is this family's aliased literal-start token, but unlike + // SLASH2 it never sat in the operator arm, so the wildcard + // already answers `Unknown` for it and listing it here + // would read as load-bearing while doing nothing. Its + // reachability is pinned by + // `ruby_subshell_start_alias_never_reaches_kind_id` + // instead. + R::BQUOTE if ancestors.parent_has_kind(node, R::Subshell as u16) => { + HalsteadType::Unknown + } // Control-flow keyword tokens. tree-sitter-ruby gives each // keyword its own anonymous numbered variant (e.g. `If2` is // the `if` keyword token; `If` is the named statement node). diff --git a/src/metrics/halstead.rs b/src/metrics/halstead.rs index cedde4817..0292206fb 100644 --- a/src/metrics/halstead.rs +++ b/src/metrics/halstead.rs @@ -6806,6 +6806,150 @@ f() { } } + #[test] + fn ruby_subshell_delimiters_are_not_operators() { + // Regression: issue #1360, the second delimiter family of the + // fabrication #1312 removed for regexes. tree-sitter-ruby + // aliases both ends of a subshell literal to `BQUOTE` — the + // same kind id the `` def ` `` method-name marker uses — so + // `` x = `echo hi` `` reported a `` ` `` operator with no + // backtick method call in the source. + // + // expected: operator `=` → n1 = N1 = 1. Operands `x` and the + // `` `echo hi` `` literal → n2 = N2 = 2. Before the guard the + // two delimiters added `` ` `` → n1 = 2, N1 = 3. + check_metrics::("x = `echo hi`\n", "foo.rb", |metric| { + assert_eq!(metric.halstead.unique_operators(), 1); + assert_eq!(metric.halstead.total_operators(), 1); + assert_eq!(metric.halstead.unique_operands(), 2); + assert_eq!(metric.halstead.total_operands(), 2); + }); + } + + #[test] + fn ruby_subshell_delimiter_choice_is_invariant() { + // Companion to the test above (#1360): `%x`-form subshells are + // the same literal spelled differently, so every delimiter + // choice must produce identical counts. tree-sitter-ruby + // aliases all of them to `BQUOTE` — verified with `bca dump`, + // which shows `` ` ``/`` ` ``, `%x{`/`}`, `%x(`/`)`, `%x[`/`]`, + // `%x<`/`>`, `%x|`/`|` and `%x!`/`!` every one emitting kind + // `BQUOTE` — so each row here genuinely exercises the guard + // rather than reaching a different, already-clean path. Note + // the `%x!…!` and `%x|…|` closers in particular: they are + // `BQUOTE`, not the `BANG` / `PIPE` operators they look like. + // + // The empty row records that `` `` `` parses to a `Subshell` + // holding nothing but its two delimiters, and still bills that + // wrapper as one operand once both are suppressed. It is + // documentation, not a discriminator: the guard deletes nothing + // from the operand arm, so grammar-dispatch §6's + // childless-variant-regresses-to-zero hazard cannot arise here + // and this row moves with the first row under every mutant. + // + // expected per variant: operator `=` → n1 = N1 = 1; operands + // `x` and the literal → n2 = N2 = 2. + for literal in [ + "`echo`", "%x{echo}", "%x(echo)", "%x[echo]", "%x", "%x|echo|", "%x!echo!", "``", + ] { + assert_halstead_counts::( + &format!("x = {literal}\n"), + "foo.rb", + [1, 1, 2, 2], + &format!("subshell literal {literal}"), + ); + } + } + + #[test] + fn ruby_backtick_method_name_survives_the_subshell_guard() { + // Control for #1360, and the reason the kind is gated rather + // than dropped from the operator arm (grammar-dispatch §6): + // `` ` `` is a legal Ruby method name. `bca dump` at the pinned + // grammar shows `` def `(cmd) `` emitting a `BQUOTE` wrapped in + // a named `operator` node, so its parent is `Operator` and the + // parent-scoped guard leaves it alone. Deleting `BQUOTE` from + // the operator arm would have scored this construct zero. + // + // expected: operators `def`, `` ` ``, `(` and `end` → + // n1 = N1 = 4 (only the `(` opener counts after #695). + // Operand `cmd` twice, parameter and use → n2 = 1, N2 = 2. + check_metrics::("def `(cmd)\n cmd\nend\n", "foo.rb", |metric| { + assert_eq!(metric.halstead.unique_operators(), 4); + assert_eq!(metric.halstead.total_operators(), 4); + assert_eq!(metric.halstead.unique_operands(), 1); + assert_eq!(metric.halstead.total_operands(), 2); + }); + } + + #[test] + fn ruby_subshell_guard_is_parent_scoped_not_ancestor_scoped() { + // The one input that separates the correct parent-scoped guard + // from the ancestor-scoped mutant of it (#1360, mirroring + // #1312's regex case): backtick *methods* used inside a + // subshell's `#{…}` interpolation. Their `BQUOTE` has the named + // `operator` node as its parent but the `Subshell` as a further + // ancestor, so an ancestor scan would swallow both. Every other + // fixture in this file passes under either spelling. Two + // markers — a definition and a call — so the mutant moves both + // n1 (7 → 6) and N1 (10 → 8), per the #1294 count-only-anchor + // lesson. + // + // expected: operators `=` (1), `def` (1), `` ` `` (2), `(` (2), + // `;` (2), `end` (1), `.` (1) → n1 = 7, N1 = 10. Operands `n`, + // `c` × 2, `s`, `"x"` → n2 = 4, N2 = 5; the wrapping `Subshell` + // is skipped because it carries `Interpolation` children (the + // #180 double-count guard), and its `a` / `b` string content is + // classified by no arm. + check_metrics::( + "n = `a#{def `(c); c; end}b#{s.`(\"x\")}`\n", + "foo.rb", + |metric| { + assert_eq!(metric.halstead.unique_operators(), 7); + assert_eq!(metric.halstead.total_operators(), 10); + assert_eq!(metric.halstead.unique_operands(), 4); + assert_eq!(metric.halstead.total_operands(), 5); + }, + ); + } + + #[test] + fn ruby_subshell_start_alias_never_reaches_kind_id() { + // Drift marker for #1360's guard, the `BQUOTE2` sibling of + // `ruby_regex_start_alias_never_reaches_kind_id`. `BQUOTE2` + // sits in the enum's literal-start alias block beside `DQUOTE`, + // `COLONDQUOTE` and `SLASH2`, and the runtime + // `public_symbol_map` collapses it to `BQUOTE` before + // `kind_id()`, exactly like `LPAREN2` in #768. + // + // `BQUOTE2` is therefore in no dispatch arm at all — not the + // guard and not the operator arm — which is the asymmetry with + // `SLASH2`, a kind #1312 had to *move* out of the arithmetic + // arm. Adding it to the guard would change nothing, since the + // wildcard already answers `Unknown` for it. What that leaves + // unguarded is the reverse direction: a bump that started + // emitting `BQUOTE2` would drop a subshell opener silently + // (already the wanted answer) but would also stop counting a + // `` def ` `` marker spelled that way. This pins the + // reachability so such a bump fails here rather than moving a + // metric unobserved. + let path = PathBuf::from("foo.rb"); + for source in ["x = `echo`\n", "x = %x{echo}\n"] { + let parser = RubyParser::new(source.as_bytes().to_vec(), &path, None); + assert!( + !ast_has_kind_id(&parser, Ruby::BQUOTE2 as u16), + "Ruby::BQUOTE2 must stay collapsed to Ruby::BQUOTE for `{source}`" + ); + // Positive control: the id the guard actually fires on is + // present, so the assertion above cannot pass merely + // because no delimiter was parsed at all. + assert!( + ast_has_kind_id(&parser, Ruby::BQUOTE as u16), + "Ruby::BQUOTE must be the delimiter kind for `{source}`" + ); + } + } + #[test] fn ruby_interpolation_opener_is_not_an_operator() { // Behaviour change, not a fabrication fix: #1314 drops From e1fd4f4b4202aa5d3797346108b81d2676450dfe Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Sun, 30 Aug 2026 23:41:31 -0700 Subject: [PATCH 06/20] fix(getter/cpp): count `this` as an operand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Cpp::This` / `Mozcpp::This` were in neither arm of `CppCode::get_op_type` or `MozcppCode::get_op_type`, so a C++ `this` contributed nothing at all: not an operator (defensible) and not an operand (wrong). `struct S { int f() { return this->x; } }` billed `S`, `f` and `x` and dropped the receiver. Same shape as #1316's character literals, and the inverse of the #1351-#1355 wrapper/leaf over-counts, so the fix adds an arm rather than deleting one. Operand, not operator, on three grounds. Structure: `field_expression` is ` -> `, and `p->x` already bills operands `p` / `x` around the `->` operator, so calling `this` an operator makes `this->x` a binary operator with one operand and scores the identical AST differently for the two spellings of the same receiver. Role: `this` is a pointer rvalue standing exactly where a variable stands — `return this;`, `*this`, `f(this)` — while `->` / `.` / `*` are the operators already counted acting on it. Precedent: ten of the thirteen languages here bill their self-reference as an operand, and the JS family's is the only reasoned one among them, its `MetaProperty` note calling `this` "one atomic operand". Keeping C++ an operand also keeps it agreeing with ObjC, whose `self` is an operand and whose `.mm` files route to this same impl. Java, C# and Kotlin disagree, each having swept `This` in as one entry in a run of keywords grouped by lexical class. That three-way split is filed as #1380 rather than settled here. Listing the leaf is safe against a grammar-dispatch section 5 double count: `this` is childless in every position the pinned grammar admits it — `this->x`, `(*this)`, `return this`, `f(this)`, `[this]`, `[=, this]`, `decltype(this->x)`, and a constructor body after a member-initialiser list — and none of `field_expression`, `pointer_expression`, `lambda_capture_specifier` or `argument_list` is classified. Neither generated enum aliases the rule, so there is no `This2` for a single-variant arm to miss. C++23's explicit object parameter does not reach the arm at all: the pinned grammar cannot parse `this S&& self` and emits `type_identifier` plus an ERROR, a spelling already operand-classed through `TypeIdentifier`. Halstead `n2` / `N2` rise by one per `this`, moving the derived volume / difficulty / effort / time / bugs and the three maintainability-index variants. 157 DeepSpeech corpus snapshots refresh; all 1000 changed operand values increased, `unique_operands` by exactly one per space, and no operator count or space moved. Fixes #1361 --- src/getter/cpp.rs | 56 ++++- src/getter/mozcpp.rs | 14 +- src/metrics/halstead.rs | 230 ++++++++++++++++++++ tests/parity/cpp_mozcpp_parity.rs | 72 +++++- tests/repositories/big-code-analysis-output | 2 +- 5 files changed, 361 insertions(+), 13 deletions(-) diff --git a/src/getter/cpp.rs b/src/getter/cpp.rs index 6a32ef810..a4d1e42b0 100644 --- a/src/getter/cpp.rs +++ b/src/getter/cpp.rs @@ -133,8 +133,62 @@ impl Getter for CppCode { // classified node in a character literal, so it bills one // operand per literal, keyed by text, and `Checker::is_string` // deliberately stays without a `CharLiteral` arm. + // + // `This` was in *neither* arm until #1361, so a C++ `this` + // contributed nothing at all: not an operator and not an + // operand. Same shape as #1316, opposite of the #1351-#1355 + // wrapper/leaf over-counts — the fix adds an arm rather than + // deleting one. + // + // Operand, not operator, on three grounds. **Structure**: + // `field_expression` is ` -> `, and `p->x` + // already bills operands `p` / `x` around the `->` operator. + // Calling `this` an operator would make `this->x` a binary + // operator with one operand, and would score the identical + // AST differently for `this->x` than for `p->x`. + // **Role**: `this` is a pointer *rvalue* and stands exactly + // where a variable stands — `return this;`, `*this`, + // `f(this)`, which would otherwise pass an operator as a + // call argument — while `->` / `.` / `*` are the operators + // already counted acting on it. **Precedent**: eleven of the + // fourteen languages here bill their self-reference as an + // operand (Rust `Zelf`, Ruby `Zelf`, PHP's `$this` + // `variable_name`, the four JS-family `This` arms, and + // Python / ObjC / Groovy / Lua, whose grammars spell it a + // plain `identifier`), and the JS-family arm in + // `src/getter.rs` is the only *reasoned* one among them — + // its `MetaProperty` note calls `this` "one atomic operand". Java / C# / Kotlin disagree, but each + // swept `This` in as one entry in a run of keywords — under + // `// Operator: keywords`, `// Operator: other keywords` and + // `// Expression-keyword operators` respectively — grouping + // it by lexical class rather than by Halstead role; that + // three-way split is #1380, deliberately not settled here. + // Keeping C++ an operand also keeps it agreeing with ObjC, + // whose `self` is an operand and whose `.mm` files route to + // this very impl. + // + // Safe against a grammar-dispatch section 5 double count: + // `this` is a childless leaf in every position the grammar + // admits it — `this->x`, `(*this)`, `return this`, `f(this)`, + // the `[this]` and `[=, this]` lambda captures, a + // `decltype(this->x)` trailing return type, and a + // constructor body after a member-initialiser list — and no + // node that *contains* it (`field_expression`, + // `pointer_expression`, `lambda_capture_specifier`, + // `argument_list`) is classified, so it can double-count in + // neither direction. Neither generated enum aliases the rule + // (`This = 215` is the sole variant spelling `"this"` in + // `language_cpp.rs` and `language_mozcpp.rs`), so there is no + // `This2` for a single-variant arm to miss; + // `cpp_this_is_a_childless_unaliased_leaf` pins both facts. + // + // C++23's explicit object parameter (`int g(this S&& self)`) + // does *not* reach this arm: the pinned grammar cannot parse + // it and emits `type_identifier "this"` plus an `ERROR`. That + // spelling is already an operand via `TypeIdentifier`, so the + // construct is unaffected either way. Identifier | TypeIdentifier | FieldIdentifier | RawStringLiteral | StringLiteral - | CharLiteral | NumberLiteral | True | False | Null | DOTDOTDOT => { + | CharLiteral | NumberLiteral | True | False | Null | This | DOTDOTDOT => { HalsteadType::Operand } // A namespace identifier is an operand only where it diff --git a/src/getter/mozcpp.rs b/src/getter/mozcpp.rs index 33030656a..70b3b4a8a 100644 --- a/src/getter/mozcpp.rs +++ b/src/getter/mozcpp.rs @@ -112,8 +112,20 @@ impl Getter for MozcppCode { // classified node in a character literal, so it bills one // operand per literal, keyed by text, and `Checker::is_string` // deliberately stays without a `CharLiteral` arm. + // + // `This` — the operator-vs-operand derivation lives on the + // same arm in `src/getter/cpp.rs` (#1361), where the fork's + // upstream carries it. In short: `this` had been in neither + // arm and so contributed nothing, and it is an operand + // because it is the *receiver* half of ` -> `, + // exactly where `p` stands in `p->x`. Mozcpp owns no file + // extension, so nothing routes to it and no integration + // snapshot reaches this clone — `cpp_and_mozcpp_agree_on_this` + // in `tests/parity/cpp_mozcpp_parity.rs` is what keeps it + // from drifting, the same guard the `RawStringLiteral` + // `LPAREN` arm above relies on. Identifier | TypeIdentifier | FieldIdentifier | RawStringLiteral | StringLiteral - | CharLiteral | NumberLiteral | True | False | Null | DOTDOTDOT => { + | CharLiteral | NumberLiteral | True | False | Null | This | DOTDOTDOT => { HalsteadType::Operand } // A namespace identifier is an operand only where it diff --git a/src/metrics/halstead.rs b/src/metrics/halstead.rs index 0292206fb..4d5372c70 100644 --- a/src/metrics/halstead.rs +++ b/src/metrics/halstead.rs @@ -7845,6 +7845,236 @@ f() { check::(Objc::CharLiteral as u16, "objc"); } + /// #1361 fixture pairing the two spellings of a member access whose + /// receiver is the enclosing object: `this->x` and `p->x`. + /// + /// The pairing is the point, not decoration. `field_expression` is + /// ` -> `, so the two lines are the same AST shape + /// and must bill the same *kinds* of thing — a receiver operand, the + /// `->` operator, a field operand. Before the fix `this` was in + /// neither arm, so the two shapes scored differently and `m1` billed + /// a `->` with only one operand around it. + /// + /// Plain C++ that the mozcpp fork parses identically, so one source + /// proves the same thing about both clones. + const CPP_THIS_RECEIVER_PARITY: &str = "struct S { + int x; + int m1() { return this->x; } + int m2(S* p) { return p->x; } + };"; + + /// #1361 fixture holding one representative position per kind of + /// container the pinned `tree-sitter-cpp` can put a `this` in: the + /// `->` receiver, a dereference inside a parenthesised expression, a + /// bare return value, both lambda-capture spellings (`[this]` and + /// `[=, this]`), a `decltype` trailing return type, a constructor + /// body following a member-initialiser list, and a call argument. + /// + /// Representative, not exhaustive — `delete this;`, `throw this;`, + /// `[&, this]`, a default member initialiser and a `this->template + /// f()` all parse cleanly too. Every one of them is the same + /// childless `{this:215}` leaf under an unclassified wrapper, which + /// is precisely why the arm needs no guard and why enumerating them + /// would add rows without adding a distinct path (grammar-dispatch + /// section 11). + /// + /// C++23's explicit object parameter (`int g(this S&& self)`) is + /// deliberately absent: the pinned grammar cannot parse it and emits + /// `type_identifier "this"` plus an `ERROR`, which + /// `for_each_node_with_chain` rejects outright. That spelling is + /// already an operand through `TypeIdentifier`, so the construct is + /// unaffected by this arm either way. + const CPP_THIS_POSITIONS: &str = "struct S { + int x; + void g(S*); + int arrow() { return this->x; } + int deref() { return (*this).x; } + S* ret() { return this; } + void cap() { auto l = [this]() { return x; }; } + void cap2() { auto l = [=, this]() { return x; }; } + auto trail() -> decltype(this->x) { return x; } + S(int v) : x(v) { this->x = v; } + void call() { g(this); } + };"; + + /// Asserts `CPP_THIS_RECEIVER_PARITY` for one C++-family language, + /// through the metrics store *and* the text-keyed `--ops` store + /// (the lesson-4 invariant `n2 == len(dedupe(ops.operands))`). + /// + /// The `--ops` half pins *which text* the new operand is billed + /// under, which the counts alone cannot see: billing the enclosing + /// `field_expression` instead of the `this` leaf would hold `n2` at + /// 6 while the vocabulary silently became `this->x`. + #[track_caller] + fn assert_this_receiver_parity(file: &str, label: &str) { + // Operators, keyed by kind_id except the text-keyed primitives: + // `{` x3 (class body, `m1`, `m2`), `int` x3, `;` x4 (the field, + // the two returns, the struct terminator), `(` x2, `return` x2, + // `->` x2, and the one `*` of `S* p`. `struct` is in no arm, and + // #695 dropped every closing delimiter. n1 = 7, N1 = 17. + // + // Operands, keyed by source text: `S` x2 (the name and the + // parameter type), `x` x3, `m1`, `m2`, `p` x2, and — the fix — + // `this` x1. n2 = 6, N2 = 10. Before #1361: n2 5, N2 9, with + // every operator count identical, which is the whole claim. + let counts = format!("{label}: this->x and p->x are the same shape"); + assert_halstead_counts::(CPP_THIS_RECEIVER_PARITY, file, [7, 17, 6, 10], &counts); + assert_ops_operands::( + CPP_THIS_RECEIVER_PARITY, + file, + 6, + vec!["S", "x", "m1", "m2", "p", "this"], + ); + } + + /// Regression for #1361: a C++ `this` is a Halstead operand. + /// + /// `Cpp::This` / `Mozcpp::This` were in neither arm of + /// `CppCode::get_op_type` / `MozcppCode::get_op_type`, so a `this` + /// contributed *nothing* — not an operator and not an operand — + /// while ten of the thirteen languages here bill their + /// self-reference as an operand. Same shape as #1316's character + /// literals, and the inverse of the #1351-#1355 over-counts. + /// + /// Each language asserts separately over the same source rather than + /// sharing one call, so reverting one clone's arm fails that row + /// alone (grammar-dispatch section 11). Mozcpp owns no file + /// extension, so no integration snapshot reaches its clone; this row + /// and `cpp_and_mozcpp_agree_on_this` in `tests/parity/` are the + /// whole coverage that arm has. + #[test] + fn cpp_this_is_an_operand() { + assert_this_receiver_parity::("this.cpp", "cpp"); + assert_this_receiver_parity::("this.cpp", "mozcpp"); + } + + /// Every `this` in `CPP_THIS_POSITIONS` classifies as an operand, + /// whichever syntactic position it stands in (#1361). + /// + /// The counts in `cpp_this_is_an_operand` are measured on one + /// position only. An arm reached through a parent-scoped guard — + /// the shape the neighbouring `RawStringLiteral` `LPAREN` arm uses, + /// and the shape a reviewer might reasonably add here — would pass + /// that test and still drop `this` in a lambda capture or a + /// `decltype`. This walk is what makes the arm's unconditional + /// reach a measurement rather than an assumption. + #[test] + fn cpp_this_is_an_operand_in_every_position() { + fn check(label: &str) { + let mut seen = 0_usize; + for_each_node_with_chain::(CPP_THIS_POSITIONS.as_bytes(), |node, chain| { + if node.kind() != "this" { + return; + } + seen += 1; + // `_with_code` is the spelling `compute_halstead` calls. + // The default forwards to the byte-less form, so the two + // agree for both languages today — which is exactly why + // asking the wrong one would read as correct right up + // until one of them grew an override + // (grammar-dispatch section 7). + assert!( + matches!( + L::get_op_type_with_code( + node, + CPP_THIS_POSITIONS.as_bytes(), + Ancestors::known(chain) + ), + HalsteadType::Operand + ), + "{label}: a `this` under `{}` is not an operand", + chain.last().map_or("", Node::kind) + ); + }); + // One per position: `->` receiver, `(*this)`, bare return, + // `[this]`, `[=, this]`, `decltype(this->x)`, the + // constructor body, and the call argument. + assert_eq!(seen, 8, "{label}: fixture lost a `this` position"); + } + + check::("cpp"); + check::("mozcpp"); + } + + /// Pins the two grammar facts the #1361 operand arm rests on, across + /// both fixtures and both languages. + /// + /// * **Grammar-dispatch section 1.** Every node the grammar spells + /// `this` carries the one `kind_id` the arm lists. That is the + /// weaker half of the alias evidence — an alias arises in a + /// *different* syntactic position, which no fixture can enumerate. + /// The strong half is that these generated enums carry + /// numeric-suffix aliases in quantity (`FunctionDefinition2..4`, + /// `QualifiedIdentifier2..4`, `LPAREN2`, `Try2`, `GT2` in this one + /// file) and neither spells a `This2` — `This = 215` is the sole + /// variant rendering `"this"` in `language_cpp.rs` and + /// `language_mozcpp.rs`. This loop is what notices if a grammar + /// bump changes that under an existing fixture. + /// * **Grammar-dispatch section 5.** A `this` is a childless leaf and + /// no node containing it is classified. That is both halves of the + /// double-count question: nothing below it can be billed a second + /// time, and no `field_expression` / `pointer_expression` / + /// `lambda_capture_specifier` / `argument_list` wrapper bills the + /// same source text from above. + #[test] + fn cpp_this_is_a_childless_unaliased_leaf() { + fn check(this: u16, label: &str) { + let mut seen = 0_usize; + for source in [CPP_THIS_RECEIVER_PARITY, CPP_THIS_POSITIONS] { + for_each_node_with_chain::(source.as_bytes(), |node, chain| { + if node.kind() != "this" { + return; + } + seen += 1; + assert_eq!( + node.kind_id(), + this, + "{label}: a `this` carries kind_id {} rather than the {this} the \ + operand arm lists — an alias the arm cannot see", + node.kind_id() + ); + assert_eq!( + node.child_count(), + 0, + "{label}: `this` grew children, which the operand arm would now \ + double-count against" + ); + assert!(!chain.is_empty(), "{label}: a `this` cannot be the root"); + // Every ancestor, not just the parent. The claim is + // that *no node containing* a `this` is classified, + // and `(*this)` alone puts two wrappers between the + // leaf and the nearest classified node — checking + // only `chain.last()` would assert something + // narrower than the comment on the arm promises. + // `chain` is the *node's* ancestry, so the ancestor + // at `i` has `chain[..i]` for its own. + for (i, ancestor) in chain.iter().enumerate() { + assert!( + matches!( + L::get_op_type_with_code( + ancestor, + source.as_bytes(), + Ancestors::known(&chain[..i]) + ), + HalsteadType::Unknown + ), + "{label}: `{}` contains a `this` and is itself classified, so the \ + reference now counts twice", + ancestor.kind() + ); + } + }); + } + // One `this` in the parity fixture, eight in the positions + // fixture. Asserted so a fixture that stopped containing one + // cannot make this test pass having checked nothing. + assert_eq!(seen, 9, "{label}: fixtures lost a `this`"); + } + + check::(Cpp::This as u16, "cpp"); + check::(Mozcpp::This as u16, "mozcpp"); + } + /// Builds a `HalsteadMaps` from explicit occurrence counts. /// /// The per-language tests above reach these maps only through a diff --git a/tests/parity/cpp_mozcpp_parity.rs b/tests/parity/cpp_mozcpp_parity.rs index 01da35e09..6d01d5266 100644 --- a/tests/parity/cpp_mozcpp_parity.rs +++ b/tests/parity/cpp_mozcpp_parity.rs @@ -45,6 +45,21 @@ fn metric_sums(lang: LANG, source: &str, ext: &str) -> Vec<(&'static str, u64)> ] } +/// The value `metric_sums` recorded for `key`, panicking with the whole +/// row set when the key is absent. +/// +/// A missing key means `metric_sums` stopped reporting that metric, and +/// the three call sites below each name several — swallowing it into a +/// `0` would turn a dropped metric into a plausible assertion failure +/// about a number the run never produced. +#[track_caller] +fn metric(sums: &[(&'static str, u64)], key: &str) -> u64 { + sums.iter().find(|(k, _)| *k == key).map_or_else( + || panic!("metric_sums omitted {key}: {sums:?}"), + |(_, v)| *v, + ) +} + #[test] fn cpp_and_mozcpp_agree_on_plain_cpp() { // Plain C++: `new` / compound-assign / `<=>` / `try`-`catch` are all @@ -78,11 +93,7 @@ fn cpp_and_mozcpp_agree_on_plain_cpp() { // all three ABC dimensions — in particular `conditions`, which is // exactly where the docstring's load-bearing `<=>` / `try` / `catch` // constructs accumulate. - let get = |key: &str| { - cpp.iter() - .find(|(k, _)| *k == key) - .map_or_else(|| panic!("metric_sums omitted {key}: {cpp:?}"), |(_, v)| *v) - }; + let get = |key: &str| metric(&cpp, key); // conditions: `<=>` +1, the `< 0` on its result +1, the `if (a < b)` // +1, `try` +1, `catch` +1, the `less ? a : b` ternary +1, and that // ternary's bare-identifier condition operand +1 (#1102) = 7. @@ -117,11 +128,7 @@ fn cpp_and_mozcpp_agree_on_raw_string_delimiters() { "Cpp and Mozcpp must agree on raw-string delimiter classification" ); - let get = |key: &str| { - cpp.iter() - .find(|(k, _)| *k == key) - .map_or_else(|| panic!("metric_sums omitted {key}: {cpp:?}"), |(_, v)| *v) - }; + let get = |key: &str| metric(&cpp, key); // Operators: `;` x 3, `=` x 3, `int`, and the one `()` from `f(a)` // -> N1 = 8. Before the guard the two raw-string openers added two // more `()` -> N1 = 10. @@ -138,3 +145,48 @@ fn cpp_and_mozcpp_agree_on_raw_string_delimiters() { "both raw-string literals must still count as operands: {cpp:?}" ); } + +#[test] +fn cpp_and_mozcpp_agree_on_this() { + // #1361 added `This` to the operand arm of both `CppCode::get_op_type` + // and its `MozcppCode` clone. Mozcpp owns no file extension, so + // nothing else at the metric level exercises its copy. + // + // The fixture pairs `this->x` with `p->x`, which are the same + // ` -> ` shape. That pairing is what separates "the + // receiver stopped counting" from "every operand stopped counting": + // an arm that dropped `this` again takes N2 to 9 while N1 stays at + // 17, and only the operand row moves. + let source = r" + struct S { + int x; + int m1() { return this->x; } + int m2(S* p) { return p->x; } + }; + "; + + let cpp = metric_sums(LANG::Cpp, source, "cpp"); + let mozcpp = metric_sums(LANG::Mozcpp, source, "cpp"); + assert_eq!( + cpp, mozcpp, + "Cpp and Mozcpp must agree on `this` classification" + ); + + let get = |key: &str| metric(&cpp, key); + // Operators: `{` x3, `int` x3, `;` x4, `(` x2, `return` x2, `->` x2, + // and the `*` of `S* p` -> N1 = 17. `this` must not appear here — + // classifying it as an operator (the Java / C# / Kotlin reading, + // #1380) would take this to 18 and the operand row to 9. + assert_eq!( + get("halstead.operators"), + 17, + "`this` must not be billed as an operator: {cpp:?}" + ); + // Operands: `S` x2, `x` x3, `m1`, `m2`, `p` x2, `this` -> N2 = 10. + // Before #1361 `this` was in neither arm, so this was 9. + assert_eq!( + get("halstead.operands"), + 10, + "`this` must be billed as an operand: {cpp:?}" + ); +} diff --git a/tests/repositories/big-code-analysis-output b/tests/repositories/big-code-analysis-output index 84370088c..6deee6034 160000 --- a/tests/repositories/big-code-analysis-output +++ b/tests/repositories/big-code-analysis-output @@ -1 +1 @@ -Subproject commit 84370088c61f092d284f46f21892fa6cc0fbf359 +Subproject commit 6deee603495a5cc433c5f605256198f5a333ebeb From fb13011cf1506f1b9ca6623eb39e5bba7e0d6347 Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Sun, 30 Aug 2026 23:49:27 -0700 Subject: [PATCH 07/20] test(halstead): name the #1361 walk for its property MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `cpp_this_is_an_operand_in_every_position` promised a completeness property its fixture does not have: `delete this;`, `throw this;`, `[&, this]`, a default member initialiser and `this->template f()` all parse cleanly and are absent from `CPP_THIS_POSITIONS`. None of them is a distinct path — every one is the same childless `this:215` leaf under an unclassified wrapper, which is exactly why the arm needs no guard — so the fix is to name the property the walk actually establishes, not to enumerate six more rows. The wrapper check also now covers every ancestor rather than only `chain.last()`, matching the claim both it and the `cpp.rs` arm comment make. `(*this)` is the case that separates them: its `parenthesized_expression` grandparent sits two levels above the leaf, and classifying it (as the aliased `ParenthesizedExpression2`, id 363 — not the `ParenthesizedExpression` 248 a reader would reach for) fails the widened loop where the parent-only form passed. --- src/metrics/halstead.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/metrics/halstead.rs b/src/metrics/halstead.rs index 4d5372c70..9abe48750 100644 --- a/src/metrics/halstead.rs +++ b/src/metrics/halstead.rs @@ -7948,8 +7948,9 @@ f() { assert_this_receiver_parity::("this.cpp", "mozcpp"); } - /// Every `this` in `CPP_THIS_POSITIONS` classifies as an operand, - /// whichever syntactic position it stands in (#1361). + /// The `This` arm is unconditional: every `this` in + /// `CPP_THIS_POSITIONS` classifies as an operand whatever encloses + /// it (#1361). /// /// The counts in `cpp_this_is_an_operand` are measured on one /// position only. An arm reached through a parent-scoped guard — @@ -7958,8 +7959,13 @@ f() { /// that test and still drop `this` in a lambda capture or a /// `decltype`. This walk is what makes the arm's unconditional /// reach a measurement rather than an assumption. + /// + /// Named for the *property* rather than for the fixture: the fixture + /// samples one position per container kind and is deliberately not + /// exhaustive (see `CPP_THIS_POSITIONS`), so a name promising "every + /// position" would claim more than it checks. #[test] - fn cpp_this_is_an_operand_in_every_position() { + fn cpp_this_is_an_operand_regardless_of_position() { fn check(label: &str) { let mut seen = 0_usize; for_each_node_with_chain::(CPP_THIS_POSITIONS.as_bytes(), |node, chain| { From cd152c927e36e6ac334565802d30f10f1e663c68 Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Mon, 31 Aug 2026 00:56:07 -0700 Subject: [PATCH 08/20] fix(getter/tcl): stop a braced literal opening a block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Tcl and iRules grammars spell a script body and a plain literal with one kind, `braced_word`. #1314 guarded the `{` of the literal form the grammars special-case (`braced_word_simple`), which left every other value position fabricating a block: `lappend x {a b}` and `puts {c d}` each reported a `{}` operator for a block the line does not contain, where the synonym `set x {a b}` reported none. No kind-scoped arm can separate the two roles, so recognise them out-of-band by the enclosing command's leading word (grammar-dispatch section 9). A braced word filling a slot of a construct the grammar models is a script; one passed to a command in `SCRIPT_TAKING_COMMANDS` (`after`, `eval`, `for`, `on`, `switch`, `time`, `trap`, `uplevel`) is a script; a defaulted `proc` parameter's value and a braced word in the command-name position are data. Everything else is a value and its `{` is a quote, not an operator. An unrecognised command therefore defaults to *value*. The script-taking set is closed and small — Tcl has no user-extensible control structures — while the value-taking set is open, holding every user proc and package command. Defaulting the other way fabricates; this way can only omit one `{}` occurrence whose vocabulary entry any real block in the file already carries. The rule revises the operator and nothing else. Suppressing a value word's contents as well would make `lappend x {a b}` score like `set x {a b}`, which is right for that line and wrong in general: a braced argument to an unrecognised command is as often real code as a list. Built and measured on a file holding an `oo::class` body, a `tcltest -body` and an `apply` lambda, it took n1 5 -> 2, N1 8 -> 2, n2 25 -> 15 and N2 32 -> 15, each body collapsing into a single operand. So the classifier withdraws a claim it cannot support and never discards code the walk has read. The residual asymmetry is filed as #1382; keeping to the brace also keeps the test O(1), where an ancestor scan measured quadratic on a deeply nested `expr`. Tcl's `switch` needs one extra step: the grammar models it with no node of its own, so the arm list parses as a command named after the first pattern and its bodies would read as that command's literals. The rescue is scoped to the arm's arguments, so a braced *pattern* stays a literal. iRules models `switch` with `switch_arm` children and never reaches it. The recognition needs the source bytes, so it lands in a `get_op_type_with_code` override in both getters -- the spelling the walk calls. `get_op_type` keeps answering the byte-less question unchanged. Fixes #1318 --- big-code-analysis-book/src/metrics.md | 30 ++- src/getter.rs | 355 +++++++++++++++++++++++++- src/getter/irules.rs | 33 ++- src/getter/tcl.rs | 80 ++++-- src/metrics/halstead.rs | 308 +++++++++++++++++++++- 5 files changed, 760 insertions(+), 46 deletions(-) diff --git a/big-code-analysis-book/src/metrics.md b/big-code-analysis-book/src/metrics.md index b7af05b72..994634a45 100644 --- a/big-code-analysis-book/src/metrics.md +++ b/big-code-analysis-book/src/metrics.md @@ -542,12 +542,30 @@ tokens involved the same way they spell real operators: and a Perl or Ruby pattern each contribute *one operand* — the literal — and no operator for the punctuation around it. Otherwise the score would move with the author's choice of delimiter, which - says nothing about the code. One known exception, tracked in - [#1318](https://github.com/dekobon/big-code-analysis/issues/1318): - the Tcl grammar parses a braced literal as a *script* everywhere - except the value slot of the handful of commands it special-cases, - so `lappend x {a b}` still reports a `{}` operator and bills the - words inside the braces rather than the literal. + says nothing about the code. Tcl and iRules need one extra step to + honour this, because their grammars spell a braced literal and a + braced script body the same way: whether `{a b}` is a block or a + quoted value depends on the command it is passed to, so the + classifier reads that command's name. `eval {…}`, `uplevel`, + `after`, `time`, and Tcl's `for` and `switch` take scripts and keep + their `{}` operator, as does every construct the grammar models with + a node of its own (`proc`, `if`, `while`, `foreach`, `catch`, `try`, + `namespace`, an iRules `when` handler) — except a defaulted `proc` + parameter (`proc p {a {b {x y}}}`), which holds data the interpreter + assigns rather than a script it runs. Every other command — + `lappend`, `puts`, `list`, and any user-defined proc — is taken to + receive a value, so its braces score no operator. + + This decides the *operator* only. The words inside a braced argument + are counted either way, because an unrecognised command is as likely + to have been handed real code (an `oo::class create C {…}` body, a + `tcltest` `-body {…}`) as a list. So a script passed to a command + outside the list — a Tk `-command {…}` callback, say — gives up one + `{}` occurrence and nothing else. The remaining asymmetry is that a + braced value scores one operand where the grammar names the command + (`set x {a b}`) and one per word where only the command name would + (`lappend x {a b}`); closing that needs a signal neither grammar + gives. - **A string-interpolation opener is not an operator.** `"{$x}"` in PHP, `"#{x}"` in Ruby and Elixir, `"${x}"` in Kotlin and Groovy and `$"{x}"` in C# all count the interpolated expression's own operators diff --git a/src/getter.rs b/src/getter.rs index 2226ca285..2e1bc227c 100644 --- a/src/getter.rs +++ b/src/getter.rs @@ -289,9 +289,10 @@ pub(crate) fn default_func_space_name<'a, 'tree>( /// are not. #[doc(hidden)] /// The kinds one Tcl-family dialect spells the braced-word construct -/// with, for [`Getter::is_subsumed_braced_word`]. A struct rather than -/// three `u16` parameters because the ids are same-typed and positional: -/// a transposed pair compiles and silently inverts the rule for that +/// with, for [`Getter::is_subsumed_braced_word`] and +/// [`Getter::braced_word_op_type`]. A struct rather than a row of `u16` +/// parameters because the ids are same-typed and positional: a +/// transposed pair compiles and silently inverts the rule for that /// dialect. pub(crate) struct BracedWordKinds { /// `braced_word_simple`, the literal *value* form. @@ -301,8 +302,99 @@ pub(crate) struct BracedWordKinds { /// `comment`, the one named child of a script that is not a /// command. pub(crate) comment: u16, + /// `command`, the generic command node. Its `name` field carries + /// the leading word [`Getter::is_value_braced_word`] recognises the + /// construct by, and it is the only parent a *generic* argument + /// list hangs from. The hidden `_command` supertype (`Command2` in + /// both enums) is deliberately absent: the parser never emits it + /// (grammar-dispatch §2). + pub(crate) command: u16, + /// `word_list`, a command's `arguments` field — and, in both + /// grammars, also the argument list of the modelled `namespace` + /// construct, which is why the rule reads the grandparent rather + /// than stopping here. + pub(crate) word_list: u16, + /// `simple_word`, the only spelling of a command name this rule + /// resolves. A computed name (`$cmd {…}`, `[pick] {…}`) parses as + /// `variable_substitution` / `command_substitution` and is not + /// statically resolvable at all. + pub(crate) simple_word: u16, + /// `argument`, one entry of a `proc` parameter list. A modelled + /// slot that holds a *value* rather than a script: a defaulted + /// parameter (`proc p {a {b {x y}}}`) spells its default as a + /// `braced_word`, and a default is never evaluated as code. + pub(crate) argument: u16, } +/// The core Tcl-family commands that evaluate a braced argument as a +/// *script* and that neither dialect's grammar models with a node of its +/// own (#1318). +/// +/// A command the grammar *does* model — `proc`, `if`, `while`, +/// `foreach`, `catch`, `try`, `namespace`, iRules' `when`, `for`, +/// `switch` and `dict for` / `dict update` / `dict with` — needs no +/// entry: its body is a child of that construct's own node rather than +/// of a generic `command`, which +/// [`Getter::generic_argument_command`] already answers `None` for. +/// `for` and `switch` appear here because the *Tcl* grammar models +/// neither (#467, #1264); the iRules grammar models both, so those two +/// rows are live for one dialect and inert for the other. +/// +/// Each entry is a command whose documented syntax puts a script in a +/// braced argument: +/// +/// | command | syntax | +/// | --- | --- | +/// | `after` | `after ms script` | +/// | `eval` | `eval arg ?arg …?` | +/// | `for` | `for start test next body` — all four are evaluated | +/// | `on` | `on error {script}`, the iRules `try` clause | +/// | `switch` | `switch ?options? string pattern body ?pattern body …?` | +/// | `time` | `time script ?count?` | +/// | `trap` | `trap {script}`, the iRules `try` clause | +/// | `uplevel` | `uplevel ?level? arg ?arg …?` | +/// +/// `on` and `trap` are listed because the iRules grammar models +/// `on_handler` / `trap_handler` only *under* `try` (pinned by +/// `irules_try_handler_kinds_appear_only_under_try`); written at +/// statement level they parse as generic commands, and neither word +/// takes a value in either dialect. +/// +/// `lmap varname list body` is deliberately **not** listed even though +/// its body is a script: the list is per-command, not per-argument, so +/// listing it would bill `lmap i {1 2 3} {…}`'s *list* as a block — +/// the same spelling sensitivity this rule exists to remove, and worse +/// than the one occurrence its body gives up. `for` and `switch` have +/// no such argument (`for`'s four are all evaluated, `switch`'s braced +/// argument is the arm list). +/// +/// Subcommand-dispatched script takers (`dict for`, `interp eval`, +/// `trace add … {script}`) are absent for a related reason: the +/// leading word alone cannot tell `dict for` from `dict set`, and +/// admitting it would misclassify the far commoner value-taking +/// spellings. Tk callbacks (`bind`, `fileevent`, a `-command {…}` +/// option) are absent for the same reason as any user proc. +/// +/// This list is the whole of the heuristic, and it is knowingly +/// incomplete — see [`Getter::is_value_braced_word`] for what an +/// unlisted command defaults to and why. +const SCRIPT_TAKING_COMMANDS: [&str; 8] = [ + "after", + "eval", + "for", + "on", + SWITCH_COMMAND, + "time", + "trap", + "uplevel", +]; + +/// Named because two rules have to agree on it: `switch` takes a +/// script, and its *arm bodies* are scripts too even though the Tcl +/// grammar hangs them off a `command` named after the pattern +/// ([`Getter::is_switch_arm`]). Spelling it twice would let one drift. +const SWITCH_COMMAND: &str = "switch"; + pub(crate) trait Getter { fn get_func_name<'a, 'tree>( node: &Node<'tree>, @@ -445,10 +537,21 @@ pub(crate) trait Getter { /// same kind serves as the value slot of every command the grammar /// does not special-case, where `lappend l {}` is an empty list and /// the brace pair is its only carrier (grammar-dispatch §6). An - /// empty or comment-only `proc` body is indistinguishable from it - /// and so also scores one operand, its whole text — the cost of a - /// grammar that spells both the same way, and the same conflation - /// `FIXME(#1318)` tracks on the operator side. + /// empty or comment-only `proc` body is spelled identically and so + /// also scores one operand, its whole text. #1318 can now tell the + /// two apart by the enclosing command, but it bills them alike, so + /// this arm needs no help from it. + /// + /// This is the *byte-less* half of the braced-word rule and it is + /// not the whole of it. [`braced_word_op_type`] suppresses the `{` + /// of a script-kind word the enclosing command shows to be a plain + /// value, and both Tcl-family getters reach it through + /// [`get_op_type_with_code`], which is what the walk calls + /// (grammar-dispatch §7). It revises no operand, so this arm's + /// answers stand unchanged. + /// + /// [`braced_word_op_type`]: Self::braced_word_op_type + /// [`get_op_type_with_code`]: Self::get_op_type_with_code fn is_subsumed_braced_word<'a>( node: &Node<'a>, ancestors: Ancestors<'a, '_>, @@ -461,6 +564,244 @@ pub(crate) trait Getter { .any(|child| child.is_named() && child.kind_id() != kinds.comment)) } + /// The generic `command` node `word` is an argument of, paired with + /// that command's own ancestry — or `None` when `word` instead + /// fills a slot of a construct the grammar models, and so is a + /// script by construction. + /// + /// Both dialects hang a *generic* command's arguments off a + /// `word_list` and a *modelled* construct's slots off that + /// construct's own node (`procedure`, `if`, `else`, `elseif`, + /// `while`, `foreach`, `catch`, `try`, `finally`, `argument`, plus + /// iRules' `when_event`, `for`, `switch_arm`, `on_handler`, + /// `trap_handler` and the three `dict` loops). Asking the parent + /// kind therefore answers "is this a generic argument" without + /// enumerating the modelled parents — a list that would be a + /// coverage claim to re-derive on every grammar bump + /// (grammar-dispatch §1), and that a grammar gaining one more + /// modelled construct would silently invalidate. + /// + /// A `word_list` whose own parent is *not* a `command` is + /// `namespace`'s argument list — the one modelled construct in + /// either grammar that reaches its body through a `word_list`. The + /// grandparent test answers `None` there, so `namespace eval ns + /// {…}` keeps its body a script. + /// + /// A braced word directly under a `command` is the command *name* + /// (`{puts} hi`), not an argument, and also answers `None`: its + /// caller decides that case before asking, because a name is + /// always a literal and must not pick up the `switch`-arm rescue + /// below. + fn generic_argument_command<'tree, 'chain>( + word: &Node<'tree>, + ancestors: Ancestors<'tree, 'chain>, + kinds: &BracedWordKinds, + ) -> Option<(Node<'tree>, Ancestors<'tree, 'chain>)> { + let mut chain = ancestors.iter(word); + let (parent, _) = chain.next()?; + if parent.kind_id() != kinds.word_list { + return None; + } + let (grandparent, grandparent_ancestors) = chain.next()?; + (grandparent.kind_id() == kinds.command).then_some((grandparent, grandparent_ancestors)) + } + + /// Whether a braced *script*-kind word is really a plain value — + /// the literal `{a b}` of `lappend x {a b}` rather than the block + /// of `eval {…}` (#1318). + /// + /// The two roles share one kind, so no kind-scoped arm can separate + /// them; recognition is out-of-band, by the enclosing command's + /// leading word (grammar-dispatch §9). A word that fills a modelled + /// construct's slot is a script; a word passed to a command in + /// [`SCRIPT_TAKING_COMMANDS`] is a script; **everything else is a + /// value**. + /// + /// That default is the load-bearing choice, and it is deliberately + /// the opposite of "keep today's answer for anything unrecognised": + /// + /// - The script-taking set is *closed* and small — Tcl defines no + /// user-extensible control structures, so a command that + /// evaluates a braced argument is either a core command listed + /// above or a proc that forwards to `eval` / `uplevel`. The + /// value-taking set is *open*: every user proc and every package + /// command that takes a list or a pattern is in it. An + /// unrecognised name is therefore far likelier to be value-taking. + /// - Defaulting to script *fabricates* — it reports a `{}` operator + /// for a block the source does not contain. Defaulting to value + /// can only *omit*, and only one `N1` occurrence of a `{}` whose + /// vocabulary entry any real block in the file already carries. + /// That asymmetry holds only because the answer is scoped to the + /// brace: see [`braced_word_op_type`] for the measurement that + /// decided it, and for why suppressing the *contents* of a value + /// would have made the omission unbounded instead. + /// - It stops the score moving with the author's choice of + /// delimiter, which is what #695, #1312 and #1314 each restored + /// elsewhere: `puts {c d}` and `puts "c d"` now agree on the + /// operator column. + /// + /// The cost is a script passed to an unlisted command — a Tk + /// `-command {…}` callback, `trace add variable v w {…}`, a + /// user-defined `with_lock {…}` — losing the `{}` its block + /// deserves. No structural signal distinguishes those cases: the + /// grammar parses `{a b}` and `{puts hi}` into the same shape. The + /// code inside them is still counted, so the loss is one operator + /// occurrence per such block and nothing else. + /// + /// [`braced_word_op_type`]: Self::braced_word_op_type + fn is_value_braced_word<'a>( + word: &Node<'a>, + code: &[u8], + ancestors: Ancestors<'a, '_>, + kinds: &BracedWordKinds, + ) -> bool { + // Two positions answer "value" without consulting any command + // name, and both must be asked before the lookup below, + // because neither hangs off a `command` the way an argument + // does — and the second must not reach the `switch`-arm + // rescue. + // + // A defaulted `proc` parameter (`proc p {a {b {x y}}}`) is the + // one modelled slot holding a value: a default is data the + // interpreter assigns, never a script it evaluates. + // + // A braced word directly under a `command` is that command's + // *name* (`{puts} hi` invokes `puts`), so it is a literal word. + // Inside a `switch` arm list the name is the arm's *pattern*, + // and a braced pattern (`switch -regexp $v { {^a.*b$} {…} }`) + // is idiomatic — rescuing it as an arm body would fabricate a + // block around a regex. + if ancestors.parent_has_kind(word, kinds.argument) + || ancestors.parent_has_kind(word, kinds.command) + { + return true; + } + let Some((command, command_ancestors)) = + Self::generic_argument_command(word, ancestors, kinds) + else { + return false; + }; + if Self::command_leading_word(&command, code, kinds) + .is_some_and(|name| SCRIPT_TAKING_COMMANDS.contains(&name)) + { + return false; + } + !Self::is_switch_arm(&command, code, command_ancestors, kinds) + } + + /// A command's leading word, when it is a statically resolvable + /// `simple_word`. + /// + /// Located by field rather than by index (grammar-dispatch §3): the + /// name is `command`'s `name` field in both grammars. A computed + /// name (`$cmd {…}`, `[pick] {…}`) parses as a substitution node and + /// is not resolvable at all, and a name spelled in quotes or braces + /// (`"eval" {…}`) is legal Tcl that this deliberately leaves + /// unresolved — the same limitation `tcl_command_name` records for + /// the Cognitive and Cyclomatic walkers. + fn command_leading_word<'c>( + command: &Node<'_>, + code: &'c [u8], + kinds: &BracedWordKinds, + ) -> Option<&'c str> { + let name = command.child_by_field_name("name")?; + if name.kind_id() != kinds.simple_word { + return None; + } + node_text(code, &name) + } + + /// Whether `command` is really one `pattern body` pair of a Tcl + /// `switch` arm list rather than a command of its own. + /// + /// Tcl models no `switch`, so `switch $v {a {…} b {…}}` parses the + /// arm list as a braced word whose interior is a `command` named + /// after the *first pattern* — `a` here — with the arm bodies as + /// its arguments (grammar-dispatch §9; the Cognitive walker reads + /// the same shape through `tcl_switch_arm_list`). Without this + /// test the bodies would read as literals passed to a command + /// called `a`, which is exactly what the value default is for + /// everywhere else. iRules models `switch` with `switch_arm` + /// children, so its arm bodies never reach here. + fn is_switch_arm<'a>( + command: &Node<'a>, + code: &[u8], + ancestors: Ancestors<'a, '_>, + kinds: &BracedWordKinds, + ) -> bool { + ancestors + .iter(command) + .next() + .is_some_and(|(arm_list, above)| { + arm_list.kind_id() == kinds.script + && Self::generic_argument_command(&arm_list, above, kinds) + .and_then(|(switch, _)| Self::command_leading_word(&switch, code, kinds)) + == Some(SWITCH_COMMAND) + }) + } + + /// Whether the `{` of `node`'s parent braced word opens a block, + /// or merely quotes a literal (#1318). + /// + /// This is #1314's guard with its kind test replaced by a role + /// test. #1314 suppressed the opener of a `braced_word_simple`, + /// the literal form the grammars emit only in the value slots they + /// special-case; everywhere else a literal is a `braced_word`, the + /// same kind a block uses, and its `{` still reported a `{}` + /// operator for a block the source does not contain — + /// `lappend x {a b}`, `puts {c d}`, and every user proc taking a + /// list. + /// + /// **It revises the operator only, and deliberately leaves the + /// words inside a value alone.** Suppressing them too would make + /// `lappend x {a b}` score like its `set x {a b}` synonym, which + /// is the tidier answer for that line — and the wrong one in + /// general, because the same braced argument of an unrecognised + /// command is just as often real code: an `oo::class create C {…}` + /// body, a `tcltest` `-body {…}`, an `apply {{x} {…}}` lambda. + /// Suppressing contents was built and measured on a file holding + /// one of each; it took n1 5 → 2, N1 8 → 2, n2 25 → 15 and + /// N2 32 → 15, the class body, the test body and the lambda each + /// collapsing into a single operand, and `halstead.effort` — a + /// gated threshold metric — collapsing with them. So the rule only + /// ever withdraws a claim the classifier cannot support; it never + /// discards code the walk has already read. The residual is the + /// asymmetry the issue opens with: a braced value scores one + /// operand where the grammar names it (`set`) and one per word + /// where only the command name would (`lappend`). Closing that + /// needs a signal neither grammar gives — filed as #1382. + /// + /// Keeping to the operator also keeps the whole thing `O(1)`: only + /// a braced word's own opener can change answer, so the test is + /// one parent lookup, the same scope #1354 and #1314 use. An + /// ancestor scan would have been `O(depth)` per node and quadratic + /// on a deeply nested `expr`, the shape #1122 warns about. + /// + /// [`get_op_type`]: Self::get_op_type + fn braced_word_op_type<'a>( + node: &Node<'a>, + code: &[u8], + ancestors: Ancestors<'a, '_>, + kinds: &BracedWordKinds, + ) -> HalsteadType { + let base = Self::get_op_type(node, ancestors); + // A braced word's only operator child is its `{` — the closer + // has never been classified, and everything between them is a + // command or a comment — so no other node can change answer. + if !matches!(base, HalsteadType::Operator) { + return base; + } + let quotes_a_literal = ancestors.iter(node).next().is_some_and(|(parent, above)| { + parent.kind_id() == kinds.script + && Self::is_value_braced_word(&parent, code, above, kinds) + }); + if quotes_a_literal { + HalsteadType::Unknown + } else { + base + } + } + fn get_operator_id_as_str(_id: u16) -> &'static str { "" } diff --git a/src/getter/irules.rs b/src/getter/irules.rs index 3a6b50114..aba17c5a6 100644 --- a/src/getter/irules.rs +++ b/src/getter/irules.rs @@ -3,14 +3,18 @@ use super::*; -/// The braced-word kinds `Getter::is_subsumed_braced_word` is -/// instantiated with (#1354): the literal *value* form the guard keys -/// on, the *script* form it gates on holding a command, and the comment -/// kind that gate must not mistake for one. +/// The braced-word kinds `Getter::is_subsumed_braced_word` and +/// `Getter::braced_word_op_type` are instantiated with (#1354, #1318) — +/// the twin of the Tcl table, at this grammar's own id block. Every +/// field means what the Tcl one documents; only the ids differ. const BRACED_WORD_KINDS: BracedWordKinds = BracedWordKinds { value: Irules::BracedWordSimple as u16, script: Irules::BracedWord as u16, comment: Irules::Comment as u16, + command: Irules::Command as u16, + word_list: Irules::WordList as u16, + simple_word: Irules::SimpleWord as u16, + argument: Irules::Argument as u16, }; impl Getter for IrulesCode { @@ -189,5 +193,26 @@ impl Getter for IrulesCode { } } + // The byte-reading half of the braced-word rule (#1318) — the twin + // of the Tcl override, which carries the derivation. This grammar + // models more of the script positions than Tcl's does (`for`, + // `switch` with `switch_arm` children, `when`, and the three `dict` + // loops all have nodes of their own), so fewer braced words reach + // the command-name recognition here; the ones that do — `eval`, + // `uplevel`, `after`, `time` and every user proc — behave exactly + // as they do in Tcl. Two entries in that list exist for this + // dialect alone: `on_handler` / `trap_handler` are emitted only + // under `try` (pinned by + // `irules_try_handler_kinds_appear_only_under_try`), so a + // statement-level `on error {…}` or `trap {…}` is a generic + // command and needs its name recognised. + fn get_op_type_with_code<'a>( + node: &Node<'a>, + code: &[u8], + ancestors: Ancestors<'a, '_>, + ) -> HalsteadType { + Self::braced_word_op_type(node, code, ancestors, &BRACED_WORD_KINDS) + } + get_operator!(Irules); } diff --git a/src/getter/tcl.rs b/src/getter/tcl.rs index 77efcf4b4..40951c714 100644 --- a/src/getter/tcl.rs +++ b/src/getter/tcl.rs @@ -3,14 +3,23 @@ use super::*; -/// The braced-word kinds `Getter::is_subsumed_braced_word` is -/// instantiated with (#1354): the literal *value* form the guard keys -/// on, the *script* form it gates on holding a command, and the comment -/// kind that gate must not mistake for one. +/// The braced-word kinds `Getter::is_subsumed_braced_word` and +/// `Getter::braced_word_op_type` are instantiated with (#1354, #1318): +/// the literal *value* form the guard keys on, the *script* form it +/// gates on holding a command, the comment kind that gate must not +/// mistake for one, and the four kinds #1318's role recognition walks — +/// the generic `command`, its `word_list` argument list, the +/// `simple_word` a resolvable command name is spelled with, and the +/// `argument` whose braced child is a parameter default rather than a +/// script. const BRACED_WORD_KINDS: BracedWordKinds = BracedWordKinds { value: Tcl::BracedWordSimple as u16, script: Tcl::BracedWord as u16, comment: Tcl::Comment as u16, + command: Tcl::Command as u16, + word_list: Tcl::WordList as u16, + simple_word: Tcl::SimpleWord as u16, + argument: Tcl::Argument as u16, }; impl Getter for TclCode { @@ -70,7 +79,11 @@ impl Getter for TclCode { // `if`/`while` condition is an `Expr` (97), and a `proc` // parameter list is `Arguments` (94). All three keep their // braces as operators, which is what makes keying the value - // half on `BracedWordSimple` safe. + // half on `BracedWordSimple` safe. (#1318 later carved one + // exception out of that list from the other side: a + // *defaulted parameter's* value is a `BracedWord` under an + // `Argument` (93), and a default is data, so it loses its + // brace — the enclosing `Arguments` list keeps its own.) // // Two limits of the value half, both deliberate and both // pinned rather than left implied. @@ -94,23 +107,38 @@ impl Getter for TclCode { // And the grammar emits `BracedWordSimple` only in the // value slot of the commands it special-cases, so // `lappend x {a b}` parses its literal as a `BracedWord` - // script and still fabricates a `{}`. Guarding that kind - // too would drop every real block, so closing it needs - // command-name recognition (grammar-dispatch §9) rather - // than another kind arm — FIXME(#1318). iRules carries the - // twin of all of this. + // script and its `{` kept fabricating a block. Guarding + // that kind here too would drop every real block, so #1318 + // closed it out-of-band instead, by the enclosing command's + // leading word (grammar-dispatch §9) — in + // `get_op_type_with_code` below, because the recognition + // needs the source bytes. It touches the brace and nothing + // else, so every operand this arm decides is unchanged. // // Cross-walked against the sibling predicates // (grammar-dispatch §7) and left as it was: // `Checker::is_string` and `Alterator::alterate` both list // `BracedWord` beside the two literal forms, so - // `bca find -f string` reports a `proc` body as a string + // `bca find --type string` reports a `proc` body as a string // literal while this arm gives it no operand. That // disagreement predates #1354 in shape — an interpolating // `QuotedWord` is already `Unknown` here and a string - // there — and calling a script body a string is #1318's - // role conflation rather than this arm's, so changing - // `find`'s answer is left to that issue. + // there. #1318 now has the predicate that would settle it + // (`Getter::is_value_braced_word`), but `Checker::is_string` + // takes neither `code` nor `ancestors`, so applying it there + // is a trait widening across all twenty-odd languages rather + // than a Tcl edit — filed as #1381. The literal half is + // already right: `bca find --type string` reports + // `lappend x {a b}`'s `{a b}`, which is a string. + // + // `Checker::is_call` needs no such follow-up. It calls + // every `Command` a call, including the ones inside a value + // braced word — and so does this arm, which still bills + // their operands. The two agree precisely because #1318 + // withdrew the brace and not the contents; a rule that + // suppressed the contents would have put the getter and + // every branching metric on opposite sides of the same + // bytes. _ if Self::is_subsumed_braced_word(node, ancestors, &BRACED_WORD_KINDS) => { HalsteadType::Unknown } @@ -215,10 +243,9 @@ impl Getter for TclCode { // whose brace pair is its only carrier, and deleting the // kind scored it zero where its `lappend l ""` synonym // scores one. An empty or comment-only `proc` body is - // spelled identically and so also counts one — accepted - // rather than argued away, since no kind-scoped arm can - // separate the two roles (the same conflation FIXME(#1318) - // tracks on the operator side). + // spelled identically and so also counts one — #1318 can + // now tell the two roles apart, but it revises only the + // brace, so nothing here changes. Tcl::SimpleWord | Tcl::Number | Tcl::BracedWord @@ -244,5 +271,22 @@ impl Getter for TclCode { } } + // The half of the braced-word rule that needs the source bytes + // (#1318). `braced_word` serves two roles — the block of + // `eval {…}` and the literal of `lappend x {a b}` — and the + // grammar spells both the same, so only the enclosing command's + // leading word tells them apart (grammar-dispatch §9). This is the + // spelling the walk calls (`compute_halstead` → + // `get_op_type_with_code`), so the revision reaches every count; + // `get_op_type` above stays the byte-less answer for the two + // callers that have no source to offer. + fn get_op_type_with_code<'a>( + node: &Node<'a>, + code: &[u8], + ancestors: Ancestors<'a, '_>, + ) -> HalsteadType { + Self::braced_word_op_type(node, code, ancestors, &BRACED_WORD_KINDS) + } + get_operator!(Tcl); } diff --git a/src/metrics/halstead.rs b/src/metrics/halstead.rs index 9abe48750..5bf30e4cb 100644 --- a/src/metrics/halstead.rs +++ b/src/metrics/halstead.rs @@ -5730,8 +5730,16 @@ f() { /// empty list whose brace pair is its only carrier. Deleting the /// kind scored that zero while its `lappend l ""` synonym — the /// last row, the control — scored one. An empty `proc` body is - /// spelled identically and so also keeps an operand; no - /// kind-scoped arm can separate the two roles. + /// spelled identically and so also keeps an operand; #1318 tells + /// the two roles apart by the enclosing command, but it bills them + /// alike, so this gate is still what keeps the operand. + /// + /// The rows' *operator* columns moved with #1318 wherever the + /// enclosing command takes a value, which is why the `lappend` + /// rows here read `0` where the `proc` and `if` rows read the + /// block's `{}`. The operand columns are untouched by it — #1318 + /// revises the brace and nothing else — so every `before` here + /// still describes #1354 alone. const SCRIPT_BODY_CASES: [BracedWordCase; 9] = [ BracedWordCase { source: "proc p {} { set b 1 }\n", @@ -5772,20 +5780,24 @@ f() { }, // The value-role twin: inside a literal string Tcl performs no // substitution, so `# x` is not a comment at all, and the word - // is its one operand exactly as `lappend l {}` below is. + // is its one operand exactly as `lappend l {}` below is. Its + // operator columns are zero since #1318 — `lappend` takes a + // value, so the brace pair is a quote and not a block. BracedWordCase { source: "lappend l {\n # x\n}\n", - counts: [1, 1, 3, 3], + counts: [0, 0, 3, 3], before: [3, 3], operands: &["lappend", "l", "{\n # x\n}"], }, - // A `braced_word` in *value* position — the #1318 misparse, - // where the same kind carries a literal list. Its interior - // words counted before and still do; only the whole-block - // operand that was double-billing them is gone. + // A `braced_word` in *value* position — the #1318 role, where + // the same kind carries a literal list. Its interior words + // counted before #1354 and still do; what #1354 removed is the + // whole-block operand that was double-billing them, and what + // #1318 removed is the `{}` operator (`lappend` takes a value, + // so the brace pair quotes rather than opens). BracedWordCase { source: "lappend l {a b}\n", - counts: [1, 1, 4, 4], + counts: [0, 0, 4, 4], before: [5, 5], operands: &["lappend", "l", "a", "b"], }, @@ -5793,13 +5805,16 @@ f() { // nothing: the brace pair is the empty list's only carrier. BracedWordCase { source: "lappend l {}\nreturn {}\n", - counts: [1, 2, 4, 5], + counts: [0, 0, 4, 5], before: [4, 5], operands: &["lappend", "l", "return", "{}"], }, // The control the row above is measured against: the quoted // spelling of the same empty value, which never depended on - // the arm and must keep scoring one operand. + // the arm and must keep scoring one operand. Since #1318 the + // two rows agree on all four columns rather than only on the + // operand pair — an empty list is an empty list however it is + // spelled. BracedWordCase { source: "lappend l \"\"\nreturn \"\"\n", counts: [0, 0, 4, 5], @@ -5914,6 +5929,277 @@ f() { assert_braced_word_children_witnessed(&witnessed, &IRULES_BRACED_WORD_KINDS, "irules"); } + /// One row of the #1318 tables: a fixture and the four Halstead + /// columns plus the operand vocabulary behind them. + /// + /// No `before` column, unlike [`BracedWordCase`]: #1318 is not a + /// guard whose absence a walk can model, it is a *classification*. + /// The pre-fix counts are recorded per row in prose instead, and + /// each was measured against a build without the + /// `get_op_type_with_code` overrides. + struct BracedWordValueCase { + source: &'static str, + /// `[n1, N1, n2, N2]`. + counts: [u64; 4], + operands: &'static [&'static str], + } + + /// Runs one #1318 table against one dialect. Both dialects run + /// every shared row, so a fix that reached one getter and not its + /// clone fails here. + fn check_braced_word_value_cases( + cases: &[BracedWordValueCase], + file: &str, + ) { + for case in cases { + assert_halstead_counts::(case.source, file, case.counts, case.source); + assert_ops_operands::( + case.source, + file, + case.operands.len(), + case.operands.to_vec(), + ); + } + } + + /// The #1318 rows both dialects share. Every independent path + /// through the role test gets a row of its own + /// (`.claude/rules/testing.md`, "give one input to each independent + /// path"): the value default, the script-command list, a modelled + /// construct reached through a `word_list`, the two modelled slots + /// that hold values, an unresolvable command name, and both + /// nesting directions. + /// + /// **Every row asserts the operand columns too, and they are the + /// half that must not move.** #1318 revises one operator and + /// nothing else, so a mutant that suppressed a value word's + /// *contents* — the tidier-looking rule, which collapses an + /// `oo::class create C {…}` body into a single operand — fails + /// here rather than passing as an improvement. + const BRACED_WORD_VALUE_CASES: [BracedWordValueCase; 12] = [ + // The reported fixture. `lappend` takes a list, so `{a b}` + // quotes rather than opens: no operator. Before #1318 this + // read n1 1 / N1 1 — a `{}` block the line does not contain. + BracedWordValueCase { + source: "lappend l {a b}\n", + counts: [0, 0, 4, 4], + operands: &["lappend", "l", "a", "b"], + }, + // The issue's second line, and the one showing the defect + // needs no second argument to appear. + BracedWordValueCase { + source: "puts {c d}\n", + counts: [0, 0, 3, 3], + operands: &["puts", "c", "d"], + }, + // The headline: one value, two commands, and until #1318 two + // different operator counts — `set` reported none, `lappend` a + // `{}`. `set` is an operator and `lappend` an operand because + // the grammar models one command and not the other; that + // asymmetry is the grammar's, not this rule's. + BracedWordValueCase { + source: "set x {a b}\nlappend y {a b}\n", + counts: [1, 1, 6, 6], + operands: &["lappend", "x", "y", "{a b}", "a", "b"], + }, + // The script side of the same shape, and the reason `LBRACE` + // is gated rather than deleted (grammar-dispatch §6): `eval` + // evaluates its argument, so the block is real and keeps its + // `{}`. + BracedWordValueCase { + source: "eval {puts hi}\n", + counts: [1, 1, 3, 3], + operands: &["eval", "puts", "hi"], + }, + // The default for an unrecognised command — a user proc here, + // but equally any package command. Nothing distinguishes it + // from `lappend` structurally, so the row exists to pin which + // way the coin lands. Note the words inside are still + // operands: the rule withdraws the block, never the code. + BracedWordValueCase { + source: "myproc {g h}\n", + counts: [0, 0, 3, 3], + operands: &["myproc", "g", "h"], + }, + // `namespace` is the one construct either grammar models that + // still reaches its body through a `word_list`, so it is the + // row that fails if the rule stops at the parent instead of + // reading the grandparent. Its body must stay a script. + BracedWordValueCase { + source: "namespace eval ns {\n set a 1\n}\n", + counts: [3, 3, 4, 4], + operands: &["eval", "ns", "a", "1"], + }, + // A literal nested in a literal: neither brace opens a block, + // and both words survive. + BracedWordValueCase { + source: "puts {a {b c}}\n", + counts: [0, 0, 4, 4], + operands: &["puts", "a", "b", "c"], + }, + // A `set` inside a literal. The `set` keyword and the `[]` + // substitution are real operators of a command the walk + // descends into, and they stay — the rule reaches exactly the + // one brace whose parent it can classify, never a subtree. + // `{x [foo] v}` is a `braced_word_simple`, so #1354 bills it + // whole and its `[foo]` interior separately, unchanged. + BracedWordValueCase { + source: "lappend l {set a {x [foo] v}}\n", + counts: [2, 2, 5, 5], + operands: &["lappend", "l", "a", "{x [foo] v}", "foo"], + }, + // A braced word in the command *name* position. Legal Tcl — + // `{puts} hi` invokes `puts` — and the one generic position + // that is not an argument, so it is answered before the + // command lookup. It must also never reach the `switch`-arm + // rescue, which is what the braced-pattern row in the Tcl + // table checks. + BracedWordValueCase { + source: "{puts} hi\n", + counts: [0, 0, 2, 2], + operands: &["puts", "hi"], + }, + // A defaulted `proc` parameter — the other modelled slot that + // holds a value, decided by the parent kind rather than by a + // command name. `{x y}` is data the interpreter assigns, never + // a script it runs, and it billed a fourth `{}` before #1318. + // The three that remain are the parameter list, the parameter + // spec and the body: those two list delimiters group a + // declaration rather than quote a value, the line #1354 drew + // for `Arguments`. + BracedWordValueCase { + source: "proc p {a {b {x y}}} { puts $a }\n", + counts: [2, 4, 7, 7], + operands: &["p", "a", "b", "x", "y", "puts", "$a"], + }, + // A literal nested in a script, the direction that must keep + // working: the `proc` body is a block (`{}` twice with the + // parameter list) and the list it appends is not. + BracedWordValueCase { + source: "proc p {} { lappend l {a b} }\n", + counts: [2, 3, 5, 5], + operands: &["p", "lappend", "l", "a", "b"], + }, + // A computed command name resolves to nothing, so the value + // default decides. `$c` is the reference's own operand. + BracedWordValueCase { + source: "set c puts\n$c {a b}\n", + counts: [1, 1, 5, 5], + operands: &["c", "puts", "$c", "a", "b"], + }, + ]; + + /// Regression for #1318. `braced_word` carries both a block and a + /// plain literal, and the grammars spell the two identically, so + /// #1314's kind-scoped guard reached only the value slots they + /// special-case: `set x {a b}` reported no operator while + /// `lappend x {a b}` reported a `{}` for a block the line does not + /// contain. The role is recognised out-of-band, by the enclosing + /// command's leading word (grammar-dispatch §9). + /// + /// The three Tcl-only rows are the constructs this grammar models + /// with no node of its own (#467, #1264) — `switch` and `for` — + /// plus the braced `switch` *pattern* that must not be mistaken + /// for an arm body. Their iRules counterparts are dedicated nodes + /// and are covered by the sibling test. + #[test] + fn tcl_braced_word_role_follows_the_enclosing_command_1318() { + check_braced_word_value_cases::(&BRACED_WORD_VALUE_CASES, "foo.tcl"); + let tcl_only: [BracedWordValueCase; 3] = [ + // `switch` arm bodies. The grammar flattens `pat body pat + // body` into a `command` named after the first pattern, so + // the bodies read as arguments of a command called `a` — + // the shape the value default would call a literal. The + // arm list's own `switch` name is what rescues them, and + // `puts` occurring in both bodies is what separates n2 + // from N2. + BracedWordValueCase { + source: "switch $v { a { puts A } b { puts B } }\n", + counts: [1, 3, 7, 8], + operands: &["switch", "$v", "a", "b", "puts", "A", "B"], + }, + // …and the limit of that rescue. A braced *pattern* is the + // arm command's `name`, not an argument, so it is a + // literal — `switch -regexp $v { {^a.*b$} {…} }` is + // idiomatic and its regex must not be wrapped in a block. + // Rescuing the whole arm command would read N1 4 here. + // The leading `-exact` / `--` options are `simple_word` + // operands and shift no index, which is why the rescue is + // structural rather than positional (grammar-dispatch §9). + BracedWordValueCase { + source: "switch -exact -- $v {\n {a b} { puts X }\n default { puts Y }\n}\n", + counts: [1, 3, 10, 11], + operands: &[ + "switch", "-exact", "--", "$v", "a", "b", "puts", "X", "default", "Y", + ], + }, + // `for` has four braced arguments and all four are + // evaluated, so all four keep their braces. `i` appears as + // the `set` target and again inside `incr`, and `$i` in + // the condition and the body, which is what separates + // n2 = 8 from N2 = 10. `<` is an *operand* here rather + // than a comparison operator: with no `for` rule there is + // no `expr` slot, so the condition parses as a command and + // its comparison never surfaces as a token — the same + // grammar limitation #1264 recorded for ABC. + BracedWordValueCase { + source: "for {set i 0} {$i < 3} {incr i} { puts $i }\n", + counts: [2, 5, 8, 10], + operands: &["for", "i", "0", "$i", "<", "3", "incr", "puts"], + }, + ]; + check_braced_word_value_cases::(&tcl_only, "foo.tcl"); + } + + /// The iRules twin of the test above. The shared table runs + /// unchanged — the two getters are deliberate clones and #1318 + /// names both — and the dialect rows cover the handler bodies, + /// which have no Tcl spelling. + #[test] + fn irules_braced_word_role_follows_the_enclosing_command_1318() { + check_braced_word_value_cases::(&BRACED_WORD_VALUE_CASES, "foo.irule"); + let irules_only: [BracedWordValueCase; 3] = [ + // A `when` handler body is a modelled slot, so the literal + // inside it is classified independently of the block that + // holds it. + BracedWordValueCase { + source: "when HTTP_REQUEST {\n lappend b {x y}\n}\n", + counts: [2, 2, 5, 5], + operands: &["HTTP_REQUEST", "lappend", "b", "x", "y"], + }, + // This grammar models `switch` with `switch_arm` children, + // so the arm body fills a modelled slot and never reaches + // the command-name list the Tcl rows need. + BracedWordValueCase { + source: "when HTTP_REQUEST {\n switch $v { a { pool p1 } }\n}\n", + counts: [3, 5, 5, 5], + operands: &["HTTP_REQUEST", "$v", "a", "pool", "p1"], + }, + // `on` and `trap` are why the command list carries two + // words no *Tcl* command spells. The grammar emits + // `on_handler` / `trap_handler` only under `try` — pinned + // by `irules_try_handler_kinds_appear_only_under_try` — so + // a statement-level handler is a generic command, and + // without the list entry its body would lose the `{}` that + // the second occurrence here counts. + BracedWordValueCase { + source: "when RULE_INIT { set x 1 }\non error { log local0. oops }\n", + counts: [3, 4, 8, 8], + operands: &[ + "RULE_INIT", + "x", + "1", + "on", + "error", + "log", + "local0.", + "oops", + ], + }, + ]; + check_braced_word_value_cases::(&irules_only, "foo.irule"); + } + #[test] fn php_operators_and_operands() { check_metrics::( From 80c0b2b449d309ebf0df42198d6f721b0f6a4504 Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Tue, 8 Sep 2026 21:49:15 -0700 Subject: [PATCH 09/20] fix(abc): stop scoring non-comparison < and > MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The comparison operator and at least one non-comparison construct are the same bare anonymous token in every grammar named here, so six ABC dispatch arms scored markup, type syntax and declarations as decisions. Re-measured at the pinned grammars, `abc.conditions` before -> after: TSX JSX tag delimiters 6 -> 0 JavaScript JSX tag delimiters 6 -> 0 Mozjs JSX tag delimiters 6 -> 0 Lua `local x = 1` attributes 4 -> 0 C# `operator <` / `operator >` overloads 4 -> 2 Kotlin `super.g()` qualified super call 2 -> 0 Perl `` / `<$fh>` readlines 2 -> 0 Take the allowlist polarity #1274 chose for Java and Groovy, and that C, C++, Objective-C, mozcpp, Rust and Go already used: count the token only when its parent is the construct that applies it. A denylist is a coverage claim a grammar bump can silently invalidate (`.claude/rules/grammar-dispatch.md` §1), and each of these four was already incomplete. TypeScript and TSX denied `type_arguments` and `type_parameters` and left three JSX productions counting; Kotlin denied the same two and left `super_expression`; C# denied `type_argument_list`, `type_parameter_list` and `function_pointer_type` and left `operator_declaration`; JavaScript, Mozjs, Lua and Perl had no gate at all, with `LT` / `GT` sitting in the unconditional condition arm. C# takes a two-sided gate rather than the one-entry allowlist, because its `relational_pattern` (`x is > 0`, and a `> 5 =>` switch-expression arm) is a genuine comparison living outside `binary_expression`; a one-entry allowlist would have under-counted it, the inverse error and the harder one to notice. Preserving that count also preserves a pre-existing double charge against the arm that owns the pattern, filed as #1383 rather than settled here as an unmeasured behaviour change riding along. Each gate rests on a `grammar.json` sweep of its pinned crate, enumerating every production that emits a bare `<` or `>` with hidden `_`-rules resolved to their visible parents: six productions in tree-sitter-typescript 0.23.2, four in tree-sitter-javascript 0.25.0 and in the vendored mozjs fork, two in tree-sitter-lua 0.5.0 (the second being `_attrib`, aliased to `attribute`), six in tree-sitter-c-sharp 0.23.5 and four in tree-sitter-kotlin-ng 1.1.0. Two rows of the issue no longer reproduce and are left alone: #1280 (f9b475bf, 01c59533) already gated Bash `file_redirect` and Ruby `superclass`, both with regression tests, and both fixtures measure 0 on this branch before any change of mine. #1275 (8246f505) already settled the issue's `?` section for C# and TS/TSX. Perl is the row the issue got wrong in the other direction. It was cleared on the grounds that `` lexes as one token, which is true — but `` and `<$fh>` are `standard_input_to_identifier` and `standard_input_to_variable`, plain three-token sequences whose brackets scored two phantom conditions each. A sweep of tree-sitter-perl 1.1.2 finds bare angle brackets in exactly those two productions plus `binary_expression`, so the same gate applies. TypeScript gets a positive-direction parity test rather than a JSX fixture: the `.ts` dialect does not parse JSX, so the construct is unreachable there. On that malformed input the flip does move the number (3 -> 2), because a `<` / `>` under an `ERROR` node no longer counts — the same trade #1274 recorded, in the direction that under-counts unparsable text rather than inflating it. One regression test per affected language, each pairing the excluded construct with a genuine condition and asserting a non-zero total, so an unparsable fixture (which also scores 0) stays distinguishable from a working gate. `csharp_relational_pattern_still_counts_as_a_condition` exists because the `operator <` fixture cannot prove the `RelationalPattern` entry alone (grammar-dispatch §11): its `is > 0` sits beside a `binary_expression` comparison, so a one-entry allowlist would still leave a plausible non-zero total. Verified by eight perturbations of the six gates against the whole 499-test `metrics::abc` suite; every new test fails under at least one, and the JS, Lua, Perl and C# gates fail nothing else. Real comparison shapes were measured rather than assumed: `for` headers, `while` predicates, parenthesised chains, a comparison inside a JSX expression container, C# LINQ `where` beside a generic method call, and Kotlin lambda comparisons all still count. Also corrects prose the change falsified or that contradicts it: the lessons-learned entry whose conclusion was "C#, Kotlin and the JS family still carry the denylist form"; a C# test comment naming an "LT/GT exclusion list" that no longer exists; the note claiming `BinaryExpression2` is reachable here, when C#'s preprocessor admits no relational operator; and the Objective-C arm's claim that its gate is kept only "for parity", when that grammar emits bare angle brackets from four non-comparison productions. Kotlin keeps a residual over-count the gate cannot reach: tree-sitter-kotlin-ng resolves `id(a)` into nested `binary_expression` nodes, so both brackets satisfy any comparison gate. Recorded on the arm and filed as #1394, alongside #1383 (C# relational-pattern double count) and #1395 (Halstead still bills every excluded bracket as an operator). `PerlCode::compute` reaches cyclomatic 15 with the new arm. It joins the `halstead` marker it already carried rather than being split: it is the same exhaustive one-arm-per-grammar-kind dispatch table that `CppCode`, `CCode`, `ObjcCode` and `MozcppCode` suppress on identical grounds, and removing `cyclomatic` from the marker re-trips the gate, so the marker is load-bearing rather than decoration. No integration-snapshot churn: the C# corpus carries no operator overload, pdf.js no JSX, and no Lua, Kotlin or Perl file is snapshotted. Fixes #1297 --- big-code-analysis-book/src/metrics.md | 1 + docs/development/lessons_learned.md | 13 +- src/metrics/abc.rs | 262 +++++++++++++++++++++++++- src/metrics/abc/csharp.rs | 69 +++++-- src/metrics/abc/js_family.rs | 52 +++-- src/metrics/abc/kotlin.rs | 32 +++- src/metrics/abc/lua.rs | 18 +- src/metrics/abc/objc.rs | 9 +- src/metrics/abc/perl.rs | 43 ++++- 9 files changed, 446 insertions(+), 53 deletions(-) diff --git a/big-code-analysis-book/src/metrics.md b/big-code-analysis-book/src/metrics.md index 994634a45..11205c291 100644 --- a/big-code-analysis-book/src/metrics.md +++ b/big-code-analysis-book/src/metrics.md @@ -151,6 +151,7 @@ application would over-count. | Ruby | Bare-predicate `if` / `unless` / `while` / `until` (block and modifier forms) count one condition | Idiomatic Ruby favours bare predicates (`if flag`, `x if flag`); counting the condition slot keeps ABC conditions at or above Ruby's cyclomatic decision count (the alignment enforced across the other languages). A comparison (`if a == b`) or `&&` / `\|\|` chain in the predicate is counted by its own operator / walker arm and is not double-counted. | | Bash | `if` / `elif` / `while` and each non-wildcard `case` arm count one condition | A Bash predicate is a *command*, so the branch keyword itself — not an embedded boolean expression — is the condition signal. Each matches a Bash cyclomatic decision; the bare `*)` case arm (the analogue of `default:`) is excluded, mirroring the cyclomatic standard count. The arithmetic ternary `$(( a ? b : c ))` therefore contributes nothing: it carries no branch keyword, so it falls outside the rule set rather than through a gap in it. | | Kotlin | `try` counts a condition alongside `catch` | Fitzpatrick counts both keywords, and Java / C# / C++ / Groovy already count both; Kotlin previously counted only the catch block. | +| C++, Objective-C, Mozcpp, Rust, Go, Java, Groovy, C#, Kotlin, TypeScript, TSX, JavaScript, Mozjs, Lua, Perl, Ruby, Bash, Elixir | A `<` or `>` that is not a comparison is not a condition | Every one of these grammars spells at least one non-comparison construct with the same bare `<` / `>` token a comparison uses, so the comparison rule is gated on the token's parent. What that excludes, per family: template and generic brackets in C++, Objective-C, Mozcpp, Rust, Go, Java, Groovy, C#, Kotlin, TypeScript and TSX (#1274); JSX tag delimiters in TypeScript, TSX, JavaScript and Mozjs; Lua 5.4 variable attributes (`local x = 1`); a C# comparison-operator overload's declared name (`operator <`); Kotlin's qualified super call (`super.g()`) (#1297); Perl's filehandle and lexical-handle readlines (``, `<$fh>` — but not ``, which the grammar lexes as one token); Ruby's superclass clause (`class Foo < Bar`) and comparison-operator method names (`def <(other)`), and Bash I/O redirection (`cmd > out`) (#1280); Elixir's sigil delimiters (`~s`) (#1256). C# additionally *keeps* the operator of a relational pattern (`x is > 0`), a genuine comparison that lives outside `binary_expression`. PHP, Python, Tcl and iRules emit a bare `<` / `>` from no non-comparison production; C carries the same gate as its C-family siblings although, having no templates, it has nothing to exclude. The gate is a claim about the grammar's productions, not about every parse: where a grammar resolves a generic *call* into nested `binary_expression` nodes, as tree-sitter-kotlin-ng does for `id(a)`, no polarity can exclude it (#1394). | | Java, Groovy, C#, TypeScript, TSX | A `?` used as type syntax is not a ternary | In each of these grammars the ternary `?` and the type-syntax `?` are the *same* anonymous token, so the ternary rule above is gated on the token's parent. Java and Groovy exclude the wildcard bound `List` (#1274); C# excludes the nullable type `int? x` and the constraint `where T : class?`; TypeScript and TSX exclude optional parameters, properties, methods, class fields and tuple elements, and conditional types (`T extends U ? X : Y`, which the type checker resolves and erases before runtime, so it is no more a branch than the `<` / `>` already excluded) (#1275). Safe navigation is untouched: C#'s `a?.b` shares the same token and still counts, while the other languages spell theirs as a distinct one. | #### Worked example diff --git a/docs/development/lessons_learned.md b/docs/development/lessons_learned.md index 8135a927c..753543ee7 100644 --- a/docs/development/lessons_learned.md +++ b/docs/development/lessons_learned.md @@ -3492,10 +3492,15 @@ a denylist must name separately. Both were rewritten to require a `binary_expression` parent. Ruby then arrived with two roles nobody would have enumerated in advance — a superclass clause (`class Foo < Bar`) and an operator-method name (`def <(other)`) — and -the positive gate covered both without being told about either. **C#, -Kotlin and the JS family still carry the denylist form**, so they are -correct only for the roles someone thought of: the state Java and Groovy -were in before #1274. +the positive gate covered both without being told about either. C#, +Kotlin and the JS family kept the denylist form for another two issues, +and it admitted a fresh role in each: JSX tag delimiters in TypeScript, +TSX, JavaScript and Mozjs, a `super_expression` in Kotlin, an +`operator_declaration` in C#, and a Lua 5.4 variable attribute where +there was no gate at all (#1297). **Every one of those roles was +invisible until someone wrote the language's construct down** — which is +the whole argument for the polarity: the allowlist needs to know only +what a comparison is, and that set does not grow. **The same token needed opposite polarities eight lines apart** (#1275, following #1274). A `?` is the ternary operator and, in C#, also diff --git a/src/metrics/abc.rs b/src/metrics/abc.rs index 0a27d6633..b73a134ba 100644 --- a/src/metrics/abc.rs +++ b/src/metrics/abc.rs @@ -3551,9 +3551,10 @@ mod tests { #[test] fn csharp_function_pointer_type_no_double_count() { // EC1 extension — `<` and `>` are also parameter-list delimiters - // for unsafe function-pointer types. `FunctionPointerType` must - // be in the LT/GT exclusion list, otherwise these brackets - // accumulate spurious `conditions` counts. + // for unsafe function-pointer types. Since #1297 the arm is an + // allowlist, so `FunctionPointerType` is excluded by being + // absent from it rather than by being named in a deny set; + // either way these brackets must not accumulate `conditions`. check_metrics::( "unsafe class A { public delegate* Adder; @@ -3585,6 +3586,67 @@ mod tests { ); } + // #1297: `operator <` names the operator a type *defines*; applying + // one is what a decision looks like. tree-sitter-c-sharp spells the + // declaration's operator with the same bare token as a comparison, + // and the previous denylist named only `type_argument_list`, + // `type_parameter_list` and `function_pointer_type`, so every + // comparison-operator overload scored a condition. + // + // The fixture carries the two overloads plus one `a < b` inside a + // `binary_expression` and one `x is > 0` inside a + // `relational_pattern`, which is the second decision parent in the + // allowlist. Every mis-aim lands on its own number: 5 pre-fix, 3 + // once both decision parents are allowed, 2 if `RelationalPattern` + // is dropped, 1 if the gate swallows `BinaryExpression` too (only + // the ternary `?` survives), 0 if the fixture stops parsing. + #[test] + fn csharp_operator_declaration_is_not_a_condition() { + check_metrics::( + "class V { + public static bool operator <(V a, V b) { return true; } + public static bool operator >(V a, V b) { return true; } + int m(int a, int b, int x) { + if (a < b) { return 1; } + return x is > 0 ? 2 : 3; + } + }", + "foo.cs", + |metric| { + assert_eq!(metric.abc.conditions_sum(), 3); + }, + ); + } + + // The `RelationalPattern` half of the allowlist above, on its own + // input. Per `.claude/rules/grammar-dispatch.md` §11, the fixture + // above cannot prove that entry alone: its `is > 0` sits beside a + // `binary_expression` comparison, so a gate allowing only + // `BinaryExpression` would still leave a non-zero, plausible total. + // Here the relational operators are the *only* `<` / `>` in the + // file, so the entry is the only thing that can produce the count. + // + // 4, not 2: a `switch_expression_arm` is counted by its own arm + // above and the pattern's operator by this one, so a relational arm + // scores twice what the constant arm `5 => 1` scores. That + // divergence from C#'s own cyclomatic decision count predates + // #1297 — the old denylist did not name `relational_pattern` + // either — and is filed as #1383 rather than changed here, which is + // why this asserts the value the gate preserves rather than the §8 + // parity value. + #[test] + fn csharp_relational_pattern_still_counts_as_a_condition() { + check_metrics::( + "class A { + int n(int x) => x switch { > 5 => 1, < 0 => 2, _ => 3 }; + }", + "foo.cs", + |metric| { + assert_eq!(metric.abc.conditions_sum(), 4); + }, + ); + } + // #1275: tree-sitter-c-sharp spells `int?`, `where T : class?`, the // ternary and `a?.b` with one and the same bare `?` token, so the // unguarded `QMARK` arm scored the two type-syntax forms as @@ -4458,6 +4520,38 @@ function f(int $a, int $b): int { ); } + // #1297: a qualified super call disambiguates its supertype with + // the same two bare tokens a comparison uses — `super.g()` is a + // `super_expression`, which the previous denylist + // (`type_arguments` / `type_parameters`) did not name, so it scored + // two conditions. One `super` against one genuine `a < b` + // separates every mis-aim: 3 pre-fix, 1 once the arm allows + // `BinaryExpression`, 0 if it allows the wrong parent or the + // fixture stops parsing. + #[test] + fn kotlin_super_type_argument_is_not_a_condition() { + check_metrics::( + "class B : A() { + override fun g(a: Int, b: Int): Int { + if (a < b) { return super.g(a, b) } + return 0 + } + }", + "foo.kt", + |metric| { + assert_eq!(metric.abc.conditions_sum(), 1); + // Non-vacuity guard: 1 is also what a body whose `if` + // survived but whose super call did not would score, so + // pin the call itself. Measured: dropping + // `super.g(a, b)` takes `branches_sum()` to 0 — the + // `A()` primary-constructor delegation in the class + // header contributes none, which is #1384 and not this + // test's subject. + assert_eq!(metric.abc.branches_sum(), 1); + }, + ); + } + #[test] fn kotlin_class_with_methods_and_branches() { check_metrics::( @@ -5017,6 +5111,44 @@ function f(int $a, int $b): int { ); } + // #1297, TypeScript half: the `<` / `>` arm flipped from denying + // `type_arguments` / `type_parameters` to allowing + // `binary_expression`, and this pins the direction that flip could + // have broken. `Array` keeps a generic in scope so the + // fixture proves both sides at once, and the assertion is the + // grammar-dispatch §8 pin: on the function's own space the ABC + // condition count equals the cyclomatic decision count + // (`cyclomatic()` minus the per-space base of 1). Both are 2, one + // per `if`. + // + // TypeScript gets no JSX fixture because the `.ts` dialect has no + // JSX to exercise: it lexes `return
…
` as a type + // assertion and a comparison chain, sometimes with `ERROR` nodes and + // sometimes without, depending on what follows the tag. Either way + // the tokens land under `type_arguments` and `binary_expression` + // rather than the `jsx_*` productions, so the construct that + // motivated #1297 is only reachable through + // `tsx_jsx_elements_are_not_conditions` below. + // + // That mis-parse still over-counts — a `.ts` file containing JSX + // scores conditions this gate cannot exclude, because the grammar + // genuinely reports a `binary_expression`. It is a wrong-dialect + // input rather than a defect in this arm, and no allowlist can + // distinguish it; see the same caveat on Kotlin's arm. + #[test] + fn typescript_comparison_operators_still_count_alongside_generics() { + check_func_space::( + "function m(xs: Array, a: number, b: number): number { + if (a < b) { return 1; } + if (a > b) { return 2; } + void xs; + return 0; + }", + "foo.ts", + |space| assert_deepest_conditions_match_cyclomatic(&space, 2), + ); + } + // #1275, TypeScript half. Eleven grammar productions emit a bare // `?` and only `ternary_expression` is a decision; the other ten are // type syntax. This fixture exercises five of them — `optional_ @@ -5334,6 +5466,33 @@ function f(int $a, int $b): int { ); } + // #1297: a JSX tag delimiter is markup punctuation, not a + // comparison. The `<` / `>` arm denied only `type_arguments` and + // `type_parameters`, so every tag bracket scored a condition — + // `jsx_opening_element` contributes both tokens, `jsx_closing_element` + // a `>` (its `` is one token). + // + // The fixture pairs seven such brackets — two elements' opening and + // closing tags, plus one self-closing tag — against one genuine + // `a < b`, so every way of mis-aiming the gate lands on its own + // number: 8 pre-fix, 1 once it allows `BinaryExpression`, 0 if it + // allows the wrong parent or the fixture stops parsing. Asserting 0 + // on a JSX-only body would not have separated those last two. + #[test] + fn tsx_jsx_elements_are_not_conditions() { + check_metrics::( + "function f(a: number, b: number) { + if (a < b) { return
hi
; } + return
; + }", + "foo.tsx", + |metric| { + assert_eq!(metric.abc.conditions_sum(), 1); + }, + ); + } + // #1275 in the second expansion of `ts_abc_compute!`. TSX shares // TypeScript's `?` productions and its own `TernaryExpression` / // `QMARK` ids, so the gate is a distinct instantiation and needs its @@ -8607,6 +8766,27 @@ function f(int $a, int $b): int { ); } + // #1297 in the `js_abc_compute!` expansion, where `LT` / `GT` had + // no gate at all: plain JavaScript has no generics, but + // tree-sitter-javascript parses JSX unconditionally, so the same + // seven tag brackets that + // `tsx_jsx_elements_are_not_conditions` covers scored seven + // conditions in a `.js` file too. Same fixture minus the type + // annotations, same discriminating numbers: 8 pre-fix, 1 after. + #[test] + fn javascript_jsx_elements_are_not_conditions() { + check_metrics::( + "function f(a, b) { + if (a < b) { return
hi
; } + return
; + }", + "foo.js", + |metric| { + assert_eq!(metric.abc.conditions_sum(), 1); + }, + ); + } + #[test] fn javascript_number_truthy_condition_counts() { // Regression for #772: JS treats every non-zero number as @@ -9148,6 +9328,28 @@ function f(int $a, int $b): int { ); } + #[test] + fn mozjs_jsx_elements_are_not_conditions() { + // #1297 in the second expansion of `js_abc_compute!`. The + // vendored mozjs fork carries the same three JSX productions as + // upstream tree-sitter-javascript with its own `kind_id`s, so + // the gate is a distinct instantiation and needs its own + // fixture — a passing JavaScript test says nothing about the + // macro's other expansion. Same shape and same discriminating + // numbers as `javascript_jsx_elements_are_not_conditions`: 8 + // pre-fix, 1 after. + check_metrics::( + "function f(a, b) { + if (a < b) { return
hi
; } + return
; + }", + "foo.js", + |metric| { + assert_eq!(metric.abc.conditions_sum(), 1); + }, + ); + } + #[test] fn mozjs_if_boolean_literal_condition() { check_metrics::( @@ -9445,6 +9647,38 @@ function f(int $a, int $b): int { ); } + // #1297's own sweep cleared Perl, and it was wrong. `` does + // lex as a single `standard_input` token and scores 0, which is + // what the issue measured — but the filehandle and lexical-handle + // readlines do not: `` is `standard_input_to_identifier` and + // `<$fh>` is `standard_input_to_variable`, each a plain three-token + // sequence whose brackets are the same bare `<` / `>` a comparison + // uses, and the arm was ungated. Each scored two phantom + // conditions. + // + // All three readline spellings against two genuine comparisons, so + // every mis-aim lands on its own number: 6 pre-fix, 2 once the arm + // allows `BinaryExpression`, 0 if it allows the wrong parent or the + // fixture stops parsing. The assertion is also the grammar-dispatch + // §8 pin — `cyclomatic()` is 3 on this space, so decisions is 2 and + // the two counts agree exactly. + #[test] + fn perl_readline_angle_brackets_are_not_conditions() { + check_func_space::( + "sub g {\n\ + my ($a, $b, $fh) = @_;\n\ + my $l = ;\n\ + my $m = <$fh>;\n\ + my $n = ;\n\ + if ($a < $b) { return 1; }\n\ + if ($a > $b) { return 2; }\n\ + return 0;\n\ + }", + "foo.pl", + |space| assert_deepest_conditions_match_cyclomatic(&space, 2), + ); + } + #[test] fn perl_numeric_and_string_comparisons_count_conditions() { // Numeric ops `==`, `!=`, `<`, `>`, `<=`, `>=`, `<=>` and @@ -9950,6 +10184,28 @@ function f(int $a, int $b): int { ); } + // #1297: Lua 5.4 brackets a variable attribute with the same two + // bare tokens a comparison uses, and the `<` / `>` arm had no gate, + // so `local x = 1` scored two conditions. Two attributes + // against one genuine `a < b` separate every mis-aim: 5 pre-fix, 1 + // once the arm allows `BinaryExpression`, 0 if it allows the wrong + // parent or the fixture stops parsing. + #[test] + fn lua_variable_attributes_are_not_conditions() { + check_metrics::( + "local function f(a, b)\n\ + local x = 1\n\ + local y = nil\n\ + if a < b then return x end\n\ + return y\n\ + end", + "foo.lua", + |metric| { + assert_eq!(metric.abc.conditions_sum(), 1); + }, + ); + } + #[test] fn lua_elseif_and_else_count_conditions() { // Each elseif / else arm of the if contributes one diff --git a/src/metrics/abc/csharp.rs b/src/metrics/abc/csharp.rs index 43cedded0..2b5a25e16 100644 --- a/src/metrics/abc/csharp.rs +++ b/src/metrics/abc/csharp.rs @@ -108,10 +108,12 @@ fn csharp_count_unary_conditions(list_node: &Node, conditions: &mut f64) { // C#-specific deltas: every aliased kind id is matched via the // `csharp_*_kinds!()` macros (lesson #2); `ObjectCreationExpression` // joins `InvocationExpression*` as a branch; the `<` / `>` parent -// guard widens to `TypeArgumentList | TypeParameterList | -// FunctionPointerType`; `ConditionalExpression` replaces Java's -// `TernaryExpression`; `for_statement` exposes its condition via the -// named `condition` field rather than positional index. +// allowlist names `RelationalPattern` alongside the two +// `BinaryExpression` ids, the only such second entry in the workspace, +// because a C# comparison can sit outside a binary expression; +// `ConditionalExpression` replaces Java's `TernaryExpression`; +// `for_statement` exposes its condition via the named `condition` +// field rather than positional index. // Whether `eq_node` initialises a `const` binding — a compile-time // constant, so its initializer is part of the declaration and not an @@ -285,20 +287,55 @@ fn csharp_count_token_condition<'a>( { stats.conditions += 1.; } - // Excludes `<` and `>` used as type-syntax delimiters: generic - // type arguments (`Dictionary`), type parameter - // declarations (`class Foo { }`), and the parameter-list - // delimiters of unsafe function-pointer types - // (`delegate*`). - GT | LT => { - if let Some(parent) = ancestors.parent(node) - && !matches!( + // Counts `<` / `>` only where they are a comparison. A + // `grammar.json` sweep of tree-sitter-c-sharp 0.23.5 finds a + // bare `<` / `>` in exactly six productions, and two of them + // are decisions: `binary_expression` (`a < b`) and + // `relational_pattern` (`x is > 0`, and the `> 5 =>` arm of a + // switch expression). The other four are type syntax — + // `type_argument_list` (`Dictionary`), + // `type_parameter_list` (`class Foo`), `function_pointer_type` + // (`delegate*`) and `operator_declaration` + // (`public static bool operator <(V a, V b)`), whose `<` names + // the operator being *defined* rather than applying it. + // + // The previous denylist named three of those four and not + // `operator_declaration`, so every comparison-operator overload + // scored a condition per declaration (#1297). Allowlist + // polarity, matching Java's #1274 fix and unlike the `QMARK` + // arm above: `<` / `>` have two decision parents against four + // type-syntax ones, and a grammar bump that grows a seventh + // production should fail closed + // (`.claude/rules/grammar-dispatch.md` §1). The `QMARK` arm + // takes the opposite polarity for a reason specific to that + // token — see its comment. + // + // `relational_pattern` is in the allowlist to preserve the + // count, not to add it: the previous denylist did not name it + // either, so the pattern's operator counted then and counts + // now. That count double-charges the enclosing + // `switch_expression_arm` or `if` condition slot, a + // pre-existing divergence from C#'s own cyclomatic decision + // count tracked in #1383 — settling it here would have been an + // unmeasured behaviour change riding along with #1297. + // + // `BinaryExpression2` is the id the grammar aliases + // `preproc_binary_expression` to, and it is listed defensively + // per §1 rather than because it is reachable: C#'s preprocessor + // admits only `== != && || !`, so no bare `<` / `>` can have + // that parent at this pin. It is genuinely reachable in the + // C / C++ arms this one mirrors, where `#if A < B` is legal. + // `<=` / `>=` and the shifts are distinct tokens and never + // reach this arm. + GT | LT + if ancestors.parent(node).is_some_and(|parent| { + matches!( parent.kind_id().into(), - TypeArgumentList | TypeParameterList | FunctionPointerType + BinaryExpression | BinaryExpression2 | RelationalPattern ) - { - stats.conditions += 1.; - } + }) => + { + stats.conditions += 1.; } _ => return false, } diff --git a/src/metrics/abc/js_family.rs b/src/metrics/abc/js_family.rs index 14b568560..0180b9957 100644 --- a/src/metrics/abc/js_family.rs +++ b/src/metrics/abc/js_family.rs @@ -392,14 +392,29 @@ macro_rules! ts_abc_compute { QMARK if ancestors.parent_has_kind(node, TernaryExpression as u16) => { stats.conditions += 1.; } - // `<` and `>` may also delimit type arguments / type - // parameters (`Array`, `class Foo {}`); skip - // those, count only comparison usage. - GT | LT - if ancestors.parent(node).is_some_and(|p| { - !matches!(p.kind_id().into(), TypeArguments | TypeParameters) - }) => - { + // Counts `<` / `>` only as the operator token of a + // `binary_expression`, the allowlist polarity C / C++ / + // Rust / Go / Java use. The previous denylist named + // `type_arguments` and `type_parameters` only, so every + // JSX tag delimiter scored a condition: a + // `jsx_opening_element` contributes `<` and `>`, a + // `jsx_closing_element` a `>` (its `` is + // one token) — six conditions for + // `
hi
` with no + // decision in it (#1297). A `grammar.json` sweep of + // tree-sitter-typescript 0.23.2 finds a bare `<` / `>` + // in exactly six productions — `binary_expression`, the + // three JSX ones, `type_arguments` and + // `type_parameters` — so the allowlist is the inverse of + // a five-entry denylist today and, unlike it, needs no + // revisiting when the grammar grows a seventh + // (`.claude/rules/grammar-dispatch.md` §1). + // + // `<=` / `>=` are the distinct `LTEQ` / `GTEQ` tokens + // counted above, and the shifts (`<<`, `>>`, `>>>`) are + // their own tokens, so none of them reaches this arm. + GT | LT if ancestors.parent_has_kind(node, BinaryExpression as u16) => { stats.conditions += 1.; } // Fitzpatrick Rule 9: each operand of a `&&` / `||` @@ -486,8 +501,10 @@ impl Abc for TsxCode { // token-level Fitzpatrick rules as `ts_abc_compute!`, with two // adjustments: // -// 1. `LT` / `GT` are always comparison operators in plain JS — there -// are no `TypeArguments` / `TypeParameters` nodes to gate against. +// 1. `LT` / `GT` take the same `binary_expression` gate, against a +// shorter list of non-comparison producers: plain JS has no +// `TypeArguments` / `TypeParameters`, but it does have the three +// JSX productions (#1297). // 2. JS runs the same `$const_binding` structural check so `const x = 5` // does not count the initializer `=` as an assignment. `let x = 5` // and `var x = 5` DO count their initializer `=` as an assignment — @@ -530,10 +547,23 @@ macro_rules! js_abc_compute { // The `default` arm is the unconditional fallthrough and // is excluded, mirroring cyclomatic's `Case`-only count // (issue #469); see the TS macro above for the rationale. - EQEQ | EQEQEQ | BANGEQ | BANGEQEQ | LTEQ | GTEQ | LT | GT | QMARK | QMARKQMARK + EQEQ | EQEQEQ | BANGEQ | BANGEQEQ | LTEQ | GTEQ | QMARK | QMARKQMARK | Instanceof | Else | Case | Try | Catch => { stats.conditions += 1.; } + // Plain JS has no generics, but it does have JSX, and + // both grammars here parse it unconditionally — a `.js` + // file returning `
hi
` scored six + // conditions from tag delimiters alone (#1297). The + // `grammar.json` sweep finds a bare `<` / `>` in exactly + // four productions in tree-sitter-javascript 0.25.0 and + // in the vendored mozjs fork — `binary_expression` plus + // the same three JSX ones as TypeScript — so this is the + // TS arm above with the two type-syntax productions + // absent. See that arm for the polarity rationale. + GT | LT if ancestors.parent_has_kind(node, BinaryExpression as u16) => { + stats.conditions += 1.; + } // Fitzpatrick Rule 9: each operand of a `&&` / `||` // chain is one condition (issue #403). AMPAMP | PIPEPIPE => { diff --git a/src/metrics/abc/kotlin.rs b/src/metrics/abc/kotlin.rs index a0ff6c7e3..f84a78e9f 100644 --- a/src/metrics/abc/kotlin.rs +++ b/src/metrics/abc/kotlin.rs @@ -242,13 +242,31 @@ impl Abc for KotlinCode { Else if ancestors.parent_has_kind(node, IfExpression as u16) => { stats.conditions += 1.; } - // `<` and `>` may appear as type-argument brackets - // (`List`); exclude those by checking the parent kind. - LT | GT - if ancestors.parent(node).is_some_and(|p| { - !matches!(p.kind_id().into(), TypeArguments | TypeParameters) - }) => - { + // Counts `<` / `>` only as the operator token of a + // `binary_expression`, the allowlist polarity C / C++ / + // Rust / Go / Java use. The previous denylist named + // `type_arguments` and `type_parameters` only, so a + // qualified super call — `super
.g()`, which brackets the + // disambiguating supertype with the same two bare tokens — + // scored two conditions (#1297). A `grammar.json` sweep of + // tree-sitter-kotlin-ng 1.1.0 finds a bare `<` / `>` in + // exactly four productions: `binary_expression`, + // `super_expression`, `type_arguments` and + // `type_parameters`. `<=` / `>=` are the distinct `LTEQ` / + // `GTEQ` tokens counted above. + // + // The enumeration is a claim about the grammar, not about + // every parse it produces. This grammar resolves a generic + // *call* the wrong way: `id(a)` comes back as nested + // `binary_expression` nodes (`id < Int`, then `> (a)`), not + // as `type_arguments`, so both brackets satisfy this gate + // and the call still scores two conditions (#1394). No + // polarity can exclude that — the parse tree genuinely says + // `binary_expression`. Groovy's arm records the mirror-image + // case, where an explicit type witness lands under `ERROR`; + // C# and TypeScript resolve the same call shape correctly + // and score it 0. + LT | GT if ancestors.parent_has_kind(node, BinaryExpression as u16) => { stats.conditions += 1.; } // Fitzpatrick Rule 9 walker: each non-comparison operand of a diff --git a/src/metrics/abc/lua.rs b/src/metrics/abc/lua.rs index 48c8c1223..8bdecc8b0 100644 --- a/src/metrics/abc/lua.rs +++ b/src/metrics/abc/lua.rs @@ -127,14 +127,28 @@ impl Abc for LuaCode { } Lua::EQEQ | Lua::TILDEEQ - | Lua::LT - | Lua::GT | Lua::LTEQ | Lua::GTEQ | Lua::ElseifStatement | Lua::ElseStatement => { stats.conditions += 1.; } + // Counts `<` / `>` only as the operator token of a + // `binary_expression`, the allowlist polarity C / C++ / + // Rust / Go / Java use. Ungated, Lua 5.4's to-be-closed and + // constant attributes scored two conditions apiece: + // `local x = 1` brackets the attribute name with the + // same two bare tokens a comparison uses (#1297). A + // `grammar.json` sweep of tree-sitter-lua 0.5.0 finds them + // in exactly two productions — `binary_expression` and the + // hidden `_attrib`, which surfaces aliased as `attribute` — + // so the gate is closed rather than a coverage claim + // (`.claude/rules/grammar-dispatch.md` §1). `<=` / `>=` and + // the 5.3 shifts `<<` / `>>` are distinct tokens and never + // reach this arm. + Lua::LT | Lua::GT if ancestors.parent_has_kind(node, Lua::BinaryExpression as u16) => { + stats.conditions += 1.; + } // Fitzpatrick Rule 9 walker: each operand of an `and` / // `or` chain is one condition (issue #403). Lua::And | Lua::Or => { diff --git a/src/metrics/abc/objc.rs b/src/metrics/abc/objc.rs index 81682c933..f27868a56 100644 --- a/src/metrics/abc/objc.rs +++ b/src/metrics/abc/objc.rs @@ -71,8 +71,13 @@ impl Abc for ObjcCode { stats.conditions += 1.; } // Plain `<` / `>` count only in comparison position (parent is - // a `binary_expression`). ObjC has no templates, but the parent - // check is kept for parity with the C/C++ impl. + // a `binary_expression`). ObjC has no C++ templates, but the + // gate is not merely parity with the C/C++ impl: a + // `grammar.json` sweep of tree-sitter-objc finds a bare + // `<` / `>` in `generic_specifier`, + // `protocol_reference_list` (`id`), + // `parameterized_arguments` and `argument_list`, every one + // of which this gate is what excludes. LT | GT if ancestors.parent(node).is_some_and(|p| { matches!(p.kind_id().into(), BinaryExpression | BinaryExpression2) diff --git a/src/metrics/abc/perl.rs b/src/metrics/abc/perl.rs index 9002f8165..ecfdd99da 100644 --- a/src/metrics/abc/perl.rs +++ b/src/metrics/abc/perl.rs @@ -280,13 +280,20 @@ impl Abc for PerlCode { ancestors: Ancestors<'a, '_>, stats: &mut Stats, ) { - // bca: suppress(halstead) + // bca: suppress(halstead, cyclomatic) // Exhaustive one-arm-per-grammar-kind dispatch table; see the - // rationale on `CppCode::compute`. Perl's arm list is the - // longest of the family — tree-sitter-perl tokenises all - // nineteen assignment operators and all six call-expression - // wrappers separately — so `halstead.effort` here is a count of - // distinct enum operands, not of reasoning a reader must do. + // rationale on `CppCode::compute`, which carries both markers + // for the same construct, as do `CCode`, `ObjcCode` and + // `MozcppCode`. Perl's arm list is the longest of the family — + // tree-sitter-perl tokenises all nineteen assignment operators + // and all six call-expression wrappers separately — so + // `halstead.effort` here is a count of distinct enum operands, + // and the cyclomatic count is the number of node kinds the + // grammar can hand us, neither being reasoning a reader must + // do. Adding the guarded `<` / `>` arm for #1297 took the + // count from 14 to 15; the arm is independent and + // self-describing like every other, and there is no semantic + // boundary to split this lookup on. use Perl as P; match node.kind_id().into() { @@ -349,8 +356,6 @@ impl Abc for PerlCode { // `elsif` / `else` clause of an `if` / `unless` chain. P::EQEQ | P::BANGEQ - | P::LT - | P::GT | P::LTEQ | P::GTEQ | P::LTEQGT @@ -367,6 +372,28 @@ impl Abc for PerlCode { | P::ElseClause => { stats.conditions += 1.; } + // Counts `<` / `>` only as the operator token of a + // `binary_expression`, the allowlist polarity the rest of + // the workspace uses. Ungated, a readline scored two + // conditions: `` and `<$fh>` are + // `standard_input_to_identifier` and + // `standard_input_to_variable`, each a plain three-token + // sequence whose brackets are the same bare `<` / `>` a + // comparison uses. A `grammar.json` sweep of + // tree-sitter-perl 1.1.2 finds them in exactly three + // productions — `binary_expression` plus those two — so the + // gate is closed (`.claude/rules/grammar-dispatch.md` §1). + // + // `` is *not* among them: the grammar lexes it as a + // single `standard_input` token, which is why #1297's sweep + // measured Perl at 0 and wrongly cleared it. The filehandle + // and lexical-handle forms are the reachable ones. + // `<=` / `>=`, the spaceship `<=>` and the word-form `lt` / + // `gt` are distinct tokens counted above, and a heredoc + // opener is its own token, so none reaches this arm. + P::LT | P::GT if ancestors.parent_has_kind(node, P::BinaryExpression as u16) => { + stats.conditions += 1.; + } // Fitzpatrick Rule 9 walker: each operand of a Perl // short-circuit / low-precedence logical chain is one // condition (issue #403). Covers `&&`, `||`, `//`, From c8c8d8c5fb9f5bcdbbf1cbd46f67819f361e8f9f Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Tue, 8 Sep 2026 21:57:39 -0700 Subject: [PATCH 10/20] test(abc): pin the #1297 fixtures against future edits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every #1297 test asserted a condition total that its own fixture could still produce after the excluded construct was deleted from it. The production revert test proves the construct reaches the arm today, but nothing failed if a later edit dropped it, so each test could quietly decay into one that asserts the surviving comparison and nothing else. Anchor the three JSX fixtures on `assignments_sum()`: the `className="x"` attribute `=` is each fixture's only assignment, so removing the JSX now fails. Anchor the Perl fixture the same way — the three readlines are three of the sub's four assignments. Assert the C# claim per space instead of through the file total, which is 3 both with the two operator overloads and without them. The overloads open function spaces, so the test can now say what it means: each overload scores no condition and `m` scores three. Lua and TypeScript get a comment rather than an anchor. Once excluded, a Lua attribute and a TypeScript type argument contribute to no ABC axis and open no space — `local x = 1` and `local x = 1` score identically — so there is nothing to assert, and the revert test is the only coverage available. Saying so stops the next reader from taking the missing anchor for an oversight. Verified by perturbing the fixtures rather than the production code: deleting the JSX from each of the three tests, the readlines from the Perl test, and the overloads from the C# test each fails exactly its own test and nothing else. --- src/metrics/abc.rs | 61 +++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 57 insertions(+), 4 deletions(-) diff --git a/src/metrics/abc.rs b/src/metrics/abc.rs index b73a134ba..5a0eba39f 100644 --- a/src/metrics/abc.rs +++ b/src/metrics/abc.rs @@ -3602,7 +3602,7 @@ mod tests { // the ternary `?` survives), 0 if the fixture stops parsing. #[test] fn csharp_operator_declaration_is_not_a_condition() { - check_metrics::( + check_func_space::( "class V { public static bool operator <(V a, V b) { return true; } public static bool operator >(V a, V b) { return true; } @@ -3612,8 +3612,21 @@ mod tests { } }", "foo.cs", - |metric| { - assert_eq!(metric.abc.conditions_sum(), 3); + |space| { + // Assert the claim per space rather than through the + // file total, which is 3 with the two overloads and 3 + // without them — an aggregate assertion would pass on a + // fixture that had lost the very construct under test. + let class = &space.spaces[0]; + assert_eq!(class.spaces.len(), 3, "two overloads plus `m`"); + for (i, member) in class.spaces.iter().enumerate().take(2) { + assert_eq!( + member.metrics.abc.conditions(), + 0, + "operator declaration {i} must score no condition" + ); + } + assert_eq!(class.spaces[2].metrics.abc.conditions(), 3); }, ); } @@ -5121,6 +5134,11 @@ function f(int $a, int $b): int { // (`cyclomatic()` minus the per-space base of 1). Both are 2, one // per `if`. // + // As with the Lua fixture, `Array` leaves no ABC trace once + // excluded, so nothing here fails if a later edit drops the + // generic; that the generic reaches the arm is established by the + // revert test, which fails this test when the gate is removed. + // // TypeScript gets no JSX fixture because the `.ts` dialect has no // JSX to exercise: it lexes `return
…
` as a type // assertion and a comparison chain, sometimes with `ERROR` nodes and @@ -5489,6 +5507,12 @@ function f(int $a, int $b): int { "foo.tsx", |metric| { assert_eq!(metric.abc.conditions_sum(), 1); + // Fixture-presence anchor: the `className="x"` attribute + // `=` is this fixture's only assignment, so the test + // fails if a later edit drops the JSX. Without it the + // surviving `a < b` alone also scores 1, and the + // conditions assertion would pass while proving nothing. + assert_eq!(metric.abc.assignments_sum(), 1); }, ); } @@ -8783,6 +8807,12 @@ function f(int $a, int $b): int { "foo.js", |metric| { assert_eq!(metric.abc.conditions_sum(), 1); + // Fixture-presence anchor: the `className="x"` attribute + // `=` is this fixture's only assignment, so the test + // fails if a later edit drops the JSX. Without it the + // surviving `a < b` alone also scores 1, and the + // conditions assertion would pass while proving nothing. + assert_eq!(metric.abc.assignments_sum(), 1); }, ); } @@ -9346,6 +9376,12 @@ function f(int $a, int $b): int { "foo.js", |metric| { assert_eq!(metric.abc.conditions_sum(), 1); + // Fixture-presence anchor: the `className="x"` attribute + // `=` is this fixture's only assignment, so the test + // fails if a later edit drops the JSX. Without it the + // surviving `a < b` alone also scores 1, and the + // conditions assertion would pass while proving nothing. + assert_eq!(metric.abc.assignments_sum(), 1); }, ); } @@ -9675,7 +9711,15 @@ function f(int $a, int $b): int { return 0;\n\ }", "foo.pl", - |space| assert_deepest_conditions_match_cyclomatic(&space, 2), + |space| { + assert_deepest_conditions_match_cyclomatic(&space, 2); + // Fixture-presence anchor: the three readlines are + // three of this sub's four assignments (the fourth is + // the `my (...) = @_` unpack), so dropping them from + // the fixture fails here rather than leaving a test + // that still reports 2 conditions and proves nothing. + assert_eq!(space.metrics.abc.assignments_sum(), 4); + }, ); } @@ -10190,6 +10234,15 @@ function f(int $a, int $b): int { // against one genuine `a < b` separate every mis-aim: 5 pre-fix, 1 // once the arm allows `BinaryExpression`, 0 if it allows the wrong // parent or the fixture stops parsing. + // + // No fixture-presence anchor is available, unlike the JSX and Perl + // fixtures: once excluded, a Lua attribute contributes to no ABC + // axis at all (`local x = 1` and `local x = 1` score + // identically on assignments, branches and conditions), and it + // opens no space. Deleting the two attributes would leave this test + // passing on the `a < b` alone. The coverage that the attributes + // are reaching the arm is the revert test: removing the gate makes + // this the only failing test in the suite. #[test] fn lua_variable_attributes_are_not_conditions() { check_metrics::( From 35b203e043bd0eff016e93bd7cab803b48818092 Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Tue, 8 Sep 2026 22:53:46 -0700 Subject: [PATCH 11/20] fix(metrics/loc): bill multi-row literals as code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #778 established that the interior rows of a multi-line string are code, not blank, mirroring Python's #415 decision, and routed thirteen languages through `add_multiline_string_ploc`. Bash, Elixir, Tcl and iRules never got an arm, so in each of them a literal spanning rows reached neither PLOC nor CLOC and `blank = sloc - ploc - cloc` claimed its interior. The issue's four reproductions measured `blank` 2, 2, 1 and 1 on files with no blank rows at all. Route each language's multi-row-capable literals: - Bash: `string`, `raw_string`, `ansi_c_string` and both `heredoc_body` ids. `string` needed an arm despite emitting a `string_content` child per row, because it emits none for a row that is empty *inside* the literal — the shape every other language already counts as code. - Elixir: `quoted_content`, the literal text every string form wraps (`"…"`, `"""` heredoc, `'''` charlist, `~s`/`~S` sigil). Matched by name: the grammar aliases it to twenty ids, and a name comparison survives a twenty-first at a measured +1.3% of walk time. - Tcl and iRules: `quoted_word`. `braced_word` stays out — both grammars parse a braced literal as a script, so routing it would turn every blank line inside every procedure body into code. Elixir's `@doc` / `@moduledoc` heredoc rows are PLOC, not CLOC. Python's carve-out to CLOC is a *bare* string expression statement whose value is discarded — a docstring by position. A module attribute is not that shape: `@doc "…"` is an assignment whose value the compiler stores and `Code.fetch_docs/1` reads back, so its Python analogue is `x = """…"""`, which Python already counts as PLOC. Two fixes to the shared helper fall out of the four new callers: - It no longer takes `end`, deriving the literal's last row from `Node::end_line` instead. A node whose end column is 0 finished at the start of the row below its last content row, so the raw end row is one the literal does not span. No current grammar makes the difference observable — every such row carries a delimiter token that credits it anyway — so this is correct-by-construction, not a behaviour change. - Its parent gate, which skips the opening row on the premise that the enclosing statement owns it, is only safe where the caller's `_` arm credits every node's start row. Bash's is leaf-gated, so a container parent contributes nothing and a childless `raw_string` is the sole node covering its own row: `'ls'` alone in a file reported `ploc 0, blank 1` until Bash's arm inserted the row itself. `bash_heredoc_loc` and `tcl_no_string_lloc` had pinned the defect (`blank 1` on all-code fixtures); both now assert the corrected values, and their snapshots move with them. No integration snapshot moves — the output submodule holds no `.sh`, `.tcl`, `.ex` or `.irule` files. Fixes #1260 --- src/metrics/loc.rs | 417 +++++++++++++++++- src/metrics/loc/bash.rs | 39 +- src/metrics/loc/c.rs | 2 +- src/metrics/loc/cpp.rs | 2 +- src/metrics/loc/csharp.rs | 2 +- src/metrics/loc/elixir.rs | 45 ++ src/metrics/loc/go.rs | 2 +- src/metrics/loc/groovy.rs | 2 +- src/metrics/loc/irules.rs | 12 + src/metrics/loc/java.rs | 2 +- src/metrics/loc/javascript.rs | 2 +- src/metrics/loc/kotlin.rs | 2 +- src/metrics/loc/lua.rs | 2 +- src/metrics/loc/mozcpp.rs | 2 +- src/metrics/loc/mozjs.rs | 2 +- src/metrics/loc/objc.rs | 2 +- src/metrics/loc/perl.rs | 2 +- src/metrics/loc/php.rs | 2 +- src/metrics/loc/ruby.rs | 2 +- src/metrics/loc/rust.rs | 2 +- src/metrics/loc/shared.rs | 31 +- src/metrics/loc/tcl.rs | 24 + src/metrics/loc/tsx.rs | 2 +- src/metrics/loc/typescript.rs | 2 +- ...metrics__loc__tests__bash_heredoc_loc.snap | 16 +- ...trics__loc__tests__tcl_no_string_lloc.snap | 16 +- 26 files changed, 591 insertions(+), 45 deletions(-) diff --git a/src/metrics/loc.rs b/src/metrics/loc.rs index 77f2f1f10..646f542bd 100644 --- a/src/metrics/loc.rs +++ b/src/metrics/loc.rs @@ -4673,6 +4673,53 @@ line3\";", "foo.cpp", assert_three_code_rows, ); + // The four languages #778 missed, added by #1260. Bash's shape is + // the single-quoted `raw_string`; Tcl and its iRules dialect spell + // the literal `quoted_word`; Elixir routes the `quoted_content` + // every one of its string forms wraps. + check_metrics::("s='line1\nline2\nline3'", "foo.sh", assert_three_code_rows); + check_metrics::( + "set s \"line1\nline2\nline3\"", + "foo.tcl", + assert_three_code_rows, + ); + check_metrics::( + "set s \"line1\nline2\nline3\"", + "foo.irule", + assert_three_code_rows, + ); + check_metrics::( + "s = \"line1\nline2\nline3\"", + "foo.ex", + assert_three_code_rows, + ); + } + + #[test] + fn multiline_literal_ending_at_eof_credits_every_row() { + // The #1260 fix lives in exactly the class both metric harnesses + // normalise away: `check_metrics` and the integration + // `read_file_with_eol` path each force a trailing newline, so a + // node ending at EOF is unreachable from either and a test written + // the ordinary way passes against unfixed code (#1051, #1067 — + // `.claude/rules/testing.md`). These fixtures reach the parser + // byte-for-byte through `metrics_verbatim`, with the closing + // delimiter as the last byte of the file. + // + // expected, in all four languages: three physical rows, all code, + // none blank. + for (lang, source) in [ + (crate::LANG::Bash, &b"s='line1\nline2\nline3'"[..]), + (crate::LANG::Tcl, &b"set s \"line1\nline2\nline3\""[..]), + (crate::LANG::Irules, &b"set s \"line1\nline2\nline3\""[..]), + (crate::LANG::Elixir, &b"s = \"line1\nline2\nline3\""[..]), + ] { + let loc = metrics_verbatim(lang, source, MetricsOptions::default()).loc; + assert_eq!(loc.sloc(), 3, "{lang:?} sloc"); + assert_eq!(loc.ploc(), 3, "{lang:?} ploc"); + assert_eq!(loc.cloc(), 0, "{lang:?} cloc"); + assert_eq!(loc.blank(), 0, "{lang:?} blank"); + } } #[test] @@ -6111,20 +6158,86 @@ try { // surrounding command should count. Mirrors lua_no_string_lloc and // elixir_no_string_content_lloc; pins the heredoc-shaped invariant // for Tcl quoted_word bodies. + // + // expected: three physical rows (`set s "line one`, `line two`, + // `line three"`), all code — the interior row of a multi-line + // string is PLOC, not blank (#1260, following #778 / #415). This + // test pinned `ploc 2 / blank 1` until #1260; the row `line two` + // held no node at all under the old arm list, so it reached + // neither PLOC nor CLOC and `blank = sloc - ploc - cloc` claimed + // it. `lloc` is unchanged: only the `set` command counts. check_metrics::( "set s \"line one\nline two\nline three\"", "foo.tcl", |metric| { assert_eq!(metric.loc.sloc(), 3); - assert_eq!(metric.loc.ploc(), 2); + assert_eq!(metric.loc.ploc(), 3); assert_eq!(metric.loc.lloc(), 1); assert_eq!(metric.loc.cloc(), 0); - assert_eq!(metric.loc.blank(), 1); + assert_eq!(metric.loc.blank(), 0); insta::assert_json_snapshot!(metric.loc); }, ); } + #[test] + fn tcl_multiline_quoted_word_credits_every_row_to_ploc() { + // Regression test for #1260. A Tcl `quoted_word` carries no child + // per row — only the two `"` tokens — so its interior rows reached + // neither PLOC nor CLOC and `blank = sloc - ploc - cloc` claimed + // them. + + // expected: 4 rows — `set msg "line one`, `line two`, + // `line three"`, `puts $msg` — all code, none blank. lloc 2 for the + // `set` and the `puts` command. + check_metrics::( + "set msg \"line one\nline two\nline three\"\nputs $msg", + "foo.tcl", + |metric| { + assert_eq!(metric.loc.sloc(), 4); + assert_eq!(metric.loc.ploc(), 4); + assert_eq!(metric.loc.lloc(), 2); + assert_eq!(metric.loc.cloc(), 0); + assert_eq!(metric.loc.blank(), 0); + }, + ); + + // expected: 3 rows whose middle row is empty *inside* the literal — + // `set msg "one`, ``, `three"`. Code, not blank, matching every + // language that already routes a multi-line literal. + check_metrics::("set msg \"one\n\nthree\"", "foo.tcl", |metric| { + assert_eq!(metric.loc.sloc(), 3); + assert_eq!(metric.loc.ploc(), 3); + assert_eq!(metric.loc.cloc(), 0); + assert_eq!(metric.loc.blank(), 0); + }); + } + + #[test] + fn tcl_braced_word_body_blank_rows_stay_blank() { + // The #1260 arm deliberately stops at `quoted_word`. Tcl spells a + // script body and a braced literal with one kind, `braced_word`, + // and the grammar parses both as scripts — `puts {a\n\nb}` yields + // `command` children exactly as a `proc` body does, which is why + // #1318 had to tell the two roles apart out-of-band. Routing it + // would turn every blank line inside every procedure into code. + // + // expected: 5 rows — `proc p {} {`, ` set x 1`, ``, + // ` set y 2`, `}` — with row 3 genuinely blank, so sloc 5, + // ploc 4, blank 1. lloc 3 = the procedure plus two `set` commands. + check_metrics::( + "proc p {} {\n set x 1\n\n set y 2\n}", + "foo.tcl", + |metric| { + assert_eq!(metric.loc.sloc(), 5); + assert_eq!(metric.loc.ploc(), 4); + assert_eq!(metric.loc.lloc(), 3); + assert_eq!(metric.loc.cloc(), 0); + assert_eq!(metric.loc.blank(), 1); + }, + ); + } + #[test] fn javascript_blank() { check_metrics::( @@ -6666,6 +6779,12 @@ function f() { #[test] fn bash_heredoc_loc() { + // expected: six physical rows (`f() {`, `cat <( "f() { cat <( + "cat <("s='one\ntwo\nthree'", "foo.sh", |metric| { + assert_eq!(metric.loc.sloc(), 3); + assert_eq!(metric.loc.ploc(), 3); + assert_eq!(metric.loc.cloc(), 0); + assert_eq!(metric.loc.blank(), 0); + }); + + // expected: 2 rows of an `ansi_c_string` (`$'…'`) — `s=$'one` and + // `two'` — both code, neither blank. + check_metrics::("s=$'one\ntwo'", "foo.sh", |metric| { + assert_eq!(metric.loc.sloc(), 2); + assert_eq!(metric.loc.ploc(), 2); + assert_eq!(metric.loc.cloc(), 0); + assert_eq!(metric.loc.blank(), 0); + }); + + // expected: 3 rows of a double-quoted `string` whose middle row is + // empty — `s="one`, ``, `three"`. The empty row is inside the + // literal, so it is code, not blank: every one of the eighteen + // languages that already route a multi-line literal answers 0 blank + // for this shape, and Bash now agrees. + check_metrics::("s=\"one\n\nthree\"", "foo.sh", |metric| { + assert_eq!(metric.loc.sloc(), 3); + assert_eq!(metric.loc.ploc(), 3); + assert_eq!(metric.loc.cloc(), 0); + assert_eq!(metric.loc.blank(), 0); + }); + + // expected: 4 rows of a quoted, tab-stripping heredoc — the + // `cat <<-'DOC'` opener, one body row, an empty body row, and the + // `DOC` terminator — all code, none blank. `<<-'DOC'` suppresses + // interpolation, which is a distinct grammar path from the bare + // `<("cat <<-'DOC'\n\tline one\n\nDOC", "foo.sh", |metric| { + assert_eq!(metric.loc.sloc(), 4); + assert_eq!(metric.loc.ploc(), 4); + assert_eq!(metric.loc.lloc(), 1); + assert_eq!(metric.loc.cloc(), 0); + assert_eq!(metric.loc.blank(), 0); + }); + + // expected: 2 rows for a heredoc with an empty body — `cat <("cat <("'ls'", "foo.sh", |metric| { + assert_eq!(metric.loc.sloc(), 1); + assert_eq!(metric.loc.ploc(), 1); + assert_eq!(metric.loc.cloc(), 0); + assert_eq!(metric.loc.blank(), 0); + }); + + // expected: the same for an `ansi_c_string`, the other childless + // single-row literal. + check_metrics::("$'ls'", "foo.sh", |metric| { + assert_eq!(metric.loc.sloc(), 1); + assert_eq!(metric.loc.ploc(), 1); + assert_eq!(metric.loc.cloc(), 0); + assert_eq!(metric.loc.blank(), 0); + }); + + // expected: 3 rows — `f() {`, ` 'ls'`, `}` — all code. The literal + // sits inside a function body, so a lost row shows as blank 1 + // rather than as an empty file. + check_metrics::("f() {\n 'ls'\n}", "foo.sh", |metric| { + assert_eq!(metric.loc.sloc(), 3); + assert_eq!(metric.loc.ploc(), 3); + assert_eq!(metric.loc.cloc(), 0); + assert_eq!(metric.loc.blank(), 0); + }); + + // expected: 2 rows — `# c` (comment-only) and `'ls'` (code). This + // also covers the `check_comment_ends_on_code_line` call the gate + // skipped along with the row: cloc stays 1 and blank stays 0. + check_metrics::("# c\n'ls'", "foo.sh", |metric| { + assert_eq!(metric.loc.sloc(), 2); + assert_eq!(metric.loc.ploc(), 1); + assert_eq!(metric.loc.cloc(), 1); + assert_eq!(metric.loc.blank(), 0); + }); + + // expected: 2 rows of a multi-row raw string that is the whole + // command — `'a` and `b'`. Both code. Before #1260 this reported + // `ploc 1` (the opening row only); the gate alone would have + // reported `ploc 1` again, having traded the opening row for the + // closing one. + check_metrics::("'a\nb'", "foo.sh", |metric| { + assert_eq!(metric.loc.sloc(), 2); + assert_eq!(metric.loc.ploc(), 2); + assert_eq!(metric.loc.cloc(), 0); + assert_eq!(metric.loc.blank(), 0); + }); + } + + #[test] + fn a_multiline_literal_credits_no_row_past_its_own_span() { + // The #1260 arms insert a *range* of rows, so the row after the + // literal is the boundary worth pinning: an off-by-one in the range + // end, or a literal node whose span is read one row too wide, would + // pull the following comment row into PLOC. Each fixture puts a + // comment-only row immediately after a closed literal, so that row + // must stay comment-only. + + // expected: 7 rows — `cat <( + "cat <("set a \"x\ny\"\n# note\nset b 1", "foo.tcl", |metric| { + assert_eq!(metric.loc.sloc(), 4); + assert_eq!(metric.loc.ploc(), 3); + assert_eq!(metric.loc.cloc(), 1); + assert_eq!(metric.loc.blank(), 0); + }); + } + #[test] fn kotlin_loc_blank() { check_metrics::( @@ -8251,6 +8554,75 @@ $y = 10 + match ($x) { 1 => 2, default => 0 };", ); } + #[test] + fn elixir_multiline_string_forms_credit_every_row_to_ploc() { + // Regression test for #1260. `quoted_content` — the literal text of + // every Elixir string form — is childless, so the leaf branch of + // the catch-all credited only its opening row and the interior rows + // reached neither PLOC nor CLOC. + + // expected: 3 rows of a plain `"…"` literal — `x = "line one`, + // `line two`, `line three"` — all code, none blank. + check_metrics::( + "x = \"line one\nline two\nline three\"", + "foo.ex", + |metric| { + assert_eq!(metric.loc.sloc(), 3); + assert_eq!(metric.loc.ploc(), 3); + assert_eq!(metric.loc.cloc(), 0); + assert_eq!(metric.loc.blank(), 0); + }, + ); + + // expected: 4 rows of a `'''` charlist whose third row is empty + // inside the literal — `c = '''`, `c1`, ``, `'''`. Code, not blank. + check_metrics::("c = '''\nc1\n\n'''", "foo.ex", |metric| { + assert_eq!(metric.loc.sloc(), 4); + assert_eq!(metric.loc.ploc(), 4); + assert_eq!(metric.loc.cloc(), 0); + assert_eq!(metric.loc.blank(), 0); + }); + + // expected: 4 rows of a `~s"""` sigil heredoc — `s = ~s"""`, `s1`, + // `s2`, `"""`. A sigil is a distinct grammar node from `string` and + // `charlist`; all three wrap the same `quoted_content`, which is + // why one arm covers them. + check_metrics::("s = ~s\"\"\"\ns1\ns2\n\"\"\"", "foo.ex", |metric| { + assert_eq!(metric.loc.sloc(), 4); + assert_eq!(metric.loc.ploc(), 4); + assert_eq!(metric.loc.cloc(), 0); + assert_eq!(metric.loc.blank(), 0); + }); + } + + #[test] + fn elixir_module_attribute_docstring_rows_are_ploc_not_cloc() { + // #1260 had to choose a bucket for `@doc` / `@moduledoc` heredoc + // rows, and this pins it: PLOC. + // + // Python's one carve-out to CLOC is a *bare* string expression + // statement whose value is discarded — a docstring by position. An + // Elixir module attribute is not that shape: `@doc "…"` is an + // assignment whose value the compiler stores and + // `Code.fetch_docs/1` reads back, so its Python analogue is + // `x = """…"""`, which Python counts as PLOC (#415). + // + // expected: 7 rows — `defmodule M do`, ` @doc """`, ` docs one`, + // ` docs two`, ` """`, ` def f, do: :ok`, `end` — all code, none + // blank and none comment. lloc 3 = defmodule + @doc + def. + check_metrics::( + "defmodule M do\n @doc \"\"\"\n docs one\n docs two\n \"\"\"\n def f, do: :ok\nend", + "foo.ex", + |metric| { + assert_eq!(metric.loc.sloc(), 7); + assert_eq!(metric.loc.ploc(), 7); + assert_eq!(metric.loc.lloc(), 3); + assert_eq!(metric.loc.cloc(), 0); + assert_eq!(metric.loc.blank(), 0); + }, + ); + } + #[test] fn elixir_rescue_arm_counts_lloc() { // Each rescue arm's body has a single expression (e.g. `:bad`) @@ -9759,9 +10131,46 @@ class A { }); } + #[test] + fn irules_multiline_quoted_word_credits_every_row_to_ploc() { + // Regression test for #1260, the dialect half of the Tcl fix: a + // `quoted_word` carries no child per row, so its interior rows + // reached neither PLOC nor CLOC and + // `blank = sloc - ploc - cloc` claimed them. + // + // expected: 6 rows — `when HTTP_REQUEST {`, + // ` set msg "line one`, `line two`, `line three"`, + // ` log local0. $msg`, `}` — all code, none blank. lloc 3 for + // the handler, the `set` and the `log` command. + check_metrics::( + "when HTTP_REQUEST {\n set msg \"line one\nline two\nline three\"\n log local0. $msg\n}", + "foo.irule", + |metric| { + assert_eq!(metric.loc.sloc(), 6); + assert_eq!(metric.loc.ploc(), 6); + assert_eq!(metric.loc.lloc(), 3); + assert_eq!(metric.loc.cloc(), 0); + assert_eq!(metric.loc.blank(), 0); + }, + ); + + // expected: 3 rows whose middle row is empty *inside* the literal — + // `set msg "one`, ``, `three"` — code, not blank. + check_metrics::("set msg \"one\n\nthree\"", "foo.irule", |metric| { + assert_eq!(metric.loc.sloc(), 3); + assert_eq!(metric.loc.ploc(), 3); + assert_eq!(metric.loc.cloc(), 0); + assert_eq!(metric.loc.blank(), 0); + }); + } + /// Interior blank lines are counted as BLANK and excluded from PLOC. /// sloc 6 (every line) / ploc 4 (4 code lines) / lloc 3 (handler + 2 /// `set`s) / cloc 0 / blank 2. + /// + /// Also the dialect's half of the #1260 carve-out: the handler body is + /// a `braced_word`, which the grammar parses as a script, so its blank + /// rows stay blank — only `quoted_word` is routed to PLOC. #[test] fn irules_blank() { check_metrics::( diff --git a/src/metrics/loc/bash.rs b/src/metrics/loc/bash.rs index 2e07fc37a..31688de2a 100644 --- a/src/metrics/loc/bash.rs +++ b/src/metrics/loc/bash.rs @@ -14,7 +14,7 @@ use super::*; impl Loc for BashCode { - fn compute(node: &Node, _ancestors: Ancestors<'_, '_>, stats: &mut Stats, is_func_space: bool) { + fn compute(node: &Node, ancestors: Ancestors<'_, '_>, stats: &mut Stats, is_func_space: bool) { use Bash::*; let (start, end) = init(node, stats, is_func_space); @@ -24,6 +24,43 @@ impl Loc for BashCode { Comment => { add_cloc_lines(stats, start, end); } + // Every Bash literal that can span rows, so its interior rows + // reach PLOC instead of being mislabelled blank by + // `blank = sloc - ploc - cloc` (#1260) — the decision #778 took + // for thirteen other languages and #415 took for Python. + // + // `String` looks covered without an arm and is not: the grammar + // emits one `string_content` child per row that *has* text, so + // `"a\n\nb"` credits rows 1 and 3 through those leaves and leaves + // row 2 blank. Every other language routes the whole literal and + // counts an empty interior row as code, so Bash does too. + // `RawString` (`'…'`), `AnsiCString` (`$'…'`) and `HeredocBody` + // are childless, so the leaf-gated `_` arm below reached only + // their opening row. `TranslatedString` (`$"…"`) needs no arm: it + // wraps a `String` that this one already covers. + // + // `HeredocBody2` is the parser-node symbol observed parse trees + // actually carry; the duplicate `HeredocBody` entry is a + // defensive arm that tree-sitter-bash 0.25.1 does not surface — + // `src/checker/bash.rs` records the same finding for `is_string` + // and omits it there (`.claude/rules/grammar-dispatch.md` + // sections 1 and 2). + // + // The two rows this arm inserts before delegating are not + // redundant. `add_multiline_string_ploc` *skips* the opening row + // when the parent starts on it, which is safe only where the + // catch-all credits every node's start row — and Bash's, below, + // is leaf-gated, so a container parent contributes nothing. A + // childless `raw_string` / `ansi_c_string` is then the only node + // covering its own row, and the skip deletes it: `'ls'` alone in + // a file reported `ploc 0, blank 1`. `LineSet::insert` is + // idempotent, so pre-inserting costs nothing where the helper + // would have inserted the row anyway. + String | RawString | AnsiCString | HeredocBody | HeredocBody2 => { + check_comment_ends_on_code_line(stats, start); + stats.ploc.lines.insert(start); + add_multiline_string_ploc(node, ancestors, stats, start); + } // LLOC: leaf statement nodes. Pipeline, Subshell, and // RedirectedStatement are excluded because they wrap inner // Command nodes that are already counted here. diff --git a/src/metrics/loc/c.rs b/src/metrics/loc/c.rs index ddb27630e..42870b1a9 100644 --- a/src/metrics/loc/c.rs +++ b/src/metrics/loc/c.rs @@ -25,7 +25,7 @@ impl Loc for CCode { // span multiple rows; credit every spanned row to PLOC to match // Python's #415 decision (#778). StringLiteral => { - add_multiline_string_ploc(node, ancestors, stats, start, end); + add_multiline_string_ploc(node, ancestors, stats, start); } Comment => { add_cloc_lines(stats, start, end); diff --git a/src/metrics/loc/cpp.rs b/src/metrics/loc/cpp.rs index cc1c5b526..5fe04ee94 100644 --- a/src/metrics/loc/cpp.rs +++ b/src/metrics/loc/cpp.rs @@ -25,7 +25,7 @@ impl Loc for CppCode { // across adjacent lines can span multiple rows; credit every // spanned row to PLOC to match Python's #415 decision (#778). RawStringLiteral | StringLiteral => { - add_multiline_string_ploc(node, ancestors, stats, start, end); + add_multiline_string_ploc(node, ancestors, stats, start); } Comment => { add_cloc_lines(stats, start, end); diff --git a/src/metrics/loc/csharp.rs b/src/metrics/loc/csharp.rs index 5921101dd..35b232365 100644 --- a/src/metrics/loc/csharp.rs +++ b/src/metrics/loc/csharp.rs @@ -28,7 +28,7 @@ impl Loc for CsharpCode { // several rows; credit every spanned row to PLOC to match Python's // #415 decision (#778). VerbatimStringLiteral | RawStringLiteral => { - add_multiline_string_ploc(node, ancestors, stats, start, end); + add_multiline_string_ploc(node, ancestors, stats, start); } BreakStatement | CheckedStatement | ContinueStatement | DoStatement | ExpressionStatement | FixedStatement | ForStatement | ForeachStatement diff --git a/src/metrics/loc/elixir.rs b/src/metrics/loc/elixir.rs index ddc4cb9e4..50fee57b0 100644 --- a/src/metrics/loc/elixir.rs +++ b/src/metrics/loc/elixir.rs @@ -13,6 +13,22 @@ use super::*; +/// The grammar rule holding the literal text of every Elixir string form. +/// +/// Matched by name rather than by `kind_id`: the Elixir grammar aliases +/// this one rule to twenty consecutive ids (`Elixir::QuotedContent` +/// through `QuotedContent20`), and a name comparison stays correct when a +/// grammar bump adds a twenty-first +/// (`.claude/rules/grammar-dispatch.md` section 1). +/// +/// The rule calls that a small runtime cost, and it is: against a +/// twenty-variant `kind_id` pattern over a 36 000-row Elixir file, the +/// name comparison measured +1.3% of total `bca metrics` wall time with +/// identical minima (interleaved A/B, 25 samples each). A first, +/// non-interleaved measurement of the same pair reported +29% — build +/// order and thermal drift, not the comparison. +const QUOTED_CONTENT: &str = "quoted_content"; + impl Loc for ElixirCode { fn compute(node: &Node, ancestors: Ancestors<'_, '_>, stats: &mut Stats, is_func_space: bool) { use Elixir as E; @@ -37,6 +53,35 @@ impl Loc for ElixirCode { // to handle here. E::StabClause => {} + // PLOC: every row a string literal spans. `quoted_content` is + // the literal text of *every* Elixir string form — `"…"`, the + // `"""` heredoc, a `'''` charlist, and both `~s`/`~S` sigil + // shapes all wrap one — and it is childless, so the leaf branch + // of the catch-all below credited only its opening row. The + // interior rows reached neither PLOC nor CLOC and + // `blank = sloc - ploc - cloc` mislabelled them as blank + // (#1260); crediting them to PLOC is the decision #778 took for + // thirteen other languages and #415 took for Python. + // + // That covers `@doc """…"""` and `@moduledoc """…"""` as PLOC, + // deliberately. Python's one carve-out to CLOC is a *bare* + // string expression statement whose value is discarded — a + // docstring by position. An Elixir module attribute is not that + // shape: `@doc "…"` is an assignment whose value the compiler + // stores and `Code.fetch_docs/1` reads back, so its Python + // analogue is `x = """…"""`, which Python already counts as + // PLOC. + // + // Routing it here rather than routing `E::String` keeps the + // LLOC branch below intact: a bare string *is* a valid Elixir + // statement and must keep counting one logical line, while + // `quoted_content`'s parent is always a string / charlist / + // sigil and so never one of the statement containers that + // branch tests for. + _ if node.kind() == QUOTED_CONTENT => { + add_multiline_string_ploc(node, ancestors, stats, start); + } + // LLOC: any named node whose parent is a statement container // is one logical line. This catches `def`/`if`/`case`/`cond` // calls (themselves `Call` nodes at the top level), diff --git a/src/metrics/loc/go.rs b/src/metrics/loc/go.rs index b25e1f5bf..98ab0a2c7 100644 --- a/src/metrics/loc/go.rs +++ b/src/metrics/loc/go.rs @@ -27,7 +27,7 @@ impl Loc for GoCode { // credit every spanned row to PLOC to match Python's #415 // decision (#778). G::RawStringLiteral => { - add_multiline_string_ploc(node, ancestors, stats, start, end); + add_multiline_string_ploc(node, ancestors, stats, start); } G::Comment => { add_cloc_lines(stats, start, end); diff --git a/src/metrics/loc/groovy.rs b/src/metrics/loc/groovy.rs index 50d653981..ab57bb53e 100644 --- a/src/metrics/loc/groovy.rs +++ b/src/metrics/loc/groovy.rs @@ -40,7 +40,7 @@ impl Loc for GroovyCode { // `string_literal` spanning several rows; credit every spanned row // to PLOC to match Python's #415 decision (#778). StringLiteral => { - add_multiline_string_ploc(node, ancestors, stats, start, end); + add_multiline_string_ploc(node, ancestors, stats, start); } // An `ExpressionStatement` whose only child is a bare // `Closure` is a Groovy-specific grammar artifact: the diff --git a/src/metrics/loc/irules.rs b/src/metrics/loc/irules.rs index 27bcb34cd..83653a59f 100644 --- a/src/metrics/loc/irules.rs +++ b/src/metrics/loc/irules.rs @@ -29,6 +29,18 @@ impl Loc for IrulesCode { add_cloc_lines(stats, start, end); } + // Same multi-row-literal gap as the Tcl impl this dialect forked + // from: a quoted word (`"…"`) carries no child per row, so its + // interior rows reached neither PLOC nor CLOC and + // `blank = sloc - ploc - cloc` mislabelled them as blank + // (#1260). `braced_word` stays out for the same reason it does + // in Tcl — the grammar parses a braced literal as a script, so + // routing it would turn every blank line inside every handler + // body into code. + Irules::QuotedWord => { + add_multiline_string_ploc(node, ancestors, stats, start); + } + // Compound-statement headers and dedicated statement productions // each count once. `when_event` / `on_handler` / `trap_handler` // are the iRules handler headers; `for` / `dict_for` are loops diff --git a/src/metrics/loc/java.rs b/src/metrics/loc/java.rs index c00474825..de37465bf 100644 --- a/src/metrics/loc/java.rs +++ b/src/metrics/loc/java.rs @@ -30,7 +30,7 @@ impl Loc for JavaCode { // is a `multiline_string_fragment` spanning several rows; credit // every spanned row to PLOC to match Python's #415 decision (#778). StringLiteral => { - add_multiline_string_ploc(node, ancestors, stats, start, end); + add_multiline_string_ploc(node, ancestors, stats, start); } AssertStatement | BreakStatement | ContinueStatement | DoStatement | EnhancedForStatement | ExpressionStatement | ForStatement | IfStatement diff --git a/src/metrics/loc/javascript.rs b/src/metrics/loc/javascript.rs index efd0f55e7..b653559df 100644 --- a/src/metrics/loc/javascript.rs +++ b/src/metrics/loc/javascript.rs @@ -28,7 +28,7 @@ impl Loc for JavascriptCode { // A `template_string` (`` `…` ``) can span multiple rows; credit // every spanned row to PLOC to match Python's #415 decision (#778). TemplateString => { - add_multiline_string_ploc(node, ancestors, stats, start, end); + add_multiline_string_ploc(node, ancestors, stats, start); } // `StatementBlock` is deliberately absent — see MozjsCode::compute // (#777). It is a brace grouping, not a logical statement. diff --git a/src/metrics/loc/kotlin.rs b/src/metrics/loc/kotlin.rs index ad3d81e45..630b4fb0c 100644 --- a/src/metrics/loc/kotlin.rs +++ b/src/metrics/loc/kotlin.rs @@ -28,7 +28,7 @@ impl Loc for KotlinCode { // spanning several rows; credit every spanned row to PLOC to match // Python's #415 decision (#778). MultilineStringLiteral => { - add_multiline_string_ploc(node, ancestors, stats, start, end); + add_multiline_string_ploc(node, ancestors, stats, start); } ForStatement | WhileStatement | DoWhileStatement | IfExpression | WhenExpression | TryExpression | ThrowExpression | ReturnExpression | Assignment diff --git a/src/metrics/loc/lua.rs b/src/metrics/loc/lua.rs index d13adfa9d..ce94b0cac 100644 --- a/src/metrics/loc/lua.rs +++ b/src/metrics/loc/lua.rs @@ -25,7 +25,7 @@ impl Loc for LuaCode { // credit every spanned row to PLOC to match Python's #415 // decision (#778). Lua::String => { - add_multiline_string_ploc(node, ancestors, stats, start, end); + add_multiline_string_ploc(node, ancestors, stats, start); } // Skip tokens that are children of comment nodes. diff --git a/src/metrics/loc/mozcpp.rs b/src/metrics/loc/mozcpp.rs index bb056feeb..211601d71 100644 --- a/src/metrics/loc/mozcpp.rs +++ b/src/metrics/loc/mozcpp.rs @@ -25,7 +25,7 @@ impl Loc for MozcppCode { // across adjacent lines can span multiple rows; credit every // spanned row to PLOC to match Python's #415 decision (#778). RawStringLiteral | StringLiteral => { - add_multiline_string_ploc(node, ancestors, stats, start, end); + add_multiline_string_ploc(node, ancestors, stats, start); } Comment => { add_cloc_lines(stats, start, end); diff --git a/src/metrics/loc/mozjs.rs b/src/metrics/loc/mozjs.rs index ac28668c5..3e457fc2a 100644 --- a/src/metrics/loc/mozjs.rs +++ b/src/metrics/loc/mozjs.rs @@ -30,7 +30,7 @@ impl Loc for MozjsCode { // A `template_string` (`` `…` ``) can span multiple rows; credit // every spanned row to PLOC to match Python's #415 decision (#778). TemplateString => { - add_multiline_string_ploc(node, ancestors, stats, start, end); + add_multiline_string_ploc(node, ancestors, stats, start); } // `StatementBlock` is a syntactic `{ … }` brace grouping, not a // logical statement, so it is deliberately absent here (#777). It diff --git a/src/metrics/loc/objc.rs b/src/metrics/loc/objc.rs index 5772d6664..be1ee74c6 100644 --- a/src/metrics/loc/objc.rs +++ b/src/metrics/loc/objc.rs @@ -29,7 +29,7 @@ impl Loc for ObjcCode { // credit every spanned row to PLOC to match Python's #415 decision // (#778). ObjC has no raw string literals. StringLiteral => { - add_multiline_string_ploc(node, ancestors, stats, start, end); + add_multiline_string_ploc(node, ancestors, stats, start); } Comment => { add_cloc_lines(stats, start, end); diff --git a/src/metrics/loc/perl.rs b/src/metrics/loc/perl.rs index 84c582278..8847a2faa 100644 --- a/src/metrics/loc/perl.rs +++ b/src/metrics/loc/perl.rs @@ -50,7 +50,7 @@ impl Loc for PerlCode { | P::StringQqQuoted | P::BacktickQuoted | P::CommandQxQuoted => { - add_multiline_string_ploc(node, ancestors, stats, start, end); + add_multiline_string_ploc(node, ancestors, stats, start); } P::Comments | P::PodStatement => { add_cloc_lines(stats, start, end); diff --git a/src/metrics/loc/php.rs b/src/metrics/loc/php.rs index 79bfe3ab4..1f86422e5 100644 --- a/src/metrics/loc/php.rs +++ b/src/metrics/loc/php.rs @@ -30,7 +30,7 @@ impl Loc for PhpCode { // nowdoc bodies already reach PLOC through their inner statement // nodes, so they are not routed here. EncapsedString | String => { - add_multiline_string_ploc(node, ancestors, stats, start, end); + add_multiline_string_ploc(node, ancestors, stats, start); } // Statement kinds that contribute one logical line each. ExpressionStatement diff --git a/src/metrics/loc/ruby.rs b/src/metrics/loc/ruby.rs index 3838eb220..26a7c7aa4 100644 --- a/src/metrics/loc/ruby.rs +++ b/src/metrics/loc/ruby.rs @@ -27,7 +27,7 @@ impl Loc for RubyCode { // body can span several rows; credit every spanned row to PLOC to // match Python's #415 decision (#778). R::String | R::HeredocBody => { - add_multiline_string_ploc(node, ancestors, stats, start, end); + add_multiline_string_ploc(node, ancestors, stats, start); } // LLOC contributors: control-flow constructs, method/class/module // declarations, postfix statement modifiers, and the dedicated diff --git a/src/metrics/loc/rust.rs b/src/metrics/loc/rust.rs index 6b0dd2563..1bdce1a77 100644 --- a/src/metrics/loc/rust.rs +++ b/src/metrics/loc/rust.rs @@ -35,7 +35,7 @@ impl Loc for RustCode { // rows; credit every spanned row to PLOC to match Python's #415 // decision (#778). StringLiteral | RawStringLiteral => { - add_multiline_string_ploc(node, ancestors, stats, start, end); + add_multiline_string_ploc(node, ancestors, stats, start); } BlockComment => { add_cloc_lines(stats, start, end); diff --git a/src/metrics/loc/shared.rs b/src/metrics/loc/shared.rs index 2fdec783c..a33ea3ce4 100644 --- a/src/metrics/loc/shared.rs +++ b/src/metrics/loc/shared.rs @@ -179,17 +179,35 @@ pub(crate) fn add_only_comment_lines(stats: &mut Stats, start: usize, end: usize // all of its rows to PLOC (#415). This helper makes every other language // agree with that decision (#778). // -// Mirrors Python's `String` arm exactly: the opening row is inserted only -// when the enclosing statement begins on an earlier row — otherwise that -// row is already attributed to the parent — and rows `start + 1..=end` -// (the interior and closing rows) are always inserted. +// Mirrors Python's `String` arm: the opening row is inserted only when the +// enclosing statement begins on an earlier row — otherwise that row is +// already attributed to the parent — and the interior and closing rows are +// always inserted. +// +// **That parent gate assumes the caller's `_` arm is ungated.** It is a +// skip, and it is only safe where some *other* node on the opening row +// inserts it — which is guaranteed when the language's catch-all credits +// every node's start row, as it does in all thirteen languages #778 +// covered. Where the catch-all is leaf-gated (`if node.child_count() == 0`, +// as in `bash.rs` and `elixir.rs`) a container parent contributes nothing, +// so a *childless* literal is the only node covering its own row and the +// gate deletes it: `'ls'` alone in a Bash file reported `ploc 0, blank 1` +// (#1260). Elixir is unaffected because a `quoted_content`'s parent always +// carries its opening delimiter as a leaf token on that row; Bash's arm +// inserts the row itself before calling here, and says why. +// +// The last row is derived from [`Node::end_line`] rather than the raw end +// row: a node whose end column is 0 finished at the *start* of the row +// below its last content row, so that row is not part of its span. Using +// the raw row credits a row the literal does not cover — visible as +// `ploc > sloc` for a string left unterminated at EOF, where the literal +// absorbs the trailing newline (#1260). #[inline] pub(crate) fn add_multiline_string_ploc( node: &Node, ancestors: Ancestors<'_, '_>, stats: &mut Stats, start: usize, - end: usize, ) { if ancestors .parent(node) @@ -198,7 +216,8 @@ pub(crate) fn add_multiline_string_ploc( check_comment_ends_on_code_line(stats, start); stats.ploc.lines.insert(start); } - (start.saturating_add(1)..=end).for_each(|line| { + let last_row = node.end_line().saturating_sub(1); + (start.saturating_add(1)..=last_row).for_each(|line| { stats.ploc.lines.insert(line); }); } diff --git a/src/metrics/loc/tcl.rs b/src/metrics/loc/tcl.rs index 1c5c8a41c..18cf28ea1 100644 --- a/src/metrics/loc/tcl.rs +++ b/src/metrics/loc/tcl.rs @@ -31,6 +31,30 @@ impl Loc for TclCode { add_cloc_lines(stats, start, end); } + // A quoted word (`"…"`) is the one Tcl literal that can span + // rows and is unambiguously data. The grammar gives it no child + // per row — only the two `"` tokens and any embedded + // substitution — so its interior rows reached neither PLOC nor + // CLOC and `blank = sloc - ploc - cloc` mislabelled them as + // blank (#1260). Credit every spanned row to PLOC, the decision + // #778 took for thirteen other languages and #415 took for + // Python. + // + // `braced_word` is deliberately *not* routed here. Tcl spells a + // script body and a braced literal with the same kind, and the + // grammar parses both as scripts — `puts {a\n\nb}` yields + // `command` children exactly as a `proc` body does (#1318 had to + // separate the two roles out-of-band, by the enclosing command's + // leading word). Routing it would turn every blank line inside + // every procedure body into code. + // + // The `LF` no-op above is unaffected: `LF` is a token child of + // the root and of `braced_word`, never of a quoted word, so the + // two arms cannot see the same node (#1135). + Tcl::QuotedWord => { + add_multiline_string_ploc(node, ancestors, stats, start); + } + Tcl::Procedure | Tcl::If | Tcl::Elseif diff --git a/src/metrics/loc/tsx.rs b/src/metrics/loc/tsx.rs index ac1486a10..f0ad58f40 100644 --- a/src/metrics/loc/tsx.rs +++ b/src/metrics/loc/tsx.rs @@ -28,7 +28,7 @@ impl Loc for TsxCode { // A `template_string` (`` `…` ``) can span multiple rows; credit // every spanned row to PLOC to match Python's #415 decision (#778). TemplateString => { - add_multiline_string_ploc(node, ancestors, stats, start, end); + add_multiline_string_ploc(node, ancestors, stats, start); } // `StatementBlock` is deliberately absent — see MozjsCode::compute // (#777). It is a brace grouping, not a logical statement. diff --git a/src/metrics/loc/typescript.rs b/src/metrics/loc/typescript.rs index 4b820c738..909c475af 100644 --- a/src/metrics/loc/typescript.rs +++ b/src/metrics/loc/typescript.rs @@ -28,7 +28,7 @@ impl Loc for TypescriptCode { // A `template_string` (`` `…` ``) can span multiple rows; credit // every spanned row to PLOC to match Python's #415 decision (#778). TemplateString => { - add_multiline_string_ploc(node, ancestors, stats, start, end); + add_multiline_string_ploc(node, ancestors, stats, start); } // `StatementBlock` is deliberately absent — see MozjsCode::compute // (#777). It is a brace grouping, not a logical statement. diff --git a/src/metrics/snapshots/big_code_analysis__metrics__loc__tests__bash_heredoc_loc.snap b/src/metrics/snapshots/big_code_analysis__metrics__loc__tests__bash_heredoc_loc.snap index 6cfe14d3d..0bd8362de 100644 --- a/src/metrics/snapshots/big_code_analysis__metrics__loc__tests__bash_heredoc_loc.snap +++ b/src/metrics/snapshots/big_code_analysis__metrics__loc__tests__bash_heredoc_loc.snap @@ -4,23 +4,23 @@ expression: metric.loc --- { "sloc": 6, - "ploc": 5, + "ploc": 6, "lloc": 2, "cloc": 0, - "blank": 1, + "blank": 0, "sloc_average": 3.0, - "ploc_average": 2.5, + "ploc_average": 3.0, "lloc_average": 1.0, "cloc_average": 0.0, - "blank_average": 0.5, + "blank_average": 0.0, "sloc_min": 6, "sloc_max": 6, "cloc_min": 0, "cloc_max": 0, - "ploc_min": 5, - "ploc_max": 5, + "ploc_min": 6, + "ploc_max": 6, "lloc_min": 2, "lloc_max": 2, - "blank_min": 1, - "blank_max": 1 + "blank_min": 0, + "blank_max": 0 } diff --git a/src/metrics/snapshots/big_code_analysis__metrics__loc__tests__tcl_no_string_lloc.snap b/src/metrics/snapshots/big_code_analysis__metrics__loc__tests__tcl_no_string_lloc.snap index ecb16ddb3..428c25985 100644 --- a/src/metrics/snapshots/big_code_analysis__metrics__loc__tests__tcl_no_string_lloc.snap +++ b/src/metrics/snapshots/big_code_analysis__metrics__loc__tests__tcl_no_string_lloc.snap @@ -4,23 +4,23 @@ expression: metric.loc --- { "sloc": 3, - "ploc": 2, + "ploc": 3, "lloc": 1, "cloc": 0, - "blank": 1, + "blank": 0, "sloc_average": 3.0, - "ploc_average": 2.0, + "ploc_average": 3.0, "lloc_average": 1.0, "cloc_average": 0.0, - "blank_average": 1.0, + "blank_average": 0.0, "sloc_min": 3, "sloc_max": 3, "cloc_min": 0, "cloc_max": 0, - "ploc_min": 2, - "ploc_max": 2, + "ploc_min": 3, + "ploc_max": 3, "lloc_min": 1, "lloc_max": 1, - "blank_min": 1, - "blank_max": 1 + "blank_min": 0, + "blank_max": 0 } From b32e594376cd6a8dd8285e09cdbf128ebf6b6381 Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Tue, 8 Sep 2026 23:52:19 -0700 Subject: [PATCH 12/20] fix(npm/ruby): model visibility calls, not just the flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ruby's `private` / `public` / `protected` were modelled as a single body-wide flag flipped by a bare identifier. That covers one of the four shapes the language actually has, and two of the other three corrupt `class_nm` / `class_na` rather than only the public split. `private def x` and `private attr_accessor :b` nest the declaration in the keyword's argument list, so a walk over the body's direct children never sees it: `class A; def pub1; private def hidden; def pub2; end` reported nm 2 against `nom.functions` 3, and `attr_accessor :a` plus `private attr_accessor :b` reported na 1. Both walkers now read what a visibility call's arguments declare, taking visibility from the keyword rather than the flag. `private :foo` demotes a method already defined, which a running flag cannot express at all. `Npm` records each declaration during the pass — name, method family, visibility — and tallies once the body is read, so a symbol argument can re-file an earlier entry. The name comes from the grammar's `name` field, which is what makes `def val=` (a `setter`) and `def ==` (an `operator`) resolvable; `%i[a b]` names several. A bare `private` governs instance methods only, so `def self.factory` stayed public where the flag had demoted it. Only `private_class_method` reaches a singleton, in both its symbol and its wrapping form, and `private def self.x` correspondingly leaves one public. Inside `class << self` the declarations are plain `method` nodes, so the flag applies there — the exemption keys on the node kind, not the container. The rules live once, in `npa/shared.rs`, and both metrics consult them, so the two cannot drift on the same Ruby question. Two guards fall out of putting them there: a receiver other than `self` means the call names another object (`Other.private :a` declared and demoted nothing before this change only because the receiver happened to be a constant), and a partially interpolated `:"get_#{s}"` resolves to no name rather than to `get_`. Deferred with issues rather than folded in: retroactive demotion of an attribute and the runtime-resolved argument forms (#1399), `initialize` being automatically private (#1400), and a `def` wrapped in a call that is not a visibility keyword (#1401). No integration snapshots move; the output submodule holds no Ruby. Fixes #1255 --- src/metrics/npa.rs | 169 +++++++- src/metrics/npa/shared.rs | 253 ++++++++++-- src/metrics/npm.rs | 387 +++++++++++++++++- src/metrics/npm/ruby.rs | 174 +++++++- ...tr_macro_counts_symbol_array_elements.snap | 15 + ...ro_on_another_object_declares_nothing.snap | 15 + ...bare_keyword_flips_the_attribute_flag.snap | 15 + ...hout_a_wrapped_macro_declares_nothing.snap | 15 + ...ll_wrapping_attr_macro_counts_symbols.snap | 15 + ..._macro_reads_the_keyword_not_the_flag.snap | 15 + ...rgument_form_visibility_does_not_flip.snap | 8 +- ...ivate_leaves_singleton_methods_public.snap | 15 + ..._method_keyword_governs_a_wrapped_def.snap | 15 + ...s_method_keywords_scope_to_singletons.snap | 15 + ...does_not_cross_the_singleton_boundary.snap | 15 + ...uby_interpolated_symbol_names_nothing.snap | 15 + ...nthesised_bare_keyword_flips_the_flag.snap | 15 + ...ate_in_a_singleton_class_body_demotes.snap | 15 + ...protected_wrapping_call_is_not_public.snap | 15 + ...does_not_cross_the_singleton_boundary.snap | 15 + ...rgument_promotes_under_a_private_flag.snap | 15 + ...ent_reads_non_identifier_method_names.snap | 15 + ...ol_array_argument_names_every_element.snap | 15 + ...ity_call_on_another_object_is_ignored.snap | 15 + ...y_visibility_call_on_self_is_honoured.snap | 15 + ...nresolvable_arguments_changes_nothing.snap | 15 + ...y_call_wrapping_def_counts_the_method.snap | 15 + ...ng_def_reads_the_keyword_not_the_flag.snap | 15 + ...ity_does_not_leak_into_a_nested_class.snap | 15 + 29 files changed, 1297 insertions(+), 54 deletions(-) create mode 100644 src/metrics/snapshots/big_code_analysis__metrics__npa__tests__ruby_attr_macro_counts_symbol_array_elements.snap create mode 100644 src/metrics/snapshots/big_code_analysis__metrics__npa__tests__ruby_attr_macro_on_another_object_declares_nothing.snap create mode 100644 src/metrics/snapshots/big_code_analysis__metrics__npa__tests__ruby_parenthesised_bare_keyword_flips_the_attribute_flag.snap create mode 100644 src/metrics/snapshots/big_code_analysis__metrics__npa__tests__ruby_visibility_call_without_a_wrapped_macro_declares_nothing.snap create mode 100644 src/metrics/snapshots/big_code_analysis__metrics__npa__tests__ruby_visibility_call_wrapping_attr_macro_counts_symbols.snap create mode 100644 src/metrics/snapshots/big_code_analysis__metrics__npa__tests__ruby_wrapped_attr_macro_reads_the_keyword_not_the_flag.snap create mode 100644 src/metrics/snapshots/big_code_analysis__metrics__npm__tests__ruby_bare_private_leaves_singleton_methods_public.snap create mode 100644 src/metrics/snapshots/big_code_analysis__metrics__npm__tests__ruby_class_method_keyword_governs_a_wrapped_def.snap create mode 100644 src/metrics/snapshots/big_code_analysis__metrics__npm__tests__ruby_class_method_keywords_scope_to_singletons.snap create mode 100644 src/metrics/snapshots/big_code_analysis__metrics__npm__tests__ruby_class_method_symbol_does_not_cross_the_singleton_boundary.snap create mode 100644 src/metrics/snapshots/big_code_analysis__metrics__npm__tests__ruby_interpolated_symbol_names_nothing.snap create mode 100644 src/metrics/snapshots/big_code_analysis__metrics__npm__tests__ruby_parenthesised_bare_keyword_flips_the_flag.snap create mode 100644 src/metrics/snapshots/big_code_analysis__metrics__npm__tests__ruby_private_in_a_singleton_class_body_demotes.snap create mode 100644 src/metrics/snapshots/big_code_analysis__metrics__npm__tests__ruby_protected_wrapping_call_is_not_public.snap create mode 100644 src/metrics/snapshots/big_code_analysis__metrics__npm__tests__ruby_symbol_argument_does_not_cross_the_singleton_boundary.snap create mode 100644 src/metrics/snapshots/big_code_analysis__metrics__npm__tests__ruby_symbol_argument_promotes_under_a_private_flag.snap create mode 100644 src/metrics/snapshots/big_code_analysis__metrics__npm__tests__ruby_symbol_argument_reads_non_identifier_method_names.snap create mode 100644 src/metrics/snapshots/big_code_analysis__metrics__npm__tests__ruby_symbol_array_argument_names_every_element.snap create mode 100644 src/metrics/snapshots/big_code_analysis__metrics__npm__tests__ruby_visibility_call_on_another_object_is_ignored.snap create mode 100644 src/metrics/snapshots/big_code_analysis__metrics__npm__tests__ruby_visibility_call_on_self_is_honoured.snap create mode 100644 src/metrics/snapshots/big_code_analysis__metrics__npm__tests__ruby_visibility_call_with_unresolvable_arguments_changes_nothing.snap create mode 100644 src/metrics/snapshots/big_code_analysis__metrics__npm__tests__ruby_visibility_call_wrapping_def_counts_the_method.snap create mode 100644 src/metrics/snapshots/big_code_analysis__metrics__npm__tests__ruby_visibility_call_wrapping_def_reads_the_keyword_not_the_flag.snap create mode 100644 src/metrics/snapshots/big_code_analysis__metrics__npm__tests__ruby_visibility_does_not_leak_into_a_nested_class.snap diff --git a/src/metrics/npa.rs b/src/metrics/npa.rs index de9e3c8c2..52d914842 100644 --- a/src/metrics/npa.rs +++ b/src/metrics/npa.rs @@ -610,8 +610,11 @@ implement_metric_trait!( clippy::too_many_lines )] mod tests { + use std::path::PathBuf; + use crate::test_support::{ - assert_child_space_kind, check_func_space_only_shim, check_metrics_only_shim, child_space, + assert_child_space_kind, ast_has_kind_id, check_func_space_only_shim, + check_metrics_only_shim, child_space, }; use super::*; @@ -2931,6 +2934,170 @@ mod tests { ); } + #[test] + fn ruby_visibility_call_wrapping_attr_macro_counts_symbols() { + // `private attr_accessor :b` nests the `attr_accessor` call + // inside the `private` call, so the macro is never a direct + // child of the body. Before #1255 its symbols were dropped from + // `na` entirely rather than counted as private attributes. + // + // expected: na = 2 (a, b), npa = 1 (a). + check_metrics::( + "class D\n attr_accessor :a\n private attr_accessor :b\nend\n", + "foo.rb", + |metric| { + assert_eq!(metric.npa.class_na_sum(), 2); + assert_eq!(metric.npa.class_npa_sum(), 1); + insta::assert_json_snapshot!(metric.npa); + }, + ); + } + + #[test] + fn ruby_wrapped_attr_macro_reads_the_keyword_not_the_flag() { + // Seeds the body-wide flag to `private` first, so the assertion + // can only pass if the wrapped macro reads the *keyword*. With + // the flag consulted instead, `b` would come out private and + // npa would be 0. + // + // expected: na = 2 (a, b), npa = 1 — `a` is private by the flag, + // `b` is public because `public attr_reader :b` says so. + check_metrics::( + "class D\n private\n attr_reader :a\n public attr_reader :b\nend\n", + "foo.rb", + |metric| { + assert_eq!(metric.npa.class_na_sum(), 2); + assert_eq!(metric.npa.class_npa_sum(), 1); + insta::assert_json_snapshot!(metric.npa); + }, + ); + } + + #[test] + fn ruby_parenthesised_bare_keyword_flips_the_attribute_flag() { + // `private()` is the explicit-parens spelling of the bare + // keyword. It is a `call`, not an `identifier`, so it reaches + // `Npa` through the visibility-call arm rather than through + // `ruby_visibility_marker` — and both walkers must agree that it + // flips the flag (grammar-dispatch rule 7). + // + // expected: na = 2 (a, b), npa = 1 (a). + check_metrics::( + "class D\n attr_accessor :a\n private()\n attr_accessor :b\nend\n", + "foo.rb", + |metric| { + assert_eq!(metric.npa.class_na_sum(), 2); + assert_eq!(metric.npa.class_npa_sum(), 1); + insta::assert_json_snapshot!(metric.npa); + }, + ); + } + + #[test] + fn ruby_attr_macro_counts_symbol_array_elements() { + // `attr_writer %i[e f]` passes one argument naming two + // attributes. Counting arguments rather than the symbols they + // name reports 1; ignoring the `symbol_array` kind reports 0. + // + // expected: na = 3 (a, e, f), npa = 3. + check_metrics::( + "class A\n attr_accessor :a\n attr_writer %i[e f]\nend\n", + "foo.rb", + |metric| { + assert_eq!(metric.npa.class_na_sum(), 3); + assert_eq!(metric.npa.class_npa_sum(), 3); + insta::assert_json_snapshot!(metric.npa); + }, + ); + } + + #[test] + fn ruby_attr_macro_on_another_object_declares_nothing() { + // `Other.attr_accessor :b` adds an attribute to `Other`, not + // here. The receiver is a `constant`, so a scan for the leading + // `identifier` would find `attr_accessor` and count it. + // + // expected: na = 1 (a), npa = 1. + check_metrics::( + "class A\n attr_accessor :a\n Other.attr_accessor :b\nend\n", + "foo.rb", + |metric| { + assert_eq!(metric.npa.class_na_sum(), 1); + assert_eq!(metric.npa.class_npa_sum(), 1); + insta::assert_json_snapshot!(metric.npa); + }, + ); + } + + #[test] + fn ruby_hash_key_symbol_is_not_an_argument() { + // Pins the defensive `HashKeySymbol` arm in + // `ruby_symbol_argument_count` as unreachable at the pinned + // grammar: tree-sitter-ruby 0.23.1 emits `hash_key_symbol` only + // as a `pair` / `keyword_pattern` key, never as a direct child + // of an `argument_list` (grammar-dispatch rule 2). A grammar + // that promoted it would make `attr_accessor a: 1` — which is + // not valid Ruby — count an attribute. + let source = "class A\n attr_accessor :x\n h = { y: 1 }\nend\n"; + let parser = RubyParser::new(source.as_bytes().to_vec(), &PathBuf::from("foo.rb"), None); + assert!( + ast_has_kind_id(&parser, Ruby::HashKeySymbol as u16), + "the fixture no longer produces a `hash_key_symbol`; the \ + negative claim below is then vacuous" + ); + assert!( + !ast_has_kind_id(&parser, Ruby::BareSymbol as u16), + "`bare_symbol` now appears outside a `%i[…]` array; \ + re-derive `ruby_symbol_argument_count`" + ); + check_metrics::(source, "foo.rb", |metric| { + assert_eq!(metric.npa.class_na_sum(), 1); + assert_eq!(metric.npa.class_npa_sum(), 1); + }); + } + + #[test] + fn ruby_hidden_call_alias_is_not_emitted() { + // The Ruby `Npm` / `Npa` walkers dispatch on + // `Call | Call2 | Call3 | Call4`. `Call5` is the hidden `_call` + // supertype and is deliberately absent; pin that rather than + // leaving it to a comment (grammar-dispatch rule 2). + let source = + "class A\n attr_accessor :a\n private attr_accessor :b\n self.private :a\nend\n"; + let parser = RubyParser::new(source.as_bytes().to_vec(), &PathBuf::from("foo.rb"), None); + assert!( + ast_has_kind_id(&parser, Ruby::Call3 as u16), + "the fixture no longer produces the `Call3` alias; the \ + negative claim below is then vacuous" + ); + assert!( + !ast_has_kind_id(&parser, Ruby::Call5 as u16), + "tree-sitter-ruby now emits the hidden `_call` supertype; \ + add `Call5` to every Ruby `Call` dispatch set" + ); + } + + #[test] + fn ruby_visibility_call_without_a_wrapped_macro_declares_nothing() { + // The visibility-call arm must not invent attributes out of the + // symbol form (`private :a`, which names a method) or out of an + // argument it cannot resolve (`private *SYMS`). Nor may an + // `attr_*` macro count an argument that is not a symbol at all + // (`attr_reader name`, whose attribute is known only at + // runtime). + // + // expected: na = 1, npa = 1 — only `attr_accessor :a` declares. + check_metrics::( + "class D\n attr_accessor :a\n attr_reader name\n private :a\n private *SYMS\nend\n", + "foo.rb", + |metric| { + assert_eq!(metric.npa.class_na_sum(), 1); + assert_eq!(metric.npa.class_npa_sum(), 1); + insta::assert_json_snapshot!(metric.npa); + }, + ); + } + // --------------------------------------------------------------- // Default-impl placeholder smoke tests (audited in #188). // diff --git a/src/metrics/npa/shared.rs b/src/metrics/npa/shared.rs index 8138a7b8f..e8d755e2c 100644 --- a/src/metrics/npa/shared.rs +++ b/src/metrics/npa/shared.rs @@ -87,25 +87,85 @@ pub(crate) fn php_is_explicit_public(declaration: &Node) -> bool { }) } +// Named arguments of a Ruby `Call`, in source order. The grammar hangs +// them off an `argument_list` — aliased to two visible ids — whose +// children include the `(`, `)` and `,` tokens of the parenthesised +// spelling, so anonymous nodes are filtered out. A call written without +// an argument list (`private`, spelled as a `Call` only when it has a +// receiver) yields nothing. +pub(crate) fn ruby_call_named_arguments<'a>(call: &Node<'a>) -> impl Iterator> { + use Ruby::*; + + call.children() + .find(|c| matches!(c.kind_id().into(), ArgumentList | ArgumentList2)) + .into_iter() + .flat_map(|args| args.children().filter(Node::is_named)) +} + +// How many attributes / methods one argument to an `attr_*` or +// visibility macro names. A `%i[a b]` word array is one argument naming +// several; every other symbol spelling names exactly one. `HashKeySymbol` +// (`a:`) reaches no argument list at the pinned grammar — it is only ever +// a `pair` or `keyword_pattern` key — but is kept as a defensive arm +// (grammar-dispatch rule 2), pinned by +// `ruby_hash_key_symbol_is_not_an_argument`. +fn ruby_symbol_argument_count(arg: &Node) -> usize { + use Ruby::*; + + match arg.kind_id().into() { + SimpleSymbol | DelimitedSymbol | HashKeySymbol | BareSymbol => 1, + SymbolArray => arg + .children() + .filter(|c| matches!(c.kind_id().into(), BareSymbol)) + .count(), + _ => 0, + } +} + // Counts the number of symbol arguments passed to an `attr_accessor` / // `attr_reader` / `attr_writer` macro `Call` node. `attr_accessor :a, // :b, :c` exposes three attributes; an `attr_*` call with no arguments // is ill-formed Ruby but defensively returns zero rather than one. pub(crate) fn ruby_attr_macro_symbol_count(call: &Node) -> usize { - use Ruby::*; + ruby_call_named_arguments(call) + .map(|arg| ruby_symbol_argument_count(&arg)) + .sum() +} - call.children() - .find(|c| matches!(c.kind_id().into(), ArgumentList | ArgumentList2)) - .map_or(0, |args| { - args.children() - .filter(|c| { - matches!( - c.kind_id().into(), - SimpleSymbol | DelimitedSymbol | HashKeySymbol | BareSymbol - ) - }) - .count() - }) +// The method name a `:foo` argument to `private` / `public` / … names. +// `simple_symbol` carries the whole literal including the leading colon +// (`:ok?`, `:==`); `delimited_symbol` (`:"val="`) and the `bare_symbol` +// elements of a `%i[…]` array both wrap their text in a `string_content` +// child. A `%i[…]` array names several methods rather than one, so its +// callers expand it into these elements first. Every other argument +// shape — a splat (`private *SYMS`), a bare identifier (`private foo`), +// a string (`private "foo"`) — names nothing this walk can resolve and +// yields `None` (#1399). +// +// A delimited symbol is resolvable only when `string_content` is its +// *whole* content: `:"get_#{suffix}"` also carries an `interpolation` +// child, and returning the literal half of it would name — and demote — +// a different method called `get_`. +pub(crate) fn ruby_symbol_name<'a>(node: &Node<'a>, source: &'a [u8]) -> Option<&'a str> { + match node.kind_id().into() { + Ruby::SimpleSymbol => node.utf8_text(source)?.strip_prefix(':'), + Ruby::DelimitedSymbol | Ruby::BareSymbol => { + let mut content = node.children().filter(Node::is_named); + let first = content.next()?; + (matches!(first.kind_id().into(), Ruby::StringContent) && content.next().is_none()) + .then(|| first.utf8_text(source))? + } + _ => None, + } +} + +// The declared name of a Ruby `method` / `singleton_method`. Read from +// the grammar's required `name` field rather than by child index: the +// name is the *second* identifier of `def obj.x`, and it is a `setter` +// (`val=`) or an `operator` (`==`) node rather than an `identifier` for +// those two spellings (grammar-dispatch rule 3). +pub(crate) fn ruby_method_name<'a>(method: &Node<'a>, source: &'a [u8]) -> Option<&'a str> { + method.child_by_field_name("name")?.utf8_text(source) } // Ruby class-body visibility state. `private` / `public` / `protected` @@ -136,14 +196,105 @@ pub(crate) fn ruby_visibility_marker(node: &Node, source: &[u8]) -> Option Option { + let (visibility, targets_singleton) = match ruby_callee_name(call, source)? { + "private" => (RubyVisibility::Private, false), + "public" => (RubyVisibility::Public, false), + "protected" => (RubyVisibility::Protected, false), + "private_class_method" => (RubyVisibility::Private, true), + "public_class_method" => (RubyVisibility::Public, true), + _ => return None, + }; + if ruby_call_named_arguments(call).next().is_none() { + return (!targets_singleton).then_some(RubyVisibilityEffect::Flag(visibility)); + } + Some(RubyVisibilityEffect::Arguments(RubyVisibilityCall { + visibility, + targets_singleton, + })) +} + +// Whether a declaration counts as public when no visibility keyword +// names it: an instance method or attribute takes the body-wide flag, +// while a singleton method (`def self.x`) ignores that flag entirely — +// Ruby's `private` governs instance methods only (#1255). +pub(crate) fn ruby_declaration_is_public(singleton: bool, body_flag: RubyVisibility) -> bool { + singleton || body_flag == RubyVisibility::Public +} + +// Whether a declaration nested in a visibility call's argument list is +// public. The keyword governs it only when it names that method family, +// so `private def self.x` leaves the singleton alone and +// `private_class_method` never reaches an instance method or an +// attribute; an ungoverned declaration falls back to its own default. +// The single source of the rule for both `Npm` and `Npa` (#1255). +pub(crate) fn ruby_wrapped_is_public( + keyword: RubyVisibilityCall, + singleton: bool, + body_flag: RubyVisibility, +) -> bool { + if keyword.targets_singleton == singleton { + keyword.visibility == RubyVisibility::Public + } else { + ruby_declaration_is_public(singleton, body_flag) + } +} + +// The bare name a Ruby `Call` invokes on the enclosing class body. +// +// A receiver other than `self` puts the call on another object, so +// `Other.private :x` and `@cfg.attr_accessor :y` name nothing in *this* +// class and yield `None`. Both halves are read from the grammar's +// `receiver` / `method` fields rather than by scanning for the leading +// `identifier` (grammar-dispatch rule 3): the scan answers correctly +// only when the receiver happens to be a local variable, and reports +// the method name as the callee for a `constant`, an instance variable, +// a global, or a chained call — all of which are `_primary` receivers. +fn ruby_callee_name<'a>(call: &Node<'a>, source: &'a [u8]) -> Option<&'a str> { + if let Some(receiver) = call.child_by_field_name("receiver") + && receiver.kind_id() != Ruby::Zelf as u16 + { + return None; + } + call.child_by_field_name("method")?.utf8_text(source) +} + // Identifies the `attr_*` macro family on a Ruby `Call` node. Each // macro takes a list of attribute symbols and synthesises the matching // reader / writer / accessor methods on the enclosing class. pub(crate) fn ruby_attr_macro_name(call: &Node, source: &[u8]) -> Option<&'static str> { - let ident = call - .children() - .find(|c| matches!(c.kind_id().into(), Ruby::Identifier))?; - match ident.utf8_text(source)? { + match ruby_callee_name(call, source)? { "attr_accessor" => Some("attr_accessor"), "attr_reader" => Some("attr_reader"), "attr_writer" => Some("attr_writer"), @@ -151,6 +302,32 @@ pub(crate) fn ruby_attr_macro_name(call: &Node, source: &[u8]) -> Option<&'stati } } +// Books `count` attributes against the class tallies, all of them +// public or none. Kept as a helper so the three declaration shapes +// `ruby_walk_class_body` recognises cannot disagree on the rule. +fn ruby_add_attributes(stats: &mut Stats, count: usize, public: bool) { + stats.class_na += count; + if public { + stats.class_npa += count; + } +} + +// Attributes declared by an `attr_*` macro nested in a visibility call's +// argument list: `private attr_accessor :b` parses as a `private` call +// whose sole argument is the `attr_accessor` call, so the macro is never +// a direct child of the body (#1255). +fn ruby_wrapped_attr_count(call: &Node, source: &[u8]) -> usize { + use Ruby::*; + + ruby_call_named_arguments(call) + .filter(|arg| { + matches!(arg.kind_id().into(), Call | Call2 | Call3 | Call4) + && ruby_attr_macro_name(arg, source).is_some() + }) + .map(|arg| ruby_attr_macro_symbol_count(&arg)) + .sum() +} + // Walks the direct children of a Ruby class / singleton-class body // (`BodyStatement` under `Class` / `SingletonClass`) tallying: // - class-scope assignments to `@var` (`InstanceVariable`) and @@ -163,8 +340,16 @@ pub(crate) fn ruby_attr_macro_name(call: &Node, source: &[u8]) -> Option<&'stati // `private` / `public` / `protected` identifier flips the default for // every subsequent declaration in the body. The default visibility at // the top of every class body is `public`. The argument-form of those -// keywords (`private :foo`, `private def x`) does not flip the body- -// wide flag — matching Ruby's runtime behaviour. +// keywords (`private :foo`, `private attr_accessor :b`) does not flip +// the body-wide flag — matching Ruby's runtime behaviour — but it does +// govern the declarations it wraps, so an `attr_*` macro nested in a +// visibility call takes the keyword's visibility (#1255). +// +// The retroactive symbol form (`private :b` after `attr_accessor :b`) +// is deliberately not modelled here. It renames the visibility of the +// generated *reader* only, leaving `b=` public, so there is no single +// answer for the attribute — unlike `Npm`, where the symbol names one +// method (see #1399). // // Attribute assignments to instance/class variables are visible only // via the methods that wrap them, so the visibility flag at the point @@ -189,19 +374,31 @@ pub(crate) fn ruby_walk_class_body(body: &Node, source: &[u8], stats: &mut Stats continue; }; if matches!(lhs.kind_id().into(), InstanceVariable | ClassVariable) { - stats.class_na += 1; - if visibility == RubyVisibility::Public { - stats.class_npa += 1; - } + ruby_add_attributes(stats, 1, ruby_declaration_is_public(false, visibility)); } } Call | Call2 | Call3 | Call4 if ruby_attr_macro_name(&child, source).is_some() => { - let count = ruby_attr_macro_symbol_count(&child); - stats.class_na += count; - if visibility == RubyVisibility::Public { - stats.class_npa += count; - } + ruby_add_attributes( + stats, + ruby_attr_macro_symbol_count(&child), + ruby_declaration_is_public(false, visibility), + ); } + Call | Call2 | Call3 | Call4 => match ruby_visibility_effect(&child, source) { + Some(RubyVisibilityEffect::Flag(flag)) => visibility = flag, + // `private attr_accessor :b`. An attribute is always an + // instance-level declaration, so a class-method keyword + // never governs one: the macro it wraps still declares + // its attributes, at the body-wide flag. + Some(RubyVisibilityEffect::Arguments(keyword)) => { + ruby_add_attributes( + stats, + ruby_wrapped_attr_count(&child, source), + ruby_wrapped_is_public(keyword, false, visibility), + ); + } + None => {} + }, _ => {} } } diff --git a/src/metrics/npm.rs b/src/metrics/npm.rs index 16b69595b..2a885389c 100644 --- a/src/metrics/npm.rs +++ b/src/metrics/npm.rs @@ -2646,14 +2646,395 @@ class C { #[test] fn ruby_argument_form_visibility_does_not_flip() { // `private :y` is a `call` node (argument form). It does NOT - // change the body-wide visibility, so `z` declared after it - // remains public. + // change the body-wide visibility, so `z` and `w` declared after + // it remain public — while `y` itself, which the call names, is + // demoted (#1255). + // + // expected: nm = 3 (y, z, w), npm = 2 (z, w). The three + // candidate behaviours are distinguishable from that one number: + // no demotion at all gives 3, a body-wide flip gives 1, and only + // "demote y, leave the flag alone" gives 2. check_metrics::( - "class A\n def y\n 1\n end\n private :y\n def z\n 1\n end\nend\n", + "class A\n def y\n 1\n end\n private :y\n def z\n 1\n end\n def w\n 1\n end\nend\n", + "foo.rb", + |metric| { + assert_eq!(metric.npm.class_npm_sum(), 2); + assert_eq!(metric.npm.class_nm_sum(), 3); + insta::assert_json_snapshot!(metric.npm); + }, + ); + } + + #[test] + fn ruby_visibility_call_wrapping_def_counts_the_method() { + // `private def hidden; end` parses as a `private` call whose sole + // argument is the `method` node, so `hidden` is never a direct + // child of the class body. Before #1255 it vanished from `nm` + // altogether instead of counting as a private method. + // + // expected: nm = 3 (pub1, hidden, pub2), npm = 2 (pub1, pub2), + // and `nom.functions_sum` = 3 — the three `def`s each open a + // function space, so `nm` must agree with it. + check_metrics_with_nom_wmc::( + "class A\n def pub1\n 1\n end\n private def hidden\n 1\n end\n def pub2\n 1\n end\nend\n", "foo.rb", |metric| { + assert_eq!(metric.npm.class_nm_sum(), 3); assert_eq!(metric.npm.class_npm_sum(), 2); + assert_eq!(metric.nom.functions_sum(), 3); + insta::assert_json_snapshot!(metric.npm); + }, + ); + } + + #[test] + fn ruby_visibility_call_wrapping_def_reads_the_keyword_not_the_flag() { + // Seeds the body-wide flag to `private` first, so the assertion + // can only pass if the wrapped `def` reads the *keyword*. With + // the flag consulted instead, `b` would come out private and + // npm would be 0. + // + // expected: nm = 2 (a, b), npm = 1 — `a` is private by the flag, + // `b` is public because `public def b` says so. + check_metrics::( + "class A\n private\n def a\n 1\n end\n public def b\n 1\n end\nend\n", + "foo.rb", + |metric| { + assert_eq!(metric.npm.class_nm_sum(), 2); + assert_eq!(metric.npm.class_npm_sum(), 1); + insta::assert_json_snapshot!(metric.npm); + }, + ); + } + + #[test] + fn ruby_bare_private_leaves_singleton_methods_public() { + // Ruby's `private` sets the default for instance methods only; + // `def self.factory` stays public until `private_class_method` + // names it (#1255). + // + // expected: nm = 2 (factory, inst), npm = 1 (factory). + check_metrics::( + "class C\n private\n def self.factory\n 1\n end\n def inst\n 1\n end\nend\n", + "foo.rb", + |metric| { + assert_eq!(metric.npm.class_nm_sum(), 2); + assert_eq!(metric.npm.class_npm_sum(), 1); + insta::assert_json_snapshot!(metric.npm); + }, + ); + } + + #[test] + fn ruby_symbol_argument_promotes_under_a_private_flag() { + // The demotion pass must be able to move a method *back* to + // public, so the flag is seeded to `private` before `public :a` + // names the already-declared `a`. Asserting a demotion from a + // default-public body could not tell an assignment from a + // one-way `&&`. + // + // expected: nm = 2 (a, c), npm = 1 — `a` promoted by `public :a`, + // `c` still private by the flag. + check_metrics::( + "class H\n private\n def a\n 1\n end\n public :a\n def c\n 1\n end\nend\n", + "foo.rb", + |metric| { assert_eq!(metric.npm.class_nm_sum(), 2); + assert_eq!(metric.npm.class_npm_sum(), 1); + insta::assert_json_snapshot!(metric.npm); + }, + ); + } + + #[test] + fn ruby_class_method_keywords_scope_to_singletons() { + // `private_class_method` is the only keyword that reaches a + // `def self.`. This fixture covers its symbol form, and + // `private def self.sf` proves the instance keyword does *not* + // reach one; the wrapping form and `public_class_method` are + // covered by `ruby_class_method_keyword_governs_a_wrapped_def`. + // + // expected: nm = 3 (sf, cm, inst), npm = 2 — `sf` stays public + // under `private` (the keyword names instance methods), `cm` is + // demoted by `private_class_method :cm`, and `inst` is public by + // default. + check_metrics::( + "class E\n private def self.sf\n 1\n end\n def self.cm\n 1\n end\n private_class_method :cm\n def inst\n 1\n end\nend\n", + "foo.rb", + |metric| { + assert_eq!(metric.npm.class_nm_sum(), 3); + assert_eq!(metric.npm.class_npm_sum(), 2); + insta::assert_json_snapshot!(metric.npm); + }, + ); + } + + #[test] + fn ruby_symbol_argument_does_not_cross_the_singleton_boundary() { + // An instance method and a singleton method may share a name. + // `private :s` names the instance one only; matching on the name + // alone would demote both and report npm = 0. + // + // expected: nm = 2, npm = 1 (`def self.s` stays public). + check_metrics::( + "class K\n def s\n 1\n end\n def self.s\n 2\n end\n private :s\nend\n", + "foo.rb", + |metric| { + assert_eq!(metric.npm.class_nm_sum(), 2); + assert_eq!(metric.npm.class_npm_sum(), 1); + insta::assert_json_snapshot!(metric.npm); + }, + ); + } + + #[test] + fn ruby_class_method_symbol_does_not_cross_the_singleton_boundary() { + // The mirror of the test above: `private_class_method :s` names + // the singleton only, so the instance `s` stays public. + // + // expected: nm = 2, npm = 1 (`def s` stays public). + check_metrics::( + "class L\n def s\n 1\n end\n def self.s\n 2\n end\n private_class_method :s\nend\n", + "foo.rb", + |metric| { + assert_eq!(metric.npm.class_nm_sum(), 2); + assert_eq!(metric.npm.class_npm_sum(), 1); + insta::assert_json_snapshot!(metric.npm); + }, + ); + } + + #[test] + fn ruby_symbol_argument_reads_non_identifier_method_names() { + // Ruby method names are not all `identifier`s: `val=` is a + // `setter` node and `==` an `operator` node, and the symbol + // naming the latter may be written delimited (`:"val="`). All + // four names must round-trip for the demotion to find them. + // + // expected: nm = 4, npm = 0 — every method is named by the + // `private` call. Reading names by child index instead of the + // grammar's `name` field, or handling `simple_symbol` only, + // leaves some of them public. + check_metrics::( + "class G\n def ok?\n 1\n end\n def bang!\n 1\n end\n def val=(v)\n v\n end\n def ==(o)\n o\n end\n private :ok?, :bang!, :\"val=\", :==\nend\n", + "foo.rb", + |metric| { + assert_eq!(metric.npm.class_nm_sum(), 4); + assert_eq!(metric.npm.class_npm_sum(), 0); + insta::assert_json_snapshot!(metric.npm); + }, + ); + } + + #[test] + fn ruby_parenthesised_bare_keyword_flips_the_flag() { + // `private()` is the explicit-parens spelling of the bare + // keyword and flips the body-wide flag just as `private` does. + // The grammar gives it a `call` node rather than an + // `identifier`, so it needs its own arm. + // + // expected: nm = 1, npm = 0. + check_metrics::( + "class F\n private()\n def after\n 1\n end\nend\n", + "foo.rb", + |metric| { + assert_eq!(metric.npm.class_nm_sum(), 1); + assert_eq!(metric.npm.class_npm_sum(), 0); + insta::assert_json_snapshot!(metric.npm); + }, + ); + } + + #[test] + fn ruby_visibility_call_with_unresolvable_arguments_changes_nothing() { + // A splat (`private *SYMS`) and a bare identifier + // (`private foo`) name methods this walk cannot resolve. Neither + // may demote anything, and neither may be mistaken for the + // argument-less flag form. + // + // expected: nm = 2, npm = 2 — both methods stay public. + check_metrics::( + "class N\n def a\n 1\n end\n private *SYMS\n private foo\n def b\n 1\n end\nend\n", + "foo.rb", + |metric| { + assert_eq!(metric.npm.class_nm_sum(), 2); + assert_eq!(metric.npm.class_npm_sum(), 2); + insta::assert_json_snapshot!(metric.npm); + }, + ); + } + + #[test] + fn ruby_protected_wrapping_call_is_not_public() { + // `protected` is a third state: `Npm` counts *public* methods, + // so a protected one lands in `nm` and not in `npm`, in the + // wrapping form as in the flag form. + // + // `module_function` is here as a bare identifier that is *not* a + // visibility keyword — it must leave the flag alone rather than + // being read as one. + // + // expected: nm = 2 (prot, pub), npm = 1 (pub). + check_metrics::( + "class A\n module_function\n protected def prot\n 1\n end\n def pub\n 2\n end\nend\n", + "foo.rb", + |metric| { + assert_eq!(metric.npm.class_nm_sum(), 2); + assert_eq!(metric.npm.class_npm_sum(), 1); + insta::assert_json_snapshot!(metric.npm); + }, + ); + } + + #[test] + fn ruby_class_method_keyword_governs_a_wrapped_def() { + // The wrapping form of the class-method keyword, plus + // `public_class_method`, which no other test reaches. This is + // the only fixture that runs `ruby_wrapped_is_public`'s + // keyword-governs branch with `targets_singleton = true`. + // + // expected: nm = 3 (a, b, c), npm = 1 — `a` is demoted by + // `private_class_method :a`, `c` by the wrapping form, and `b` + // is public (`public_class_method :b` restates its default). + check_metrics::( + "class L\n def self.a\n 1\n end\n def self.b\n 2\n end\n private_class_method def self.c\n 3\n end\n public_class_method :b\n private_class_method :a\nend\n", + "foo.rb", + |metric| { + assert_eq!(metric.npm.class_nm_sum(), 3); + assert_eq!(metric.npm.class_npm_sum(), 1); + insta::assert_json_snapshot!(metric.npm); + }, + ); + } + + #[test] + fn ruby_private_in_a_singleton_class_body_demotes() { + // The one place a bare `private` legitimately demotes a class + // method: inside `class << self` the declarations are plain + // `method` nodes, so the body-wide flag applies to them. The + // singleton exemption keys on the node kind, not on the + // enclosing container, which is what makes this work. + // + // expected: nm = 2 (s1, s2), npm = 1 (s1). + // + // No edit to the current code perturbs this; what it rules out + // is the *other* reading of "singletons ignore the flag" — + // exempting every declaration under a `SingletonClass` — which + // would report npm = 2. + check_metrics::( + "class A\n class << self\n def s1\n 1\n end\n private\n def s2\n 2\n end\n end\nend\n", + "foo.rb", + |metric| { + assert_eq!(metric.npm.class_nm_sum(), 2); + assert_eq!(metric.npm.class_npm_sum(), 1); + insta::assert_json_snapshot!(metric.npm); + }, + ); + } + + #[test] + fn ruby_visibility_call_on_another_object_is_ignored() { + // A receiver other than `self` puts the call on a different + // object, so it declares and demotes nothing here. Reading the + // callee as the leading `identifier` instead of the grammar's + // `method` field gets this right only for a local-variable + // receiver: a `constant` or an instance variable is not an + // `identifier`, so the scan would find `private` and fire — + // booking `foreign` into `nm` and demoting `a`. + // + // expected: nm = 2 (a, b), npm = 2 — `foreign` belongs to + // `Other`, not to this class. + check_metrics::( + "class A\n def a\n 1\n end\n def b\n 2\n end\n Other.private :a\n @cfg.private def foreign\n 3\n end\nend\n", + "foo.rb", + |metric| { + assert_eq!(metric.npm.class_nm_sum(), 2); + assert_eq!(metric.npm.class_npm_sum(), 2); + insta::assert_json_snapshot!(metric.npm); + }, + ); + } + + #[test] + fn ruby_visibility_call_on_self_is_honoured() { + // The receiver gate above must still let `self.private :a` + // through — it is the same call on the same class. + // + // expected: nm = 2, npm = 1. + check_metrics::( + "class A\n def a\n 1\n end\n def b\n 2\n end\n self.private :a\nend\n", + "foo.rb", + |metric| { + assert_eq!(metric.npm.class_nm_sum(), 2); + assert_eq!(metric.npm.class_npm_sum(), 1); + insta::assert_json_snapshot!(metric.npm); + }, + ); + } + + #[test] + fn ruby_symbol_array_argument_names_every_element() { + // `private %i[g h]` is one argument naming two methods. Reading + // it as a single symbol resolves nothing and leaves both public. + // + // expected: nm = 3 (g, h, i), npm = 1 (i). + check_metrics::( + "class A\n def g\n 1\n end\n def h\n 2\n end\n def i\n 3\n end\n private %i[g h]\nend\n", + "foo.rb", + |metric| { + assert_eq!(metric.npm.class_nm_sum(), 3); + assert_eq!(metric.npm.class_npm_sum(), 1); + insta::assert_json_snapshot!(metric.npm); + }, + ); + } + + #[test] + fn ruby_interpolated_symbol_names_nothing() { + // `:"get_#{suffix}"` is a `delimited_symbol` carrying a + // `string_content` *and* an `interpolation`. Taking the first + // `string_content` alone would resolve it to `get_` and demote + // the unrelated method of that name. + // + // The wholly-interpolated `:"#{whatever}"` must be rejected on + // the other branch of the same test: it carries no + // `string_content` at all, so the guard's first half is what + // stops it. + // + // expected: nm = 2 (get_, keep), npm = 2 — nothing is demoted. + check_metrics::( + "class A\n def get_\n 1\n end\n def keep\n 2\n end\n private :\"get_#{suffix}\", :\"#{whatever}\"\nend\n", + "foo.rb", + |metric| { + assert_eq!(metric.npm.class_nm_sum(), 2); + assert_eq!(metric.npm.class_npm_sum(), 2); + insta::assert_json_snapshot!(metric.npm); + }, + ); + } + + #[test] + fn ruby_visibility_does_not_leak_into_a_nested_class() { + // Each class body opens its own `body_statement`, so the flag a + // nested class sets must not reach the outer body's later + // declarations — nor the reverse. + // + // expected: nm = 3 (Inner#i1, O#after, O#before), npm = 2 + // (before, after) — only `i1` is private. + // + // The isolation half is correct-by-construction and has no + // perturbation: the flag is a local of `compute`, which runs once + // per body, so no edit short of threading it through the walk can + // leak it. What the numbers do pin is that a nested `class` child + // of the body contributes nothing to the *outer* count — a walk + // that descended into it rather than leaving it to its own space + // would report nm = 4. + check_metrics::( + "class O\n def before\n 1\n end\n class Inner\n private\n def i1\n 1\n end\n end\n def after\n 1\n end\nend\n", + "foo.rb", + |metric| { + assert_eq!(metric.npm.class_nm_sum(), 3); + assert_eq!(metric.npm.class_npm_sum(), 2); insta::assert_json_snapshot!(metric.npm); }, ); diff --git a/src/metrics/npm/ruby.rs b/src/metrics/npm/ruby.rs index 47a344019..082dd9fcf 100644 --- a/src/metrics/npm/ruby.rs +++ b/src/metrics/npm/ruby.rs @@ -6,15 +6,159 @@ clippy::cast_sign_loss )] +use super::npa::{ + RubyVisibility, RubyVisibilityCall, RubyVisibilityEffect, ruby_call_named_arguments, + ruby_declaration_is_public, ruby_method_name, ruby_symbol_name, ruby_visibility_effect, + ruby_visibility_marker, ruby_wrapped_is_public, +}; use super::*; -// Ruby `Method` and `SingletonMethod` declared directly inside a -// `Class` or `SingletonClass` body count as methods. Visibility flips -// follow the same keyword-marker rule as `Npa`: a bare `private` -// `public` `protected` `Identifier` child of the body changes the -// running visibility for every subsequent declaration. The -// argument-form (`private :foo`, `private def x`) does NOT flip the -// body-wide flag — matching Ruby's runtime semantics. +// One method declared by a Ruby class body. Visibility cannot be settled +// arm-by-arm: `private :foo` demotes a method declared *earlier* in the +// same body, so the tally is taken once the whole body has been read +// (#1255). The name is borrowed from the source bytes for that match — +// Ruby spells the target as a symbol, so identity is in the bytes +// (grammar-dispatch rule 10). +struct RubyMethodDecl<'a> { + name: Option<&'a str>, + singleton: bool, + public: bool, +} + +// The state one pass over a Ruby class body carries: the source, the +// running body-wide visibility flag, and the declarations seen so far. +// Every arm of the walk needs at least two of the three, and the +// retroactive symbol form needs to reach back into the third, so they +// travel together rather than through a parameter list. +struct RubyClassBody<'a> { + code: &'a [u8], + visibility: RubyVisibility, + methods: Vec>, +} + +impl<'a> RubyClassBody<'a> { + fn new(code: &'a [u8]) -> Self { + Self { + code, + // Ruby class bodies open in default-public state, whatever + // the previous body's trailing visibility was. + visibility: RubyVisibility::Public, + methods: Vec::new(), + } + } + + // Records a `method` / `singleton_method` node. + fn declare(&mut self, method: &Node<'a>, singleton: bool, public: bool) { + let name = ruby_method_name(method, self.code); + self.methods.push(RubyMethodDecl { + name, + singleton, + public, + }); + } + + // Re-files every already-declared method of `name` in the family the + // keyword names. Ruby's `private :foo` reaches back over the body, + // and only over the body: a `def foo` written *after* the call keeps + // the visibility it was declared with. + fn refile(&mut self, name: &str, singleton: bool, public: bool) { + for target in self + .methods + .iter_mut() + .filter(|m| m.singleton == singleton && m.name == Some(name)) + { + target.public = public; + } + } + + // Re-files every method one visibility-call argument names. A + // `%i[a b]` word array names several; every other resolvable + // spelling names one. + fn refile_argument(&mut self, arg: &Node<'a>, singleton: bool, public: bool) { + let code = self.code; + if matches!(arg.kind_id().into(), Ruby::SymbolArray) { + for name in arg + .children() + .filter_map(|sym| ruby_symbol_name(&sym, code)) + { + self.refile(name, singleton, public); + } + } else if let Some(name) = ruby_symbol_name(arg, code) { + self.refile(name, singleton, public); + } + } + + // Applies a visibility call's arguments (`private def x`, + // `private :foo`, `private_class_method :factory`). + // + // A nested `def` is a declaration this walk would otherwise never + // see — the `method` node hangs off the call's argument list, not + // off the body — so it is recorded here. A symbol argument names a + // method already recorded and re-files it. + // + // Either way the keyword governs the declaration only when it names + // that method family: `private def self.x` demotes the *instance* + // method `x` (a NameError in practice), never the singleton, and + // only `private_class_method` reaches a `def self.`. + fn apply_visibility_call(&mut self, call: &Node<'a>, keyword: RubyVisibilityCall) { + use Ruby::*; + + let public = keyword.visibility == RubyVisibility::Public; + for arg in ruby_call_named_arguments(call) { + let singleton = match arg.kind_id().into() { + Method => false, + SingletonMethod => true, + _ => { + self.refile_argument(&arg, keyword.targets_singleton, public); + continue; + } + }; + let declared_public = ruby_wrapped_is_public(keyword, singleton, self.visibility); + self.declare(&arg, singleton, declared_public); + } + } + + // Reads one direct child of the class body. + fn visit(&mut self, child: &Node<'a>) { + use Ruby::*; + + if let Some(marker) = ruby_visibility_marker(child, self.code) { + self.visibility = marker; + return; + } + let kind = child.kind_id().into(); + match kind { + Method | SingletonMethod => { + let singleton = matches!(kind, SingletonMethod); + let public = ruby_declaration_is_public(singleton, self.visibility); + self.declare(child, singleton, public); + } + Call | Call2 | Call3 | Call4 => match ruby_visibility_effect(child, self.code) { + Some(RubyVisibilityEffect::Flag(flag)) => self.visibility = flag, + Some(RubyVisibilityEffect::Arguments(keyword)) => { + self.apply_visibility_call(child, keyword); + } + None => {} + }, + _ => {} + } + } +} + +// Ruby `Method` and `SingletonMethod` declared inside a `Class` or +// `SingletonClass` body count as methods, whether they stand as direct +// children of the body or nested in a visibility call's argument list +// (`private def x`, which is the whole of that method's declaration). +// +// Visibility follows Ruby's own rules, which the keyword-marker flag +// alone does not cover (#1255): +// - a bare `private` / `public` / `protected` `Identifier` child of the +// body sets the default for every subsequent *instance* method; +// - `def self.x` is a singleton method and ignores that flag — only +// `private_class_method` demotes one; +// - the argument forms do not touch the flag, but do govern what they +// name: `private def x` declares a private `x`, and `private :foo` +// re-files a method declared earlier in the same body. // // `Module` bodies are not classes (the getter routes them to // `SpaceKind::Namespace`); they do not contribute to `Npm` so a @@ -38,18 +182,12 @@ impl Npm for RubyCode { return; } - let mut visibility = super::npa::RubyVisibility::Public; + let mut body = RubyClassBody::new(code); for child in node.children() { - if let Some(marker) = super::npa::ruby_visibility_marker(&child, code) { - visibility = marker; - continue; - } - if matches!(child.kind_id().into(), Method | SingletonMethod) { - stats.class_nm += 1; - if visibility == super::npa::RubyVisibility::Public { - stats.class_npm += 1; - } - } + body.visit(&child); } + + stats.class_nm += body.methods.len(); + stats.class_npm += body.methods.iter().filter(|m| m.public).count(); } } diff --git a/src/metrics/snapshots/big_code_analysis__metrics__npa__tests__ruby_attr_macro_counts_symbol_array_elements.snap b/src/metrics/snapshots/big_code_analysis__metrics__npa__tests__ruby_attr_macro_counts_symbol_array_elements.snap new file mode 100644 index 000000000..1b4c51a36 --- /dev/null +++ b/src/metrics/snapshots/big_code_analysis__metrics__npa__tests__ruby_attr_macro_counts_symbol_array_elements.snap @@ -0,0 +1,15 @@ +--- +source: src/metrics/npa.rs +expression: metric.npa +--- +{ + "class_npa_sum": 3, + "interface_npa_sum": 0, + "class_attributes": 3, + "interface_attributes": 0, + "class_cda": 1.0, + "interface_cda": 0.0, + "total": 3, + "total_attributes": 3, + "cda": 1.0 +} diff --git a/src/metrics/snapshots/big_code_analysis__metrics__npa__tests__ruby_attr_macro_on_another_object_declares_nothing.snap b/src/metrics/snapshots/big_code_analysis__metrics__npa__tests__ruby_attr_macro_on_another_object_declares_nothing.snap new file mode 100644 index 000000000..784ccc3f0 --- /dev/null +++ b/src/metrics/snapshots/big_code_analysis__metrics__npa__tests__ruby_attr_macro_on_another_object_declares_nothing.snap @@ -0,0 +1,15 @@ +--- +source: src/metrics/npa.rs +expression: metric.npa +--- +{ + "class_npa_sum": 1, + "interface_npa_sum": 0, + "class_attributes": 1, + "interface_attributes": 0, + "class_cda": 1.0, + "interface_cda": 0.0, + "total": 1, + "total_attributes": 1, + "cda": 1.0 +} diff --git a/src/metrics/snapshots/big_code_analysis__metrics__npa__tests__ruby_parenthesised_bare_keyword_flips_the_attribute_flag.snap b/src/metrics/snapshots/big_code_analysis__metrics__npa__tests__ruby_parenthesised_bare_keyword_flips_the_attribute_flag.snap new file mode 100644 index 000000000..82e6f1fff --- /dev/null +++ b/src/metrics/snapshots/big_code_analysis__metrics__npa__tests__ruby_parenthesised_bare_keyword_flips_the_attribute_flag.snap @@ -0,0 +1,15 @@ +--- +source: src/metrics/npa.rs +expression: metric.npa +--- +{ + "class_npa_sum": 1, + "interface_npa_sum": 0, + "class_attributes": 2, + "interface_attributes": 0, + "class_cda": 0.5, + "interface_cda": 0.0, + "total": 1, + "total_attributes": 2, + "cda": 0.5 +} diff --git a/src/metrics/snapshots/big_code_analysis__metrics__npa__tests__ruby_visibility_call_without_a_wrapped_macro_declares_nothing.snap b/src/metrics/snapshots/big_code_analysis__metrics__npa__tests__ruby_visibility_call_without_a_wrapped_macro_declares_nothing.snap new file mode 100644 index 000000000..784ccc3f0 --- /dev/null +++ b/src/metrics/snapshots/big_code_analysis__metrics__npa__tests__ruby_visibility_call_without_a_wrapped_macro_declares_nothing.snap @@ -0,0 +1,15 @@ +--- +source: src/metrics/npa.rs +expression: metric.npa +--- +{ + "class_npa_sum": 1, + "interface_npa_sum": 0, + "class_attributes": 1, + "interface_attributes": 0, + "class_cda": 1.0, + "interface_cda": 0.0, + "total": 1, + "total_attributes": 1, + "cda": 1.0 +} diff --git a/src/metrics/snapshots/big_code_analysis__metrics__npa__tests__ruby_visibility_call_wrapping_attr_macro_counts_symbols.snap b/src/metrics/snapshots/big_code_analysis__metrics__npa__tests__ruby_visibility_call_wrapping_attr_macro_counts_symbols.snap new file mode 100644 index 000000000..82e6f1fff --- /dev/null +++ b/src/metrics/snapshots/big_code_analysis__metrics__npa__tests__ruby_visibility_call_wrapping_attr_macro_counts_symbols.snap @@ -0,0 +1,15 @@ +--- +source: src/metrics/npa.rs +expression: metric.npa +--- +{ + "class_npa_sum": 1, + "interface_npa_sum": 0, + "class_attributes": 2, + "interface_attributes": 0, + "class_cda": 0.5, + "interface_cda": 0.0, + "total": 1, + "total_attributes": 2, + "cda": 0.5 +} diff --git a/src/metrics/snapshots/big_code_analysis__metrics__npa__tests__ruby_wrapped_attr_macro_reads_the_keyword_not_the_flag.snap b/src/metrics/snapshots/big_code_analysis__metrics__npa__tests__ruby_wrapped_attr_macro_reads_the_keyword_not_the_flag.snap new file mode 100644 index 000000000..82e6f1fff --- /dev/null +++ b/src/metrics/snapshots/big_code_analysis__metrics__npa__tests__ruby_wrapped_attr_macro_reads_the_keyword_not_the_flag.snap @@ -0,0 +1,15 @@ +--- +source: src/metrics/npa.rs +expression: metric.npa +--- +{ + "class_npa_sum": 1, + "interface_npa_sum": 0, + "class_attributes": 2, + "interface_attributes": 0, + "class_cda": 0.5, + "interface_cda": 0.0, + "total": 1, + "total_attributes": 2, + "cda": 0.5 +} diff --git a/src/metrics/snapshots/big_code_analysis__metrics__npm__tests__ruby_argument_form_visibility_does_not_flip.snap b/src/metrics/snapshots/big_code_analysis__metrics__npm__tests__ruby_argument_form_visibility_does_not_flip.snap index 0db3ecd0e..bd862aa7b 100644 --- a/src/metrics/snapshots/big_code_analysis__metrics__npm__tests__ruby_argument_form_visibility_does_not_flip.snap +++ b/src/metrics/snapshots/big_code_analysis__metrics__npm__tests__ruby_argument_form_visibility_does_not_flip.snap @@ -5,11 +5,11 @@ expression: metric.npm { "class_npm_sum": 2, "interface_npm_sum": 0, - "class_methods": 2, + "class_methods": 3, "interface_methods": 0, - "class_coa": 1.0, + "class_coa": 0.6666666666666666, "interface_coa": 0.0, "total": 2, - "total_methods": 2, - "coa": 1.0 + "total_methods": 3, + "coa": 0.6666666666666666 } diff --git a/src/metrics/snapshots/big_code_analysis__metrics__npm__tests__ruby_bare_private_leaves_singleton_methods_public.snap b/src/metrics/snapshots/big_code_analysis__metrics__npm__tests__ruby_bare_private_leaves_singleton_methods_public.snap new file mode 100644 index 000000000..18abc2dbe --- /dev/null +++ b/src/metrics/snapshots/big_code_analysis__metrics__npm__tests__ruby_bare_private_leaves_singleton_methods_public.snap @@ -0,0 +1,15 @@ +--- +source: src/metrics/npm.rs +expression: metric.npm +--- +{ + "class_npm_sum": 1, + "interface_npm_sum": 0, + "class_methods": 2, + "interface_methods": 0, + "class_coa": 0.5, + "interface_coa": 0.0, + "total": 1, + "total_methods": 2, + "coa": 0.5 +} diff --git a/src/metrics/snapshots/big_code_analysis__metrics__npm__tests__ruby_class_method_keyword_governs_a_wrapped_def.snap b/src/metrics/snapshots/big_code_analysis__metrics__npm__tests__ruby_class_method_keyword_governs_a_wrapped_def.snap new file mode 100644 index 000000000..c7dee7256 --- /dev/null +++ b/src/metrics/snapshots/big_code_analysis__metrics__npm__tests__ruby_class_method_keyword_governs_a_wrapped_def.snap @@ -0,0 +1,15 @@ +--- +source: src/metrics/npm.rs +expression: metric.npm +--- +{ + "class_npm_sum": 1, + "interface_npm_sum": 0, + "class_methods": 3, + "interface_methods": 0, + "class_coa": 0.3333333333333333, + "interface_coa": 0.0, + "total": 1, + "total_methods": 3, + "coa": 0.3333333333333333 +} diff --git a/src/metrics/snapshots/big_code_analysis__metrics__npm__tests__ruby_class_method_keywords_scope_to_singletons.snap b/src/metrics/snapshots/big_code_analysis__metrics__npm__tests__ruby_class_method_keywords_scope_to_singletons.snap new file mode 100644 index 000000000..bd862aa7b --- /dev/null +++ b/src/metrics/snapshots/big_code_analysis__metrics__npm__tests__ruby_class_method_keywords_scope_to_singletons.snap @@ -0,0 +1,15 @@ +--- +source: src/metrics/npm.rs +expression: metric.npm +--- +{ + "class_npm_sum": 2, + "interface_npm_sum": 0, + "class_methods": 3, + "interface_methods": 0, + "class_coa": 0.6666666666666666, + "interface_coa": 0.0, + "total": 2, + "total_methods": 3, + "coa": 0.6666666666666666 +} diff --git a/src/metrics/snapshots/big_code_analysis__metrics__npm__tests__ruby_class_method_symbol_does_not_cross_the_singleton_boundary.snap b/src/metrics/snapshots/big_code_analysis__metrics__npm__tests__ruby_class_method_symbol_does_not_cross_the_singleton_boundary.snap new file mode 100644 index 000000000..18abc2dbe --- /dev/null +++ b/src/metrics/snapshots/big_code_analysis__metrics__npm__tests__ruby_class_method_symbol_does_not_cross_the_singleton_boundary.snap @@ -0,0 +1,15 @@ +--- +source: src/metrics/npm.rs +expression: metric.npm +--- +{ + "class_npm_sum": 1, + "interface_npm_sum": 0, + "class_methods": 2, + "interface_methods": 0, + "class_coa": 0.5, + "interface_coa": 0.0, + "total": 1, + "total_methods": 2, + "coa": 0.5 +} diff --git a/src/metrics/snapshots/big_code_analysis__metrics__npm__tests__ruby_interpolated_symbol_names_nothing.snap b/src/metrics/snapshots/big_code_analysis__metrics__npm__tests__ruby_interpolated_symbol_names_nothing.snap new file mode 100644 index 000000000..0db3ecd0e --- /dev/null +++ b/src/metrics/snapshots/big_code_analysis__metrics__npm__tests__ruby_interpolated_symbol_names_nothing.snap @@ -0,0 +1,15 @@ +--- +source: src/metrics/npm.rs +expression: metric.npm +--- +{ + "class_npm_sum": 2, + "interface_npm_sum": 0, + "class_methods": 2, + "interface_methods": 0, + "class_coa": 1.0, + "interface_coa": 0.0, + "total": 2, + "total_methods": 2, + "coa": 1.0 +} diff --git a/src/metrics/snapshots/big_code_analysis__metrics__npm__tests__ruby_parenthesised_bare_keyword_flips_the_flag.snap b/src/metrics/snapshots/big_code_analysis__metrics__npm__tests__ruby_parenthesised_bare_keyword_flips_the_flag.snap new file mode 100644 index 000000000..e7b6a5c6a --- /dev/null +++ b/src/metrics/snapshots/big_code_analysis__metrics__npm__tests__ruby_parenthesised_bare_keyword_flips_the_flag.snap @@ -0,0 +1,15 @@ +--- +source: src/metrics/npm.rs +expression: metric.npm +--- +{ + "class_npm_sum": 0, + "interface_npm_sum": 0, + "class_methods": 1, + "interface_methods": 0, + "class_coa": 0.0, + "interface_coa": 0.0, + "total": 0, + "total_methods": 1, + "coa": 0.0 +} diff --git a/src/metrics/snapshots/big_code_analysis__metrics__npm__tests__ruby_private_in_a_singleton_class_body_demotes.snap b/src/metrics/snapshots/big_code_analysis__metrics__npm__tests__ruby_private_in_a_singleton_class_body_demotes.snap new file mode 100644 index 000000000..18abc2dbe --- /dev/null +++ b/src/metrics/snapshots/big_code_analysis__metrics__npm__tests__ruby_private_in_a_singleton_class_body_demotes.snap @@ -0,0 +1,15 @@ +--- +source: src/metrics/npm.rs +expression: metric.npm +--- +{ + "class_npm_sum": 1, + "interface_npm_sum": 0, + "class_methods": 2, + "interface_methods": 0, + "class_coa": 0.5, + "interface_coa": 0.0, + "total": 1, + "total_methods": 2, + "coa": 0.5 +} diff --git a/src/metrics/snapshots/big_code_analysis__metrics__npm__tests__ruby_protected_wrapping_call_is_not_public.snap b/src/metrics/snapshots/big_code_analysis__metrics__npm__tests__ruby_protected_wrapping_call_is_not_public.snap new file mode 100644 index 000000000..18abc2dbe --- /dev/null +++ b/src/metrics/snapshots/big_code_analysis__metrics__npm__tests__ruby_protected_wrapping_call_is_not_public.snap @@ -0,0 +1,15 @@ +--- +source: src/metrics/npm.rs +expression: metric.npm +--- +{ + "class_npm_sum": 1, + "interface_npm_sum": 0, + "class_methods": 2, + "interface_methods": 0, + "class_coa": 0.5, + "interface_coa": 0.0, + "total": 1, + "total_methods": 2, + "coa": 0.5 +} diff --git a/src/metrics/snapshots/big_code_analysis__metrics__npm__tests__ruby_symbol_argument_does_not_cross_the_singleton_boundary.snap b/src/metrics/snapshots/big_code_analysis__metrics__npm__tests__ruby_symbol_argument_does_not_cross_the_singleton_boundary.snap new file mode 100644 index 000000000..18abc2dbe --- /dev/null +++ b/src/metrics/snapshots/big_code_analysis__metrics__npm__tests__ruby_symbol_argument_does_not_cross_the_singleton_boundary.snap @@ -0,0 +1,15 @@ +--- +source: src/metrics/npm.rs +expression: metric.npm +--- +{ + "class_npm_sum": 1, + "interface_npm_sum": 0, + "class_methods": 2, + "interface_methods": 0, + "class_coa": 0.5, + "interface_coa": 0.0, + "total": 1, + "total_methods": 2, + "coa": 0.5 +} diff --git a/src/metrics/snapshots/big_code_analysis__metrics__npm__tests__ruby_symbol_argument_promotes_under_a_private_flag.snap b/src/metrics/snapshots/big_code_analysis__metrics__npm__tests__ruby_symbol_argument_promotes_under_a_private_flag.snap new file mode 100644 index 000000000..18abc2dbe --- /dev/null +++ b/src/metrics/snapshots/big_code_analysis__metrics__npm__tests__ruby_symbol_argument_promotes_under_a_private_flag.snap @@ -0,0 +1,15 @@ +--- +source: src/metrics/npm.rs +expression: metric.npm +--- +{ + "class_npm_sum": 1, + "interface_npm_sum": 0, + "class_methods": 2, + "interface_methods": 0, + "class_coa": 0.5, + "interface_coa": 0.0, + "total": 1, + "total_methods": 2, + "coa": 0.5 +} diff --git a/src/metrics/snapshots/big_code_analysis__metrics__npm__tests__ruby_symbol_argument_reads_non_identifier_method_names.snap b/src/metrics/snapshots/big_code_analysis__metrics__npm__tests__ruby_symbol_argument_reads_non_identifier_method_names.snap new file mode 100644 index 000000000..5f27accbf --- /dev/null +++ b/src/metrics/snapshots/big_code_analysis__metrics__npm__tests__ruby_symbol_argument_reads_non_identifier_method_names.snap @@ -0,0 +1,15 @@ +--- +source: src/metrics/npm.rs +expression: metric.npm +--- +{ + "class_npm_sum": 0, + "interface_npm_sum": 0, + "class_methods": 4, + "interface_methods": 0, + "class_coa": 0.0, + "interface_coa": 0.0, + "total": 0, + "total_methods": 4, + "coa": 0.0 +} diff --git a/src/metrics/snapshots/big_code_analysis__metrics__npm__tests__ruby_symbol_array_argument_names_every_element.snap b/src/metrics/snapshots/big_code_analysis__metrics__npm__tests__ruby_symbol_array_argument_names_every_element.snap new file mode 100644 index 000000000..c7dee7256 --- /dev/null +++ b/src/metrics/snapshots/big_code_analysis__metrics__npm__tests__ruby_symbol_array_argument_names_every_element.snap @@ -0,0 +1,15 @@ +--- +source: src/metrics/npm.rs +expression: metric.npm +--- +{ + "class_npm_sum": 1, + "interface_npm_sum": 0, + "class_methods": 3, + "interface_methods": 0, + "class_coa": 0.3333333333333333, + "interface_coa": 0.0, + "total": 1, + "total_methods": 3, + "coa": 0.3333333333333333 +} diff --git a/src/metrics/snapshots/big_code_analysis__metrics__npm__tests__ruby_visibility_call_on_another_object_is_ignored.snap b/src/metrics/snapshots/big_code_analysis__metrics__npm__tests__ruby_visibility_call_on_another_object_is_ignored.snap new file mode 100644 index 000000000..0db3ecd0e --- /dev/null +++ b/src/metrics/snapshots/big_code_analysis__metrics__npm__tests__ruby_visibility_call_on_another_object_is_ignored.snap @@ -0,0 +1,15 @@ +--- +source: src/metrics/npm.rs +expression: metric.npm +--- +{ + "class_npm_sum": 2, + "interface_npm_sum": 0, + "class_methods": 2, + "interface_methods": 0, + "class_coa": 1.0, + "interface_coa": 0.0, + "total": 2, + "total_methods": 2, + "coa": 1.0 +} diff --git a/src/metrics/snapshots/big_code_analysis__metrics__npm__tests__ruby_visibility_call_on_self_is_honoured.snap b/src/metrics/snapshots/big_code_analysis__metrics__npm__tests__ruby_visibility_call_on_self_is_honoured.snap new file mode 100644 index 000000000..18abc2dbe --- /dev/null +++ b/src/metrics/snapshots/big_code_analysis__metrics__npm__tests__ruby_visibility_call_on_self_is_honoured.snap @@ -0,0 +1,15 @@ +--- +source: src/metrics/npm.rs +expression: metric.npm +--- +{ + "class_npm_sum": 1, + "interface_npm_sum": 0, + "class_methods": 2, + "interface_methods": 0, + "class_coa": 0.5, + "interface_coa": 0.0, + "total": 1, + "total_methods": 2, + "coa": 0.5 +} diff --git a/src/metrics/snapshots/big_code_analysis__metrics__npm__tests__ruby_visibility_call_with_unresolvable_arguments_changes_nothing.snap b/src/metrics/snapshots/big_code_analysis__metrics__npm__tests__ruby_visibility_call_with_unresolvable_arguments_changes_nothing.snap new file mode 100644 index 000000000..0db3ecd0e --- /dev/null +++ b/src/metrics/snapshots/big_code_analysis__metrics__npm__tests__ruby_visibility_call_with_unresolvable_arguments_changes_nothing.snap @@ -0,0 +1,15 @@ +--- +source: src/metrics/npm.rs +expression: metric.npm +--- +{ + "class_npm_sum": 2, + "interface_npm_sum": 0, + "class_methods": 2, + "interface_methods": 0, + "class_coa": 1.0, + "interface_coa": 0.0, + "total": 2, + "total_methods": 2, + "coa": 1.0 +} diff --git a/src/metrics/snapshots/big_code_analysis__metrics__npm__tests__ruby_visibility_call_wrapping_def_counts_the_method.snap b/src/metrics/snapshots/big_code_analysis__metrics__npm__tests__ruby_visibility_call_wrapping_def_counts_the_method.snap new file mode 100644 index 000000000..bd862aa7b --- /dev/null +++ b/src/metrics/snapshots/big_code_analysis__metrics__npm__tests__ruby_visibility_call_wrapping_def_counts_the_method.snap @@ -0,0 +1,15 @@ +--- +source: src/metrics/npm.rs +expression: metric.npm +--- +{ + "class_npm_sum": 2, + "interface_npm_sum": 0, + "class_methods": 3, + "interface_methods": 0, + "class_coa": 0.6666666666666666, + "interface_coa": 0.0, + "total": 2, + "total_methods": 3, + "coa": 0.6666666666666666 +} diff --git a/src/metrics/snapshots/big_code_analysis__metrics__npm__tests__ruby_visibility_call_wrapping_def_reads_the_keyword_not_the_flag.snap b/src/metrics/snapshots/big_code_analysis__metrics__npm__tests__ruby_visibility_call_wrapping_def_reads_the_keyword_not_the_flag.snap new file mode 100644 index 000000000..18abc2dbe --- /dev/null +++ b/src/metrics/snapshots/big_code_analysis__metrics__npm__tests__ruby_visibility_call_wrapping_def_reads_the_keyword_not_the_flag.snap @@ -0,0 +1,15 @@ +--- +source: src/metrics/npm.rs +expression: metric.npm +--- +{ + "class_npm_sum": 1, + "interface_npm_sum": 0, + "class_methods": 2, + "interface_methods": 0, + "class_coa": 0.5, + "interface_coa": 0.0, + "total": 1, + "total_methods": 2, + "coa": 0.5 +} diff --git a/src/metrics/snapshots/big_code_analysis__metrics__npm__tests__ruby_visibility_does_not_leak_into_a_nested_class.snap b/src/metrics/snapshots/big_code_analysis__metrics__npm__tests__ruby_visibility_does_not_leak_into_a_nested_class.snap new file mode 100644 index 000000000..bd862aa7b --- /dev/null +++ b/src/metrics/snapshots/big_code_analysis__metrics__npm__tests__ruby_visibility_does_not_leak_into_a_nested_class.snap @@ -0,0 +1,15 @@ +--- +source: src/metrics/npm.rs +expression: metric.npm +--- +{ + "class_npm_sum": 2, + "interface_npm_sum": 0, + "class_methods": 3, + "interface_methods": 0, + "class_coa": 0.6666666666666666, + "interface_coa": 0.0, + "total": 2, + "total_methods": 3, + "coa": 0.6666666666666666 +} From a4601cdad15439c3d21f2c539b986e48b45377e6 Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Tue, 8 Sep 2026 23:57:20 -0700 Subject: [PATCH 13/20] test(npa/ruby): make the hash-key-symbol pin adversarial The fixture put its `hash_key_symbol` in an unrelated hash literal, so the test asserted the arm was unreached without ever offering it the chance to fire. `attr_accessor :x, foo: 1` puts one inside the very argument list `ruby_symbol_argument_count` walks -- wrapped in a `pair`, which is the claim -- so the na = 1 result now fails if the defensive arm is ever reachable. Perturbing its `_ => 0` to `_ => 1` fails this test; against the old fixture it did not. --- src/metrics/npa.rs | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/src/metrics/npa.rs b/src/metrics/npa.rs index 52d914842..c91efab45 100644 --- a/src/metrics/npa.rs +++ b/src/metrics/npa.rs @@ -3030,25 +3030,23 @@ mod tests { } #[test] - fn ruby_hash_key_symbol_is_not_an_argument() { + fn ruby_hash_key_symbol_declares_no_attribute() { // Pins the defensive `HashKeySymbol` arm in // `ruby_symbol_argument_count` as unreachable at the pinned - // grammar: tree-sitter-ruby 0.23.1 emits `hash_key_symbol` only - // as a `pair` / `keyword_pattern` key, never as a direct child - // of an `argument_list` (grammar-dispatch rule 2). A grammar - // that promoted it would make `attr_accessor a: 1` — which is - // not valid Ruby — count an attribute. - let source = "class A\n attr_accessor :x\n h = { y: 1 }\nend\n"; + // grammar (grammar-dispatch rule 2). `attr_accessor :x, foo: 1` + // puts a `hash_key_symbol` inside this very argument list, but + // wrapped in a `pair` — tree-sitter-ruby 0.23.1 emits it only as + // a `pair` / `keyword_pattern` key, never as a direct argument. + // + // expected: na = 1 (x), npa = 1. A grammar that promoted + // `hash_key_symbol` to a direct argument would reach the arm and + // report 2, counting a keyword argument as an attribute. + let source = "class A\n attr_accessor :x, foo: 1\nend\n"; let parser = RubyParser::new(source.as_bytes().to_vec(), &PathBuf::from("foo.rb"), None); assert!( ast_has_kind_id(&parser, Ruby::HashKeySymbol as u16), "the fixture no longer produces a `hash_key_symbol`; the \ - negative claim below is then vacuous" - ); - assert!( - !ast_has_kind_id(&parser, Ruby::BareSymbol as u16), - "`bare_symbol` now appears outside a `%i[…]` array; \ - re-derive `ruby_symbol_argument_count`" + claim below is then vacuous" ); check_metrics::(source, "foo.rb", |metric| { assert_eq!(metric.npa.class_na_sum(), 1); From 811387d473b2ded0a12bc269b764de9b83d89508 Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Wed, 9 Sep 2026 00:40:01 -0700 Subject: [PATCH 14/20] fix(py): threshold nargs on the per-space own count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `bca.to_sarif(thresholds={"nargs": N})` compared `metrics.nargs.total`, the subtree sum, while `bca check --threshold nargs=N` has gated on the callable's own parameter list since #1196. The two front-ends therefore disagreed on which spaces breach, against `to_sarif`'s documented parity with `bca check --report-format sarif`: a two-argument function holding a three- and a two-argument closure scored 7 in the binding and 2 in the CLI, so closure-heavy code drew SARIF findings no CLI run produces. The binding walks JSON and no serialized key carried the gate's number — #1196 moved the extractor without one. Follow the #958 precedent and serialize it as `nargs.value`, additive and `#[serde(default)]`, alongside the unchanged `nargs.total`. The sum now has one definition, `nargs::Stats::own_args()`, which the CLI extractor, the report hotspot table and the wire projection call instead of spelling `function_args() + closure_args()` three times. `metric_catalog`'s documentary `skip_at_unit` flag was stale for the same reason and is corrected to `true`; its pinned set had asserted a JSON-vs-accessor parity that stopped holding at #1196. STABILITY.md now states what #958 and this change both practise — a `wire` struct field addition is additive within 2.x — and `_flatten.py`'s CSV-parity docstring names the five `*.value` columns CSV_HEADER does not carry. The baseline refresh also records two decreases from earlier commits on this branch, and `extract_summaries_inner`'s abc drops from 40.1 to 39.2 because `own_args()` is one call where the open-coded sum was two. Fixes #1236 --- .bca-baseline.toml | 12 +- STABILITY.md | 12 ++ big-code-analysis-book/src/metrics.md | 10 +- big-code-analysis-book/src/python/sarif.md | 18 +- big-code-analysis-cli/src/markdown_report.rs | 2 +- big-code-analysis-cli/src/thresholds.rs | 15 +- .../python/big_code_analysis/_flatten.py | 25 ++- .../python/big_code_analysis/_native.pyi | 9 +- .../python/big_code_analysis/_types.py | 5 +- big-code-analysis-py/src/sarif.rs | 79 +++++---- big-code-analysis-py/src/types_codegen.rs | 5 +- big-code-analysis-py/tests/test_sarif.py | 131 ++++++++++++++- big-code-analysis-web/src/web/server_tests.rs | 10 +- src/metric_catalog.rs | 70 +++++--- src/metrics/nargs.rs | 158 +++++++++++++++++- ...rgs__tests__kotlin_anonymous_function.snap | 1 + ...cs__nargs__tests__kotlin_default_args.snap | 1 + ...cs__nargs__tests__kotlin_empty_lambda.snap | 1 + ...s__nargs__tests__kotlin_multiple_args.snap | 1 + ...rics__nargs__tests__kotlin_single_arg.snap | 1 + ...trics__nargs__tests__kotlin_zero_args.snap | 1 + ..._nargs__tests__lua_colon_method_nargs.snap | 1 + ..._metrics__nargs__tests__lua_functions.snap | 1 + ..._tests__lua_no_functions_and_closures.snap | 1 + ...ics__nargs__tests__lua_single_closure.snap | 1 + ...cs__nargs__tests__lua_single_function.snap | 1 + ...cs__nargs__tests__lua_vararg_function.snap | 1 + ...etrics__nargs__tests__tcl_args_vararg.snap | 1 + ...etrics__nargs__tests__tcl_default_arg.snap | 1 + ..._metrics__nargs__tests__tcl_functions.snap | 1 + ...s__nargs__tests__tcl_nested_functions.snap | 1 + ...trics__nargs__tests__tcl_no_functions.snap | 1 + ...cs__nargs__tests__tcl_single_function.snap | 1 + ...s__tests__tcl_single_function_no_args.snap | 1 + src/wire/metrics.rs | 19 ++- tests/repositories/big-code-analysis-output | 2 +- 36 files changed, 487 insertions(+), 114 deletions(-) diff --git a/.bca-baseline.toml b/.bca-baseline.toml index bb5aa32cf..19b29f1e9 100644 --- a/.bca-baseline.toml +++ b/.bca-baseline.toml @@ -285,7 +285,7 @@ value = 5.0 path = "big-code-analysis-cli/src/markdown_report.rs" qualified = "extract_summaries_inner" metric = "abc" -value = 40.124805295477756 +value = 39.153543900903784 [[entry]] path = "big-code-analysis-cli/src/markdown_report.rs" @@ -531,7 +531,7 @@ value = 6.0 path = "big-code-analysis-py/src/types_codegen.rs" qualified = "" metric = "loc.ploc" -value = 745.0 +value = 748.0 [[entry]] path = "big-code-analysis-py/src/vcs.rs" @@ -1029,13 +1029,7 @@ value = 5.0 path = "src/metrics/loc/perl.rs" qualified = "PerlCode::compute" metric = "halstead.effort" -value = 52134.594881912846 - -[[entry]] -path = "src/metrics/loc/shared.rs" -qualified = "add_multiline_string_ploc" -metric = "nargs" -value = 5.0 +value = 51486.205050501565 [[entry]] path = "src/metrics/npa/python.rs" diff --git a/STABILITY.md b/STABILITY.md index 6e80601b4..e01c2d03d 100644 --- a/STABILITY.md +++ b/STABILITY.md @@ -522,6 +522,18 @@ serialized shape** (the compute types' own `Serialize` impls delegate to them), so the document a compute type emits is exactly the document the matching `wire` type parses. +**Field additions are additive within `2.x`.** A `wire` struct gains a +field whenever the serialized shape does — `cyclomatic.value`, +`cyclomatic.modified.value`, `cognitive.value` and `abc.value` in #958, +`nargs.value` in #1236 — and each new field carries a serde default, so +a document written by an older version still parses. These structs +carry public fields and no `#[non_exhaustive]`, so a field addition +*does* break code that builds one with struct-literal syntax or +destructures it exhaustively; treat them the way the *Per-metric +`Stats`* rule above asks — read fields by name, construct by +deserializing rather than by literal — and a field addition costs you +nothing. Removing or renaming a field remains a `3.0` break. + **Round-trip contract.** For a value produced by this library and read back with *this library's* serde stack: diff --git a/big-code-analysis-book/src/metrics.md b/big-code-analysis-book/src/metrics.md index 11205c291..4dae80c09 100644 --- a/big-code-analysis-book/src/metrics.md +++ b/big-code-analysis-book/src/metrics.md @@ -778,8 +778,8 @@ is reported separately for *functions* and *closures* so a Rust file heavy on `|…| …` closures and a Java file with only methods produce comparable numbers. The serialised output (`src/metrics/nargs.rs`) is `function_args`, `closure_args`, -`function_args_average`, `closure_args_average`, `total`, `average`, -`function_args_min`, `function_args_max`, `closure_args_min`, +`function_args_average`, `closure_args_average`, `total`, `value`, +`average`, `function_args_min`, `function_args_max`, `closure_args_min`, `closure_args_max`. The implementation handles default arguments, variadic arguments, keyword-only arguments, and destructured parameters consistently per @@ -833,6 +833,12 @@ gate read that sum, so a three-parameter function with a two-parameter sort comparator was flagged at 5 — and the remediation its number implied, fewer parameters, was not the one that would clear it. +The number the gate does read is serialized as `value`, added in +[#1236](https://github.com/dekobon/big-code-analysis/issues/1236) so a +JSON-consuming front-end can reproduce the gate. Until then the Python +`to_sarif` binding had nothing but `total` to compare and reported +breaches `bca check` did not. + Nothing escapes the narrower rule. In the twelve grammars whose closures open their own space — Rust, JavaScript, TypeScript, TSX, MozJS, C#, Go, PHP, Perl, Ruby, Lua and Elixir — a closure is gated on its own offender diff --git a/big-code-analysis-book/src/python/sarif.md b/big-code-analysis-book/src/python/sarif.md index f888a8570..021db8f57 100644 --- a/big-code-analysis-book/src/python/sarif.md +++ b/big-code-analysis-book/src/python/sarif.md @@ -83,13 +83,17 @@ the upload to the repository's Code Scanning alerts. container, and each leaf function or closure — whose **own** value breaches its limit, exactly matching `bca check --report-format sarif`. For most metrics the JSON headline at a space already is that space's own value. -The four subtree-aggregate metrics — `cyclomatic`, -`cyclomatic.modified`, `cognitive`, and `abc` — additionally expose a -`sum` / `magnitude` rolled up across child spaces; the binding reads -their per-space `value` field instead, so it reports an interior breach -(for example a function whose own complexity breaches even though a -nested closure's does not) without being fooled by the larger -aggregate. +The five subtree-aggregate metrics — `cyclomatic`, +`cyclomatic.modified`, `cognitive`, `abc` and `nargs` — additionally +expose a `sum` / `magnitude` / `total` rolled up across child spaces; +the binding reads their per-space `value` field instead, so it reports +an interior breach (for example a function whose own complexity breaches +even though a nested closure's does not) without being fooled by the +larger aggregate. For `nargs` that means a function is gated on its own +parameter list, exactly as `bca check` has been since +[#1196](https://github.com/dekobon/big-code-analysis/issues/1196); a +closure with its own space produces its own finding rather than +inflating the enclosing function's. Unit findings carry `logicalLocations: [{"fullyQualifiedName": ""}]`. Every other space carries its qualified symbol. Within diff --git a/big-code-analysis-cli/src/markdown_report.rs b/big-code-analysis-cli/src/markdown_report.rs index 86c60a861..65d0a6edd 100644 --- a/big-code-analysis-cli/src/markdown_report.rs +++ b/big-code-analysis-cli/src/markdown_report.rs @@ -203,7 +203,7 @@ fn extract_summaries_inner( // so reading different quantities let `bca check` exit 0 // while `bca report` flagged the same function under "Many // parameters" for a number the gate no longer enforced. - nargs: (m.nargs.function_args() + m.nargs.closure_args()) as usize, + nargs: m.nargs.own_args() as usize, nexits: m.nexits.nexits_sum() as usize, nom: m.nom.total() as usize, abc: m.abc.magnitude(), diff --git a/big-code-analysis-cli/src/thresholds.rs b/big-code-analysis-cli/src/thresholds.rs index cc01e0194..db3ef3a42 100644 --- a/big-code-analysis-cli/src/thresholds.rs +++ b/big-code-analysis-cli/src/thresholds.rs @@ -75,7 +75,7 @@ struct MetricExtractor { /// /// - *Per-space own* value — `cognitive()`, `cyclomatic()`, /// `cyclomatic_modified()`, the `halstead.*`, `mi.*`, `loc.*`, and -/// `abc` accessors, plus `nargs`'s `function_args() + closure_args()`. +/// `abc` accessors, plus `nargs`'s `own_args()`. /// These read the value for the single function space under test, /// without rolling up nested children. /// - *Sum / total over the subtree* — `tokens_sum()`, `nexits_sum()`, @@ -86,10 +86,13 @@ struct MetricExtractor { /// `nargs` moved from the second group to the first in #1196. It is the /// one metric whose subtree sum was actively misleading: a closure's /// parameters are not part of the enclosing function's signature, and -/// every comparable tool counts one callable at a time. Note the -/// serialized `nargs` keys are still subtree sums — only the gate's -/// reading changed — so this is also the one entry where the extractor -/// and the JSON field of the same name disagree. +/// every comparable tool counts one callable at a time. The serialized +/// `nargs.total` key is still the subtree sum, so the gate no longer +/// reads the JSON field of the same name; the value it does read is +/// serialized alongside as `nargs.value` (#1236), which is how the +/// JSON-walking `to_sarif` binding reaches it. Adding an extractor whose +/// accessor has no serialized counterpart re-opens that divergence — the +/// two front-ends then disagree about which spaces breach. /// /// The split follows each metric's library accessor and its natural unit /// of measurement; it is intentional, not an oversight. When adding a new @@ -208,7 +211,7 @@ const EXTRACTORS: &[MetricExtractor] = &[ // anchors `default_thresholds.rs` derives the shipped limit from, // so before this the default was calibrated against a different // quantity than the gate enforced. - extract: |m| (m.nargs.function_args() + m.nargs.closure_args()) as f64, + extract: |m| m.nargs.own_args() as f64, metric: Metric::Nargs, }, MetricExtractor { diff --git a/big-code-analysis-py/python/big_code_analysis/_flatten.py b/big-code-analysis-py/python/big_code_analysis/_flatten.py index 4366513d8..9be5358f3 100644 --- a/big-code-analysis-py/python/big_code_analysis/_flatten.py +++ b/big-code-analysis-py/python/big_code_analysis/_flatten.py @@ -10,17 +10,24 @@ scalar-only ``dict`` per ``FuncSpace`` node. Metric keys use the same dotted convention as the CLI's CSV writer (see ``src/output/csv.rs::CSV_HEADER``) — ``cyclomatic.modified.sum``, -``halstead.volume``, ``loc.lloc_average``, … Metric *columns* line -up with CSV_HEADER's metric columns; the identity columns are +``halstead.volume``, ``loc.lloc_average``, … The identity columns are **not** identical (CSV uses ``space_name`` / ``space_kind`` and omits ``parent_name`` / ``depth``; flat records use ``name`` / -``kind`` and add the parent / depth pair). One known metric also -diverges: the ``tokens`` family flattens to ``tokens.tokens``, -``tokens.average``, ``tokens.min``, ``tokens.max`` (the JSON shape). -Only the sum leaf differs from CSV_HEADER, which spells it -``tokens.sum``; the ``average`` / ``min`` / ``max`` leaves now match -(#590). Rename the sum leaf in the consumer if you need an exact CSV -match. +``kind`` and add the parent / depth pair), and the metric columns are +a **superset** of CSV_HEADER's rather than a match: + +* ``cognitive.value``, ``cyclomatic.value``, + ``cyclomatic.modified.value`` and ``abc.value`` (#958) plus + ``nargs.value`` (#1236) are each a space's own per-space scalar — + the number ``bca check`` thresholds against. They exist in the JSON + shape and in these records; CSV_HEADER, a frozen positional + contract, does not carry them. +* The ``tokens`` family flattens to ``tokens.tokens``, + ``tokens.average``, ``tokens.min``, ``tokens.max`` (the JSON shape). + Only the sum leaf differs from CSV_HEADER, which spells it + ``tokens.sum``; the ``average`` / ``min`` / ``max`` leaves now match + (#590). Rename the sum leaf in the consumer if you need an exact CSV + match. Both walkers (the space tree and each space's metrics subtree) use explicit stacks rather than recursion, so pathological inputs — diff --git a/big-code-analysis-py/python/big_code_analysis/_native.pyi b/big-code-analysis-py/python/big_code_analysis/_native.pyi index 0d99510c8..c5d8780b2 100644 --- a/big-code-analysis-py/python/big_code_analysis/_native.pyi +++ b/big-code-analysis-py/python/big_code_analysis/_native.pyi @@ -992,10 +992,13 @@ def to_sarif( its limit, exactly matching ``bca check --report-format sarif``. Emission is scope-gated per metric (``loc.*`` at the file unit, ``nom`` / ``wmc`` / ``npm`` / ``npa`` at containers, the rest at - function spaces), and the four subtree-aggregate metrics + function spaces), and the five subtree-aggregate metrics (``cyclomatic``, ``cyclomatic.modified``, ``cognitive``, - ``abc``) read the per-space ``value`` field rather than the - rolled-up aggregate (#958, #969). + ``abc``, ``nargs``) read the per-space ``value`` field rather + than the rolled-up aggregate (#958, #969, #1236). For ``nargs`` + that means a function is scored on its own parameter list, with + each closure that opens a space of its own scored on its own + finding — never on the sum of the two. Unit-level findings carry ``logicalLocations: [{"fullyQualifiedName": ""}]``; every other space carries its qualified symbol. Within that symbol, a closure/lambda (the ```` name every grammar diff --git a/big-code-analysis-py/python/big_code_analysis/_types.py b/big-code-analysis-py/python/big_code_analysis/_types.py index 92ff7b4d1..2cb360a0a 100644 --- a/big-code-analysis-py/python/big_code_analysis/_types.py +++ b/big-code-analysis-py/python/big_code_analysis/_types.py @@ -192,13 +192,16 @@ class MiDict(TypedDict): class NargsDict(TypedDict): - """Number-of-arguments metric block.""" + """Number-of-arguments metric block. `value` is this space's own argument count, excluding + nested function/closure spaces (#1236); `total` is the subtree aggregate. + """ function_args: int closure_args: int function_args_average: float | None closure_args_average: float | None total: int + value: int average: float | None function_args_min: int function_args_max: int diff --git a/big-code-analysis-py/src/sarif.rs b/big-code-analysis-py/src/sarif.rs index 6d48a346f..771c10427 100644 --- a/big-code-analysis-py/src/sarif.rs +++ b/big-code-analysis-py/src/sarif.rs @@ -47,19 +47,32 @@ //! //! For most metrics the JSON's headline field at any space already IS //! that space's own value (e.g. `loc.sloc`, `wmc.total`, `mi.original`, -//! `halstead.volume`). Four metrics — `cyclomatic`, `cyclomatic.modified`, -//! `cognitive`, and `abc` — additionally expose a `sum`/`*_sum` aggregate -//! across child spaces that, at interior spaces, exceeds the per-space -//! scalar the CLI thresholds against. Before #958 the wire shape exposed -//! *only* that aggregate for the four, so the binding could compare it -//! safely only at leaf spaces (where aggregate == own) and conservatively -//! skipped interior spaces — under-emitting the interior breaches the CLI -//! reports (#855 closed the over-emission; this residual under-emission -//! remained). As of #958 the wire shape serializes each of the four's -//! per-space own value alongside the aggregate (`cyclomatic.value`, -//! `cyclomatic.modified.value`, `cognitive.value`, `abc.value`), so -//! [`METRIC_FIELDS`] points every metric at the own-value field and the -//! binding emits at every space — no leaf-only special-casing remains. +//! `halstead.volume`). Five metrics — `cyclomatic`, `cyclomatic.modified`, +//! `cognitive`, `abc`, and `nargs` — additionally expose a +//! `sum`/`*_sum`/`total` aggregate across child spaces that, at interior +//! spaces, exceeds the per-space scalar the CLI thresholds against. +//! Before #958 the wire shape exposed *only* that aggregate for the first +//! four, so the binding could compare it safely only at leaf spaces +//! (where aggregate == own) and conservatively skipped interior spaces — +//! under-emitting the interior breaches the CLI reports (#855 closed the +//! over-emission; this residual under-emission remained). As of #958 the +//! wire shape serializes those four spaces' own values alongside the +//! aggregate (`cyclomatic.value`, `cyclomatic.modified.value`, +//! `cognitive.value`, `abc.value`), so [`METRIC_FIELDS`] points every +//! metric at the own-value field and the binding emits at every space — +//! no leaf-only special-casing remains. +//! +//! `nargs` is the fifth and arrived by the opposite route: its serialized +//! shape did not change, its *gate* did. #1196 moved the CLI extractor +//! from `total()` to the callable's own parameter list, leaving this +//! binding comparing the subtree sum — so a two-argument function +//! containing a three-argument closure scored 5 here and 2 in +//! `bca check`. #1236 serialized the own value as `nargs.value` and +//! pointed this table at it. The lesson generalises: a threshold whose +//! CLI accessor has no serialized counterpart cannot be evaluated by a +//! JSON-walking front-end, and neither drift guard below can see it — +//! they pin metric *names* and JSON *paths*, not the semantics of the +//! field a path reaches. //! //! Defaults: `thresholds=None` is equivalent to `thresholds={}` — the //! CLI itself has no built-in defaults (every check run must supply @@ -100,9 +113,9 @@ const FILE_SYMBOL: &str = ""; /// Every entry's `path` reaches that space's **own** per-space scalar — /// the value the CLI thresholds against — so a breach is emitted at each /// space level the metric's scope admits (#969). For `cyclomatic`, -/// `cyclomatic.modified`, -/// `cognitive`, and `abc` that scalar is the `value` field the wire -/// shape gained in #958 (the sibling `sum`/`magnitude` field is a +/// `cyclomatic.modified`, `cognitive` and `abc` that scalar is the +/// `value` field the wire shape gained in #958, and for `nargs` the one +/// it gained in #1236 (the sibling `sum`/`magnitude`/`total` field is a /// subtree aggregate and is deliberately *not* used here); for every /// other metric the headline JSON field already is the per-space value. #[derive(Clone, Copy)] @@ -130,11 +143,11 @@ struct MetricField { /// pinned by `metric_field_paths_are_pinned` so a path edit is a /// deliberate, reviewed change rather than silent drift. const METRIC_FIELDS: &[MetricField] = &[ - // The four aggregate-shaped metrics read the per-space `value` field - // (the wire shape's own-scalar projection added in #958), never the - // sibling `sum`/`magnitude` subtree aggregate — so a breach is - // reported at every space exactly as the CLI's per-space accessor - // does (#441, #855, #958). + // The five aggregate-shaped metrics read the per-space `value` field + // (the wire shape's own-scalar projection added in #958, extended to + // `nargs` in #1236), never the sibling `sum`/`magnitude`/`total` + // subtree aggregate — so a breach is reported at every space exactly + // as the CLI's per-space accessor does (#441, #855, #958, #1236). MetricField { name: "cognitive", path: &["cognitive", "value"], @@ -151,6 +164,15 @@ const METRIC_FIELDS: &[MetricField] = &[ name: "abc", path: &["abc", "value"], }, + // `nargs` joined this group in #1236. Its gate has read the + // callable's own parameter list since #1196, while `nargs.total` + // kept its meaning as the subtree sum, so the binding scored a + // two-argument function containing a three-argument closure at 5 — + // a finding `bca check` never reports. + MetricField { + name: "nargs", + path: &["nargs", "value"], + }, // Everything below: the headline JSON field already is the CLI's // per-space accessor. MetricField { @@ -205,10 +227,6 @@ const METRIC_FIELDS: &[MetricField] = &[ name: "nexits", path: &["nexits", "sum"], }, - MetricField { - name: "nargs", - path: &["nargs", "total"], - }, MetricField { name: "mi.original", path: &["mi", "original"], @@ -788,10 +806,11 @@ mod tests { /// build failure. /// /// The registry's `skip_at_unit` flag is no longer mirrored here: - /// since #958 every metric reads its per-space own value (the four - /// aggregate-shaped metrics via their `value` field), so the binding - /// emits at every space and the flag has no consumer in this front-end - /// (it still documents the `sum`-field divergence in the registry). + /// since #958 (and #1236 for `nargs`) every metric reads its + /// per-space own value — the five aggregate-shaped metrics via their + /// `value` field — so the binding emits at every space and the flag + /// has no consumer in this front-end (it still documents the + /// aggregate-field divergence in the registry). #[test] fn metric_fields_agree_with_shared_registry() { use big_code_analysis::metric_catalog::METRICS; @@ -825,6 +844,7 @@ mod tests { ("cyclomatic", &["cyclomatic", "value"]), ("cyclomatic.modified", &["cyclomatic", "modified", "value"]), ("abc", &["abc", "value"]), + ("nargs", &["nargs", "value"]), ("halstead.volume", &["halstead", "volume"]), ("halstead.difficulty", &["halstead", "difficulty"]), ("halstead.effort", &["halstead", "effort"]), @@ -838,7 +858,6 @@ mod tests { ("nom", &["nom", "total"]), ("tokens", &["tokens", "tokens"]), ("nexits", &["nexits", "sum"]), - ("nargs", &["nargs", "total"]), ("mi.original", &["mi", "original"]), ("mi.sei", &["mi", "sei"]), ("mi.visual_studio", &["mi", "visual_studio"]), diff --git a/big-code-analysis-py/src/types_codegen.rs b/big-code-analysis-py/src/types_codegen.rs index 812c9f723..b7c3ec53c 100644 --- a/big-code-analysis-py/src/types_codegen.rs +++ b/big-code-analysis-py/src/types_codegen.rs @@ -277,13 +277,16 @@ const SPECS: &[DictSpec] = &[ }, DictSpec { class: "NargsDict", - doc: "Number-of-arguments metric block.", + doc: "Number-of-arguments metric block. `value` is this space's \ + own argument count, excluding nested function/closure \ + spaces (#1236); `total` is the subtree aggregate.", fields: &[ req("function_args", Int), req("closure_args", Int), req("function_args_average", Float), req("closure_args_average", Float), req("total", Int), + req("value", Int), req("average", Float), req("function_args_min", Int), req("function_args_max", Int), diff --git a/big-code-analysis-py/tests/test_sarif.py b/big-code-analysis-py/tests/test_sarif.py index 536c99df2..e3484ffe7 100644 --- a/big-code-analysis-py/tests/test_sarif.py +++ b/big-code-analysis-py/tests/test_sarif.py @@ -54,6 +54,13 @@ def _cli_check_sarif(bca_path: str, path: Path, *, threshold: str) -> dict[str, argv = [ bca_path, "check", + # Hermetic: without this the run inherits the repository's own + # `bca.toml` (pytest's cwd is inside the checkout), so every + # manifest threshold and `[check]` key joins the reference run + # alongside the one metric under test. Harmless today, but these + # tests assert exact finding sets, so a future manifest edit + # would surface as a binding divergence that is not one. + "--no-config", "--threshold", threshold, "-O", @@ -676,6 +683,107 @@ def _qualified_names(doc: dict[str, Any]) -> set[str]: ) +def _sarif_rows(results: list[dict[str, Any]]) -> list[tuple[int, str, str]]: + """Reduce SARIF results to ``(startLine, fullyQualifiedName, message)`` + triples in a deterministic order, so a test can compare them against a + hand-written sequence instead of two structurally-equal containers. + + The sort is a normalisation of the *observed* value, which is normally + the wrong side to normalise — but emission order genuinely differs + between the two front-ends and is not what these tests are about. The + binding walks the space tree with an explicit LIFO stack, so it emits + sibling spaces in reverse relative to ``bca check``: on the fixture + below it reports ``outer``, ````, ```` where the CLI + reports ``outer``, ````, ````. ``_assert_sarif_results_match`` + sorts for the same reason. Ordering therefore has to be pinned + somewhere else if it is ever made part of the parity contract. + """ + return [ + ( + int(r["locations"][0]["physicalLocation"]["region"]["startLine"]), + r["locations"][0]["logicalLocations"][0]["fullyQualifiedName"], + r["message"]["text"], + ) + for r in sorted(results, key=_sarif_sort_key) + ] + + +def test_to_sarif_matches_cli_check_for_nargs_own_parameter_list( + bca_binary: str, tmp_path: Path +) -> None: + """CLI parity for ``nargs``, which gates a callable's **own** + parameter list (#1196) rather than the ``nargs.total`` subtree sum + the JSON headline reports. + + The fixture is #1236's reproducer: ``outer`` declares two parameters + and contains a three- and a two-parameter closure, so the subtree sum + is 7 while no space owns more than 3. The binding compared + ``nargs.total`` and reported ``outer`` at 7 against a limit of 5 — a + finding ``bca check`` never emits. A fixture whose own count equals + its total would pass whichever field the binding reads. + """ + src = tmp_path / "closures.rs" + src.write_text( + "fn outer(a: i32, b: i32) {\n" + " let f = |x: i32, y: i32, z: i32| x + y + z;\n" + " let g = |p: i32, q: i32| p + q;\n" + " f(a, b, 0);\n" + " g(a, b);\n" + "}\n" + ) + + analyzed = bca.analyze(src) + assert analyzed is not None, "fixture must not be skipped" + # The two fields must disagree at the root, or nothing below can fail: + # `total` rolls the whole file up, `value` is the root's own count. + root_nargs = analyzed["metrics"]["nargs"] + assert (root_nargs["total"], root_nargs["value"]) == (7, 0), ( + f"fixture must separate the subtree sum from the per-space own count; got {root_nargs!r}" + ) + + # A limit of 5 sits above every space's own count (2, 3, 2) and below + # the subtree sum (7) — the exact gap the bug lived in. + py_findings = _parse(bca.to_sarif(analyzed, thresholds={"nargs": 5}))["runs"][0]["results"] + cli_findings = _cli_check_sarif(bca_binary, src, threshold="nargs=5")["runs"][0]["results"] + assert _sarif_rows(py_findings) == [], ( + f"no space owns more than 3 arguments; got {_sarif_rows(py_findings)!r}" + ) + assert _sarif_rows(cli_findings) == [], ( + f"CLI reference must agree; got {_sarif_rows(cli_findings)!r}" + ) + + # A limit of 1 puts all three callables over, each scored on its own + # list: the closures are separate findings, not part of `outer`'s. + py_doc = _parse(bca.to_sarif(analyzed, thresholds={"nargs": 1})) + cli_doc = _cli_check_sarif(bca_binary, src, threshold="nargs=1") + py_results = py_doc["runs"][0]["results"] + cli_results = cli_doc["runs"][0]["results"] + expected = [ + (1, "outer", "nargs 2 exceeds limit 1"), + (2, "outer::", "nargs 3 exceeds limit 1"), + (3, "outer::", "nargs 2 exceeds limit 1"), + ] + assert _sarif_rows(py_results) == expected + assert _sarif_rows(cli_results) == expected + _assert_sarif_results_match(py_results, cli_results) + + # The exact-limit value is acceptable in both front-ends (strict `>`, + # #698): at 3 the three-argument closure ties and is not reported; at + # 2 it is the only breach. + for limit, want in ( + (3, []), + (2, [(2, "outer::", "nargs 3 exceeds limit 2")]), + ): + py_rows = _sarif_rows( + _parse(bca.to_sarif(analyzed, thresholds={"nargs": limit}))["runs"][0]["results"] + ) + cli_rows = _sarif_rows( + _cli_check_sarif(bca_binary, src, threshold=f"nargs={limit}")["runs"][0]["results"] + ) + assert py_rows == want, f"binding at nargs={limit}: {py_rows!r}" + assert cli_rows == want, f"CLI at nargs={limit}: {cli_rows!r}" + + def test_to_sarif_anonymous_space_collapses_to_anon_line() -> None: """A space whose name is the literal ```` (every grammar's closure/lambda sentinel) collapses to ````, @@ -870,34 +978,39 @@ def test_to_sarif_emits_container_level_finding_for_oo_metrics() -> None: def _own_value_block(metric_name: str, own: float) -> dict[str, Any]: """Build a minimal ``metrics`` sub-dict carrying ``own`` at the path - the binding thresholds ``metric_name`` against since #958 (the - per-space ``value`` field, or ``modified.value`` for the modified - variant). Only the walked path needs to be present — ``extract_metric`` - ignores the sibling aggregate/min/max keys — so the fixtures stay - readable. + the binding thresholds ``metric_name`` against since #958 — #1236 for + ``nargs`` — (the per-space ``value`` field, or ``modified.value`` for + the modified variant). Only the walked path needs to be present — + ``extract_metric`` ignores the sibling aggregate/min/max keys — so the + fixtures stay readable. """ if metric_name == "cyclomatic.modified": return {"cyclomatic": {"modified": {"value": own}}} if metric_name == "cyclomatic": return {"cyclomatic": {"value": own}} - if metric_name in ("cognitive", "abc"): + if metric_name in ("cognitive", "abc", "nargs"): return {metric_name: {"value": own}} raise AssertionError(f"unhandled metric {metric_name!r}") @pytest.mark.parametrize( "metric_name", - ["cyclomatic", "cyclomatic.modified", "cognitive", "abc"], + ["cyclomatic", "cyclomatic.modified", "cognitive", "abc", "nargs"], ) def test_to_sarif_emits_interior_space_when_own_value_breaches(metric_name: str) -> None: """#958: an interior space (here a function owning a nested closure) whose *own* value breaches the limit is now reported — exactly as the - CLI's per-space accessor does. For these four metrics the JSON exposes - a subtree aggregate (``sum``/``magnitude``) *and*, since #958, the + CLI's per-space accessor does. For these five metrics the JSON exposes + a subtree aggregate (``sum``/``magnitude``/``total``) *and* the per-space ``value``; the binding reads ``value``, so it no longer has to skip interior spaces. Before #958 it could read only the aggregate, so it skipped every interior space and silently under-emitted this breach (the residual gap #855's leaf-only fix left open). + + ``nargs`` is the fifth and joined by the opposite route: its + serialized shape did not change in #958, its *gate* changed in #1196, + and #1236 serialized the value that gate reads (see the CLI-parity + test above for the end-to-end form). """ # outer.value (5) breaches the limit (3); the closure it owns and the # file unit stay below it, so only `outer` may be reported. diff --git a/big-code-analysis-web/src/web/server_tests.rs b/big-code-analysis-web/src/web/server_tests.rs index b0f366ae7..3a6252496 100644 --- a/big-code-analysis-web/src/web/server_tests.rs +++ b/big-code-analysis-web/src/web/server_tests.rs @@ -617,7 +617,7 @@ async fn test_web_metrics_json() { "end_line": 4, "metrics": {"cyclomatic": {"sum": 2, "value": 1, "average": 2.0, "min":1, "max":1, "modified": {"sum": 2, "value": 1, "average": 2.0, "min":1, "max":1}}, "cognitive": {"sum": 0, "value": 0, "average": 0.0, "min":0, "max":0}, - "nargs": {"function_args": 0, "function_args_average": 0.0, "closure_args": 0, "closure_args_average": 0.0, "total": 0, "average": 0.0, "closure_args_max": 0, "closure_args_min": 0, "function_args_max": 0, "function_args_min": 0}, + "nargs": {"function_args": 0, "function_args_average": 0.0, "closure_args": 0, "closure_args_average": 0.0, "total": 0, "value": 0, "average": 0.0, "closure_args_max": 0, "closure_args_min": 0, "function_args_max": 0, "function_args_min": 0}, "nexits": {"sum": 0, "average": 0.0,"min":0,"max":0}, "halstead": {"bugs": 0.000_942_552_557_372_941_4, "difficulty": 1.0, @@ -649,7 +649,7 @@ async fn test_web_metrics_json() { "end_line": 4, "metrics": {"cyclomatic": {"sum": 1, "value": 1, "average": 1.0, "min":1, "max":1, "modified": {"sum": 1, "value": 1, "average": 1.0, "min":1, "max":1}}, "cognitive": {"sum": 0, "value": 0, "average": 0.0 , "min":0, "max":0}, - "nargs": {"function_args": 0, "function_args_average": 0.0, "closure_args": 0, "closure_args_average": 0.0, "total": 0, "average": 0.0, "closure_args_max": 0, "closure_args_min": 0, "function_args_max": 0, "function_args_min": 0}, + "nargs": {"function_args": 0, "function_args_average": 0.0, "closure_args": 0, "closure_args_average": 0.0, "total": 0, "value": 0, "average": 0.0, "closure_args_max": 0, "closure_args_min": 0, "function_args_max": 0, "function_args_min": 0}, "nexits": {"sum": 0, "average": 0.0,"min":0,"max":0}, "halstead": {"bugs": 0.000_942_552_557_372_941_4, "difficulty": 1.0, @@ -710,7 +710,7 @@ async fn test_web_metrics_json_unit() { "end_line": 2, "metrics": {"cyclomatic": {"sum": 2, "value": 1, "average": 2.0, "min":1, "max":1, "modified": {"sum": 2, "value": 1, "average": 2.0, "min":1, "max":1}}, "cognitive": {"sum": 0, "value": 0, "average": 0.0 , "min":0, "max":0}, - "nargs": {"function_args": 0, "function_args_average": 0.0, "closure_args": 0, "closure_args_average": 0.0, "total": 0, "average": 0.0, "closure_args_max": 0, "closure_args_min": 0, "function_args_max": 0, "function_args_min": 0}, + "nargs": {"function_args": 0, "function_args_average": 0.0, "closure_args": 0, "closure_args_average": 0.0, "total": 0, "value": 0, "average": 0.0, "closure_args_max": 0, "closure_args_min": 0, "function_args_max": 0, "function_args_min": 0}, "nexits": {"sum": 0, "average": 0.0,"min":0,"max":0}, "halstead": {"bugs": 0.000_942_552_557_372_941_4, "difficulty": 1.0, @@ -816,7 +816,7 @@ async fn test_web_metrics_plain() { "end_line": 2, "metrics": {"cyclomatic": {"sum": 2, "value": 1, "average": 2.0, "min": 1,"max": 1, "modified": {"sum": 2, "value": 1, "average": 2.0, "min": 1,"max": 1}}, "cognitive": {"sum": 0, "value": 0, "average": 0.0 , "min":0, "max":0}, - "nargs": {"function_args": 0, "function_args_average": 0.0, "closure_args": 0, "closure_args_average": 0.0, "total": 0, "average": 0.0, "closure_args_max": 0, "closure_args_min": 0, "function_args_max": 0, "function_args_min": 0}, + "nargs": {"function_args": 0, "function_args_average": 0.0, "closure_args": 0, "closure_args_average": 0.0, "total": 0, "value": 0, "average": 0.0, "closure_args_max": 0, "closure_args_min": 0, "function_args_max": 0, "function_args_min": 0}, "nexits": {"sum": 0, "average": 0.0,"min":0,"max":0}, "halstead": {"bugs": 0.000_942_552_557_372_941_4, "difficulty": 1.0, @@ -848,7 +848,7 @@ async fn test_web_metrics_plain() { "end_line": 2, "metrics": {"cyclomatic": {"sum": 1, "value": 1, "average": 1.0, "min": 1,"max": 1, "modified": {"sum": 1, "value": 1, "average": 1.0, "min": 1,"max": 1}}, "cognitive": {"sum": 0, "value": 0, "average": 0.0 , "min":0, "max":0}, - "nargs": {"function_args": 0, "function_args_average": 0.0, "closure_args": 0, "closure_args_average": 0.0, "total": 0, "average": 0.0, "closure_args_max": 0, "closure_args_min": 0, "function_args_max": 0, "function_args_min": 0}, + "nargs": {"function_args": 0, "function_args_average": 0.0, "closure_args": 0, "closure_args_average": 0.0, "total": 0, "value": 0, "average": 0.0, "closure_args_max": 0, "closure_args_min": 0, "function_args_max": 0, "function_args_min": 0}, "nexits": {"sum": 0, "average": 0.0,"min":0,"max":0}, "halstead": {"bugs": 0.000_942_552_557_372_941_4, "difficulty": 1.0, diff --git a/src/metric_catalog.rs b/src/metric_catalog.rs index 454bbe4eb..b1ccf1578 100644 --- a/src/metric_catalog.rs +++ b/src/metric_catalog.rs @@ -129,24 +129,27 @@ pub struct MetricInfo { /// the CLI threshold accessor's per-space scalar at any interior /// space. /// - /// `true` for the four metrics whose serialized JSON value diverges + /// `true` for the five metrics whose serialized headline diverges /// from the per-space accessor — `cognitive`, `cyclomatic`, - /// `cyclomatic.modified`, and `abc` (#441). The aggregate equals the - /// per-space scalar only at a leaf space (no descendant - /// function/closure spaces); at any interior space — the file-level - /// `unit` or a container with descendants — it is larger. + /// `cyclomatic.modified`, `abc` (#441) and, since #1196 moved its + /// gate onto the callable's own parameter list, `nargs`. The + /// aggregate is at least as large everywhere and equal only where + /// the descendants contribute nothing — always at a leaf space (no + /// descendant function/closure spaces), and incidentally at an + /// interior one whose descendants all score zero. /// - /// This flag describes the `sum`/`*_sum` *aggregate* field, which - /// still diverges. As of #958 the wire shape **also** serializes each - /// of these four metrics' per-space own value (`cyclomatic.value`, - /// `cyclomatic.modified.value`, `cognitive.value`, `abc.value`), so a - /// JSON-walking front-end no longer needs this flag to stay correct: - /// it reads the own value directly and emits at every space exactly - /// like the CLI. The Python `to_sarif` binding was switched to that - /// path in #958; before it, the binding emitted these only at leaf - /// spaces to avoid subtree-wide values masquerading as per-space - /// findings the CLI never produces (#855). The flag name retains its - /// original unit-only framing. + /// This flag describes the `sum`/`*_sum`/`total` *aggregate* field, + /// which still diverges. As of #958 (and #1236 for `nargs`) the wire + /// shape **also** serializes each of these metrics' per-space own + /// value (`cyclomatic.value`, `cyclomatic.modified.value`, + /// `cognitive.value`, `abc.value`, `nargs.value`), so a JSON-walking + /// front-end no longer needs this flag to stay correct: it reads the + /// own value directly and emits at every space exactly like the CLI. + /// The Python `to_sarif` binding was switched to that path in #958; + /// before it, the binding emitted these only at leaf spaces to avoid + /// subtree-wide values masquerading as per-space findings the CLI + /// never produces (#855). The flag name retains its original + /// unit-only framing. /// /// The flag is **not** derivable from the JSON path string: `nexits` /// also serialises a `sum` field, but its CLI accessor (`nexits_sum()`) @@ -223,7 +226,7 @@ pub const METRICS: &[MetricInfo] = &[ MetricInfo { id: "nom", family: "nom", long_description: "Number of methods/functions exceeds the configured threshold.", direction: Direction::HigherIsWorse, skip_at_unit: false, scope: MetricScope::Container }, MetricInfo { id: "tokens", family: "tokens", long_description: "Number of tokens exceeds the configured threshold.", direction: Direction::HigherIsWorse, skip_at_unit: false, scope: MetricScope::Function }, MetricInfo { id: "nexits", family: "nexits", long_description: "Number of exit points exceeds the configured threshold.", direction: Direction::HigherIsWorse, skip_at_unit: false, scope: MetricScope::Function }, - MetricInfo { id: "nargs", family: "nargs", long_description: "Number of function arguments exceeds the configured threshold.", direction: Direction::HigherIsWorse, skip_at_unit: false, scope: MetricScope::Function }, + MetricInfo { id: "nargs", family: "nargs", long_description: "Number of function arguments exceeds the configured threshold.", direction: Direction::HigherIsWorse, skip_at_unit: true, scope: MetricScope::Function }, MetricInfo { id: "mi.original", family: "mi", long_description: "Maintainability Index falls below the configured threshold.", direction: Direction::LowerIsWorse, skip_at_unit: false, scope: MetricScope::Function }, MetricInfo { id: "mi.sei", family: "mi", long_description: "Maintainability Index (SEI) falls below the configured threshold.", direction: Direction::LowerIsWorse, skip_at_unit: false, scope: MetricScope::Function }, MetricInfo { id: "mi.visual_studio", family: "mi", long_description: "Maintainability Index (Visual Studio) falls below the configured threshold.", direction: Direction::LowerIsWorse, skip_at_unit: false, scope: MetricScope::Function }, @@ -492,14 +495,23 @@ mod tests { } } - /// `skip_at_unit` is `true` for exactly the four metrics whose - /// serialized JSON headline at the file-level `unit` space is an - /// aggregate over descendant spaces that does not match the CLI - /// threshold accessor's per-space scalar (#441). The Python - /// `to_sarif` binding mirrors this registry; a cross-crate test in - /// `big-code-analysis-py/src/sarif.rs` pins its `METRIC_FIELDS` - /// table's flags to these values, so this set is the single source - /// of truth both front-ends derive from (#442). + /// `skip_at_unit` is `true` for exactly the metrics whose serialized + /// JSON headline at the file-level `unit` space is an aggregate over + /// descendant spaces that does not match the CLI threshold + /// accessor's per-space scalar (#441). + /// + /// Since #958 the flag has no consumer: both front-ends read a + /// per-space `value` field instead, and + /// `big-code-analysis-py/src/sarif.rs` pins its own JSON paths + /// (`metric_field_paths_are_pinned`) rather than deriving them from + /// this flag. So what this enumeration buys is a review tripwire, + /// not a derived contract — which is exactly what it failed to be + /// between #1196 and #1236: #1196 moved the `nargs` gate from + /// `total()` (a subtree sum) to the callable's own parameter list + /// while `nargs.total` kept its meaning, and the row here stayed + /// `false`, leaving the registry asserting a parity that no longer + /// held. Editing the set means re-deriving it against the CLI + /// `EXTRACTORS` accessors, one row at a time. /// /// The property is deliberately enumerated rather than derived from /// the id string: `nexits` also serialises a `sum` field but reads @@ -514,7 +526,13 @@ mod tests { skip.sort_unstable(); assert_eq!( skip, - ["abc", "cognitive", "cyclomatic", "cyclomatic.modified"], + [ + "abc", + "cognitive", + "cyclomatic", + "cyclomatic.modified", + "nargs" + ], "skip_at_unit set drifted from the JSON-aggregate-vs-CLI-accessor \ property; review against the CLI EXTRACTORS accessors before editing", ); diff --git a/src/metrics/nargs.rs b/src/metrics/nargs.rs index c7480d256..090508dc0 100644 --- a/src/metrics/nargs.rs +++ b/src/metrics/nargs.rs @@ -140,6 +140,24 @@ impl Stats { self.function_args_sum() + self.closure_args_sum() } + /// Returns this space's **own** argument count: its function + /// parameters plus the parameters of any lambda the language folds + /// into it (a lambda that opens no space of its own). + /// + /// This is the quantity `bca check --threshold nargs=N` gates on + /// since #1196, and the one the wire shape serializes as + /// `nargs.value` — as opposed to [`total`](Self::total), which sums + /// the whole subtree and so charges a function for every nested + /// callable's parameters. Named here rather than spelled out at + /// each call site so the CLI gate, the serialized field, and any + /// future front-end read one definition instead of three copies of + /// the same sum (#1236). + #[inline] + #[must_use] + pub fn own_args(&self) -> u64 { + self.function_args() + self.closure_args() + } + /// Returns the `NArgs` metric average value /// /// This value is computed dividing the `NArgs` value @@ -804,7 +822,7 @@ impl NArgs for GroovyCode { clippy::too_many_lines )] mod tests { - use crate::test_support::check_metrics_only_shim; + use crate::test_support::{check_func_space_only_shim, check_metrics_only_shim}; use super::*; @@ -812,6 +830,9 @@ mod tests { // what this module's `metric.nom.functions_sum()` / // `closures_sum()` assertions read. check_metrics_only_shim!(check_metrics, Nargs); + // The same selection, handed the whole space tree: the per-space own + // count is only observable one space at a time. + check_func_space_only_shim!(check_spaces, Nargs); /// Regression for #227: a `Stats::default()` that never sees an /// observation must not leak the `usize::MAX` sentinel for @@ -824,6 +845,70 @@ mod tests { assert_eq!(stats.closure_args_min(), 0); } + /// The per-space **own** argument count, at every space of a tree + /// where it differs from the subtree `total` at every level. + /// + /// `bca check --threshold nargs=N` has gated on `own_args()` since + /// #1196, and #1236 serialized it as `nargs.value` so the JSON-walking + /// `to_sarif` binding can reproduce that gate. Before #1236 the + /// binding compared `nargs.total` and reported `outer` at 7 — a + /// finding `bca check` never emits, because `outer` declares two + /// parameters and the other five belong to closures that are gated + /// on rows of their own. + /// + /// The fixture is the #1236 reproducer, picked because no space in it + /// has `own == total`: a fixture where the two agree passes whichever + /// one the code reads. + #[test] + fn own_args_excludes_nested_closure_spaces() { + fn walk(space: &FuncSpace, rows: &mut Vec<(String, usize, u64, u64)>) { + rows.push(( + space.name.clone().unwrap_or_default(), + space.start_line, + space.metrics.nargs.own_args(), + space.metrics.nargs.total(), + )); + for child in &space.spaces { + walk(child, rows); + } + } + + check_spaces::( + "fn outer(a: i32, b: i32) { + let f = |x: i32, y: i32, z: i32| x + y + z; + let g = |p: i32, q: i32| p + q; + f(a, b, 0); + g(a, b); + }", + "closures.rs", + |space| { + let mut rows = Vec::new(); + walk(&space, &mut rows); + // Hand-pinned in walk order, `(name, start_line, own, total)`. + // The file root owns no parameters; `outer` owns its two; + // each closure owns its own list. Every row's `total` is + // that space's own count plus its descendants'. + assert_eq!( + rows, + vec![ + ("closures.rs".to_owned(), 1, 0, 7), + ("outer".to_owned(), 1, 2, 7), + ("".to_owned(), 2, 3, 3), + ("".to_owned(), 3, 2, 2), + ], + ); + + // The serialized projection the Python `to_sarif` binding + // walks: `value` is the gate's number, `total` is not. + let outer = &space.spaces[0]; + let json = serde_json::to_value(&outer.metrics.nargs) + .expect("nargs serializes through its wire projection"); + assert_eq!(json["value"], 2, "nargs.value must be `outer`'s own args"); + assert_eq!(json["total"], 7, "nargs.total stays the subtree sum"); + }, + ); + } + #[test] fn python_no_functions_and_closures() { check_metrics::("a = 42", "foo.py", |metric| { @@ -837,6 +922,7 @@ mod tests { "function_args_average": 0.0, "closure_args_average": 0.0, "total": 0, + "value": 0, "average": 0.0, "function_args_min": 0, "function_args_max": 0, @@ -861,6 +947,7 @@ mod tests { "function_args_average": 0.0, "closure_args_average": 0.0, "total": 0, + "value": 0, "average": 0.0, "function_args_min": 0, "function_args_max": 0, @@ -885,6 +972,7 @@ mod tests { "function_args_average": 0.0, "closure_args_average": 0.0, "total": 0, + "value": 0, "average": 0.0, "function_args_min": 0, "function_args_max": 0, @@ -909,6 +997,7 @@ mod tests { "function_args_average": 0.0, "closure_args_average": 0.0, "total": 0, + "value": 0, "average": 0.0, "function_args_min": 0, "function_args_max": 0, @@ -938,6 +1027,7 @@ mod tests { "function_args_average": 2.0, "closure_args_average": 0.0, "total": 2, + "value": 0, "average": 2.0, "function_args_min": 0, "function_args_max": 2, @@ -970,6 +1060,7 @@ mod tests { "function_args_average": 2.0, "closure_args_average": 0.0, "total": 2, + "value": 0, "average": 2.0, "function_args_min": 0, "function_args_max": 2, @@ -1002,6 +1093,7 @@ mod tests { "function_args_average": 2.0, "closure_args_average": 0.0, "total": 2, + "value": 0, "average": 2.0, "function_args_min": 0, "function_args_max": 2, @@ -1032,6 +1124,7 @@ mod tests { "function_args_average": 2.0, "closure_args_average": 0.0, "total": 2, + "value": 0, "average": 2.0, "function_args_min": 0, "function_args_max": 2, @@ -1057,6 +1150,7 @@ mod tests { "function_args_average": 0.0, "closure_args_average": 1.0, "total": 1, + "value": 1, "average": 1.0, "function_args_min": 0, "function_args_max": 0, @@ -1081,6 +1175,7 @@ mod tests { "function_args_average": 0.0, "closure_args_average": 1.0, "total": 1, + "value": 0, "average": 1.0, "function_args_min": 0, "function_args_max": 0, @@ -1108,6 +1203,7 @@ mod tests { "function_args_average": 0.0, "closure_args_average": 2.0, "total": 2, + "value": 2, "average": 2.0, "function_args_min": 0, "function_args_max": 0, @@ -1133,6 +1229,7 @@ mod tests { "function_args_average": 0.0, "closure_args_average": 2.0, "total": 2, + "value": 0, "average": 2.0, "function_args_min": 0, "function_args_max": 0, @@ -1165,6 +1262,7 @@ mod tests { "function_args_average": 2.0, "closure_args_average": 0.0, "total": 4, + "value": 0, "average": 2.0, "function_args_min": 0, "function_args_max": 2, @@ -1195,6 +1293,7 @@ mod tests { "function_args_average": 2.5, "closure_args_average": 0.0, "total": 5, + "value": 0, "average": 2.5, "function_args_min": 0, "function_args_max": 3, @@ -1232,6 +1331,7 @@ mod tests { "function_args_average": 2.0, "closure_args_average": 0.0, "total": 4, + "value": 0, "average": 2.0, "function_args_min": 0, "function_args_max": 2, @@ -1266,6 +1366,7 @@ mod tests { "function_args_average": 2.5, "closure_args_average": 0.0, "total": 5, + "value": 0, "average": 2.5, "function_args_min": 0, "function_args_max": 3, @@ -1351,6 +1452,7 @@ mod tests { "function_args_average": 2.0, "closure_args_average": 0.0, "total": 4, + "value": 0, "average": 2.0, "function_args_min": 0, "function_args_max": 2, @@ -1385,6 +1487,7 @@ mod tests { "function_args_average": 2.5, "closure_args_average": 0.0, "total": 5, + "value": 0, "average": 2.5, "function_args_min": 0, "function_args_max": 3, @@ -1418,6 +1521,7 @@ mod tests { "function_args_average": 2.0, "closure_args_average": 0.0, "total": 4, + "value": 0, "average": 2.0, "function_args_min": 0, "function_args_max": 2, @@ -1448,6 +1552,7 @@ mod tests { "function_args_average": 2.5, "closure_args_average": 0.0, "total": 5, + "value": 0, "average": 2.5, "function_args_min": 0, "function_args_max": 3, @@ -1481,6 +1586,7 @@ mod tests { "function_args_average": 1.5, "closure_args_average": 1.0, "total": 5, + "value": 0, "average": 1.25, "function_args_min": 0, "function_args_max": 2, @@ -1516,6 +1622,7 @@ mod tests { "function_args_average": 1.5, "closure_args_average": 1.5, "total": 6, + "value": 0, "average": 1.5, "function_args_min": 0, "function_args_max": 2, @@ -1548,6 +1655,7 @@ mod tests { "function_args_average": 3.0, "closure_args_average": 1.5, "total": 6, + "value": 0, "average": 2.0, "function_args_min": 0, "function_args_max": 3, @@ -1584,6 +1692,7 @@ mod tests { "function_args_average": 3.0, "closure_args_average": 0.0, "total": 3, + "value": 0, "average": 3.0, "function_args_min": 0, "function_args_max": 3, @@ -1622,6 +1731,7 @@ mod tests { "function_args_average": 2.0, "closure_args_average": 0.0, "total": 2, + "value": 0, "average": 2.0, "function_args_min": 0, "function_args_max": 2, @@ -1662,6 +1772,7 @@ mod tests { "function_args_average": 2.0, "closure_args_average": 0.0, "total": 2, + "value": 0, "average": 2.0, "function_args_min": 0, "function_args_max": 2, @@ -1703,6 +1814,7 @@ mod tests { "function_args_average": 0.0, "closure_args_average": 2.0, "total": 2, + "value": 0, "average": 1.0, "function_args_min": 0, "function_args_max": 0, @@ -1744,6 +1856,7 @@ mod tests { "function_args_average": 1.0, "closure_args_average": 0.0, "total": 1, + "value": 0, "average": 1.0, "function_args_min": 0, "function_args_max": 1, @@ -1772,6 +1885,7 @@ mod tests { "function_args_average": 0.0, "closure_args_average": 0.0, "total": 0, + "value": 0, "average": 0.0, "function_args_min": 0, "function_args_max": 0, @@ -1800,6 +1914,7 @@ mod tests { "function_args_average": 3.0, "closure_args_average": 0.0, "total": 3, + "value": 0, "average": 3.0, "function_args_min": 0, "function_args_max": 3, @@ -1832,6 +1947,7 @@ mod tests { "function_args_average": 1.0, "closure_args_average": 0.0, "total": 1, + "value": 0, "average": 1.0, "function_args_min": 0, "function_args_max": 1, @@ -1860,6 +1976,7 @@ mod tests { "function_args_average": 1.0, "closure_args_average": 0.0, "total": 1, + "value": 0, "average": 1.0, "function_args_min": 0, "function_args_max": 1, @@ -1890,6 +2007,7 @@ mod tests { "function_args_average": 3.0, "closure_args_average": 0.0, "total": 3, + "value": 0, "average": 3.0, "function_args_min": 0, "function_args_max": 3, @@ -1919,6 +2037,7 @@ mod tests { "function_args_average": 0.0, "closure_args_average": 2.0, "total": 2, + "value": 0, "average": 2.0, "function_args_min": 0, "function_args_max": 0, @@ -1954,6 +2073,7 @@ mod tests { "function_args_average": 2.0, "closure_args_average": 1.0, "total": 7, + "value": 0, "average": 1.75, "function_args_min": 0, "function_args_max": 2, @@ -1988,6 +2108,7 @@ mod tests { "function_args_average": 0.0, "closure_args_average": 0.0, "total": 0, + "value": 0, "average": 0.0, "function_args_min": 0, "function_args_max": 0, @@ -2025,6 +2146,7 @@ mod tests { "function_args_average": 0.0, "closure_args_average": 0.0, "total": 0, + "value": 0, "average": 0.0, "function_args_min": 0, "function_args_max": 0, @@ -2062,6 +2184,7 @@ mod tests { "function_args_average": 0.0, "closure_args_average": 0.0, "total": 0, + "value": 0, "average": 0.0, "function_args_min": 0, "function_args_max": 0, @@ -2098,6 +2221,7 @@ mod tests { "function_args_average": 0.0, "closure_args_average": 0.0, "total": 0, + "value": 0, "average": 0.0, "function_args_min": 0, "function_args_max": 0, @@ -2133,6 +2257,7 @@ mod tests { "function_args_average": 0.0, "closure_args_average": 0.0, "total": 0, + "value": 0, "average": 0.0, "function_args_min": 0, "function_args_max": 0, @@ -2168,6 +2293,7 @@ mod tests { "function_args_average": 2.0, "closure_args_average": 0.0, "total": 2, + "value": 0, "average": 2.0, "function_args_min": 0, "function_args_max": 2, @@ -2204,6 +2330,7 @@ mod tests { "function_args_average": 3.0, "closure_args_average": 0.0, "total": 3, + "value": 0, "average": 3.0, "function_args_min": 0, "function_args_max": 3, @@ -2242,6 +2369,7 @@ mod tests { "function_args_average": 1.5, "closure_args_average": 0.0, "total": 3, + "value": 0, "average": 1.5, "function_args_min": 0, "function_args_max": 3, @@ -2278,6 +2406,7 @@ mod tests { "function_args_average": 1.0, "closure_args_average": 0.0, "total": 1, + "value": 0, "average": 1.0, "function_args_min": 0, "function_args_max": 1, @@ -2318,6 +2447,7 @@ mod tests { "function_args_average": 3.0, "closure_args_average": 0.0, "total": 3, + "value": 0, "average": 3.0, "function_args_min": 0, "function_args_max": 3, @@ -2421,6 +2551,7 @@ mod tests { "function_args_average": 0.0, "closure_args_average": 0.0, "total": 0, + "value": 0, "average": 0.0, "function_args_min": 0, "function_args_max": 0, @@ -2451,6 +2582,7 @@ mod tests { "function_args_average": 0.0, "closure_args_average": 0.0, "total": 0, + "value": 0, "average": 0.0, "function_args_min": 0, "function_args_max": 0, @@ -2482,6 +2614,7 @@ mod tests { "function_args_average": 2.0, "closure_args_average": 0.0, "total": 2, + "value": 0, "average": 2.0, "function_args_min": 0, "function_args_max": 2, @@ -2516,6 +2649,7 @@ mod tests { "function_args_average": 2.0, "closure_args_average": 0.0, "total": 4, + "value": 0, "average": 2.0, "function_args_min": 0, "function_args_max": 3, @@ -2547,6 +2681,7 @@ mod tests { "function_args_average": 2.0, "closure_args_average": 0.0, "total": 2, + "value": 0, "average": 2.0, "function_args_min": 0, "function_args_max": 2, @@ -2641,6 +2776,7 @@ mod tests { "function_args_average": 0.0, "closure_args_average": 2.0, "total": 2, + "value": 0, "average": 1.0, "function_args_min": 0, "function_args_max": 0, @@ -2746,6 +2882,7 @@ mod tests { "function_args_average": 0.0, "closure_args_average": 0.0, "total": 0, + "value": 0, "average": 0.0, "function_args_min": 0, "function_args_max": 0, @@ -2777,6 +2914,7 @@ mod tests { "function_args_average": 2.0, "closure_args_average": 0.0, "total": 2, + "value": 0, "average": 2.0, "function_args_min": 0, "function_args_max": 2, @@ -2811,6 +2949,7 @@ mod tests { "function_args_average": 2.0, "closure_args_average": 0.0, "total": 4, + "value": 0, "average": 2.0, "function_args_min": 0, "function_args_max": 3, @@ -2842,6 +2981,7 @@ mod tests { "function_args_average": 2.0, "closure_args_average": 0.0, "total": 2, + "value": 0, "average": 2.0, "function_args_min": 0, "function_args_max": 2, @@ -2873,6 +3013,7 @@ mod tests { "function_args_average": 0.0, "closure_args_average": 2.0, "total": 2, + "value": 0, "average": 1.0, "function_args_min": 0, "function_args_max": 0, @@ -2903,6 +3044,7 @@ mod tests { "function_args_average": 2.0, "closure_args_average": 0.0, "total": 4, + "value": 0, "average": 2.0, "function_args_min": 0, "function_args_max": 2, @@ -2933,6 +3075,7 @@ mod tests { "function_args_average": 2.0, "closure_args_average": 0.0, "total": 4, + "value": 0, "average": 2.0, "function_args_min": 0, "function_args_max": 3, @@ -2962,6 +3105,7 @@ mod tests { "function_args_average": 2.0, "closure_args_average": 0.0, "total": 2, + "value": 0, "average": 2.0, "function_args_min": 0, "function_args_max": 2, @@ -2986,6 +3130,7 @@ mod tests { "function_args_average": 0.0, "closure_args_average": 2.0, "total": 2, + "value": 0, "average": 2.0, "function_args_min": 0, "function_args_max": 0, @@ -3144,6 +3289,7 @@ mod tests { "function_args_average": 2.0, "closure_args_average": 2.0, "total": 4, + "value": 0, "average": 2.0, "function_args_min": 0, "function_args_max": 2, @@ -3431,6 +3577,7 @@ proc g {x y z} { puts $x }", "function_args_average": 0.0, "closure_args_average": 0.0, "total": 0, + "value": 0, "average": 0.0, "function_args_min": 0, "function_args_max": 0, @@ -3462,6 +3609,7 @@ proc g {x y z} { puts $x }", "function_args_average": 2.0, "closure_args_average": 0.0, "total": 2, + "value": 0, "average": 2.0, "function_args_min": 0, "function_args_max": 2, @@ -3493,6 +3641,7 @@ proc g {x y z} { puts $x }", "function_args_average": 0.0, "closure_args_average": 1.5, "total": 3, + "value": 0, "average": 1.5, "function_args_min": 0, "function_args_max": 0, @@ -3523,6 +3672,7 @@ proc g {x y z} { puts $x }", "function_args_average": 1.5, "closure_args_average": 0.0, "total": 3, + "value": 0, "average": 1.5, "function_args_min": 0, "function_args_max": 2, @@ -3559,6 +3709,7 @@ proc g {x y z} { puts $x }", "function_args_average": 1.0, "closure_args_average": 1.0, "total": 2, + "value": 0, "average": 1.0, "function_args_min": 0, "function_args_max": 1, @@ -3934,6 +4085,7 @@ when HTTP_REQUEST { log local0. \"hit\" } "function_args_average": 0.0, "closure_args_average": 0.0, "total": 0, + "value": 0, "average": 0.0, "function_args_min": 0, "function_args_max": 0, @@ -3966,6 +4118,7 @@ when HTTP_REQUEST { log local0. \"hit\" } "function_args_average": 2.0, "closure_args_average": 0.0, "total": 2, + "value": 0, "average": 2.0, "function_args_min": 0, "function_args_max": 2, @@ -3996,6 +4149,7 @@ when HTTP_REQUEST { log local0. \"hit\" } "function_args_average": 3.0, "closure_args_average": 0.0, "total": 3, + "value": 0, "average": 3.0, "function_args_min": 0, "function_args_max": 3, @@ -4032,6 +4186,7 @@ when HTTP_REQUEST { log local0. \"hit\" } "function_args_average": 0.0, "closure_args_average": 2.0, "total": 2, + "value": 0, "average": 1.0, "function_args_min": 0, "function_args_max": 0, @@ -4073,6 +4228,7 @@ when HTTP_REQUEST { log local0. \"hit\" } "function_args_average": 0.0, "closure_args_average": 0.0, "total": 0, + "value": 0, "average": 0.0, "function_args_min": 0, "function_args_max": 0, diff --git a/src/metrics/snapshots/big_code_analysis__metrics__nargs__tests__kotlin_anonymous_function.snap b/src/metrics/snapshots/big_code_analysis__metrics__nargs__tests__kotlin_anonymous_function.snap index 2f2729c15..bfa59b1a7 100644 --- a/src/metrics/snapshots/big_code_analysis__metrics__nargs__tests__kotlin_anonymous_function.snap +++ b/src/metrics/snapshots/big_code_analysis__metrics__nargs__tests__kotlin_anonymous_function.snap @@ -8,6 +8,7 @@ expression: metric.nargs "function_args_average": 0.0, "closure_args_average": 2.0, "total": 2, + "value": 2, "average": 2.0, "function_args_min": 0, "function_args_max": 0, diff --git a/src/metrics/snapshots/big_code_analysis__metrics__nargs__tests__kotlin_default_args.snap b/src/metrics/snapshots/big_code_analysis__metrics__nargs__tests__kotlin_default_args.snap index 0e697d8c2..c37936c11 100644 --- a/src/metrics/snapshots/big_code_analysis__metrics__nargs__tests__kotlin_default_args.snap +++ b/src/metrics/snapshots/big_code_analysis__metrics__nargs__tests__kotlin_default_args.snap @@ -8,6 +8,7 @@ expression: metric.nargs "function_args_average": 2.0, "closure_args_average": 0.0, "total": 2, + "value": 0, "average": 2.0, "function_args_min": 0, "function_args_max": 2, diff --git a/src/metrics/snapshots/big_code_analysis__metrics__nargs__tests__kotlin_empty_lambda.snap b/src/metrics/snapshots/big_code_analysis__metrics__nargs__tests__kotlin_empty_lambda.snap index b75c8e58a..14fe956e9 100644 --- a/src/metrics/snapshots/big_code_analysis__metrics__nargs__tests__kotlin_empty_lambda.snap +++ b/src/metrics/snapshots/big_code_analysis__metrics__nargs__tests__kotlin_empty_lambda.snap @@ -8,6 +8,7 @@ expression: metric.nargs "function_args_average": 0.0, "closure_args_average": 1.0, "total": 2, + "value": 0, "average": 0.6666666666666666, "function_args_min": 0, "function_args_max": 0, diff --git a/src/metrics/snapshots/big_code_analysis__metrics__nargs__tests__kotlin_multiple_args.snap b/src/metrics/snapshots/big_code_analysis__metrics__nargs__tests__kotlin_multiple_args.snap index a329d3c4c..77de6c4be 100644 --- a/src/metrics/snapshots/big_code_analysis__metrics__nargs__tests__kotlin_multiple_args.snap +++ b/src/metrics/snapshots/big_code_analysis__metrics__nargs__tests__kotlin_multiple_args.snap @@ -8,6 +8,7 @@ expression: metric.nargs "function_args_average": 3.0, "closure_args_average": 0.0, "total": 3, + "value": 0, "average": 3.0, "function_args_min": 0, "function_args_max": 3, diff --git a/src/metrics/snapshots/big_code_analysis__metrics__nargs__tests__kotlin_single_arg.snap b/src/metrics/snapshots/big_code_analysis__metrics__nargs__tests__kotlin_single_arg.snap index fd2a86ae0..34453a6f5 100644 --- a/src/metrics/snapshots/big_code_analysis__metrics__nargs__tests__kotlin_single_arg.snap +++ b/src/metrics/snapshots/big_code_analysis__metrics__nargs__tests__kotlin_single_arg.snap @@ -8,6 +8,7 @@ expression: metric.nargs "function_args_average": 1.0, "closure_args_average": 0.0, "total": 1, + "value": 0, "average": 1.0, "function_args_min": 0, "function_args_max": 1, diff --git a/src/metrics/snapshots/big_code_analysis__metrics__nargs__tests__kotlin_zero_args.snap b/src/metrics/snapshots/big_code_analysis__metrics__nargs__tests__kotlin_zero_args.snap index 6fa5b67de..23109f44c 100644 --- a/src/metrics/snapshots/big_code_analysis__metrics__nargs__tests__kotlin_zero_args.snap +++ b/src/metrics/snapshots/big_code_analysis__metrics__nargs__tests__kotlin_zero_args.snap @@ -8,6 +8,7 @@ expression: metric.nargs "function_args_average": 0.0, "closure_args_average": 0.0, "total": 0, + "value": 0, "average": 0.0, "function_args_min": 0, "function_args_max": 0, diff --git a/src/metrics/snapshots/big_code_analysis__metrics__nargs__tests__lua_colon_method_nargs.snap b/src/metrics/snapshots/big_code_analysis__metrics__nargs__tests__lua_colon_method_nargs.snap index 0e697d8c2..c37936c11 100644 --- a/src/metrics/snapshots/big_code_analysis__metrics__nargs__tests__lua_colon_method_nargs.snap +++ b/src/metrics/snapshots/big_code_analysis__metrics__nargs__tests__lua_colon_method_nargs.snap @@ -8,6 +8,7 @@ expression: metric.nargs "function_args_average": 2.0, "closure_args_average": 0.0, "total": 2, + "value": 0, "average": 2.0, "function_args_min": 0, "function_args_max": 2, diff --git a/src/metrics/snapshots/big_code_analysis__metrics__nargs__tests__lua_functions.snap b/src/metrics/snapshots/big_code_analysis__metrics__nargs__tests__lua_functions.snap index d2998a0e6..8a63e2a9a 100644 --- a/src/metrics/snapshots/big_code_analysis__metrics__nargs__tests__lua_functions.snap +++ b/src/metrics/snapshots/big_code_analysis__metrics__nargs__tests__lua_functions.snap @@ -8,6 +8,7 @@ expression: metric.nargs "function_args_average": 2.0, "closure_args_average": 0.0, "total": 4, + "value": 0, "average": 2.0, "function_args_min": 0, "function_args_max": 3, diff --git a/src/metrics/snapshots/big_code_analysis__metrics__nargs__tests__lua_no_functions_and_closures.snap b/src/metrics/snapshots/big_code_analysis__metrics__nargs__tests__lua_no_functions_and_closures.snap index 6fa5b67de..23109f44c 100644 --- a/src/metrics/snapshots/big_code_analysis__metrics__nargs__tests__lua_no_functions_and_closures.snap +++ b/src/metrics/snapshots/big_code_analysis__metrics__nargs__tests__lua_no_functions_and_closures.snap @@ -8,6 +8,7 @@ expression: metric.nargs "function_args_average": 0.0, "closure_args_average": 0.0, "total": 0, + "value": 0, "average": 0.0, "function_args_min": 0, "function_args_max": 0, diff --git a/src/metrics/snapshots/big_code_analysis__metrics__nargs__tests__lua_single_closure.snap b/src/metrics/snapshots/big_code_analysis__metrics__nargs__tests__lua_single_closure.snap index f10fbcec2..a780b5d9a 100644 --- a/src/metrics/snapshots/big_code_analysis__metrics__nargs__tests__lua_single_closure.snap +++ b/src/metrics/snapshots/big_code_analysis__metrics__nargs__tests__lua_single_closure.snap @@ -8,6 +8,7 @@ expression: metric.nargs "function_args_average": 0.0, "closure_args_average": 2.0, "total": 2, + "value": 0, "average": 2.0, "function_args_min": 0, "function_args_max": 0, diff --git a/src/metrics/snapshots/big_code_analysis__metrics__nargs__tests__lua_single_function.snap b/src/metrics/snapshots/big_code_analysis__metrics__nargs__tests__lua_single_function.snap index 0e697d8c2..c37936c11 100644 --- a/src/metrics/snapshots/big_code_analysis__metrics__nargs__tests__lua_single_function.snap +++ b/src/metrics/snapshots/big_code_analysis__metrics__nargs__tests__lua_single_function.snap @@ -8,6 +8,7 @@ expression: metric.nargs "function_args_average": 2.0, "closure_args_average": 0.0, "total": 2, + "value": 0, "average": 2.0, "function_args_min": 0, "function_args_max": 2, diff --git a/src/metrics/snapshots/big_code_analysis__metrics__nargs__tests__lua_vararg_function.snap b/src/metrics/snapshots/big_code_analysis__metrics__nargs__tests__lua_vararg_function.snap index 0e697d8c2..c37936c11 100644 --- a/src/metrics/snapshots/big_code_analysis__metrics__nargs__tests__lua_vararg_function.snap +++ b/src/metrics/snapshots/big_code_analysis__metrics__nargs__tests__lua_vararg_function.snap @@ -8,6 +8,7 @@ expression: metric.nargs "function_args_average": 2.0, "closure_args_average": 0.0, "total": 2, + "value": 0, "average": 2.0, "function_args_min": 0, "function_args_max": 2, diff --git a/src/metrics/snapshots/big_code_analysis__metrics__nargs__tests__tcl_args_vararg.snap b/src/metrics/snapshots/big_code_analysis__metrics__nargs__tests__tcl_args_vararg.snap index a329d3c4c..77de6c4be 100644 --- a/src/metrics/snapshots/big_code_analysis__metrics__nargs__tests__tcl_args_vararg.snap +++ b/src/metrics/snapshots/big_code_analysis__metrics__nargs__tests__tcl_args_vararg.snap @@ -8,6 +8,7 @@ expression: metric.nargs "function_args_average": 3.0, "closure_args_average": 0.0, "total": 3, + "value": 0, "average": 3.0, "function_args_min": 0, "function_args_max": 3, diff --git a/src/metrics/snapshots/big_code_analysis__metrics__nargs__tests__tcl_default_arg.snap b/src/metrics/snapshots/big_code_analysis__metrics__nargs__tests__tcl_default_arg.snap index 0e697d8c2..c37936c11 100644 --- a/src/metrics/snapshots/big_code_analysis__metrics__nargs__tests__tcl_default_arg.snap +++ b/src/metrics/snapshots/big_code_analysis__metrics__nargs__tests__tcl_default_arg.snap @@ -8,6 +8,7 @@ expression: metric.nargs "function_args_average": 2.0, "closure_args_average": 0.0, "total": 2, + "value": 0, "average": 2.0, "function_args_min": 0, "function_args_max": 2, diff --git a/src/metrics/snapshots/big_code_analysis__metrics__nargs__tests__tcl_functions.snap b/src/metrics/snapshots/big_code_analysis__metrics__nargs__tests__tcl_functions.snap index 4474c8e04..8f76be8ea 100644 --- a/src/metrics/snapshots/big_code_analysis__metrics__nargs__tests__tcl_functions.snap +++ b/src/metrics/snapshots/big_code_analysis__metrics__nargs__tests__tcl_functions.snap @@ -8,6 +8,7 @@ expression: metric.nargs "function_args_average": 2.5, "closure_args_average": 0.0, "total": 5, + "value": 0, "average": 2.5, "function_args_min": 0, "function_args_max": 3, diff --git a/src/metrics/snapshots/big_code_analysis__metrics__nargs__tests__tcl_nested_functions.snap b/src/metrics/snapshots/big_code_analysis__metrics__nargs__tests__tcl_nested_functions.snap index 86fd5fb25..bcd94fbc3 100644 --- a/src/metrics/snapshots/big_code_analysis__metrics__nargs__tests__tcl_nested_functions.snap +++ b/src/metrics/snapshots/big_code_analysis__metrics__nargs__tests__tcl_nested_functions.snap @@ -8,6 +8,7 @@ expression: metric.nargs "function_args_average": 1.5, "closure_args_average": 0.0, "total": 3, + "value": 0, "average": 1.5, "function_args_min": 0, "function_args_max": 2, diff --git a/src/metrics/snapshots/big_code_analysis__metrics__nargs__tests__tcl_no_functions.snap b/src/metrics/snapshots/big_code_analysis__metrics__nargs__tests__tcl_no_functions.snap index 6fa5b67de..23109f44c 100644 --- a/src/metrics/snapshots/big_code_analysis__metrics__nargs__tests__tcl_no_functions.snap +++ b/src/metrics/snapshots/big_code_analysis__metrics__nargs__tests__tcl_no_functions.snap @@ -8,6 +8,7 @@ expression: metric.nargs "function_args_average": 0.0, "closure_args_average": 0.0, "total": 0, + "value": 0, "average": 0.0, "function_args_min": 0, "function_args_max": 0, diff --git a/src/metrics/snapshots/big_code_analysis__metrics__nargs__tests__tcl_single_function.snap b/src/metrics/snapshots/big_code_analysis__metrics__nargs__tests__tcl_single_function.snap index 0e697d8c2..c37936c11 100644 --- a/src/metrics/snapshots/big_code_analysis__metrics__nargs__tests__tcl_single_function.snap +++ b/src/metrics/snapshots/big_code_analysis__metrics__nargs__tests__tcl_single_function.snap @@ -8,6 +8,7 @@ expression: metric.nargs "function_args_average": 2.0, "closure_args_average": 0.0, "total": 2, + "value": 0, "average": 2.0, "function_args_min": 0, "function_args_max": 2, diff --git a/src/metrics/snapshots/big_code_analysis__metrics__nargs__tests__tcl_single_function_no_args.snap b/src/metrics/snapshots/big_code_analysis__metrics__nargs__tests__tcl_single_function_no_args.snap index 6fa5b67de..23109f44c 100644 --- a/src/metrics/snapshots/big_code_analysis__metrics__nargs__tests__tcl_single_function_no_args.snap +++ b/src/metrics/snapshots/big_code_analysis__metrics__nargs__tests__tcl_single_function_no_args.snap @@ -8,6 +8,7 @@ expression: metric.nargs "function_args_average": 0.0, "closure_args_average": 0.0, "total": 0, + "value": 0, "average": 0.0, "function_args_min": 0, "function_args_max": 0, diff --git a/src/wire/metrics.rs b/src/wire/metrics.rs index 302db3a58..950393029 100644 --- a/src/wire/metrics.rs +++ b/src/wire/metrics.rs @@ -78,9 +78,10 @@ pub struct Cognitive { /// function/closure spaces. Equals [`sum`](Self::sum) only at a leaf /// space; at an interior space the sum rolls up descendants while /// this stays the per-space scalar the CLI thresholds against - /// (#958). `#[serde(default)]` so pre-#958 JSON (which lacks the - /// field) still deserializes — e.g. when `bca diff` reads an older - /// metrics file. + /// (#958). `#[serde(default)]` so a document written before #958 + /// still deserializes through the public `wire` read-back path + /// STABILITY.md advertises. (`bca diff` is *not* that consumer — it + /// walks a raw `serde_json::Value`, see `metric_diff.rs`.) #[serde(default)] pub value: u64, /// Average cognitive complexity per function. @@ -367,6 +368,17 @@ pub struct Nargs { pub closure_args_average: f64, /// Total arguments (functions + closures). pub total: u64, + /// This space's own argument count — its function parameters plus + /// its closure parameters — excluding nested function/closure + /// spaces. Equals [`total`](Self::total) only at a leaf space; at an + /// interior space `total` rolls up descendants while this stays the + /// per-space scalar the CLI thresholds `nargs` against (#1196, + /// #1236). `#[serde(default)]` so a document written before #1236 + /// still deserializes through the public `wire` read-back path + /// STABILITY.md advertises. (`bca diff` is *not* that consumer — it + /// walks a raw `serde_json::Value`, see `metric_diff.rs`.) + #[serde(default)] + pub value: u64, /// Average arguments per function/closure. #[serde(default = "nan_default", with = "non_finite")] pub average: f64, @@ -388,6 +400,7 @@ impl From<&nargs::Stats> for Nargs { function_args_average: s.function_args_average(), closure_args_average: s.closure_args_average(), total: s.total(), + value: s.own_args(), average: s.average(), function_args_min: s.function_args_min(), function_args_max: s.function_args_max(), diff --git a/tests/repositories/big-code-analysis-output b/tests/repositories/big-code-analysis-output index 6deee6034..3cd242f27 160000 --- a/tests/repositories/big-code-analysis-output +++ b/tests/repositories/big-code-analysis-output @@ -1 +1 @@ -Subproject commit 6deee603495a5cc433c5f605256198f5a333ebeb +Subproject commit 3cd242f27e0e74a94b821639b3db64fab8d2c367 From e0d6495f22ab76680259a2a1977ce2647d69c67f Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Wed, 9 Sep 2026 00:44:23 -0700 Subject: [PATCH 15/20] docs(changelog): consolidate entries from batch fix Record the ten issue fixes merged onto this branch: one additive wire field (nargs.value) and nine corrections spanning preproc output ordering, ABC condition counting, LOC blank-line classification, Ruby visibility modelling, and four Halstead classifier defects. --- CHANGELOG.md | 121 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index cd1bc4405..f09ebdd3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,18 @@ for historical reference. ## [Unreleased] +### Added + +- Per-space *own* value for `nargs` in the serialized wire shape: + `nargs.value` (#1236). `nargs.total` remains the subtree sum; the new + field is the per-space scalar `bca check --threshold nargs=N` has + gated on since #1196, exposed so a JSON-walking front-end can + reproduce the gate. SemVer-additive, with `#[serde(default)]` so + older documents still parse; it appears in every output format + (JSON / YAML / TOML / CBOR), in `bca dump`'s metric tree, as a new + column in `flatten_spaces` records, and in the Python `NargsDict` + TypedDict. The library accessor is `nargs::Stats::own_args()`. + ### Performance - The metric walk's cognitive nesting map no longer grows to one entry @@ -59,6 +71,115 @@ for historical reference. `MISSING` match and a corrected alternative-step skip) that nothing here uses. +### Fixed + +- **`bca preproc` documents are byte-identical across runs** (#1304). + `PreprocResults.files` and `PreprocFile`'s three `HashSet` + fields serialized straight off hash order, so an unchanged tree + produced a different document on nearly every run — eight distinct + hashes over eight runs. They now emit sorted, on stdout as well as + through `--output`, closing the last destination in the #1244 / + #1303 family. The public field types are unchanged: the ordering is + imposed at the serialization seam, so no `BTreeMap` / `BTreeSet` + break is needed. Note the document uses two comparators — `files` + sorts its `PathBuf` keys component-wise, matching `metrics --output` + and `ops --output`, while the `String` name sets sort + byte-lexicographically — so `a-b/x.h` precedes `a/x.h` in an include + array and follows it as a key. `STABILITY.md` records the emitted + order as contract through `3.0`. + +- ABC no longer scores a non-comparison `<` or `>` as a condition + (#1297). JSX tag delimiters (TypeScript, TSX, JavaScript, Mozjs), + Lua 5.4 variable attributes, a C# comparison-operator overload's + declared name, a Kotlin qualified super call (`super
.g()`), and + Perl's `` / `<$fh>` readlines each scored phantom conditions. + Perl was listed as immune by the original survey and was not. C# + additionally keeps counting a relational pattern's operator + (`x is > 0`), a genuine comparison outside `binary_expression`. + +- Multi-line strings and heredocs no longer read as blank lines in + Bash, Elixir, Tcl and iRules (#1260). Each language's + multi-row-capable literals now credit every spanned row to PLOC, + matching the thirteen languages #778 covered and Python's #415 + decision. Elixir `@doc` / `@moduledoc` rows are PLOC — a module + attribute is an assignment whose value the compiler stores, so its + Python analogue is `x = """…"""`, not a discarded docstring — + and Tcl / iRules `braced_word` is excluded because both grammars + parse a braced literal as a script. + +- **Ruby `npm` / `npa`: visibility calls no longer hide methods or + mis-scope singletons** (#1255). `private def x`, + `private attr_accessor :b`, `private :foo`, `%i[…]` argument arrays + and the `private_class_method` / `public_class_method` pair are now + modelled, and a bare `private` no longer demotes a `def self.x`. The + first two corrupted the `class_nm` / `class_na` totals, not just the + public split. + +- Tcl and iRules braced literals no longer report a `{}` operator for + a block the source does not contain (#1318). Both grammars spell a + script body and a quoted value with one kind, `braced_word`, so + #1314's guard on the literal `braced_word_simple` form reached only + the value slots the grammars special-case; everywhere else — + `lappend x {a b}`, `puts {c d}`, and every user-defined proc — the + brace still opened a block. The role is now recognised by the + enclosing command's leading word: `after`, `eval`, `for`, `on`, + `switch`, `time`, `trap` and `uplevel` take scripts, as does every + construct the grammar models with a node of its own, except a + defaulted `proc` parameter and a braced word in the command-name + position. An unrecognised command is taken to receive a value. + **Metric drift:** Tcl and iRules `halstead.unique_operators` / + `total_operators`, the derived values, and hence `mi` fall for any + file passing a braced literal to a command the grammar does not + model. Operand counts are deliberately unchanged. Supersedes the + note in 2.2.0 that a value-position braced literal still reports a + `{}` operator. + +- Bash translated strings (`$"…"`) are no longer counted twice in + Halstead `N2` (#1358). `BashCode::get_op_type` classified both the + `translated_string` wrapper and the single required `string` child + the grammar gives it, so `a=$"x"` reported `N2` 3 for two operands. + The wrapper also defeated the #180 expansion guard, which inspects a + node's own children: a `translated_string` has only `$` and + `string`, so `b=$"$y"` counted both `$"$y"` and `$y`. **Metric + drift:** Bash `N2` falls by one per `$"…"` literal, moving the + derived values with it. + +- Ruby suffixed numeric literals (`1r`, `2i`, `3ri`) are billed once + in Halstead rather than once per level of the grammar's `complex` / + `rational` nest (#1359). The wrapper is kept rather than the leaf, + because the suffix that distinguishes `1`, `1r`, `1i` and `1ri` as + four constants lives on the wrapper's span. **Metric drift:** + `halstead.*` and every derived value change for Ruby sources + containing a rational or complex literal; the operand vocabulary now + records the wrapper's full text, so `1` and `1r` are distinct + operands where they previously collapsed. + +- Ruby subshell literals no longer fabricate two `` ` `` operators + each (#1360) — the delimiter class #1256 and #1312 removed for + Elixir and for regexes. tree-sitter-ruby aliases both ends of a + subshell to the same backtick token, the one the `` def ` `` + method-name marker also uses, so `` u = `echo hi` `` reported `n1` 2 + / `N1` 3 for a line whose only operation is the assignment, in all + seven spellings (`` `…` `` and the six `%x` forms). The delimiters + are now parent-guarded under the `subshell`, so a real backtick + method, defined or called, still counts. Operand counts do not move. + +- **Metric drift:** C++ and mozcpp `this` is now a Halstead operand + (#1361). It had been classified as neither operator nor operand, so + every `this` was dropped from the vocabulary — the same class of + silent drop as #1316's character literals. `n2` rises by one per + space and `N2` by one per occurrence; the derived volume / + difficulty / effort / time / bugs and the three maintainability-index + variants move. Note `difficulty` is `(n1/2)·(N2/n2)`, so a larger + `n2` pushes it down as often as up. + +- Python `to_sarif`: `thresholds={"nargs": N}` now gates each space on + its own argument count, matching `bca check --report-format sarif` + exactly (#1236). It compared the `nargs.total` subtree sum, so a + two-argument function containing a three- and a two-argument closure + was reported at 7 — a finding the CLI never emits — and + closure-heavy code drew spurious SARIF findings. + ## [2.2.0] - 2026-08-29 One entry below is marked **(breaking)**: the Python bindings' From 0c987134ccc600d5dd82e592b62924116d066169 Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Wed, 9 Sep 2026 06:25:28 -0700 Subject: [PATCH 16/20] fix(metrics): correct four defects found reviewing the batch Tcl/iRules braced_word_op_type withdrew every operator child of a value-role braced word rather than only its opening brace, so a `;` command separator was silently dropped and halstead.effort collapsed to 0.0 for an unmodelled command. The Bash lloc arm listed only the unsuffixed VariableAssignment id, which the parser never emits, so every Bash assignment scored zero logical lines. Added the alias, gated so a declaration_command or a command environment prefix does not double-count. The #1297 sweep skipped Elixir, whose denylist named only sigil delimiters, so an operator named rather than applied (`& 0`), a genuine comparison outside `binary_expression`. + Elixir was swept the same way in the same release: an operator + *named* rather than applied — the capture `& list[tuple[int, str, str]]: reports ``outer``, ````, ````. ``_assert_sarif_results_match`` sorts for the same reason. Ordering therefore has to be pinned somewhere else if it is ever made part of the parity contract. + + The first two fields come from ``_sarif_sort_key`` rather than being + read out again here, so the SARIF location traversal has one spelling + in this file and a shape change cannot leave the row extractor + raising ``KeyError`` while the sort key still resolves. """ return [ - ( - int(r["locations"][0]["physicalLocation"]["region"]["startLine"]), - r["locations"][0]["logicalLocations"][0]["fullyQualifiedName"], - r["message"]["text"], - ) - for r in sorted(results, key=_sarif_sort_key) + (*_sarif_sort_key(r), r["message"]["text"]) for r in sorted(results, key=_sarif_sort_key) ] diff --git a/src/getter.rs b/src/getter.rs index 2e1bc227c..39aeb72a8 100644 --- a/src/getter.rs +++ b/src/getter.rs @@ -324,6 +324,16 @@ pub(crate) struct BracedWordKinds { /// parameter (`proc p {a {b {x y}}}`) spells its default as a /// `braced_word`, and a default is never evaluated as code. pub(crate) argument: u16, + /// `{`, the brace opener — the *only* node + /// [`Getter::braced_word_op_type`] may revise. A braced word's + /// operator children are not the opener alone: `_terminator` is a + /// hidden rule, so a `;` separating two commands is inlined as a + /// direct child of the `braced_word` and both dialects classify it + /// as an operator. Keying the revision on the parent kind alone + /// therefore swallowed those separators too, taking + /// `lappend x {puts a ; puts b}` to `n1` 0 / `N1` 0 and + /// `halstead.effort` — a gated threshold metric — to `0.0`. + pub(crate) open_brace: u16, } /// The core Tcl-family commands that evaluate a braced argument as a @@ -671,8 +681,9 @@ pub(crate) trait Getter { // and a braced pattern (`switch -regexp $v { {^a.*b$} {…} }`) // is idiomatic — rescuing it as an arm body would fabricate a // block around a regex. - if ancestors.parent_has_kind(word, kinds.argument) - || ancestors.parent_has_kind(word, kinds.command) + if ancestors + .parent(word) + .is_some_and(|parent| [kinds.argument, kinds.command].contains(&parent.kind_id())) { return true; } @@ -708,7 +719,18 @@ pub(crate) trait Getter { if name.kind_id() != kinds.simple_word { return None; } - node_text(code, &name) + let text = node_text(code, &name)?; + // `::eval` *is* `eval` — a leading `::` names the global + // namespace, and inside a `namespace eval` body it is the + // spelling that guarantees the core command rather than a local + // proc shadowing it. Without this the qualified form fell to + // the value default and lost its block, so the score moved with + // how the author spelled a command that resolves identically. + // + // Only the *leading* qualifier is stripped: `ns::eval` is a + // different command living in `ns`, and must not be mistaken + // for the core one. + Some(text.strip_prefix("::").unwrap_or(text)) } /// Whether `command` is really one `pattern body` pair of a Tcl @@ -772,8 +794,9 @@ pub(crate) trait Getter { /// needs a signal neither grammar gives — filed as #1382. /// /// Keeping to the operator also keeps the whole thing `O(1)`: only - /// a braced word's own opener can change answer, so the test is - /// one parent lookup, the same scope #1354 and #1314 use. An + /// a braced word's own opener can change answer, so the test is one + /// kind comparison and one parent lookup, the same scope #1354 and + /// #1314 use. An /// ancestor scan would have been `O(depth)` per node and quadratic /// on a deeply nested `expr`, the shape #1122 warns about. /// @@ -785,10 +808,15 @@ pub(crate) trait Getter { kinds: &BracedWordKinds, ) -> HalsteadType { let base = Self::get_op_type(node, ancestors); - // A braced word's only operator child is its `{` — the closer - // has never been classified, and everything between them is a - // command or a comment — so no other node can change answer. - if !matches!(base, HalsteadType::Operator) { + // Only the opener can change answer, and the test has to say so + // rather than infer it from the parent kind. A braced word's + // operator children are the `{` *and* every `;` separating two + // of its commands — `_terminator` is a hidden rule, so the + // separator is inlined as a direct child of the `braced_word` + // and both dialects list `SEMI` as an operator. The closer is + // unclassified and a `\n` terminator is not an operator, so + // those two are the whole set. + if node.kind_id() != kinds.open_brace || !matches!(base, HalsteadType::Operator) { return base; } let quotes_a_literal = ancestors.iter(node).next().is_some_and(|(parent, above)| { diff --git a/src/getter/cpp.rs b/src/getter/cpp.rs index a4d1e42b0..404b58b44 100644 --- a/src/getter/cpp.rs +++ b/src/getter/cpp.rs @@ -157,7 +157,8 @@ impl Getter for CppCode { // Python / ObjC / Groovy / Lua, whose grammars spell it a // plain `identifier`), and the JS-family arm in // `src/getter.rs` is the only *reasoned* one among them — - // its `MetaProperty` note calls `this` "one atomic operand". Java / C# / Kotlin disagree, but each + // its `MetaProperty` note calls `this` "one atomic + // operand". Java / C# / Kotlin disagree, but each // swept `This` in as one entry in a run of keywords — under // `// Operator: keywords`, `// Operator: other keywords` and // `// Expression-keyword operators` respectively — grouping diff --git a/src/getter/irules.rs b/src/getter/irules.rs index aba17c5a6..663f35b4e 100644 --- a/src/getter/irules.rs +++ b/src/getter/irules.rs @@ -15,6 +15,7 @@ const BRACED_WORD_KINDS: BracedWordKinds = BracedWordKinds { word_list: Irules::WordList as u16, simple_word: Irules::SimpleWord as u16, argument: Irules::Argument as u16, + open_brace: Irules::LBRACE as u16, }; impl Getter for IrulesCode { diff --git a/src/getter/ruby.rs b/src/getter/ruby.rs index f5f448079..caacf0b13 100644 --- a/src/getter/ruby.rs +++ b/src/getter/ruby.rs @@ -288,10 +288,9 @@ impl Getter for RubyCode { // unobservable — a `rational` directly inside a `rational` // is not a shape this grammar admits. R::Integer | R::Float | R::Rational - if matches!( - ancestors.parent(node).map(|p| p.kind_id().into()), - Some(R::Complex | R::Rational) - ) => + if ancestors + .parent(node) + .is_some_and(|p| matches!(p.kind_id().into(), R::Complex | R::Rational)) => { HalsteadType::Unknown } diff --git a/src/getter/tcl.rs b/src/getter/tcl.rs index 40951c714..f44ab7ba8 100644 --- a/src/getter/tcl.rs +++ b/src/getter/tcl.rs @@ -20,6 +20,7 @@ const BRACED_WORD_KINDS: BracedWordKinds = BracedWordKinds { word_list: Tcl::WordList as u16, simple_word: Tcl::SimpleWord as u16, argument: Tcl::Argument as u16, + open_brace: Tcl::LBRACE as u16, }; impl Getter for TclCode { diff --git a/src/metrics/abc.rs b/src/metrics/abc.rs index 5a0eba39f..5b8c83c3f 100644 --- a/src/metrics/abc.rs +++ b/src/metrics/abc.rs @@ -7981,6 +7981,55 @@ function f(int $a, int $b): int { }); } + // #1297's sweep converted six grammars from a denylist of + // non-comparison `<` / `>` parents to an allowlist and recorded + // Elixir as already covered by its sigil guard. It was not: an + // operator *named* rather than applied — the capture `&( + "defmodule M do\n\ + def asc(l), do: Enum.sort(l, & = module + .spaces + .iter() + .map(|f| { + ( + f.name.clone().unwrap_or_default(), + f.metrics.abc.conditions(), + ) + }) + .collect(); + assert_eq!( + scored, + vec![ + ("asc".to_owned(), 0), + ("lt".to_owned(), 0), + ("big".to_owned(), 0), + ("cmp".to_owned(), 1), + ], + ); + }, + ); + } + // ----- C++ ----- #[test] diff --git a/src/metrics/abc/elixir.rs b/src/metrics/abc/elixir.rs index 788438c53..81dc5ca4d 100644 --- a/src/metrics/abc/elixir.rs +++ b/src/metrics/abc/elixir.rs @@ -13,6 +13,15 @@ use super::{Abc, Stats}; use crate::macros::elixir_bool_terminal_kinds; use crate::*; +/// The grammar rule an applied binary operator hangs off, as opposed to +/// `operator_identifier`, which is how one is *named* +/// (`&` (`~s`) are spelling, not - // comparisons — suppressed with the same parent-is-`Sigil` - // guard the Halstead getter uses (#1256). Of the other - // delimiter kinds the getter guards (`SLASH` / `LPAREN` / - // `LBRACE` / `LBRACK` / `PIPE`), none has an arm in this - // impl, so `LT` / `GT` are the only overlap to guard. - E::LT | E::GT - if ancestors.parent_has_kind(node, E::Sigil as u16) => {} - // Comparison operator tokens. `Elixir::LT` / `Elixir::GT` - // reach here only outside a sigil (the guard arm above - // consumes the delimiter case); Elixir has no Go-style - // generic-instantiation brackets, so what remains is a - // genuine comparison. - E::EQEQ | E::EQEQEQ | E::BANGEQ | E::BANGEQEQ - | E::LT | E::GT | E::LTEQ | E::GTEQ + E::EQEQ | E::EQEQEQ | E::BANGEQ | E::BANGEQEQ | E::LTEQ | E::GTEQ // Guard `when` token: introduces the guard clause of a // function head or `case` arm. | E::When => { stats.conditions += 1.; } + // Counts `<` / `>` only as the operator token of a + // `binary_operator`, the allowlist polarity the rest of the + // workspace moved to in #1274 and #1297. The previous + // denylist excluded a sigil delimiter (`~s`, #1256) and + // nothing else, on the reasoning that "Elixir has no + // Go-style generic-instantiation brackets, so what remains + // is a genuine comparison" — a coverage claim the grammar + // contradicts. A `grammar.json` sweep of the pinned + // tree-sitter-elixir finds a bare `<` / `>` in + // `binary_operator`, the two quoted-angle sigil rules, and + // `operator_identifier`, which is how an operator is + // *named* rather than applied: `&=` and `<<` / `>>`.) + // + // Matched by rule name rather than by `kind_id`: this + // grammar aliases `binary_operator` to three ids + // (`E::BinaryOperator`, `BinaryOperator2`, + // `BinaryOperator3`), and a name comparison stays correct + // when a bump adds a fourth + // (`.claude/rules/grammar-dispatch.md` §1, the same call + // `QUOTED_CONTENT` makes in `src/metrics/loc/elixir.rs`). + // The runtime cost that trade buys there is not paid here: + // the guard runs only for a `<` or `>` token, not for every + // node. + E::LT | E::GT + if ancestors + .parent(node) + .is_some_and(|parent| parent.kind() == BINARY_OPERATOR) => + { + stats.conditions += 1.; + } // Fitzpatrick Rule 9 walker: each non-comparison operand of a // `&&` / `||` / `and` / `or` chain is one condition (issue // #557). The short-circuit operators are not counted directly diff --git a/src/metrics/halstead.rs b/src/metrics/halstead.rs index 5bf30e4cb..7986187ed 100644 --- a/src/metrics/halstead.rs +++ b/src/metrics/halstead.rs @@ -5976,7 +5976,26 @@ f() { /// *contents* — the tidier-looking rule, which collapses an /// `oo::class create C {…}` body into a single operand — fails /// here rather than passing as an improvement. - const BRACED_WORD_VALUE_CASES: [BracedWordValueCase; 12] = [ + const BRACED_WORD_VALUE_CASES: [BracedWordValueCase; 16] = [ + // The rule reaches the opener and *only* the opener. A `;` + // separating two commands is a direct child of the + // `braced_word` — `_terminator` is a hidden rule, so it is + // inlined rather than wrapped — and both dialects classify + // `SEMI` as an operator, so a revision keyed on the parent + // kind alone swallowed it too: this row read n1 0 / N1 0 and + // `halstead.effort` 0.0, against a line that is two commands. + // The pairing with the `eval` row below is the point — same + // brace, same separator, one command name apart. + BracedWordValueCase { + source: "lappend l {puts a ; puts b}\n", + counts: [1, 1, 5, 6], + operands: &["lappend", "l", "puts", "a", "b"], + }, + BracedWordValueCase { + source: "eval {puts a ; puts b}\n", + counts: [2, 2, 4, 5], + operands: &["eval", "puts", "a", "b"], + }, // The reported fixture. `lappend` takes a list, so `{a b}` // quotes rather than opens: no operator. Before #1318 this // read n1 1 / N1 1 — a `{}` block the line does not contain. @@ -6011,6 +6030,27 @@ f() { counts: [1, 1, 3, 3], operands: &["eval", "puts", "hi"], }, + // The same command named through the global namespace, which + // is the spelling a `namespace eval` body uses to guarantee the + // core command over a local proc shadowing it. `::eval` *is* + // `eval`, so the two rows must agree on the operator column; + // before the leading `::` was stripped this one fell to the + // value default and lost its block. + BracedWordValueCase { + source: "::eval {puts hi}\n", + counts: [1, 1, 3, 3], + operands: &["::eval", "puts", "hi"], + }, + // The control for that strip: only the *leading* qualifier is + // a namespace path to the core command. `ns::eval` is a + // different proc living in `ns`, so it takes the value default + // like any other unrecognised name — a strip of every `::` + // segment would have promoted it to a script. + BracedWordValueCase { + source: "ns::eval {a b}\n", + counts: [0, 0, 3, 3], + operands: &["ns::eval", "a", "b"], + }, // The default for an unrecognised command — a user proc here, // but equally any package command. Nothing distinguishes it // from `lappend` structurally, so the row exists to pin which @@ -7104,12 +7144,12 @@ f() { // expected: operator `=` → n1 = N1 = 1. Operands `x` and the // `` `echo hi` `` literal → n2 = N2 = 2. Before the guard the // two delimiters added `` ` `` → n1 = 2, N1 = 3. - check_metrics::("x = `echo hi`\n", "foo.rb", |metric| { - assert_eq!(metric.halstead.unique_operators(), 1); - assert_eq!(metric.halstead.total_operators(), 1); - assert_eq!(metric.halstead.unique_operands(), 2); - assert_eq!(metric.halstead.total_operands(), 2); - }); + assert_halstead_counts::( + "x = `echo hi`\n", + "foo.rb", + [1, 1, 2, 2], + "backtick subshell", + ); } #[test] @@ -7160,12 +7200,12 @@ f() { // expected: operators `def`, `` ` ``, `(` and `end` → // n1 = N1 = 4 (only the `(` opener counts after #695). // Operand `cmd` twice, parameter and use → n2 = 1, N2 = 2. - check_metrics::("def `(cmd)\n cmd\nend\n", "foo.rb", |metric| { - assert_eq!(metric.halstead.unique_operators(), 4); - assert_eq!(metric.halstead.total_operators(), 4); - assert_eq!(metric.halstead.unique_operands(), 1); - assert_eq!(metric.halstead.total_operands(), 2); - }); + assert_halstead_counts::( + "def `(cmd)\n cmd\nend\n", + "foo.rb", + [4, 4, 1, 2], + "backtick method name", + ); } #[test] diff --git a/src/metrics/loc.rs b/src/metrics/loc.rs index 646f542bd..97b8526d7 100644 --- a/src/metrics/loc.rs +++ b/src/metrics/loc.rs @@ -6704,7 +6704,11 @@ function f() { |metric| { assert_eq!(metric.loc.sloc(), 7); assert_eq!(metric.loc.ploc(), 7); - assert_eq!(metric.loc.lloc(), 4); + // `f()`, `local n=5`, `while`, `echo $n`, and the + // `n=$((n - 1))` assignment. That last one read as zero + // until the LLOC arm learned the `variable_assignment` + // alias the parser actually emits, so this asserted 4. + assert_eq!(metric.loc.lloc(), 5); assert_eq!(metric.loc.cloc(), 0); assert_eq!(metric.loc.blank(), 0); insta::assert_json_snapshot!(metric.loc); @@ -6712,6 +6716,38 @@ function f() { ); } + /// A Bash assignment is a logical line where it stands as a + /// statement, and no line of its own where it is part of one. + /// + /// The parser emits `variable_assignment` under the id the enum + /// spells `VariableAssignment2`; the unsuffixed `VariableAssignment` + /// it never emits, so the LLOC arm listing only that scored a bare + /// `a=1` zero (`.claude/rules/grammar-dispatch.md` §1). Adding the + /// alias ungated then double-counted the two positions where the + /// assignment hangs off a node the same arm already counts — a + /// `declaration_command` and a `command`'s environment prefix (§5). + /// + /// Both halves need a row: a table of standalone assignments alone + /// passes with the parent gate deleted, and a table of wrapped ones + /// alone passes with the alias never added. + #[test] + fn bash_assignment_counts_one_logical_line_per_statement() { + for (source, lloc) in [ + (&b"a=1\nb=2\nc=3\n"[..], 3), + (&b"local d=4\n"[..], 1), + (&b"export e=5\n"[..], 1), + (&b"declare -a g=(1 2)\n"[..], 1), + (&b"X=1 cmd arg\n"[..], 1), + (&b"(h=6)\n"[..], 1), + (&b"for i in 1; do j=7; done\n"[..], 2), + ] { + let measured = metrics_verbatim(crate::LANG::Bash, source, MetricsOptions::default()) + .loc + .lloc(); + assert_eq!(measured, lloc, "{:?}", String::from_utf8_lossy(source)); + } + } + #[test] fn bash_case_lloc() { check_metrics::( diff --git a/src/metrics/loc/bash.rs b/src/metrics/loc/bash.rs index 31688de2a..80e254778 100644 --- a/src/metrics/loc/bash.rs +++ b/src/metrics/loc/bash.rs @@ -46,20 +46,50 @@ impl Loc for BashCode { // and omits it there (`.claude/rules/grammar-dispatch.md` // sections 1 and 2). // - // The two rows this arm inserts before delegating are not - // redundant. `add_multiline_string_ploc` *skips* the opening row - // when the parent starts on it, which is safe only where the - // catch-all credits every node's start row — and Bash's, below, - // is leaf-gated, so a container parent contributes nothing. A - // childless `raw_string` / `ansi_c_string` is then the only node - // covering its own row, and the skip deletes it: `'ls'` alone in - // a file reported `ploc 0, blank 1`. `LineSet::insert` is - // idempotent, so pre-inserting costs nothing where the helper - // would have inserted the row anyway. + // This arm owns its opening row, which is why it calls + // `add_string_interior_ploc` rather than the parent-gated + // `add_multiline_string_ploc` its thirteen siblings use. That + // gate *skips* the opening row when the parent starts on it, + // which is safe only where the catch-all credits every node's + // start row — and Bash's, below, is leaf-gated, so a container + // parent contributes nothing. A childless `raw_string` / + // `ansi_c_string` is then the only node covering its own row, + // and the skip deletes it: `'ls'` alone in a file reported + // `ploc 0, blank 1`. String | RawString | AnsiCString | HeredocBody | HeredocBody2 => { check_comment_ends_on_code_line(stats, start); stats.ploc.lines.insert(start); - add_multiline_string_ploc(node, ancestors, stats, start); + add_string_interior_ploc(node, stats, start); + } + // An assignment standing as a statement of its own is one + // logical line — but only then. + // + // `VariableAssignment2` is the id observed parse trees + // actually carry for `variable_assignment`, in every + // position: at top level, in a `compound_statement` or + // `subshell` or `do_group`, under a `declaration_command` + // (`local n=5`, `export A=1`, `declare -a B=(1 2)`, + // `readonly C=3`) and as a `command`'s environment prefix + // (`X=1 cmd`). The unsuffixed `VariableAssignment` below is + // never emitted, so listing only it scored `a=1` zero + // logical lines (`.claude/rules/grammar-dispatch.md` §1); + // it stays as a defensive arm rather than being swapped, + // the same shape as the `HeredocBody` pair above. + // + // The last two positions need the parent gate. Both + // `declaration_command` and `command` are counted as + // logical lines by the arm below, and the assignment is + // *part* of that line rather than another one, so listing + // the kind ungated billed `local n=5` twice (§5's + // container/leaf double count). Gated rather than dropped + // per §6: the standalone spelling has no other arm to fall + // back on. + VariableAssignment2 + if !ancestors.parent(node).is_some_and(|p| { + matches!(p.kind_id().into(), DeclarationCommand | Command) + }) => + { + stats.lloc.count_logical_line(); } // LLOC: leaf statement nodes. Pipeline, Subshell, and // RedirectedStatement are excluded because they wrap inner diff --git a/src/metrics/loc/shared.rs b/src/metrics/loc/shared.rs index a33ea3ce4..a8b190884 100644 --- a/src/metrics/loc/shared.rs +++ b/src/metrics/loc/shared.rs @@ -184,24 +184,19 @@ pub(crate) fn add_only_comment_lines(stats: &mut Stats, start: usize, end: usize // already attributed to the parent — and the interior and closing rows are // always inserted. // -// **That parent gate assumes the caller's `_` arm is ungated.** It is a -// skip, and it is only safe where some *other* node on the opening row -// inserts it — which is guaranteed when the language's catch-all credits -// every node's start row, as it does in all thirteen languages #778 -// covered. Where the catch-all is leaf-gated (`if node.child_count() == 0`, -// as in `bash.rs` and `elixir.rs`) a container parent contributes nothing, -// so a *childless* literal is the only node covering its own row and the -// gate deletes it: `'ls'` alone in a Bash file reported `ploc 0, blank 1` -// (#1260). Elixir is unaffected because a `quoted_content`'s parent always -// carries its opening delimiter as a leaf token on that row; Bash's arm -// inserts the row itself before calling here, and says why. -// -// The last row is derived from [`Node::end_line`] rather than the raw end -// row: a node whose end column is 0 finished at the *start* of the row -// below its last content row, so that row is not part of its span. Using -// the raw row credits a row the literal does not cover — visible as -// `ploc > sloc` for a string left unterminated at EOF, where the literal -// absorbs the trailing newline (#1260). +// **That parent gate assumes some other node credits the opening row.** +// It is a skip, and it is only safe where the language's catch-all +// credits every node's start row, as it does in all thirteen languages +// #778 covered. Where the catch-all is leaf-gated +// (`if node.child_count() == 0`, as in `bash.rs` and `elixir.rs`) a +// container parent contributes nothing, so a *childless* literal is the +// only node covering its own row and the gate deletes it: `'ls'` alone +// in a Bash file reported `ploc 0, blank 1` (#1260). Elixir is +// unaffected because a `quoted_content`'s parent always carries its +// opening delimiter as a leaf token on that row. Bash's arm owns its +// opening row outright and calls +// [`add_string_interior_ploc`] instead — do not reach for this one +// there, where the gate can only be wrong. #[inline] pub(crate) fn add_multiline_string_ploc( node: &Node, @@ -216,8 +211,30 @@ pub(crate) fn add_multiline_string_ploc( check_comment_ends_on_code_line(stats, start); stats.ploc.lines.insert(start); } - let last_row = node.end_line().saturating_sub(1); - (start.saturating_add(1)..=last_row).for_each(|line| { - stats.ploc.lines.insert(line); - }); + add_string_interior_ploc(node, stats, start); +} + +// Adds a multi-row literal's interior and closing rows to PLOC, leaving +// its opening row to the caller. The half of +// [`add_multiline_string_ploc`] a language whose catch-all is leaf-gated +// wants on its own: those callers must credit the opening row +// unconditionally, so the parent gate above is dead weight for them and +// re-running its two statements after the fact reads as a second rule. +// +// The last row is derived from [`Node::end_line`] rather than the raw +// end row: a node whose end column is 0 finished at the *start* of the +// row below its last content row, so that row is not part of its span, +// and crediting it claims a row the literal does not cover. +// +// `insert_range` rather than a row-at-a-time loop: the range is one +// bitmap span, and Bash's heredoc bodies push thousands of rows through +// here where the loop paid a reserve and a bounds check per row. +#[inline] +pub(crate) fn add_string_interior_ploc(node: &Node, stats: &mut Stats, start: usize) { + // Inclusive, and `insert_range` no-ops on an inverted span, so a + // single-row literal inserts nothing here. + stats + .ploc + .lines + .insert_range(start.saturating_add(1), node.end_line().saturating_sub(1)); } diff --git a/src/metrics/npa/shared.rs b/src/metrics/npa/shared.rs index e8d755e2c..4a4e8279a 100644 --- a/src/metrics/npa/shared.rs +++ b/src/metrics/npa/shared.rs @@ -108,7 +108,7 @@ pub(crate) fn ruby_call_named_arguments<'a>(call: &Node<'a>) -> impl Iterator usize { use Ruby::*; @@ -152,8 +152,9 @@ pub(crate) fn ruby_symbol_name<'a>(node: &Node<'a>, source: &'a [u8]) -> Option< Ruby::DelimitedSymbol | Ruby::BareSymbol => { let mut content = node.children().filter(Node::is_named); let first = content.next()?; - (matches!(first.kind_id().into(), Ruby::StringContent) && content.next().is_none()) - .then(|| first.utf8_text(source))? + (first.kind_id() == Ruby::StringContent as u16 && content.next().is_none()) + .then_some(first)? + .utf8_text(source) } _ => None, } @@ -290,16 +291,14 @@ fn ruby_callee_name<'a>(call: &Node<'a>, source: &'a [u8]) -> Option<&'a str> { call.child_by_field_name("method")?.utf8_text(source) } -// Identifies the `attr_*` macro family on a Ruby `Call` node. Each -// macro takes a list of attribute symbols and synthesises the matching -// reader / writer / accessor methods on the enclosing class. -pub(crate) fn ruby_attr_macro_name(call: &Node, source: &[u8]) -> Option<&'static str> { - match ruby_callee_name(call, source)? { - "attr_accessor" => Some("attr_accessor"), - "attr_reader" => Some("attr_reader"), - "attr_writer" => Some("attr_writer"), - _ => None, - } +// Whether a Ruby `Call` node invokes the `attr_*` macro family on the +// enclosing class. Each macro takes a list of attribute symbols and +// synthesises the matching reader / writer / accessor methods. +pub(crate) fn ruby_is_attr_macro(call: &Node, source: &[u8]) -> bool { + matches!( + ruby_callee_name(call, source), + Some("attr_accessor" | "attr_reader" | "attr_writer") + ) } // Books `count` attributes against the class tallies, all of them @@ -322,7 +321,7 @@ fn ruby_wrapped_attr_count(call: &Node, source: &[u8]) -> usize { ruby_call_named_arguments(call) .filter(|arg| { matches!(arg.kind_id().into(), Call | Call2 | Call3 | Call4) - && ruby_attr_macro_name(arg, source).is_some() + && ruby_is_attr_macro(arg, source) }) .map(|arg| ruby_attr_macro_symbol_count(&arg)) .sum() @@ -377,7 +376,7 @@ pub(crate) fn ruby_walk_class_body(body: &Node, source: &[u8], stats: &mut Stats ruby_add_attributes(stats, 1, ruby_declaration_is_public(false, visibility)); } } - Call | Call2 | Call3 | Call4 if ruby_attr_macro_name(&child, source).is_some() => { + Call | Call2 | Call3 | Call4 if ruby_is_attr_macro(&child, source) => { ruby_add_attributes( stats, ruby_attr_macro_symbol_count(&child), diff --git a/src/metrics/snapshots/big_code_analysis__metrics__loc__tests__bash_while_lloc.snap b/src/metrics/snapshots/big_code_analysis__metrics__loc__tests__bash_while_lloc.snap index adf6006ab..9a3256351 100644 --- a/src/metrics/snapshots/big_code_analysis__metrics__loc__tests__bash_while_lloc.snap +++ b/src/metrics/snapshots/big_code_analysis__metrics__loc__tests__bash_while_lloc.snap @@ -5,12 +5,12 @@ expression: metric.loc { "sloc": 7, "ploc": 7, - "lloc": 4, + "lloc": 5, "cloc": 0, "blank": 0, "sloc_average": 3.5, "ploc_average": 3.5, - "lloc_average": 2.0, + "lloc_average": 2.5, "cloc_average": 0.0, "blank_average": 0.0, "sloc_min": 7, @@ -19,8 +19,8 @@ expression: metric.loc "cloc_max": 0, "ploc_min": 7, "ploc_max": 7, - "lloc_min": 4, - "lloc_max": 4, + "lloc_min": 5, + "lloc_max": 5, "blank_min": 0, "blank_max": 0 } From de043cdd3f20a389ab2cc5f4cba8aa80f946c3c3 Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Wed, 9 Sep 2026 07:35:23 -0700 Subject: [PATCH 17/20] fix(getter/tcl): keep one-line switch patterns literal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `switch` arm list is `pattern body pattern body …`, and the Tcl grammar breaks it into commands at newlines. The #1318 rescue kept the *first* braced pattern a literal — it is the arm command's name — but every later one on the same line is an argument, and the rescue read all of an arm command's arguments as bodies. So a one-line `switch -regexp $v { {^a} {…} {^b} {…} }` billed a `{}` around `{^b}` (N1 4) that the same arms written one per line did not (N1 3): the score moved with layout, which is the thing the rule exists to stop. Gate the rescue on argument position. Tcl pairs the list up by index with no marker on either half, so even-indexed arguments are bodies and odd-indexed ones patterns; a `-` fall-through body and a `default` pattern are `simple_word`s that take one slot each, so the parity survives them. Two rows pin it, and perturbing the gate to "always a body" fails exactly the new one-line row across the halstead suite. Also corrects the cost bound #1318 recorded. Its doc-comment, the book and the changelog claimed the value default "can only omit one `N1` occurrence of a `{}` whose vocabulary entry any real block in the file already carries". Measured against `main`: a mixed file omitted seven occurrences, and a top-level `dict for {k v} $d { puts $k }` with nothing around it loses the vocabulary entry too, so `n1` is 0 and `halstead.effort` reads `0.0` (13.5 on `main`, with the `{k v}` list fabricated as a block). Inside a `proc` the keyword and body brace keep the space non-zero (48.4 -> 41.5), which is the scope the metric is gated at, and `set x {a b}` already produced the same `0.0` before #1318 — Halstead's shape on an operator-free space, not a new failure mode. The prose now says so instead of understating it. Lesson 89's #1297 paragraph is pared back to what that fix showed. The rewrite in 80c0b2b4 closed on "the allowlist needs to know only what a comparison is, and that set does not grow", which the same commit contradicts: C# needed a second allowlist entry for `relational_pattern`, a comparison outside `binary_expression`, and a one-entry allowlist would have under-counted it silently. It also listed JavaScript and Mozjs among the denylist languages when they had no gate at all, and dropped Perl, the row the issue had declared immune. The paragraph now records both directions. --- CHANGELOG.md | 16 +++-- big-code-analysis-book/src/metrics.md | 17 ++++-- docs/development/lessons_learned.md | 20 ++++--- src/getter.rs | 86 ++++++++++++++++++++++----- src/metrics/halstead.rs | 40 ++++++++++--- 5 files changed, 138 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6648fbecd..79bf6a183 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -155,13 +155,21 @@ for historical reference. `::` is stripped before the command lookup, so `::eval {…}` — the spelling a `namespace eval` body uses to reach the core command past a local proc — keeps its block like the unqualified form; a - `ns::eval` prefix is a different command and does not. + `ns::eval` prefix is a different command and does not. Inside a + Tcl `switch` arm list, only even-positioned arguments of an arm + command are bodies, so a one-line `switch -regexp $v { {^a} {…} + {^b} {…} }` no longer wraps `{^b}` in a block that the same arms + written one per line did not. **Metric drift:** Tcl and iRules `halstead.unique_operators` / `total_operators`, the derived values, and hence `mi` fall for any file passing a braced literal to a command the grammar does not - model. Operand counts are deliberately unchanged. Supersedes the - note in 2.2.0 that a value-position braced literal still reports a - `{}` operator. + model. A script handed to an unlisted command (`dict for {k v} $d + {…}`, a Tk `-command {…}`) loses its `{}` too, and a top-level + fragment with no other block then has no operator at all, so its + `halstead.effort` reads `0.0`; a `proc` keeps its own keyword and + body brace and is unaffected. Operand counts are deliberately + unchanged. Supersedes the note in 2.2.0 that a value-position + braced literal still reports a `{}` operator. - A Bash assignment counts as a logical line. `variable_assignment` reaches the walk under the alias id the enum spells diff --git a/big-code-analysis-book/src/metrics.md b/big-code-analysis-book/src/metrics.md index 4dae80c09..aa340b6e1 100644 --- a/big-code-analysis-book/src/metrics.md +++ b/big-code-analysis-book/src/metrics.md @@ -561,12 +561,17 @@ tokens involved the same way they spell real operators: are counted either way, because an unrecognised command is as likely to have been handed real code (an `oo::class create C {…}` body, a `tcltest` `-body {…}`) as a list. So a script passed to a command - outside the list — a Tk `-command {…}` callback, say — gives up one - `{}` occurrence and nothing else. The remaining asymmetry is that a - braced value scores one operand where the grammar names the command - (`set x {a b}`) and one per word where only the command name would - (`lappend x {a b}`); closing that needs a signal neither grammar - gives. + outside the list — a Tk `-command {…}` callback, a `dict for` body — + gives up one `{}` occurrence per block and nothing else, with one + visible consequence: a space whose *only* blocks are of that kind + has no operator left, and because Halstead's difficulty multiplies + by the operator count, its `effort` reads `0.0` rather than slightly + low. A `proc` keeps its own `proc` keyword and body brace, so this + reaches top-level script fragments, not functions. The remaining + asymmetry is that a braced value scores one operand where the + grammar names the command (`set x {a b}`) and one per word where + only the command name would (`lappend x {a b}`); closing that needs + a signal neither grammar gives. - **A string-interpolation opener is not an operator.** `"{$x}"` in PHP, `"#{x}"` in Ruby and Elixir, `"${x}"` in Kotlin and Groovy and `$"{x}"` in C# all count the interpolated expression's own operators diff --git a/docs/development/lessons_learned.md b/docs/development/lessons_learned.md index 753543ee7..8c1068622 100644 --- a/docs/development/lessons_learned.md +++ b/docs/development/lessons_learned.md @@ -3493,14 +3493,18 @@ a denylist must name separately. Both were rewritten to require a would have enumerated in advance — a superclass clause (`class Foo < Bar`) and an operator-method name (`def <(other)`) — and the positive gate covered both without being told about either. C#, -Kotlin and the JS family kept the denylist form for another two issues, -and it admitted a fresh role in each: JSX tag delimiters in TypeScript, -TSX, JavaScript and Mozjs, a `super_expression` in Kotlin, an -`operator_declaration` in C#, and a Lua 5.4 variable attribute where -there was no gate at all (#1297). **Every one of those roles was -invisible until someone wrote the language's construct down** — which is -the whole argument for the polarity: the allowlist needs to know only -what a comparison is, and that set does not grow. +Kotlin and TypeScript carried the denylist form until #1297, and by +then each had admitted a role nobody listed — JSX tag delimiters, a +`super_expression`, an `operator_declaration` — while JavaScript (and +its Mozjs fork), Lua and Perl had no gate at all, and Perl was the row +the issue had declared immune. The same fix is also the caveat on the +polarity: C# needed a *second* allowlist entry, because `x is > 0` is +a comparison that lives outside `binary_expression`, and a one-entry +allowlist would have under-counted it silently — the closed form's +failure, which lesson 19 describes and no snapshot shows. Neither +polarity is free; the allowlist is preferred because its set is the +smaller one to enumerate and its miss reads as a zero rather than a +phantom. **The same token needed opposite polarities eight lines apart** (#1275, following #1274). A `?` is the ternary operator and, in C#, also diff --git a/src/getter.rs b/src/getter.rs index 39aeb72a8..5d3abdd27 100644 --- a/src/getter.rs +++ b/src/getter.rs @@ -639,12 +639,22 @@ pub(crate) trait Getter { /// unrecognised name is therefore far likelier to be value-taking. /// - Defaulting to script *fabricates* — it reports a `{}` operator /// for a block the source does not contain. Defaulting to value - /// can only *omit*, and only one `N1` occurrence of a `{}` whose - /// vocabulary entry any real block in the file already carries. - /// That asymmetry holds only because the answer is scoped to the - /// brace: see [`braced_word_op_type`] for the measurement that - /// decided it, and for why suppressing the *contents* of a value - /// would have made the omission unbounded instead. + /// can only *omit*: one `N1` occurrence per script handed to an + /// unlisted command, and — when no modelled construct in the same + /// space opens a block — the `{}` vocabulary entry with them. + /// That last case is where the cost shows: a top-level + /// `dict for {k v} $d { puts $k }` with nothing around it has no + /// operator left, and Halstead's difficulty is a product with + /// `n1` in it, so `effort` reads `0.0` rather than a little low. + /// Inside a `proc` the `proc` keyword and body brace keep the + /// space non-zero, which is the scope `halstead.effort` is gated + /// at; and the same `0.0` was already the answer for a file of + /// `set x {a b}` lines, so it is Halstead's shape on an + /// operator-free space, not a new failure mode. The omission + /// stays bounded only because the answer is scoped to the brace: + /// see [`braced_word_op_type`] for the measurement that decided + /// it, and for why suppressing the *contents* of a value would + /// have made it unbounded instead. /// - It stops the score moving with the author's choice of /// delimiter, which is what #695, #1312 and #1314 each restored /// elsewhere: `puts {c d}` and `puts "c d"` now agree on the @@ -652,11 +662,12 @@ pub(crate) trait Getter { /// /// The cost is a script passed to an unlisted command — a Tk /// `-command {…}` callback, `trace add variable v w {…}`, a - /// user-defined `with_lock {…}` — losing the `{}` its block - /// deserves. No structural signal distinguishes those cases: the - /// grammar parses `{a b}` and `{puts hi}` into the same shape. The - /// code inside them is still counted, so the loss is one operator - /// occurrence per such block and nothing else. + /// subcommand dispatch (`dict for`, `interp eval`), a user-defined + /// `with_lock {…}` — losing the `{}` its block deserves. No + /// structural signal distinguishes those cases: the grammar parses + /// `{a b}` and `{puts hi}` into the same shape. The code inside + /// them is still counted, so the loss is one operator occurrence + /// per such block, plus the vocabulary entry in the case above. /// /// [`braced_word_op_type`]: Self::braced_word_op_type fn is_value_braced_word<'a>( @@ -697,7 +708,47 @@ pub(crate) trait Getter { { return false; } - !Self::is_switch_arm(&command, code, command_ancestors, kinds) + !(Self::is_switch_arm(&command, code, command_ancestors, kinds) + && Self::is_switch_arm_body(word, &command)) + } + + /// Whether `word`, an argument of a `switch` arm command + /// ([`is_switch_arm`]), sits in a *body* position rather than a + /// *pattern* position. + /// + /// The arm list is a flat run of `pattern body pattern body …`, + /// and the grammar breaks it into commands at newlines. One arm per + /// line gives each command a pattern for its name and a body for + /// its sole argument; several arms on one line give the first + /// pattern's command the whole run as arguments, so the arguments + /// alternate `body pattern body …`. The command-name test above + /// keeps the *first* braced pattern a literal, but without this + /// gate every later one was rescued as a body, and a one-line + /// `switch -regexp $v { {^a} {…} {^b} {…} }` fabricated a `{}` + /// around `{^b}` that the same arms written one per line did not — + /// the score moving with layout, which is what the rule exists to + /// stop. + /// + /// Position is the only signal because it is what Tcl itself uses: + /// `switch` pairs the list up by index, with no marker on either + /// half. The parity holds across the words that can interpose — a + /// `-` fall-through body and a `default` pattern are both + /// `simple_word`s and take a slot each — so an even index is a body + /// and an odd one a pattern. This is the one place the rule counts + /// siblings rather than asking a parent kind; the scan is bounded by + /// the arms an author put on one line, and runs once per braced + /// argument of such a command. + /// + /// [`is_switch_arm`]: Self::is_switch_arm + fn is_switch_arm_body(word: &Node<'_>, command: &Node<'_>) -> bool { + command + .child_by_field_name("arguments") + .and_then(|arguments| { + arguments + .children() + .position(|argument| argument.id() == word.id()) + }) + .is_some_and(|index| index.is_multiple_of(2)) } /// A command's leading word, when it is a statically resolvable @@ -796,9 +847,14 @@ pub(crate) trait Getter { /// Keeping to the operator also keeps the whole thing `O(1)`: only /// a braced word's own opener can change answer, so the test is one /// kind comparison and one parent lookup, the same scope #1354 and - /// #1314 use. An - /// ancestor scan would have been `O(depth)` per node and quadratic - /// on a deeply nested `expr`, the shape #1122 warns about. + /// #1314 use — with the single exception of a `switch` arm + /// command, where [`is_switch_arm_body`] scans that command's + /// arguments, a run bounded by the arms an author wrote on one + /// line. An ancestor scan would have been `O(depth)` per node and + /// quadratic on a deeply nested `expr`, the shape #1122 warns + /// about. + /// + /// [`is_switch_arm_body`]: Self::is_switch_arm_body /// /// [`get_op_type`]: Self::get_op_type fn braced_word_op_type<'a>( diff --git a/src/metrics/halstead.rs b/src/metrics/halstead.rs index 7986187ed..657efb119 100644 --- a/src/metrics/halstead.rs +++ b/src/metrics/halstead.rs @@ -6137,15 +6137,16 @@ f() { /// contain. The role is recognised out-of-band, by the enclosing /// command's leading word (grammar-dispatch §9). /// - /// The three Tcl-only rows are the constructs this grammar models - /// with no node of its own (#467, #1264) — `switch` and `for` — - /// plus the braced `switch` *pattern* that must not be mistaken - /// for an arm body. Their iRules counterparts are dedicated nodes - /// and are covered by the sibling test. + /// The Tcl-only rows are the constructs this grammar models with + /// no node of its own (#467, #1264) — `switch` and `for` — plus the + /// braced `switch` *pattern* that must not be mistaken for an arm + /// body, in both the one-arm-per-line and the one-line layouts. + /// Their iRules counterparts are dedicated nodes and are covered by + /// the sibling test. #[test] fn tcl_braced_word_role_follows_the_enclosing_command_1318() { check_braced_word_value_cases::(&BRACED_WORD_VALUE_CASES, "foo.tcl"); - let tcl_only: [BracedWordValueCase; 3] = [ + let tcl_only: [BracedWordValueCase; 5] = [ // `switch` arm bodies. The grammar flattens `pat body pat // body` into a `command` named after the first pattern, so // the bodies read as arguments of a command called `a` — @@ -6164,8 +6165,9 @@ f() { // idiomatic and its regex must not be wrapped in a block. // Rescuing the whole arm command would read N1 4 here. // The leading `-exact` / `--` options are `simple_word` - // operands and shift no index, which is why the rescue is - // structural rather than positional (grammar-dispatch §9). + // operands of the *`switch`* command and shift nothing: + // the arm list is found as the sole `braced_word` argument, + // not by index (grammar-dispatch §9). BracedWordValueCase { source: "switch -exact -- $v {\n {a b} { puts X }\n default { puts Y }\n}\n", counts: [1, 3, 10, 11], @@ -6173,6 +6175,28 @@ f() { "switch", "-exact", "--", "$v", "a", "b", "puts", "X", "default", "Y", ], }, + // The same arms on one line. Only the *first* pattern is + // the arm command's name; `{^b}` is its third argument, and + // the name test alone rescued it as a body — N1 4 here, + // against N1 3 for the identical arms written one per line. + // The arm list is `pattern body pattern body …` by Tcl's + // own definition, so the arguments alternate and the + // even-indexed ones are the bodies. + BracedWordValueCase { + source: "switch -regexp $v { {^a} { puts A } {^b} { puts B } }\n", + counts: [1, 3, 8, 9], + operands: &["switch", "-regexp", "$v", "^a", "puts", "A", "^b", "B"], + }, + // The words that can interpose between a pattern and its + // body take one slot each and leave the parity intact: a + // `-` fall-through body and a `default` pattern are both + // `simple_word`s. The two braced bodies are at indexes 2 + // and 4 of the run and both keep their `{}`. + BracedWordValueCase { + source: "switch $v { a - b { puts AB } default { puts D } }\n", + counts: [1, 3, 9, 10], + operands: &["switch", "$v", "a", "-", "b", "puts", "AB", "default", "D"], + }, // `for` has four braced arguments and all four are // evaluated, so all four keep their braces. `i` appears as // the `set` target and again inside `incr`, and `$i` in From a86261692ab97b516f7045160c7646525dadcb32 Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Wed, 9 Sep 2026 11:12:34 -0700 Subject: [PATCH 18/20] docs(lessons): add lesson 93 and route three batch findings to rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lesson 93 records the #1196/#1236 shape: a threshold gate moved to a typed per-space accessor while the wire kept serializing only subtree sums, so the JSON-walking SARIF binding diverged from `bca check` with no failing test. The extractor table had noted the disagreement and named no consumer, which is the part of the mechanism worth keeping. The other batch findings had better homes than a lesson: - lesson 66 gains the #1318 layout sub-example (Tcl splits `switch` arm lists at newlines, so the same source reaches the recogniser as one command or several); - grammar-dispatch §6 gets the identity tiebreak for a wrapper/leaf pair with no childless-zero hazard (#1359 vs #1358); - testing.md gets "perturb the fixture as well as the production line" from the #1297 anchors; - batch-fix gains Step 6c, a whole-branch review in a fresh context, after ten per-issue pipelines left four bugs that one pass found. --- .claude/rules/grammar-dispatch.md | 10 +++++ .claude/rules/testing.md | 26 +++++++++++ .claude/skills/batch-fix/SKILL.md | 18 ++++++++ docs/development/lessons_learned.md | 69 ++++++++++++++++++++++++++++- 4 files changed, 122 insertions(+), 1 deletion(-) diff --git a/.claude/rules/grammar-dispatch.md b/.claude/rules/grammar-dispatch.md index a35e3750d..938515254 100644 --- a/.claude/rules/grammar-dispatch.md +++ b/.claude/rules/grammar-dispatch.md @@ -154,6 +154,16 @@ than assumed. PHP's `primitive_type` is childless for `callable`, leaf — the "obvious" innermost choice — would have scored six types zero (#1293). +When neither choice can strand a childless node — every wrapper requires +its leaf and the unwrapped spelling carries no wrapper at all — the +hazard is absent and the tiebreak is identity: keep the node whose span +*is* the operand. Ruby's `1r`, `1i` and `1ri` are three constants +distinct from `1`, and only the `rational` / `complex` wrapper's text +says which; billing the leaf files all four under `1` (#1359). Bash's +`$"…"` went the other way for the first reason, not this one — its +`string` child is the node present in argument position, where the +grammar emits no wrapper (#1358). + ## 7. Walk the sibling predicates for parity `Checker::is_string`, `Getter::get_op_type`, `Checker::is_call`, diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md index 10a6b682f..0fdf247c5 100644 --- a/.claude/rules/testing.md +++ b/.claude/rules/testing.md @@ -186,6 +186,32 @@ confirm it by perturbing the exact production line the assertion names — per the sections above, a test that cannot fail is worse than no test, because it reads as coverage. +## Perturb the fixture as well as the production line + +A revert test proves the construct reaches the arm *today*. It says +nothing about whether the fixture still contains the construct tomorrow. +A test that pairs an excluded construct with a genuine condition and +asserts the non-zero total keeps passing after someone trims the +construct out of the fixture, because the surviving comparison supplies +the total on its own — and every #1297 test had exactly that shape, so +each could decay into an assertion about the comparison alone with no +signal. + +Anchor the fixture on a second axis that only the construct contributes, +then verify by deleting the construct from the fixture and watching +exactly that test fail: + +- The three JSX fixtures anchor on `assignments_sum()`: the + `className="x"` attribute `=` is each fixture's only assignment. +- The C# operator overloads are asserted per space rather than through + the file total, which is 3 with the overloads and without them. +- The Perl readlines are three of the sub's four assignments. + +Where the construct contributes to no axis once excluded — a Lua +`` attribute, a TypeScript type argument — there is nothing to +anchor on, and the revert test is the only coverage available. Say so in +a comment, so the missing anchor is not read as an oversight. + ## Coverage measures execution, not discrimination A coverage report answers "did any test run this line?" It never answers diff --git a/.claude/skills/batch-fix/SKILL.md b/.claude/skills/batch-fix/SKILL.md index f4702af02..10fe497a2 100644 --- a/.claude/skills/batch-fix/SKILL.md +++ b/.claude/skills/batch-fix/SKILL.md @@ -670,6 +670,24 @@ git checkout git stash pop # if stash was used ``` +### 6c: Review the whole branch as one diff + +Once 6a passes, review `main...HEAD` as a single diff in a context that +has not seen the fixes: spawn an agent that runs `review branch` (the +worktree-agent rule against invoking skills does not apply to the +orchestrator), or ask the user to run `/code-review` on the branch. +Phase 3 reviewed each fix on its own; the defects that survive it are +the ones visible only across fixes — a sibling one sweep skipped, a +helper two fixes now share under different rules, an alias one +language's arm lists and another's does not, a rule whose stated cost +bound the next fix falsifies. On the 2026-08-30 batch, ten per-issue +pipelines plus `audit-tests` and a green gate left four real bugs that +the user's whole-branch `/code-review` found on its first reading. + +Fix the findings as a further commit on the integration branch, re-run +6a, and only then proceed to Step 7. Do not re-run the review over the +fix commit — that is the pass Phase 3 already says finds nothing. + --- ## Step 7: Summary diff --git a/docs/development/lessons_learned.md b/docs/development/lessons_learned.md index 8c1068622..2f64a62e0 100644 --- a/docs/development/lessons_learned.md +++ b/docs/development/lessons_learned.md @@ -136,6 +136,7 @@ number and the higher number stays as a redirect. | [90](#90-re-reading-a-single-consumption-source-yields-empty-not-an-error) | A re-read of a consumable source yields empty, not an error | | [91](#91-a-gate-can-filter-out-its-own-subject-before-the-check-runs) | A gate can filter out its own subject before the check runs | | [92](#92-an-optimizations-rationale-can-encode-the-waste-it-optimizes-for) | An optimization's rationale can encode the waste it optimizes for | +| [93](#93-a-gate-that-reads-a-typed-accessor-is-invisible-to-the-front-ends-that-read-the-wire) | A gate that reads a typed accessor is invisible to the front-ends that read the wire | --- @@ -2509,7 +2510,10 @@ grammars) for builtins that are control flow, recognise them out-of-band by their leading word, and locate sub-parts by **structural position** rather than a fixed index so detection survives optional option/flag prefixes. Add a fixture scoring above the base and test-via-revert that -the arm fires. See +the arm fires, and add it in every layout the grammar splits +differently — a construct with no node of its own has no fixed shape +either, so the same source can reach the recogniser as one command or +as several depending on where the newlines fall. See [`grammar-dispatch.md` §9](../../.claude/rules/grammar-dispatch.md). The gap is not a missing enum arm — the *kind* the dispatcher would need @@ -2529,6 +2533,19 @@ argument — not a fixed child index, because the optional `-exact` / index would have broken on every option-form switch (lesson 53's failure mode). +**The same `switch` arms scored differently by line layout** (#1318). +The Tcl grammar breaks the arm list into `command` nodes at newlines, so +`pattern body pattern body` on one line is one command carrying three +arguments and written one arm per line is two commands carrying one +each. The #1318 rescue that keeps an arm command's braced *body* a +script read every argument of that command as a body, which billed a +`{}` around the second pattern of a one-line `switch -regexp` and +nothing around the same pattern on its own line. Tcl pairs the list +positionally with no marker on either half, so an argument's role is its +index parity — a place §3's "never by index" inverts, because the +index is the grammar's only encoding of the role. The multi-line fixture +alone had passed throughout. + --- ## 67. "Compute it once" is the wrong altitude when the consumers don't share the transform's parameters @@ -3642,3 +3659,53 @@ the wire clone and the reorder buffer — and `bca check`, which has no reorder buffer at all, still peaked at 4.5 GB on the same tree. --- + +## 93. A gate that reads a typed accessor is invisible to the front-ends that read the wire + +**Lesson:** When a threshold gate changes *which number* it measures, +follow the number to the wire before calling the change done. `bca +check` extracts from typed per-space accessors; the Python binding, the +CSV flattener and any JSON-walking consumer can read only what +`wire.rs` serializes. If the accessor's value has no serialized key, +every wire consumer keeps the old definition and no test fails, because +each front-end is consistent with itself. Serialize the own value as +`.value` (additive, `#[serde(default)]`), give the sum one +definition that the extractor, the wire projection and the report all +call, and pin the two front-ends against each other on one input. + +The divergence has no failure signature. Each front-end passes its own +suite; the parity claim between them lives in prose — the binding's +docs, `metric_catalog`'s `skip_at_unit` flag — that nothing measures; +and the JSON shape did not change, so no snapshot moved. Nor does +writing the divergence down close it: a note at the extractor that the +JSON field of the same name now disagrees names no consumer, so nobody +follows it to the binding that reads that field. The consumer that is +wrong is the one that could not have been right: the number it needed +was never on the wire. + +**`to_sarif` gated `nargs` on the subtree sum after `bca check` stopped** +(#1196, #1236). #1196 moved the CLI's `nargs` extractor from `total()` +— own parameters plus every nested closure's — to the callable's own +list, and touched neither the wire nor the binding: the wire's `Nargs` +carried sums, averages and extrema but no field for the own count, and +the binding kept reading `total` because that had been the CLI's +accessor. A two-argument function holding a three- and a two-argument +closure scored 7 in the binding and 2 in the CLI, so closure-heavy code +drew SARIF findings no CLI run produces. Filed five days later; nothing +had failed. The fix +serializes `nargs.value`, routes the CLI extractor, the report hotspot +table and the wire projection through one `Stats::own_args()`, and +corrects `skip_at_unit` for `nargs` — a documentary flag whose pinned +set had asserted a parity that stopped holding at #1196. + +**The same gap had already been closed once, for four other metrics** +(#855, #958). `cyclomatic`, `cyclomatic.modified`, `cognitive` and `abc` +serialized only subtree aggregates, so the binding first over-emitted at +interior spaces, then — restricted to leaves — could not emit an interior +space whose own value breached. #958 added those four `*.value` fields; +`nargs` was not among them because its gate did not yet read the own +value. When #1196 moved it, the extractor table noted that the JSON +field now disagreed — and stopped there; the binding that reads that +field is the consumer the note needed to name. + +--- From 54a32232371ae54b6a42cea207a28a42fce93ac6 Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Wed, 9 Sep 2026 11:39:55 -0700 Subject: [PATCH 19/20] docs: correct two language counts from the branch review #778 wired `add_multiline_string_ploc` into eighteen languages, not thirteen; the #1260 comments and changelog entry all copied the wrong number. The `this` regression test's doc said "ten of the thirteen" where the getter comment it mirrors says "eleven of the fourteen". Also record in `.rustfmt-bail-baseline.txt` why `src/getter/ruby.rs` went 3 -> 4 in #1360 (a new arm in an already-bailing match, nothing new to hoist) and lock in the silent `abc/elixir.rs` decrease. --- .rustfmt-bail-baseline.txt | 10 +++++++++- CHANGELOG.md | 2 +- src/metrics/halstead.rs | 2 +- src/metrics/loc/bash.rs | 4 ++-- src/metrics/loc/elixir.rs | 2 +- src/metrics/loc/shared.rs | 2 +- src/metrics/loc/tcl.rs | 2 +- 7 files changed, 16 insertions(+), 8 deletions(-) diff --git a/.rustfmt-bail-baseline.txt b/.rustfmt-bail-baseline.txt index 5b34e906b..30a12c092 100644 --- a/.rustfmt-bail-baseline.txt +++ b/.rustfmt-bail-baseline.txt @@ -38,6 +38,14 @@ # tests/common/mod.rs an `insta::with_settings!` invocation, # whose `key => value` token tree is not a # match at all +# +# Known cause-1 increases that added no comment: the arm joined a match +# that already bailed on section comments inside its or-patterns, the +# kind #1136 decided to keep, so the count grew by one with nothing new +# to hoist. Hand-check the arm's formatting instead. +# +# src/getter/ruby.rs 3 -> 4, the `BQUOTE` subshell guard +# (#1360) in `get_op_type` # src/vcs/error.rs `classify_error_variants!` (#1245): its # matcher, plus the 11 `$pat => $sample` # entries at the invocation, which are @@ -125,7 +133,7 @@ src/getter/python.rs 7 src/getter/ruby.rs 4 src/getter/tcl.rs 6 src/macros/mod.rs 38 -src/metrics/abc/elixir.rs 6 +src/metrics/abc/elixir.rs 5 src/metrics/cognitive/perl.rs 8 src/metrics/cyclomatic.rs 7 src/metrics/cyclomatic/irules.rs 2 diff --git a/CHANGELOG.md b/CHANGELOG.md index 79bf6a183..27a9d35ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -117,7 +117,7 @@ for historical reference. - Multi-line strings and heredocs no longer read as blank lines in Bash, Elixir, Tcl and iRules (#1260). Each language's multi-row-capable literals now credit every spanned row to PLOC, - matching the thirteen languages #778 covered and Python's #415 + matching the eighteen languages #778 covered and Python's #415 decision. Elixir `@doc` / `@moduledoc` rows are PLOC — a module attribute is an assignment whose value the compiler stores, so its Python analogue is `x = """…"""`, not a discarded docstring — diff --git a/src/metrics/halstead.rs b/src/metrics/halstead.rs index 657efb119..2dfd4d3d3 100644 --- a/src/metrics/halstead.rs +++ b/src/metrics/halstead.rs @@ -8282,7 +8282,7 @@ f() { /// `Cpp::This` / `Mozcpp::This` were in neither arm of /// `CppCode::get_op_type` / `MozcppCode::get_op_type`, so a `this` /// contributed *nothing* — not an operator and not an operand — - /// while ten of the thirteen languages here bill their + /// while eleven of the fourteen languages here bill their /// self-reference as an operand. Same shape as #1316's character /// literals, and the inverse of the #1351-#1355 over-counts. /// diff --git a/src/metrics/loc/bash.rs b/src/metrics/loc/bash.rs index 80e254778..496b78935 100644 --- a/src/metrics/loc/bash.rs +++ b/src/metrics/loc/bash.rs @@ -27,7 +27,7 @@ impl Loc for BashCode { // Every Bash literal that can span rows, so its interior rows // reach PLOC instead of being mislabelled blank by // `blank = sloc - ploc - cloc` (#1260) — the decision #778 took - // for thirteen other languages and #415 took for Python. + // for eighteen other languages and #415 took for Python. // // `String` looks covered without an arm and is not: the grammar // emits one `string_content` child per row that *has* text, so @@ -48,7 +48,7 @@ impl Loc for BashCode { // // This arm owns its opening row, which is why it calls // `add_string_interior_ploc` rather than the parent-gated - // `add_multiline_string_ploc` its thirteen siblings use. That + // `add_multiline_string_ploc` its siblings use. That // gate *skips* the opening row when the parent starts on it, // which is safe only where the catch-all credits every node's // start row — and Bash's, below, is leaf-gated, so a container diff --git a/src/metrics/loc/elixir.rs b/src/metrics/loc/elixir.rs index 50fee57b0..b8e82ce61 100644 --- a/src/metrics/loc/elixir.rs +++ b/src/metrics/loc/elixir.rs @@ -61,7 +61,7 @@ impl Loc for ElixirCode { // interior rows reached neither PLOC nor CLOC and // `blank = sloc - ploc - cloc` mislabelled them as blank // (#1260); crediting them to PLOC is the decision #778 took for - // thirteen other languages and #415 took for Python. + // eighteen other languages and #415 took for Python. // // That covers `@doc """…"""` and `@moduledoc """…"""` as PLOC, // deliberately. Python's one carve-out to CLOC is a *bare* diff --git a/src/metrics/loc/shared.rs b/src/metrics/loc/shared.rs index a8b190884..f8134717f 100644 --- a/src/metrics/loc/shared.rs +++ b/src/metrics/loc/shared.rs @@ -186,7 +186,7 @@ pub(crate) fn add_only_comment_lines(stats: &mut Stats, start: usize, end: usize // // **That parent gate assumes some other node credits the opening row.** // It is a skip, and it is only safe where the language's catch-all -// credits every node's start row, as it does in all thirteen languages +// credits every node's start row, as it does in all eighteen languages // #778 covered. Where the catch-all is leaf-gated // (`if node.child_count() == 0`, as in `bash.rs` and `elixir.rs`) a // container parent contributes nothing, so a *childless* literal is the diff --git a/src/metrics/loc/tcl.rs b/src/metrics/loc/tcl.rs index 18cf28ea1..8807c7006 100644 --- a/src/metrics/loc/tcl.rs +++ b/src/metrics/loc/tcl.rs @@ -37,7 +37,7 @@ impl Loc for TclCode { // substitution — so its interior rows reached neither PLOC nor // CLOC and `blank = sloc - ploc - cloc` mislabelled them as // blank (#1260). Credit every spanned row to PLOC, the decision - // #778 took for thirteen other languages and #415 took for + // #778 took for eighteen other languages and #415 took for // Python. // // `braced_word` is deliberately *not* routed here. Tcl spells a From 4d7ec3edd8a7461eeb61b47f76e8bf846b31cf5c Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Wed, 9 Sep 2026 12:35:01 -0700 Subject: [PATCH 20/20] test(metrics): cover four uncovered patch lines Codecov on PR #1403 reported two production arms no test executed: the C `string_literal` and mozjs `template_string` arms that credit a multi-row string to PLOC. Only `.jsm` routes to the mozjs copy, and C has no raw string, so neither was reached by the sibling fixtures. Add both to the cross-language parity test; each arm perturbed alone now fails it. Two Ruby `?` fall-throughs were reachable but unexercised: the empty delimited symbol `:""` (no `string_content` child) and `self.(...)` (a `call` with a `self` receiver and no `method` field). Extend the #1399 unresolvable-arguments fixture and the another-object npa fixture with one each; perturbing either line fails exactly its own test. The remaining lines are non-UTF-8 or grammar-unreachable `?` branches and assertion failure messages inside tests, none of which a passing test can execute. --- src/metrics/loc.rs | 15 +++++++++++++++ src/metrics/npa.rs | 7 ++++++- src/metrics/npm.rs | 11 ++++++----- 3 files changed, 27 insertions(+), 6 deletions(-) diff --git a/src/metrics/loc.rs b/src/metrics/loc.rs index 97b8526d7..3bf51776b 100644 --- a/src/metrics/loc.rs +++ b/src/metrics/loc.rs @@ -4673,6 +4673,21 @@ line3\";", "foo.cpp", assert_three_code_rows, ); + // C has no raw string; tree-sitter-c folds a backslash-newline + // continuation into one `string_literal` spanning every row. + // Mozjs carries the same `template_string` arm as its upstream + // JS siblings, but only `.jsm` routes to it, so the JavaScript + // and TypeScript template tests never reach this copy. + check_metrics::( + "const char* s = \"line1\\\nline2\\\nline3\";", + "foo.c", + assert_three_code_rows, + ); + check_metrics::( + "const s = `line1\nline2\nline3`;", + "foo.jsm", + assert_three_code_rows, + ); // The four languages #778 missed, added by #1260. Bash's shape is // the single-quoted `raw_string`; Tcl and its iRules dialect spell // the literal `quoted_word`; Elixir routes the `quoted_content` diff --git a/src/metrics/npa.rs b/src/metrics/npa.rs index c91efab45..ffaac9846 100644 --- a/src/metrics/npa.rs +++ b/src/metrics/npa.rs @@ -3017,9 +3017,14 @@ mod tests { // here. The receiver is a `constant`, so a scan for the leading // `identifier` would find `attr_accessor` and count it. // + // `self.(:c)` is `self.call(:c)` spelled without a method name: + // the `call` node has a `self` receiver and no `method` field, + // so the callee lookup must yield nothing rather than assume + // every `self`-received call names one. + // // expected: na = 1 (a), npa = 1. check_metrics::( - "class A\n attr_accessor :a\n Other.attr_accessor :b\nend\n", + "class A\n attr_accessor :a\n Other.attr_accessor :b\n self.(:c)\nend\n", "foo.rb", |metric| { assert_eq!(metric.npa.class_na_sum(), 1); diff --git a/src/metrics/npm.rs b/src/metrics/npm.rs index 2a885389c..4ce9ba60c 100644 --- a/src/metrics/npm.rs +++ b/src/metrics/npm.rs @@ -2847,14 +2847,15 @@ class C { #[test] fn ruby_visibility_call_with_unresolvable_arguments_changes_nothing() { - // A splat (`private *SYMS`) and a bare identifier - // (`private foo`) name methods this walk cannot resolve. Neither - // may demote anything, and neither may be mistaken for the - // argument-less flag form. + // A splat (`private *SYMS`), a bare identifier (`private foo`) + // and the empty delimited symbol (`private :""`, a + // `delimited_symbol` with no `string_content` child at all) name + // methods this walk cannot resolve. None may demote anything, + // and none may be mistaken for the argument-less flag form. // // expected: nm = 2, npm = 2 — both methods stay public. check_metrics::( - "class N\n def a\n 1\n end\n private *SYMS\n private foo\n def b\n 1\n end\nend\n", + "class N\n def a\n 1\n end\n private *SYMS\n private foo\n private :\"\"\n def b\n 1\n end\nend\n", "foo.rb", |metric| { assert_eq!(metric.npm.class_nm_sum(), 2);