diff --git a/.bca-baseline.toml b/.bca-baseline.toml index 723afe43a..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" @@ -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 = 120865.54836941602 [[entry]] path = "src/metrics/abc/csharp.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" @@ -1047,7 +1041,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 +1197,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/.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/.rustfmt-bail-baseline.txt b/.rustfmt-bail-baseline.txt index aa97d0d3f..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 @@ -122,10 +130,10 @@ 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 +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 cd1bc4405..27a9d35ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,29 @@ 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()`. + `bca metrics -O csv` is the one output that does not carry it — + `CSV_HEADER` is a frozen positional contract, as it already is for + the four `.value` fields #958 added. `bca diff` walks the raw + document rather than the `wire` types, so diffing a pre-#1236 file + against a current one reports `nargs.value` as a change wherever the + older side's absent field differs from the newer side's value. + `metric_catalog::METRICS` now records `nargs` with + `skip_at_unit: true`, matching the four metrics whose serialized + aggregate diverges from the CLI accessor; nothing in the workspace + reads the flag, so this is a correction to a published description + rather than a behaviour change. + ### Performance - The metric walk's cognitive nesting map no longer grows to one entry @@ -59,6 +82,152 @@ 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`. + Elixir was swept the same way in the same release: an operator + *named* rather than applied — the capture `&` 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 @@ -542,12 +543,35 @@ 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, 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 @@ -759,8 +783,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 @@ -814,6 +838,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-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/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-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/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..2d8a0a7ce 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. + + 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 [ + (*_sarif_sort_key(r), 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/docs/development/lessons_learned.md b/docs/development/lessons_learned.md index 8135a927c..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 @@ -3492,10 +3509,19 @@ 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 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 @@ -3633,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. + +--- diff --git a/src/getter.rs b/src/getter.rs index 2226ca285..5d3abdd27 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,109 @@ 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 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 +/// *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 +547,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 +574,318 @@ 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*: 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 + /// operator column. + /// + /// The cost is a script passed to an unlisted command — a Tk + /// `-command {…}` callback, `trace add variable v w {…}`, a + /// 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>( + 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(word) + .is_some_and(|parent| [kinds.argument, kinds.command].contains(&parent.kind_id())) + { + 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) + && 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 + /// `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; + } + 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 + /// `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 + /// kind comparison and one parent lookup, the same scope #1354 and + /// #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>( + node: &Node<'a>, + code: &[u8], + ancestors: Ancestors<'a, '_>, + kinds: &BracedWordKinds, + ) -> HalsteadType { + let base = Self::get_op_type(node, ancestors); + // 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)| { + 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/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/getter/cpp.rs b/src/getter/cpp.rs index 6a32ef810..404b58b44 100644 --- a/src/getter/cpp.rs +++ b/src/getter/cpp.rs @@ -133,8 +133,63 @@ 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/irules.rs b/src/getter/irules.rs index 3a6b50114..663f35b4e 100644 --- a/src/getter/irules.rs +++ b/src/getter/irules.rs @@ -3,14 +3,19 @@ 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, + open_brace: Irules::LBRACE as u16, }; impl Getter for IrulesCode { @@ -189,5 +194,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/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/getter/ruby.rs b/src/getter/ruby.rs index 96fc18cd1..caacf0b13 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). @@ -184,6 +226,75 @@ 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 ancestors + .parent(node) + .is_some_and(|p| matches!(p.kind_id().into(), 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/getter/tcl.rs b/src/getter/tcl.rs index 77efcf4b4..f44ab7ba8 100644 --- a/src/getter/tcl.rs +++ b/src/getter/tcl.rs @@ -3,14 +3,24 @@ 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, + open_brace: Tcl::LBRACE as u16, }; impl Getter for TclCode { @@ -70,7 +80,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 +108,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 +244,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 +272,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/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/abc.rs b/src/metrics/abc.rs index 0a27d6633..5b8c83c3f 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,80 @@ 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_func_space::( + "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", + |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); + }, + ); + } + + // 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 +4533,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 +5124,49 @@ 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`. + // + // 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 + // 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 +5484,39 @@ 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); + // 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); + }, + ); + } + // #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 @@ -7798,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] @@ -8607,6 +8839,33 @@ 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); + // 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); + }, + ); + } + #[test] fn javascript_number_truthy_condition_counts() { // Regression for #772: JS treats every non-zero number as @@ -9148,6 +9407,34 @@ 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); + // 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); + }, + ); + } + #[test] fn mozjs_if_boolean_literal_condition() { check_metrics::( @@ -9445,6 +9732,46 @@ 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); + // 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); + }, + ); + } + #[test] fn perl_numeric_and_string_comparisons_count_conditions() { // Numeric ops `==`, `!=`, `<`, `>`, `<=`, `>=`, `<=>` and @@ -9950,6 +10277,37 @@ 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. + // + // 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::( + "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/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/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 `&&`, `||`, `//`, diff --git a/src/metrics/halstead.rs b/src/metrics/halstead.rs index 61754b9a0..2dfd4d3d3 100644 --- a/src/metrics/halstead.rs +++ b/src/metrics/halstead.rs @@ -4714,6 +4714,78 @@ 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 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. + /// - `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}`). + /// + /// 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, + 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 +4798,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 +4823,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 +4845,112 @@ 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. + // + // 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"); + 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); } } @@ -5609,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", @@ -5651,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"], }, @@ -5672,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], @@ -5793,6 +5929,341 @@ 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; 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. + 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 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 + // 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 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; 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` — + // 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 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], + operands: &[ + "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 + // 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::( @@ -6685,6 +7156,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. + assert_halstead_counts::( + "x = `echo hi`\n", + "foo.rb", + [1, 1, 2, 2], + "backtick subshell", + ); + } + + #[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. + assert_halstead_counts::( + "def `(cmd)\n cmd\nend\n", + "foo.rb", + [4, 4, 1, 2], + "backtick method name", + ); + } + + #[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 @@ -6855,6 +7470,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 (`{}` `[]` `()`), @@ -7419,6 +8195,242 @@ 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 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. + /// + /// 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"); + } + + /// 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 — + /// 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. + /// + /// 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_regardless_of_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/src/metrics/loc.rs b/src/metrics/loc.rs index 77f2f1f10..3bf51776b 100644 --- a/src/metrics/loc.rs +++ b/src/metrics/loc.rs @@ -4673,6 +4673,68 @@ 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` + // 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 +6173,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::( @@ -6591,7 +6719,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); @@ -6599,6 +6731,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::( @@ -6666,6 +6830,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 +8605,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 +10182,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..496b78935 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,73 @@ 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 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 + // `"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). + // + // 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 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_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 // 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..b8e82ce61 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 + // 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* + // 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..f8134717f 100644 --- a/src/metrics/loc/shared.rs +++ b/src/metrics/loc/shared.rs @@ -179,17 +179,30 @@ 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 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 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 +// 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, ancestors: Ancestors<'_, '_>, stats: &mut Stats, start: usize, - end: usize, ) { if ancestors .parent(node) @@ -198,7 +211,30 @@ 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| { - 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/loc/tcl.rs b/src/metrics/loc/tcl.rs index 1c5c8a41c..8807c7006 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 eighteen 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/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/npa.rs b/src/metrics/npa.rs index de9e3c8c2..ffaac9846 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,173 @@ 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. + // + // `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\n self.(:c)\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_declares_no_attribute() { + // Pins the defensive `HashKeySymbol` arm in + // `ruby_symbol_argument_count` as unreachable at the pinned + // 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 \ + claim below is then vacuous" + ); + 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..4a4e8279a 100644 --- a/src/metrics/npa/shared.rs +++ b/src/metrics/npa/shared.rs @@ -87,25 +87,86 @@ 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_declares_no_attribute`. +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()?; + (first.kind_id() == Ruby::StringContent as u16 && content.next().is_none()) + .then_some(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,19 +197,134 @@ pub(crate) fn ruby_visibility_marker(node: &Node, source: &[u8]) -> Option Option<&'static str> { - let ident = call - .children() - .find(|c| matches!(c.kind_id().into(), Ruby::Identifier))?; - match ident.utf8_text(source)? { - "attr_accessor" => Some("attr_accessor"), - "attr_reader" => Some("attr_reader"), - "attr_writer" => Some("attr_writer"), - _ => None, +// A Ruby visibility keyword used in call position. `targets_singleton` +// records *which* method family the keyword names: the bare keywords +// govern instance methods only, and demoting a `def self.x` takes the +// dedicated `private_class_method` / `public_class_method` pair (#1255). +#[derive(Clone, Copy)] +pub(crate) struct RubyVisibilityCall { + pub(crate) visibility: RubyVisibility, + pub(crate) targets_singleton: bool, +} + +// What a visibility-keyword `Call` in a Ruby class body does. Shared by +// `Npm` and `Npa` so the two walkers cannot drift on the same Ruby rule +// (grammar-dispatch rule 7). +pub(crate) enum RubyVisibilityEffect { + // `private()` — the explicit-parens spelling of the bare keyword, + // which sets the default for every later instance-level declaration + // exactly as `private` on its own line does. (The bare spelling is + // an `identifier`, not a `Call`, and is matched by + // `ruby_visibility_marker`.) + Flag(RubyVisibility), + // `private def x` / `private :foo` / `private attr_accessor :b` — + // the keyword governs only what its argument list names, leaving the + // body-wide flag alone. + Arguments(RubyVisibilityCall), +} + +// Classifies a Ruby class-body `Call` whose callee is a visibility +// keyword. +// +// An argument-less `private_class_method` is an ArgumentError in Ruby +// rather than a flag flip, so it yields no effect at all. +pub(crate) fn ruby_visibility_effect(call: &Node, source: &[u8]) -> 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) +} + +// 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 +// 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_is_attr_macro(arg, source) + }) + .map(|arg| ruby_attr_macro_symbol_count(&arg)) + .sum() } // Walks the direct children of a Ruby class / singleton-class body @@ -163,8 +339,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 +373,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; - } + Call | Call2 | Call3 | Call4 if ruby_is_attr_macro(&child, source) => { + 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..4ce9ba60c 100644 --- a/src/metrics/npm.rs +++ b/src/metrics/npm.rs @@ -2646,14 +2646,396 @@ 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`), 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 private :\"\"\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__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__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 } 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 } 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/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 +} 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"); +} 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/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..3cd242f27 160000 --- a/tests/repositories/big-code-analysis-output +++ b/tests/repositories/big-code-analysis-output @@ -1 +1 @@ -Subproject commit 84370088c61f092d284f46f21892fa6cc0fbf359 +Subproject commit 3cd242f27e0e74a94b821639b3db64fab8d2c367