From 5f3ad18511f6bae5b16b7d450b8d4c478067494e Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Sun, 2 Aug 2026 22:32:13 -0700 Subject: [PATCH 01/27] fix(lint): lex char literals in the snapshot-anchor gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `utils/check-snapshot-anchors.py` classified comment and string spans before deciding whether a snapshot call was anchored, but never lexed char literals. A `b'"'` or `'"'` read as an unpaired double quote opened a string span running to the next `"` anywhere later in the file, so every `insta::assert_json_snapshot!` in between became invisible and the gate reported a clean file. That is the exact "reads as clean" failure the gate exists to prevent, so it must not be its own failure mode. Ports `char_literal_end()` from `check-rustfmt-bail.py`, which hit the identical defect in #1136. Returning `None` for a lifetime is the half that matters: `'a`, `'_` and `'outer:` have no closing quote, and a greedy variant that treats one as a literal swallows the rest of the file in the other direction. Three further gaps found while fixing it, all the same failure mode: - `find_macro_call_end` is a second, independent lexer in the same file and carried both defects. A char literal in the argument list ran the body scan past the real closing paren and into the next call, whose inline `@"…"` the bare call then claimed as its own anchor. - The raw-string branch tested for `r` only, so `br"…"` / `br#"…"#` were unrecognised openers. Extracted `raw_string_end` / `regular_string_end` so both lexers share one spelling. - `default_targets()` used a non-recursive `glob`, so the 126 files under `src/metrics/{abc,cognitive,cyclomatic,loc,npa,npm}/` created by the #969 split were never scanned at all. Now `rglob`. Latent, not live: every live per-file count is unchanged, confirming the issue's assessment. Zero counts are no longer written to the baseline — an unlisted file is already allowed zero via `load_baseline`'s `.get(rel, 0)`, so the recursive scan adds no noise and the baseline stays a list of debt. That made the `if not baseline` guard wrong for a tree with no debt anywhere, which now tests existence instead. Also ports `baseline_key()`, so naming a file outside the repository reports a count rather than a `relative_to` traceback. Adds `utils/check-snapshot-anchors-test.py` (15 tests) and wires it into `make snapshot-anchors-test`, the pre-commit/CI DAGs, the `lint` aggregate, and an explicit CI step, matching the rustfmt-bail pair. Each end-to-end fixture was verified by perturbation: removing the char branch, making it greedy, and removing the byte-raw branch each fail the test that names the behaviour, and nothing else. Three first-draft fixtures passed against the bug and were replaced — an even number of lifetimes ahead of the call pairs off harmlessly, and a raw string with balanced quotes lexes the same either way. Fixes #1192 --- .github/workflows/ci.yml | 5 + .pre-commit-config.yaml | 10 ++ .snapshot-anchor-baseline.txt | 14 +- AGENTS.md | 21 ++- Makefile | 23 ++- utils/check-snapshot-anchors-test.py | 260 +++++++++++++++++++++++++++ utils/check-snapshot-anchors.py | 230 ++++++++++++++++++------ 7 files changed, 490 insertions(+), 73 deletions(-) create mode 100644 utils/check-snapshot-anchors-test.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 84b23617d..c33a02c5e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -552,6 +552,11 @@ jobs: # this check. - name: snapshot anchors (explicit) run: python3 utils/check-snapshot-anchors.py + # Run the gate's own unittests as their own explicit step, for the + # same reason as the rustfmt-bail twin below: a refactor that + # breaks the script must not be able to disable the gate silently. + - name: snapshot-anchors self-tests (explicit) + run: python3 -m unittest -q utils/check-snapshot-anchors-test.py # Defensive twin for the rustfmt-bail gate (#1136): a comment # inside a match pattern makes rustfmt emit the whole match # verbatim while `cargo fmt --check` exits 0, so the fmt job diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index cc8621a62..89bafbceb 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -80,6 +80,16 @@ repos: entry: python3 utils/check-snapshot-anchors.py pass_filenames: false + # Self-tests for the snapshot-anchors gate itself. Kept as a + # separate hook so the gate hook above remains a one-line + # invariant check, matching the rustfmt-bail pair below. + - id: snapshot-anchors-test + name: snapshot-anchors-test + language: system + files: '^utils/check-snapshot-anchors(-test)?\.py$' + entry: python3 -m unittest -q utils/check-snapshot-anchors-test.py + pass_filenames: false + # Block new match arms rustfmt refuses to format (#1136). A # comment inside a match *pattern* makes rustfmt emit the whole # match verbatim while `cargo fmt --check` still exits 0, so the diff --git a/.snapshot-anchor-baseline.txt b/.snapshot-anchor-baseline.txt index 368dee17e..7aa33afe0 100644 --- a/.snapshot-anchor-baseline.txt +++ b/.snapshot-anchor-baseline.txt @@ -2,18 +2,16 @@ # Maintained by check-snapshot-anchors.py; see AGENTS.md. # Lower-or-equal current counts pass; any increase fails CI. # Regenerate with: ./utils/check-snapshot-anchors.py --update +# +# Only files with outstanding bare calls are listed. An unlisted +# file is allowed zero, which `load_baseline`'s `.get(rel, 0)` +# already encodes — so omitting them keeps this a list of debt +# rather than a census of every scanned file (#1192 made the +# scan recursive, which would otherwise have added 126 zeroes). src/metrics/abc.rs 27 -src/metrics/cognitive.rs 0 src/metrics/cyclomatic.rs 1 src/metrics/halstead.rs 3 -src/metrics/loc.rs 0 -src/metrics/mi.rs 0 -src/metrics/mod.rs 0 -src/metrics/nargs.rs 0 -src/metrics/nexits.rs 0 -src/metrics/nom.rs 0 src/metrics/npa.rs 12 src/metrics/npm.rs 23 -src/metrics/tokens.rs 0 src/metrics/wmc.rs 13 diff --git a/AGENTS.md b/AGENTS.md index 615a405d4..7c684c498 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -436,12 +436,27 @@ as part of `make pre-commit` and `make ci`, the `.github/workflows/ci.yml`) invokes `./utils/check-snapshot-anchors.py`, which scans every `insta::assert_json_snapshot!(metric.…)` call under `src/metrics/` -and counts the unanchored ones per file. The current per-file -counts are checked in at `.snapshot-anchor-baseline.txt`; CI fails -on any *increase*. Decreases are silent and may be locked in with +— **subdirectories included since #1192**; before that the glob was +non-recursive and the 126 files under `abc/`, `cognitive/`, +`cyclomatic/`, `loc/`, `npa/` and `npm/` were invisible to it — and +counts the unanchored ones per file. Outstanding counts are checked +in at `.snapshot-anchor-baseline.txt`; CI fails on any *increase*. +A file with no bare calls is **not** listed, and an unlisted file is +allowed zero, so the baseline reads as a list of debt rather than a +census. Decreases are silent and may be locked in with `./utils/check-snapshot-anchors.py --update`, which regenerates the baseline from the working tree. +The gate lexes Rust literals to decide what is live code, and that +lexer is itself gated: `make snapshot-anchors-test` runs +`utils/check-snapshot-anchors-test.py`, which pins both directions of +the char-literal rule (#1192). A `b'"'` read as an unpaired quote +opens a string span that hides every later snapshot call, and a +lifetime (`'a`, `'outer:`) read as a literal swallows the rest of the +file the other way. Both failure modes make the gate report a clean +file — the outcome it exists to prevent — so neither may be left to +inspection. + **`cargo fmt --check` is not the whole formatting gate.** A comment inside a match *pattern* makes rustfmt emit the enclosing match verbatim, silently, with `cargo fmt --check` still exiting `0` — so diff --git a/Makefile b/Makefile index 47ca8fb86..7fca3e28b 100644 --- a/Makefile +++ b/Makefile @@ -88,7 +88,7 @@ find-by-ext = $(if $(FD),$(FD) --extension $(1) $(FD_EXCLUDE) $(2),find . -name NEXTEST := $(shell command -v cargo-nextest 2>/dev/null) TEST_CMD = $(if $(NEXTEST),$(NEXTEST) nextest run --workspace --all-features,cargo test --workspace --all-features --lib --bins --tests) -.PHONY: help check-tools worktree-setup worktree-setup-test build build-release check test test-doc chain-audit fmt fmt-check markdown-fmt markdown-lint shellcheck sh-fmt sh-fmt-check toml-fmt toml-fmt-check toml-lint makefile-check actionlint snapshot-anchors rustfmt-bail rustfmt-bail-test grammar-marker-sync grammar-marker-sync-test check-versions check-excluded-manifests check-excluded-manifests-test check-manpage-assets gate-status-test enums-check enums-codegen-drift enums-codegen-drift-test self-scan self-scan-headroom self-scan-write-baseline self-scan-write-baseline-headroom vcs lint clippy udeps insta-review insta-accept clean distclean install install-cli install-web doc doc-open doc-check doc-check-docsrs book book-serve book-pot book-po-update book-ja book-deploy all pre-commit ci release-check verify-changelog pkg-deb-local pkg-rpm-local dev-env-build dev-env-run dev-env-shell dev-env-rm py-bootstrap py-sync py-relock py-clean py-fmt py-fmt-check py-lint py-typecheck py-test py-stubtest smoke smoke-cli smoke-lib bench bench-scaling bench-walk _check-find _pc-all _pc-fmt _pc-clippy _pc-test _pc-doc-check _pc-udeps _pc-shellcheck _pc-markdown-lint _pc-toml-lint _pc-makefile-check _pc-actionlint _pc-snapshot-anchors _pc-rustfmt-bail _pc-rustfmt-bail-test _pc-grammar-marker-sync _pc-grammar-marker-sync-test _pc-check-versions _pc-check-versions-test _pc-check-grammar-crate-test _pc-check-excluded-manifests _pc-check-excluded-manifests-test _pc-check-manpage-assets _pc-worktree-setup-test _pc-gate-status-test _pc-enums-check _pc-enums-codegen-drift _pc-enums-codegen-drift-test _pc-self-scan _pc-self-scan-headroom _pc-py-fmt _pc-py-typecheck _pc-py-test _pc-py-stubtest _ci-all _ci-fmt-check _ci-clippy _ci-test _ci-doc-check _ci-build _ci-udeps _ci-shellcheck _ci-markdown-lint _ci-toml-lint _ci-makefile-check _ci-actionlint _ci-snapshot-anchors _ci-rustfmt-bail _ci-rustfmt-bail-test _ci-grammar-marker-sync _ci-grammar-marker-sync-test _ci-check-versions _ci-check-versions-test _ci-check-grammar-crate-test _ci-check-excluded-manifests _ci-check-excluded-manifests-test _ci-check-manpage-assets _ci-worktree-setup-test _ci-gate-status-test _ci-enums-check _ci-enums-codegen-drift _ci-enums-codegen-drift-test _ci-enums-codegen-drift-test _ci-self-scan _ci-self-scan-headroom _ci-cargo-pipeline _ci-py-fmt-check _ci-py-lint _ci-py-typecheck _ci-py-test _ci-py-stubtest +.PHONY: help check-tools worktree-setup worktree-setup-test build build-release check test test-doc chain-audit fmt fmt-check markdown-fmt markdown-lint shellcheck sh-fmt sh-fmt-check toml-fmt toml-fmt-check toml-lint makefile-check actionlint snapshot-anchors snapshot-anchors-test rustfmt-bail rustfmt-bail-test grammar-marker-sync grammar-marker-sync-test check-versions check-excluded-manifests check-excluded-manifests-test check-manpage-assets gate-status-test enums-check enums-codegen-drift enums-codegen-drift-test self-scan self-scan-headroom self-scan-write-baseline self-scan-write-baseline-headroom vcs lint clippy udeps insta-review insta-accept clean distclean install install-cli install-web doc doc-open doc-check doc-check-docsrs book book-serve book-pot book-po-update book-ja book-deploy all pre-commit ci release-check verify-changelog pkg-deb-local pkg-rpm-local dev-env-build dev-env-run dev-env-shell dev-env-rm py-bootstrap py-sync py-relock py-clean py-fmt py-fmt-check py-lint py-typecheck py-test py-stubtest smoke smoke-cli smoke-lib bench bench-scaling bench-walk _check-find _pc-all _pc-fmt _pc-clippy _pc-test _pc-doc-check _pc-udeps _pc-shellcheck _pc-markdown-lint _pc-toml-lint _pc-makefile-check _pc-actionlint _pc-snapshot-anchors _pc-snapshot-anchors-test _pc-rustfmt-bail _pc-rustfmt-bail-test _pc-grammar-marker-sync _pc-grammar-marker-sync-test _pc-check-versions _pc-check-versions-test _pc-check-grammar-crate-test _pc-check-excluded-manifests _pc-check-excluded-manifests-test _pc-check-manpage-assets _pc-worktree-setup-test _pc-gate-status-test _pc-enums-check _pc-enums-codegen-drift _pc-enums-codegen-drift-test _pc-self-scan _pc-self-scan-headroom _pc-py-fmt _pc-py-typecheck _pc-py-test _pc-py-stubtest _ci-all _ci-fmt-check _ci-clippy _ci-test _ci-doc-check _ci-build _ci-udeps _ci-shellcheck _ci-markdown-lint _ci-toml-lint _ci-makefile-check _ci-actionlint _ci-snapshot-anchors _ci-snapshot-anchors-test _ci-rustfmt-bail _ci-rustfmt-bail-test _ci-grammar-marker-sync _ci-grammar-marker-sync-test _ci-check-versions _ci-check-versions-test _ci-check-grammar-crate-test _ci-check-excluded-manifests _ci-check-excluded-manifests-test _ci-check-manpage-assets _ci-worktree-setup-test _ci-gate-status-test _ci-enums-check _ci-enums-codegen-drift _ci-enums-codegen-drift-test _ci-enums-codegen-drift-test _ci-self-scan _ci-self-scan-headroom _ci-cargo-pipeline _ci-py-fmt-check _ci-py-lint _ci-py-typecheck _ci-py-test _ci-py-stubtest # Default target help: @@ -128,6 +128,7 @@ help: @echo " makefile-check Lint Makefile with checkmake" @echo " actionlint Lint GitHub Actions workflows with actionlint" @echo " snapshot-anchors Block new bare insta snapshots" + @echo " snapshot-anchors-test Self-tests for the snapshot-anchors gate" @echo " rustfmt-bail Block new match arms rustfmt refuses to format" @echo " rustfmt-bail-test Self-tests for the rustfmt-bail gate" @echo " grammar-marker-sync Block grammar-marker bumps without source regen" @@ -406,6 +407,13 @@ snapshot-anchors: @echo "Checking insta snapshot anchors..." @python3 $(BASE_DIR)utils/check-snapshot-anchors.py +# Self-tests for the snapshot-anchors gate. Separate target for the +# same reason as rustfmt-bail-test below: the gate stays a one-line +# invariant check and test failures get their own parallel-arm output. +snapshot-anchors-test: + @echo "Running snapshot-anchors self-tests..." + @(cd $(BASE_DIR) && python3 -m unittest -q utils/check-snapshot-anchors-test.py) + # rustfmt-bail gate (#1136). `cargo fmt --check` exits 0 over regions # rustfmt declined to format — a comment inside a match pattern makes # it emit the whole match verbatim, silently. This probe over-indents @@ -975,7 +983,7 @@ lint: $(MAKE) -j --output-sync=target \ _ci-clippy \ _ci-shellcheck _ci-markdown-lint _ci-toml-lint _ci-makefile-check \ - _ci-actionlint _ci-snapshot-anchors _ci-rustfmt-bail _ci-rustfmt-bail-test _ci-grammar-marker-sync _ci-grammar-marker-sync-test _ci-check-versions _ci-check-versions-test _ci-check-grammar-crate-test _ci-check-excluded-manifests _ci-check-excluded-manifests-test _ci-check-manpage-assets _ci-worktree-setup-test _ci-gate-status-test _ci-enums-check _ci-enums-codegen-drift _ci-enums-codegen-drift-test + _ci-actionlint _ci-snapshot-anchors _ci-snapshot-anchors-test _ci-rustfmt-bail _ci-rustfmt-bail-test _ci-grammar-marker-sync _ci-grammar-marker-sync-test _ci-check-versions _ci-check-versions-test _ci-check-grammar-crate-test _ci-check-excluded-manifests _ci-check-excluded-manifests-test _ci-check-manpage-assets _ci-worktree-setup-test _ci-gate-status-test _ci-enums-check _ci-enums-codegen-drift _ci-enums-codegen-drift-test # --------------------------------------------------------------------------- # Maintenance @@ -1140,7 +1148,7 @@ _pc-all: $(MAKE) -j --output-sync=target \ _pc-test \ _pc-shellcheck _pc-markdown-lint _pc-toml-lint _pc-makefile-check \ - _pc-actionlint _pc-snapshot-anchors _pc-rustfmt-bail _pc-rustfmt-bail-test _pc-grammar-marker-sync _pc-grammar-marker-sync-test _pc-check-versions _pc-check-versions-test _pc-check-grammar-crate-test _pc-check-excluded-manifests _pc-check-excluded-manifests-test _pc-check-manpage-assets _pc-worktree-setup-test _pc-gate-status-test _pc-enums-check _pc-enums-codegen-drift _pc-enums-codegen-drift-test \ + _pc-actionlint _pc-snapshot-anchors _pc-snapshot-anchors-test _pc-rustfmt-bail _pc-rustfmt-bail-test _pc-grammar-marker-sync _pc-grammar-marker-sync-test _pc-check-versions _pc-check-versions-test _pc-check-grammar-crate-test _pc-check-excluded-manifests _pc-check-excluded-manifests-test _pc-check-manpage-assets _pc-worktree-setup-test _pc-gate-status-test _pc-enums-check _pc-enums-codegen-drift _pc-enums-codegen-drift-test \ _pc-manpages \ _pc-self-scan _pc-self-scan-headroom \ _pc-py-fmt _pc-py-typecheck _pc-py-test _pc-py-stubtest @@ -1150,7 +1158,7 @@ _ci-all: $(MAKE) -j --output-sync=target \ _ci-cargo-pipeline \ _ci-shellcheck _ci-markdown-lint _ci-toml-lint _ci-makefile-check \ - _ci-actionlint _ci-snapshot-anchors _ci-rustfmt-bail _ci-rustfmt-bail-test _ci-grammar-marker-sync _ci-grammar-marker-sync-test _ci-check-versions _ci-check-versions-test _ci-check-grammar-crate-test _ci-check-excluded-manifests _ci-check-excluded-manifests-test _ci-check-manpage-assets _ci-worktree-setup-test _ci-gate-status-test _ci-enums-check _ci-enums-codegen-drift _ci-enums-codegen-drift-test \ + _ci-actionlint _ci-snapshot-anchors _ci-snapshot-anchors-test _ci-rustfmt-bail _ci-rustfmt-bail-test _ci-grammar-marker-sync _ci-grammar-marker-sync-test _ci-check-versions _ci-check-versions-test _ci-check-grammar-crate-test _ci-check-excluded-manifests _ci-check-excluded-manifests-test _ci-check-manpage-assets _ci-worktree-setup-test _ci-gate-status-test _ci-enums-check _ci-enums-codegen-drift _ci-enums-codegen-drift-test \ _ci-py-fmt-check _ci-py-lint _ci-py-typecheck _ci-py-test _ci-py-stubtest # --------------------------------------------------------------------------- @@ -1178,6 +1186,7 @@ _ci-all: # ├── _pc-makefile-check # ├── _pc-actionlint # ├── _pc-snapshot-anchors +# ├── _pc-snapshot-anchors-test # ├── _pc-rustfmt-bail # ├── _pc-rustfmt-bail-test # ├── _pc-grammar-marker-sync @@ -1251,6 +1260,9 @@ _pc-actionlint: _pc-fmt _pc-snapshot-anchors: _pc-fmt $(MAKE) snapshot-anchors +_pc-snapshot-anchors-test: _pc-fmt + $(MAKE) snapshot-anchors-test + _pc-rustfmt-bail: _pc-fmt $(MAKE) rustfmt-bail @@ -1421,6 +1433,9 @@ _ci-actionlint: _ci-snapshot-anchors: $(MAKE) snapshot-anchors +_ci-snapshot-anchors-test: + $(MAKE) snapshot-anchors-test + _ci-rustfmt-bail: $(MAKE) rustfmt-bail diff --git a/utils/check-snapshot-anchors-test.py b/utils/check-snapshot-anchors-test.py new file mode 100644 index 000000000..570f20e61 --- /dev/null +++ b/utils/check-snapshot-anchors-test.py @@ -0,0 +1,260 @@ +#!/usr/bin/env python3 +"""Tests for check-snapshot-anchors.py. + +Three kinds of test, matching the check-rustfmt-bail-test.py pattern: + +* Unit tests for the literal lexers, weighted toward the inputs that + made the scanner under-count before #1192: a char literal holding a + double quote, a lifetime that must *not* be read as one, and the + byte-string prefixes the raw-string branch used to miss. +* Unit tests for ``count_bare_in_source`` over synthetic Rust, pinning + both directions — an anchored call stays uncounted, a bare call is + counted even when a quote-bearing literal precedes it. +* ``main()`` tests over a synthetic tree covering the pass, regress and + ratchet branches, plus a smoke test asserting the real repository is + clean against its checked-in baseline. + +The gate scripts are hyphen-named and so cannot be imported normally; +the loader below mirrors the sibling test file. + +Run with: + python3 -m unittest -q utils/check-snapshot-anchors-test.py +""" + +from __future__ import annotations + +import contextlib +import importlib.util +import io +import pathlib +import sys +import tempfile +import unittest + +UTILS_DIR = pathlib.Path(__file__).resolve().parent +REPO_ROOT = UTILS_DIR.parent +SCRIPT_SRC = UTILS_DIR / "check-snapshot-anchors.py" + + +def _load_module(): # type: ignore[no-untyped-def] + spec = importlib.util.spec_from_file_location("check_snapshot_anchors", SCRIPT_SRC) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +gate = _load_module() + + +class CharLiteralEndTest(unittest.TestCase): + """Ported from check-rustfmt-bail-test.py, which hit this first. + + Both directions are pinned deliberately: the pre-#1192 behaviour + (no char-literal branch at all) fails the quote-bearing cases, and a + *greedy* variant that treats a lifetime as a literal fails + ``test_lifetimes_and_labels_are_not_literals``. + """ + + def test_forms_and_their_ends(self) -> None: + for source, expected in [ + ("'a'", 3), + ("'_'", 3), + (r"'\n'", 4), + (r"'\''", 4), + (r"'\\'", 4), + ("'\"'", 3), + (r"'\u{1F600}'", 11), + ]: + with self.subTest(source=source): + self.assertEqual(gate.char_literal_end(source, 0), expected) + + def test_lifetimes_and_labels_are_not_literals(self) -> None: + for source in ["'a>", "'a,", "'static ", "'_>", "'outer: loop", "'"]: + with self.subTest(source=source): + self.assertIsNone(gate.char_literal_end(source, 0)) + + +class RawStringEndTest(unittest.TestCase): + def test_raw_and_byte_raw_forms(self) -> None: + for source, expected in [ + ('r"a"', 4), + ('r#"a"#', 6), + ('r##"a"##', 8), + ('br"a"', 5), + ('br#"a"#', 7), + ]: + with self.subTest(source=source): + self.assertEqual(gate.raw_string_end(source, 0), expected) + + def test_non_raw_openers_are_rejected(self) -> None: + # `b"…"` is deliberately not handled here: the `b` falls through + # as an ordinary character and the `"` opens a regular literal, + # which escapes identically. + for source in ['b"a"', "rust", "bar", "r", "b"]: + with self.subTest(source=source): + self.assertIsNone(gate.raw_string_end(source, 0)) + + +class CountBareTest(unittest.TestCase): + def test_char_literal_holding_a_quote_does_not_hide_a_later_call(self) -> None: + """The #1192 reproducer. + + Before the fix the `b'"'` opened a string span that ran to the + next `"` anywhere later in the file, so the bare call between + them was invisible and the gate reported zero. + """ + source = """\ +#[test] +fn t() { + let quote = b'"'; + insta::assert_json_snapshot!(metric.cognitive); + let _ = quote; +} +""" + self.assertEqual(gate.count_bare_in_source(source), 1) + + def test_lifetime_does_not_swallow_the_call_that_follows_it(self) -> None: + """Three lifetimes, not two, and a real char literal after the call. + + The count of quotes before the call is what makes this + discriminate. A *greedy* variant that scans from one `'` to the + next pairs them off, so with an even number of lifetimes ahead + of the call every span closes before reaching it and the test + passes against the bug. With three, the last lifetime pairs with + the `'z'` below and the span swallows the call: 1 -> 0. + """ + source = """\ +fn f<'a>(x: &'a str, y: &'a str) { + insta::assert_json_snapshot!(metric.x); + let c = 'z'; +} +""" + self.assertEqual(gate.count_bare_in_source(source), 1) + + def test_byte_raw_string_does_not_hide_a_later_call(self) -> None: + """The raw string holds an *odd* number of double quotes. + + `br#"a"#` alone proves nothing: with the byte-raw branch removed + the inner `"a"` still lexes as a balanced regular string and the + call stays visible. An unpaired quote is what makes the missing + branch bite. + """ + source = 'let s = br#"a"b"#;\ninsta::assert_json_snapshot!(metric.x);\n' + self.assertEqual(gate.count_bare_in_source(source), 1) + + def test_char_literal_in_the_arguments_does_not_borrow_a_later_anchor(self) -> None: + """`find_macro_call_end` carries the same lexer and the same gap. + + A quote-bearing char literal in the argument list ran the body + scan past the real closing paren and into the *next* call, whose + `@"…"` the bare call then claimed as its own anchor — so the + pair counted 0 instead of 1. The second call must be anchored + for this to discriminate; that is the anchor being stolen. + """ + source = ( + "insta::assert_json_snapshot!(m.x(b'\"'));\n" + 'insta::assert_json_snapshot!(m.y, @"1");\n' + ) + self.assertEqual(gate.count_bare_in_source(source), 1) + + def test_anchored_forms_stay_uncounted(self) -> None: + for name, source in [ + ("non-raw inline", 'insta::assert_json_snapshot!(m.x, @"123");'), + ("raw inline", 'insta::assert_json_snapshot!(m.x, @r###"1"###);'), + ("assert_eq above", "assert_eq!(m.branches(), 2);\ninsta::assert_json_snapshot!(m.x);"), + ("expected comment", "// expected: 2 branches\ninsta::assert_json_snapshot!(m.x);"), + ("line-commented out", "// insta::assert_json_snapshot!(m.x);\n"), + ("block-commented out", "/* insta::assert_json_snapshot!(m.x); */"), + ]: + with self.subTest(name=name): + self.assertEqual(gate.count_bare_in_source(source), 0) + + def test_bare_call_is_counted(self) -> None: + self.assertEqual(gate.count_bare_in_source("insta::assert_json_snapshot!(m.x);"), 1) + + def test_slashes_in_a_string_do_not_mask_a_bare_call(self) -> None: + source = 'let url = "http://x"; insta::assert_json_snapshot!(m.x);\n' + self.assertEqual(gate.count_bare_in_source(source), 1) + + +class BaselineTest(unittest.TestCase): + """`main()` over a synthetic tree: pass, regress and ratchet.""" + + def _run(self, argv: list[str]) -> tuple[int, str, str]: + out, err = io.StringIO(), io.StringIO() + old = sys.argv + sys.argv = ["check-snapshot-anchors.py", *argv] + try: + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + code = gate.main() + finally: + sys.argv = old + return code, out.getvalue(), err.getvalue() + + def test_pass_regress_and_ratchet(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = pathlib.Path(tmp) + target = root / "m.rs" + baseline = root / "baseline.txt" + target.write_text("insta::assert_json_snapshot!(m.x);\n", encoding="utf-8") + + code, _, _ = self._run([str(target), "--baseline", str(baseline), "--update"]) + self.assertEqual(code, 0) + self.assertIn("1", baseline.read_text(encoding="utf-8")) + + code, _, _ = self._run([str(target), "--baseline", str(baseline)]) + self.assertEqual(code, 0, "unchanged tree must pass") + + target.write_text( + "insta::assert_json_snapshot!(m.x);\ninsta::assert_json_snapshot!(m.y);\n", + encoding="utf-8", + ) + code, _, err = self._run([str(target), "--baseline", str(baseline)]) + self.assertEqual(code, 1, "an increase must fail") + self.assertIn("baseline 1, current 2", err) + + target.write_text('insta::assert_json_snapshot!(m.x, @"1");\n', encoding="utf-8") + code, _, _ = self._run([str(target), "--baseline", str(baseline)]) + self.assertEqual(code, 0, "a decrease must pass silently") + + def test_zero_counts_are_omitted_from_the_written_baseline(self) -> None: + """An unlisted file is allowed zero via `.get(rel, 0)`. + + Pinned because the omission is what keeps the baseline a list of + debt after #1192 made the scan recursive over 126 more files. + """ + with tempfile.TemporaryDirectory() as tmp: + root = pathlib.Path(tmp) + clean = root / "clean.rs" + baseline = root / "baseline.txt" + clean.write_text("fn f() {}\n", encoding="utf-8") + + self._run([str(clean), "--baseline", str(baseline), "--update"]) + self.assertNotIn("clean.rs", baseline.read_text(encoding="utf-8")) + + clean.write_text("insta::assert_json_snapshot!(m.x);\n", encoding="utf-8") + code, _, err = self._run([str(clean), "--baseline", str(baseline)]) + self.assertEqual(code, 1, "an unlisted file must still be held at zero") + self.assertIn("baseline 0, current 1", err) + + +class RepositoryTest(unittest.TestCase): + def test_default_targets_reach_the_metric_subdirectories(self) -> None: + """#1192: the scan was non-recursive and missed 126 files. + + Asserts a positive property rather than a count, so splitting or + merging a per-language module cannot make it stale. + """ + targets = gate.default_targets() + self.assertTrue(targets, "no metric files found") + nested = [p for p in targets if p.parent != gate.METRICS_DIR] + self.assertTrue(nested, "subdirectories of src/metrics/ are unscanned") + + def test_repository_is_clean_against_its_baseline(self) -> None: + code, _, err = BaselineTest()._run([]) + self.assertEqual(code, 0, f"repository gate failed:\n{err}") + + +if __name__ == "__main__": + unittest.main() diff --git a/utils/check-snapshot-anchors.py b/utils/check-snapshot-anchors.py index 38c4ebc4b..530b5ab10 100755 --- a/utils/check-snapshot-anchors.py +++ b/utils/check-snapshot-anchors.py @@ -46,14 +46,92 @@ LOOKBACK_LINES = 5 +def char_literal_end(source: str, i: int) -> int | None: + """End index (exclusive) of the char literal at ``i``, else ``None``. + + Rust spells lifetimes (``'a``), anonymous lifetimes (``'_``) and loop + labels (``'outer:``) with the same leading quote and no terminator, + so the two are told apart by looking for the closing ``'`` rather + than by the opener alone. Returning ``None`` for a lifetime is what + keeps it from opening a span that swallows the rest of the file. + + Shared in shape (and in tests) with ``check-rustfmt-bail.py``, which + hit the identical defect first (#1136). The scripts are hyphen-named + and so not importable, hence the copy rather than a shared module. + """ + n = len(source) + j = i + 1 + if j >= n: + return None + if source[j] == "\\": + j += 1 + if j >= n: + return None + if source[j] == "u": + # '\u{1F600}' — the braced form is the only multi-char escape. + close = source.find("}", j) + if close == -1: + return None + j = close + 1 + else: + j += 1 + else: + j += 1 + return j + 1 if j < n and source[j] == "'" else None + + +def raw_string_end(source: str, i: int) -> int | None: + """End index (exclusive) of the raw string at ``i``, else ``None``. + + Covers ``r"…"``, ``r#"…"#``, and the byte-string spellings ``br"…"`` + / ``br##"…"##``. A plain ``b"…"`` needs no special case: the ``b`` is + consumed as an ordinary character and the ``"`` that follows opens a + regular literal, which escapes identically. + """ + n = len(source) + j = i + if source[j] == "b" and j + 1 < n and source[j + 1] == "r": + j += 1 + if j >= n or source[j] != "r": + return None + j += 1 + hashes = 0 + while j < n and source[j] == "#": + hashes += 1 + j += 1 + if j >= n or source[j] != '"': + return None + close = '"' + ("#" * hashes) + end = source.find(close, j + 1) + return n if end == -1 else end + len(close) + + +def regular_string_end(source: str, i: int) -> int: + """End index (exclusive) of the ``"``-delimited literal at ``i``.""" + n = len(source) + j = i + 1 + while j < n: + if source[j] == "\\" and j + 1 < n: + j += 2 + continue + if source[j] == '"': + break + j += 1 + return j + 1 + + def find_macro_call_end(source: str, open_paren_idx: int) -> int: """Return the index *after* the closing ``)`` of a macro call. ``open_paren_idx`` must point at the ``(`` that opens the call. - Tracks parens, skipping string literals (including Rust raw strings - of the form ``r"…"``, ``r#"…"#``, …) and ``//`` line comments so - nested ``)`` inside the inline ``@r###"…"###`` anchor does not - confuse the depth counter. + Tracks parens, skipping string literals (regular, raw and byte), + char literals and ``//`` line comments so a ``)`` inside the inline + ``@r###"…"###`` anchor does not confuse the depth counter. + + Char literals are skipped for the same reason ``scan_ignore_spans`` + skips them: a ``b'"'`` in the macro body would otherwise open a + bogus string span, run past the real closing paren, and hand back a + body the anchor test then reads the wrong answer out of. """ depth = 0 i = open_paren_idx @@ -65,29 +143,23 @@ def find_macro_call_end(source: str, open_paren_idx: int) -> int: nl = source.find("\n", i) i = n if nl == -1 else nl + 1 continue - # Raw string: r"…", r#"…"#, r##"…"##, … - if ch == "r" and i + 1 < n and source[i + 1] in ('"', "#"): - j = i + 1 - hashes = 0 - while j < n and source[j] == "#": - hashes += 1 - j += 1 - if j < n and source[j] == '"': - close = '"' + ("#" * hashes) - end = source.find(close, j + 1) - i = n if end == -1 else end + len(close) + # Raw / byte-raw string: r"…", r#"…"#, br##"…"##, … + if ch in "rb": + stop = raw_string_end(source, i) + if stop is not None: + i = stop continue + # Char literal — or a lifetime, which `char_literal_end` rejects. + if ch == "'": + stop = char_literal_end(source, i) + if stop is not None: + i = stop + continue + i += 1 + continue # Regular string literal. if ch == '"': - j = i + 1 - while j < n: - if source[j] == "\\" and j + 1 < n: - j += 2 - continue - if source[j] == '"': - break - j += 1 - i = j + 1 + i = regular_string_end(source, i) continue if ch == "(": depth += 1 @@ -104,11 +176,15 @@ class IgnoreSpans: A single source walk classifies every ``//`` line comment, every ``/* … */`` block comment (Rust block comments nest), and every - string literal (regular and Rust raw strings) into disjoint span - lists. Keeping them separate lets ``count_bare`` answer two distinct - questions with the same scan: *is this match inside a comment or - string?* (block + string spans) and *is the ``//`` before it a real - line comment, or just text inside a string?* (line-comment spans). + string or char literal into disjoint span lists. Keeping them + separate lets ``count_bare`` answer two distinct questions with the + same scan: *is this match inside a comment or literal?* (block + + literal spans) and *is the ``//`` before it a real line comment, or + just text inside a string?* (line-comment spans). + + Char literals share the ``strings`` list rather than getting a + fourth: no ``insta::assert_json_snapshot!`` can begin inside one, so + the only thing that matters is that the walk *consumes* them. """ def __init__(self) -> None: @@ -122,11 +198,19 @@ def scan_ignore_spans(source: str) -> IgnoreSpans: The walk skips string literals before testing for comment openers, so a ``//`` or ``/*`` that appears inside a string (or a ``"`` - inside a comment) is never misread. This is the single string-aware + inside a comment) is never misread. This is the single literal-aware scanner the bare-call counter relies on: over-counting a commented or quoted ``insta::assert_json_snapshot!`` occurrence produces a spurious gate failure, and under-counting (treating a ``//`` inside a string as a real comment) lets an unanchored snapshot through. + + Char literals are classified for one reason (#1192): a ``'"'`` or + ``b'"'`` holds an unpaired double quote, which would otherwise open + a bogus string span running to the next ``"`` anywhere later in the + file and hide every snapshot call in between. The gate would then + report zero for a file that really does carry a bare snapshot — the + exact "reads as clean" failure it exists to prevent, which must not + be its own failure mode. """ spans = IgnoreSpans() i = 0 @@ -157,31 +241,26 @@ def scan_ignore_spans(source: str) -> IgnoreSpans: i += 1 spans.block_comments.append((start, i)) continue - # Raw string: r"…", r#"…"#, … - if ch == "r" and i + 1 < n and source[i + 1] in ('"', "#"): - j = i + 1 - hashes = 0 - while j < n and source[j] == "#": - hashes += 1 - j += 1 - if j < n and source[j] == '"': - close = '"' + ("#" * hashes) - end = source.find(close, j + 1) - stop = n if end == -1 else end + len(close) + # Raw / byte-raw string: r"…", r#"…"#, br##"…"##, … + if ch in "rb": + stop = raw_string_end(source, i) + if stop is not None: + spans.strings.append((i, stop)) + i = stop + continue + # Char literal — or a lifetime, which `char_literal_end` rejects + # so it cannot open a span that swallows the rest of the file. + if ch == "'": + stop = char_literal_end(source, i) + if stop is not None: spans.strings.append((i, stop)) i = stop continue + i += 1 + continue # Regular string literal. if ch == '"': - j = i + 1 - while j < n: - if source[j] == "\\" and j + 1 < n: - j += 2 - continue - if source[j] == '"': - break - j += 1 - stop = j + 1 + stop = regular_string_end(source, i) spans.strings.append((i, stop)) i = stop continue @@ -244,11 +323,25 @@ def count_bare(path: pathlib.Path) -> int: return count_bare_in_source(path.read_text(encoding="utf-8")) +def baseline_key(path: pathlib.Path, root: pathlib.Path) -> str: + """The baseline's spelling of ``path``: repo-relative when it is under + ``root``, absolute otherwise. + + A named file outside the repository has no repo-relative spelling, and + ``Path.relative_to`` raises rather than saying so — an argument typo + then surfaces as a traceback instead of a count. Ported from + ``check-rustfmt-bail.py``, which fixed the same crash first. + """ + try: + return path.relative_to(root).as_posix() + except ValueError: + return path.as_posix() + + def collect_counts(files: list[pathlib.Path]) -> "OrderedDict[str, int]": counts: OrderedDict[str, int] = OrderedDict() for path in sorted(files): - rel = path.relative_to(REPO_ROOT).as_posix() - counts[rel] = count_bare(path) + counts[baseline_key(path, REPO_ROOT)] = count_bare(path) return counts @@ -274,15 +367,31 @@ def write_baseline(path: pathlib.Path, counts: dict[str, int]) -> None: "# Maintained by check-snapshot-anchors.py; see AGENTS.md.", "# Lower-or-equal current counts pass; any increase fails CI.", "# Regenerate with: ./utils/check-snapshot-anchors.py --update", + "#", + "# Only files with outstanding bare calls are listed. An unlisted", + "# file is allowed zero, which `load_baseline`'s `.get(rel, 0)`", + "# already encodes — so omitting them keeps this a list of debt", + "# rather than a census of every scanned file (#1192 made the", + "# scan recursive, which would otherwise have added 126 zeroes).", "", ] for rel, count in counts.items(): - body.append(f"{rel} {count}") + if count: + body.append(f"{rel} {count}") path.write_text("\n".join(body) + "\n", encoding="utf-8") def default_targets() -> list[pathlib.Path]: - return sorted(p for p in METRICS_DIR.glob("*.rs") if p.is_file()) + """Every Rust file under ``src/metrics/``, subdirectories included. + + ``rglob``, not ``glob``: the non-recursive form scanned only the 14 + top-level modules, so the 126 files under ``abc/``, ``cognitive/``, + ``cyclomatic/``, ``loc/``, ``npa/`` and ``npm/`` — created when the + per-language impls were split out (#969) — were invisible to the + gate. No bare call lives there today, but a new one would have + landed silently, which is the failure this gate exists to prevent. + """ + return sorted(p for p in METRICS_DIR.rglob("*.rs") if p.is_file()) def _self_test() -> int: @@ -375,18 +484,23 @@ def main() -> int: if args.update: write_baseline(args.baseline, counts) - print(f"Baseline updated: {args.baseline.relative_to(REPO_ROOT)}") + print(f"Baseline updated: {baseline_key(args.baseline, REPO_ROOT)}") for rel, count in counts.items(): print(f" {rel} {count}") return 0 - baseline = load_baseline(args.baseline) - if not baseline: + # Existence, not emptiness: since zero counts are omitted from the + # written baseline, a tree with no outstanding bare calls anywhere + # legitimately produces a file of nothing but comments. Testing the + # parsed dict for truthiness would report that clean state as a + # missing baseline and fail the gate with exit 2. + if not args.baseline.exists(): sys.stderr.write( - f"error: baseline file not found or empty: {args.baseline}\n" + f"error: baseline file not found: {args.baseline}\n" " run with --update to create it.\n" ) return 2 + baseline = load_baseline(args.baseline) regressions: list[tuple[str, int, int]] = [] for rel, count in counts.items(): From ebf37132f7bde14e1452a1689b582b4d70f9f36f Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Sun, 2 Aug 2026 22:35:00 -0700 Subject: [PATCH 02/27] fix(py): assert what the VCS thread test can actually observe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `test_analyze_vcs_true_releases_gil_for_walk` failed intermittently on `assert progressed > 0` — once during `make pre-commit`, then 5/5 clean on an unchanged tree. The worker signalled `started.set()` before calling `analyze()`, so the main thread could wake, be descheduled, and find the walk already over. The fixture repo has one commit, so the walk is sub-millisecond. "The worker finished before I looked" and "the GIL was held so I could not run" are indistinguishable from the main thread's side with this design, so the assertion samples a race rather than measuring the property. Takes direction 3 from the issue: drop `progressed`, rename to `test_analyze_vcs_true_completes_off_the_main_thread`, and keep the claim a worker thread can actually support — the walk completes rather than deadlocking the interpreter. The equivalence the old docstring asserted in prose but never checked is now a real assertion: the threaded block must equal a serial call's. Two secondary defects fixed while here. The worker's `assert out is not None` was swallowed by the thread and resurfaced in the main thread as a KeyError on `result`, hiding the cause; it is now captured and re-raised. And `worker.join(timeout=30.0)` was dead code, because the polling loop only exited once the thread was already gone — `join` is now the wait, so the timeout is load-bearing and a genuine deadlock fails rather than hangs the suite. Evidence: 25 runs of the new test under full CPU saturation, 0 failures. The old form also survived 25 such runs, so the flake was not reproduced on demand — but the race window is demonstrable directly. Stalling the main thread 50 ms after `started.wait()`, which is what a loaded machine does to it, yields `progressed == 0` against a healthy GIL-releasing walk, while a 0 ms stall yields 7. The assertion measured scheduling luck, not the contract. Fixes #1191 --- big-code-analysis-py/tests/test_vcs.py | 64 +++++++++++++++----------- 1 file changed, 38 insertions(+), 26 deletions(-) diff --git a/big-code-analysis-py/tests/test_vcs.py b/big-code-analysis-py/tests/test_vcs.py index 1c8c6dace..8a39ba26e 100644 --- a/big-code-analysis-py/tests/test_vcs.py +++ b/big-code-analysis-py/tests/test_vcs.py @@ -104,17 +104,26 @@ def test_analyze_vcs_true_attaches_block(tmp_path: Path) -> None: assert hotspot > 0.0 -def test_analyze_vcs_true_releases_gil_for_walk(tmp_path: Path) -> None: - """The ``analyze(vcs=True)`` history walk runs off-GIL (#620). - - Coarse no-deadlock / progress check, not a wall-clock threshold (CI - timing is too flaky for a hard budget): run the VCS-injecting analyze - in a worker thread and confirm the main thread keeps making progress — - it counts up while the walk runs and the walk returns its block. If the - walk held the GIL for its whole duration the worker would still finish, - so this cannot guarantee true parallelism, but it pins the contract - that the off-GIL call completes and produces the same result a serial - call does. +def test_analyze_vcs_true_completes_off_the_main_thread(tmp_path: Path) -> None: + """The ``analyze(vcs=True)`` history walk does not deadlock (#620). + + The off-GIL *interleaving* is deliberately not asserted here, and the + name says so. This test used to start a worker, then count loop + iterations while it was alive and require ``progressed > 0`` — but + "the worker finished before I looked" and "the GIL was held so I + could not run" are indistinguishable from the main thread's side. + The fixture repo has a single commit, so the walk is sub-millisecond + and the main thread routinely lost that race: it failed under `make + pre-commit`, which saturates every core, and passed 5/5 on an + unchanged tree immediately after (#1191). A tighter loop or shorter + sleep changes the odds, not the logic. + + What remains is the regression that actually matters and that a + worker thread *can* observe: the walk completes rather than + deadlocking the interpreter, and it returns the same block a serial + call does. An assertion that fires on load and not on breakage is + worse than no assertion, because it teaches people to re-run the + gate — which is the habit that lets a real failure through. """ import threading @@ -122,28 +131,31 @@ def test_analyze_vcs_true_releases_gil_for_walk(tmp_path: Path) -> None: target = repo / "work.rs" result: dict[str, Any] = {} - started = threading.Event() + failure: list[BaseException] = [] def run() -> None: - started.set() - out = bca.analyze(target, vcs=True) - assert out is not None - result["vcs"] = out["metrics"]["vcs"] + # A bare `assert` here would be swallowed by the thread and + # resurface in the main thread as a confusing KeyError on + # `result`, hiding the real cause. + try: + out = bca.analyze(target, vcs=True) + assert out is not None + result["vcs"] = out["metrics"]["vcs"] + except BaseException as exc: + failure.append(exc) worker = threading.Thread(target=run) worker.start() - started.wait() - # The main thread advances while the worker walks history; with the GIL - # released for the walk this loop interleaves rather than blocking until - # the worker is fully done. - progressed = 0 - while worker.is_alive(): - progressed += 1 - time.sleep(0.001) + # `join` is the wait, so the timeout is load-bearing: a walk that + # deadlocks the interpreter fails here rather than hanging the suite. worker.join(timeout=30.0) - assert not worker.is_alive(), "VCS walk worker thread deadlocked" - assert progressed > 0 + if failure: + raise failure[0] + + serial = bca.analyze(target, vcs=True) + assert serial is not None + assert result["vcs"] == serial["metrics"]["vcs"] assert result["vcs"]["commits_long"] == 1 From 898db4b8576df4db204a575a56178fd7ef3ea1ae Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Sun, 2 Aug 2026 23:08:07 -0700 Subject: [PATCH 03/27] fix(spaces): anchor the file-level unit span at line 1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `spaces::line_span` kept a `child_count() == 0 -> (0, 0)` case for a childless root, so a whitespace-only file reported `unit 0..0` where every other space is 1-based. `tests/parity/space_span_containment.rs` asserts the file-level unit spans `(1, line_count)`; no fixture reached the case, so the test passed while the invariant it states was false. The measured cause is not what the guard's comment claimed. A childless root is not zero-extent: tree-sitter collapses it to a *point at the end* of the whitespace, so `start_row` is the last row, not row 0. The general rule would therefore have returned an inverted `3..2` for `"\n\n"`, not the `1..2` the issue assumed — clamping the start is the fix, not widening the guard's condition. A control test written to pin that the carve-out did not leak into ordinary files then failed, surfacing a second half the issue does not mention: tree-sitter starts the root at the first *token*, so any file opening with blank lines reported a unit that omitted them. `"\n\n\nfn a() {}\n\n\n"` gave `4..6` for a 6-line file. A leading comment was always fine, since comments are in the tree. Fixing only the childless case would have left the invariant false for this input, so both are fixed together: the unit's start is now anchored at 1 rather than measured, which is what "the file-level unit" means. An empty file keeps `0..0`. It is the one input with no lines at all, so `(1, line_count)` would demand the inverted `1..0`; that carve-out is pinned as a unit test and called out in the parity test's doc. Both callers — `FuncSpace::new` and `Ops::new` — go through this, so the metrics and ops walks move together and their parity is preserved. Tests: four unit tests beside the fix using `space_verbatim` (the ordinary harnesses append a trailing newline and cannot reach these inputs), and a whitespace-only sweep added to the parity suite so the `(1, line_count)` rule is exercised in every language rather than only against real code fixtures. Each was verified by perturbation: restoring the old guard fails the whitespace and leading-blank-line tests, dropping the empty-file carve-out fails the empty-file test, and anchoring every kind rather than only `Unit` fails the nested-space control. Three CSV snapshots move, all in one field: their `r#"` fixtures open with a newline, so the unit row's start goes 2 -> 1 with every other value byte-identical. The integration-snapshot submodule is unchanged. Fixes #1195 --- src/spaces.rs | 25 +++++- src/spaces_tests.rs | 82 +++++++++++++++++++ ...put_formats__csv_test__csv_cpp_widget.snap | 2 +- ...formats__csv_test__csv_python_greeter.snap | 2 +- ...t_formats__csv_test__csv_rust_counter.snap | 2 +- tests/parity/space_span_containment.rs | 36 ++++++++ 6 files changed, 142 insertions(+), 7 deletions(-) diff --git a/src/spaces.rs b/src/spaces.rs index 79fb80969..1fb8059d1 100644 --- a/src/spaces.rs +++ b/src/spaces.rs @@ -281,10 +281,27 @@ crate::recursion::impl_iterative_drop!(FuncSpace, spaces); /// `SpaceKind::Unit` instead — as both walks did until #1163 — put /// Perl's trailing `sub` a line past its parent unit and past EOF. pub(crate) fn line_span(node: &Node<'_>, kind: SpaceKind) -> (usize, usize) { - // An empty file occupies no lines at all; the general rule would - // report the inverted `1..0`. - if kind == SpaceKind::Unit && node.child_count() == 0 { - return (0, 0); + if kind == SpaceKind::Unit { + // The file-level unit starts at line 1 by definition — it *is* + // the file — so its start is anchored rather than measured. The + // root node's own `start_row` is not line 1 whenever the file + // opens with blank lines, because tree-sitter starts the root at + // the first token: `"\n\n\nfn a() {}\n"` put the unit at 4..4 of + // a 4-line file, omitting the three lines above it. A leading + // *comment* was always fine, since comments are in the tree. + // + // A childless root is the acute form of the same thing: with no + // token to start at, the node collapses to a point at the end of + // the whitespace, so `start_row` is the *last* row and the + // measured rule inverts outright — `"\n\n"` gives 3..2 (#1195). + // + // An empty file is the one input that genuinely has no lines. + // `end_line` is 0 there and `1..0` would be inverted in turn, so + // it keeps the degenerate `0..0` — and is therefore the one + // input `space_span_containment`'s `(1, line_count)` rule + // cannot be asked about. + let end = node.end_line(); + return if end == 0 { (0, 0) } else { (1, end) }; } (node.start_row() + 1, node.end_line()) } diff --git a/src/spaces_tests.rs b/src/spaces_tests.rs index 0772c0cf1..455b79a05 100644 --- a/src/spaces_tests.rs +++ b/src/spaces_tests.rs @@ -2027,3 +2027,85 @@ int helper(int y) { ); }); } + +/// The `line_span` carve-out for a root that parses to no children +/// (#1195). +/// +/// These inputs are unreachable through `check_metrics` and the +/// integration harnesses, both of which append a trailing newline (see +/// `.claude/rules/testing.md`), so they go through `space_verbatim`. +#[cfg(all(test, feature = "rust"))] +mod childless_root_span { + use crate::test_support::space_verbatim; + use crate::{LANG, MetricsOptions}; + + fn span(source: &str) -> (usize, usize) { + let space = space_verbatim(LANG::Rust, source.as_bytes(), MetricsOptions::default()); + (space.start_line, space.end_line) + } + + #[test] + fn a_whitespace_only_file_spans_its_lines() { + // tree-sitter collapses a childless root to a point at the end + // of the whitespace, so `start_row` is the *last* row: before + // #1195 the guard returned `0..0` and the general rule would + // have returned the inverted `3..2`. Neither is the answer every + // other space gives, which is `1..line_count`. + for source in ["\n", "\n\n", " \n \n", " ", "\t\n\n\n"] { + let lines = source.lines().count(); + assert_eq!( + span(source), + (1, lines), + "whitespace-only source {source:?} has {lines} lines" + ); + } + } + + #[test] + fn an_empty_file_keeps_the_degenerate_zero_span() { + // The one input with no lines at all. `1..0` would be inverted, + // which is what the containment invariant forbids, so `0..0` is + // deliberate rather than an oversight. + assert_eq!(span(""), (0, 0)); + assert_eq!("".lines().count(), 0); + } + + #[test] + fn a_file_with_code_is_unaffected() { + // Pins that the anchoring is scoped to the unit: a *nested* + // space still takes `start_row + 1`, so a fix that hard-coded 1 + // everywhere would pass the cases above and fail here. + assert_eq!(span("fn a() {}\n"), (1, 1)); + assert_eq!(span("fn a() {}\nfn b() {}\n"), (1, 2)); + + let space = space_verbatim( + LANG::Rust, + b"\n\nfn a() {\n let x = 1;\n}\n", + MetricsOptions::default(), + ); + assert_eq!( + (space.spaces[0].start_line, space.spaces[0].end_line), + (3, 5), + "the nested function keeps its measured span" + ); + } + + #[test] + fn leading_blank_lines_do_not_shorten_the_unit() { + // The second half of the same defect, found by the control test + // above. tree-sitter starts the root at the first *token*, so a + // file opening with blank lines reported a unit that omitted + // them: 6 lines of source, span 4..6. + for source in ["\n\nfn a() {}\n", "\nfn a() {}\n", "\n\n\nfn a() {}\n\n\n"] { + let lines = source.lines().count(); + assert_eq!( + span(source), + (1, lines), + "source {source:?} has {lines} lines" + ); + } + // A leading comment was never affected: comments are in the + // tree, so the root already started at line 1. + assert_eq!(span("// c\nfn a() {}\n"), (1, 2)); + } +} diff --git a/tests/output_formats/snapshots/output_formats__csv_test__csv_cpp_widget.snap b/tests/output_formats/snapshots/output_formats__csv_test__csv_cpp_widget.snap index fd7011b19..13899f36d 100644 --- a/tests/output_formats/snapshots/output_formats__csv_test__csv_cpp_widget.snap +++ b/tests/output_formats/snapshots/output_formats__csv_test__csv_cpp_widget.snap @@ -3,7 +3,7 @@ source: tests/output_formats/csv_test.rs expression: out --- path,space_name,space_kind,start_line,end_line,cognitive.sum,cognitive.average,cognitive.min,cognitive.max,cyclomatic.sum,cyclomatic.average,cyclomatic.min,cyclomatic.max,cyclomatic.modified.sum,cyclomatic.modified.average,cyclomatic.modified.min,cyclomatic.modified.max,halstead.unique_operators,halstead.total_operators,halstead.unique_operands,halstead.total_operands,halstead.length,halstead.estimated_program_length,halstead.purity_ratio,halstead.vocabulary,halstead.volume,halstead.difficulty,halstead.level,halstead.effort,halstead.time,halstead.bugs,loc.sloc,loc.ploc,loc.lloc,loc.cloc,loc.blank,loc.sloc_average,loc.ploc_average,loc.lloc_average,loc.cloc_average,loc.blank_average,loc.sloc_min,loc.sloc_max,loc.cloc_min,loc.cloc_max,loc.ploc_min,loc.ploc_max,loc.lloc_min,loc.lloc_max,loc.blank_min,loc.blank_max,nom.functions,nom.closures,nom.functions_average,nom.closures_average,nom.total,nom.average,nom.functions_min,nom.functions_max,nom.closures_min,nom.closures_max,nargs.function_args,nargs.closure_args,nargs.function_args_average,nargs.closure_args_average,nargs.total,nargs.average,nargs.function_args_min,nargs.function_args_max,nargs.closure_args_min,nargs.closure_args_max,nexits.sum,nexits.average,nexits.min,nexits.max,tokens.sum,tokens.average,tokens.min,tokens.max,abc.assignments,abc.branches,abc.conditions,abc.magnitude,abc.assignments_average,abc.branches_average,abc.conditions_average,abc.assignments_min,abc.assignments_max,abc.branches_min,abc.branches_max,abc.conditions_min,abc.conditions_max,wmc.class_wmc_sum,wmc.interface_wmc_sum,wmc.total,npm.class_npm_sum,npm.interface_npm_sum,npm.class_methods,npm.interface_methods,npm.class_coa,npm.interface_coa,npm.total,npm.total_methods,npm.coa,npa.class_npa_sum,npa.interface_npa_sum,npa.class_attributes,npa.interface_attributes,npa.class_cda,npa.interface_cda,npa.total,npa.total_attributes,npa.cda,mi.original,mi.sei,mi.visual_studio -widget.cc,widget.cc,unit,2,10,0,0,0,0,5,2.5,1,1,5,2.5,1,1,8,18,6,9,27,39.50977500432694,1.4633250001602571,14,102.79858289555531,6,0.16666666666666666,616.7914973733318,34.266194298518435,0.024152995332196794,9,9,2,0,0,1.8,1.8,0.4,0,0,1,9,0,0,1,9,1,2,0,0,2,0,0.4,0,2,0.4,0,1,0,0,1,0,0.5,0,1,0.5,0,1,0,0,1,0.5,0,1,38,7.6,0,12,1,0,0,1,0.2,0,0,0,1,0,0,0,0,2,0,2,2,0,2,0,1,0,2,2,1,0,0,1,0,0,0,0,1,0,110.16454969426378,83.7420968306897,64.4237132714993 +widget.cc,widget.cc,unit,1,10,0,0,0,0,5,2.5,1,1,5,2.5,1,1,8,18,6,9,27,39.50977500432694,1.4633250001602571,14,102.79858289555531,6,0.16666666666666666,616.7914973733318,34.266194298518435,0.024152995332196794,9,9,2,0,0,1.8,1.8,0.4,0,0,1,9,0,0,1,9,1,2,0,0,2,0,0.4,0,2,0.4,0,1,0,0,1,0,0.5,0,1,0.5,0,1,0,0,1,0.5,0,1,38,7.6,0,12,1,0,0,1,0.2,0,0,0,1,0,0,0,0,2,0,2,2,0,2,0,1,0,2,2,1,0,0,1,0,0,0,0,1,0,110.16454969426378,83.7420968306897,64.4237132714993 widget.cc,ns,namespace,2,10,0,0,0,0,4,2,1,1,4,2,1,1,8,18,6,9,27,39.50977500432694,1.4633250001602571,14,102.79858289555531,6,0.16666666666666666,616.7914973733318,34.266194298518435,0.024152995332196794,9,9,2,0,0,2.25,2.25,0.5,0,0,1,9,0,0,1,9,1,2,0,0,2,0,0.5,0,2,0.5,0,1,0,0,1,0,0.5,0,1,0.5,0,1,0,0,1,0.5,0,1,38,9.5,5,12,1,0,0,1,0.25,0,0,0,1,0,0,0,0,2,0,2,2,0,2,0,1,0,2,2,1,0,0,1,0,0,0,0,1,0,110.3945496942638,83.97209683068972,64.55821619547591 widget.cc,Widget,class,3,9,0,0,0,0,3,1.5,1,1,3,1.5,1,1,8,16,5,8,24,35.60964047443681,1.4837350197682004,13,88.81055323538621,6.4,0.15625,568.3875407064718,31.57708559480399,0.022872226594196698,7,7,2,0,0,2.3333333333333335,2.3333333333333335,0.6666666666666666,0,0,1,7,0,0,1,7,1,2,0,0,2,0,0.6666666666666666,0,2,0.6666666666666666,0,1,0,0,1,0,0.5,0,1,0.5,0,1,0,0,1,0.5,0,1,33,11,10,12,1,0,0,1,0.3333333333333333,0,0,0,1,0,0,0,0,2,0,2,2,0,2,0,1,0,2,2,1,0,0,1,0,0,0,0,1,0,115.45642705977977,91.17302234410326,67.51837839753202 widget.cc,value,function,5,5,0,0,0,0,1,1,1,1,1,1,1,1,5,5,2,2,7,13.60964047443681,1.944234353490973,7,19.651484454403228,2.5,0.4,49.12871113600807,2.7293728408893374,0.004471319041702271,1,1,1,0,0,1,1,1,0,0,1,1,0,0,1,1,1,1,0,0,1,0,1,0,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,10,10,10,10,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,155.2836050228807,148.4278547652622,90.80912574437467 diff --git a/tests/output_formats/snapshots/output_formats__csv_test__csv_python_greeter.snap b/tests/output_formats/snapshots/output_formats__csv_test__csv_python_greeter.snap index ef35ac278..358d57c58 100644 --- a/tests/output_formats/snapshots/output_formats__csv_test__csv_python_greeter.snap +++ b/tests/output_formats/snapshots/output_formats__csv_test__csv_python_greeter.snap @@ -3,7 +3,7 @@ source: tests/output_formats/csv_test.rs expression: out --- path,space_name,space_kind,start_line,end_line,cognitive.sum,cognitive.average,cognitive.min,cognitive.max,cyclomatic.sum,cyclomatic.average,cyclomatic.min,cyclomatic.max,cyclomatic.modified.sum,cyclomatic.modified.average,cyclomatic.modified.min,cyclomatic.modified.max,halstead.unique_operators,halstead.total_operators,halstead.unique_operands,halstead.total_operands,halstead.length,halstead.estimated_program_length,halstead.purity_ratio,halstead.vocabulary,halstead.volume,halstead.difficulty,halstead.level,halstead.effort,halstead.time,halstead.bugs,loc.sloc,loc.ploc,loc.lloc,loc.cloc,loc.blank,loc.sloc_average,loc.ploc_average,loc.lloc_average,loc.cloc_average,loc.blank_average,loc.sloc_min,loc.sloc_max,loc.cloc_min,loc.cloc_max,loc.ploc_min,loc.ploc_max,loc.lloc_min,loc.lloc_max,loc.blank_min,loc.blank_max,nom.functions,nom.closures,nom.functions_average,nom.closures_average,nom.total,nom.average,nom.functions_min,nom.functions_max,nom.closures_min,nom.closures_max,nargs.function_args,nargs.closure_args,nargs.function_args_average,nargs.closure_args_average,nargs.total,nargs.average,nargs.function_args_min,nargs.function_args_max,nargs.closure_args_min,nargs.closure_args_max,nexits.sum,nexits.average,nexits.min,nexits.max,tokens.sum,tokens.average,tokens.min,tokens.max,abc.assignments,abc.branches,abc.conditions,abc.magnitude,abc.assignments_average,abc.branches_average,abc.conditions_average,abc.assignments_min,abc.assignments_max,abc.branches_min,abc.branches_max,abc.conditions_min,abc.conditions_max,wmc.class_wmc_sum,wmc.interface_wmc_sum,wmc.total,npm.class_npm_sum,npm.interface_npm_sum,npm.class_methods,npm.interface_methods,npm.class_coa,npm.interface_coa,npm.total,npm.total_methods,npm.coa,npa.class_npa_sum,npa.interface_npa_sum,npa.class_attributes,npa.interface_attributes,npa.class_cda,npa.interface_cda,npa.total,npa.total_attributes,npa.cda,mi.original,mi.sei,mi.visual_studio -greeter.py,greeter.py,unit,2,9,1,0.5,0,1,5,2.5,1,2,5,2.5,1,2,6,10,6,14,24,31.019550008653873,1.292481250360578,12,86.03910001730775,7,0.14285714285714285,602.2737001211542,33.45965000673079,0.02377249024853662,8,7,4,0,1,2,1.75,1,0,0.25,2,8,0,0,2,7,1,4,0,1,2,0,0.5,0,2,0.5,0,1,0,0,3,0,1.5,0,3,1.5,0,2,0,0,2,1,0,2,41,10.25,0,25,1,0,1,1.4142135623730951,0.25,0,0.25,0,1,0,0,0,1,3,0,3,2,0,2,0,1,0,2,2,1,1,0,1,0,1,0,1,1,1,112.998077434453,87.83001324968197,66.08074703769181 +greeter.py,greeter.py,unit,1,9,1,0.5,0,1,5,2.5,1,2,5,2.5,1,2,6,10,6,14,24,31.019550008653873,1.292481250360578,12,86.03910001730775,7,0.14285714285714285,602.2737001211542,33.45965000673079,0.02377249024853662,8,7,4,0,1,2,1.75,1,0,0.25,2,8,0,0,2,7,1,4,0,1,2,0,0.5,0,2,0.5,0,1,0,0,3,0,1.5,0,3,1.5,0,2,0,0,2,1,0,2,41,10.25,0,25,1,0,1,1.4142135623730951,0.25,0,0.25,0,1,0,0,0,1,3,0,3,2,0,2,0,1,0,2,2,1,1,0,1,0,1,0,1,1,1,112.998077434453,87.83001324968197,66.08074703769181 greeter.py,Greeter,class,2,9,1,0.5,0,1,4,2,1,2,4,2,1,2,6,10,6,14,24,31.019550008653873,1.292481250360578,12,86.03910001730775,7,0.14285714285714285,602.2737001211542,33.45965000673079,0.02377249024853662,8,7,4,0,1,2.6666666666666665,2.3333333333333335,1.3333333333333333,0,0.3333333333333333,2,8,0,0,2,7,1,4,0,1,2,0,0.6666666666666666,0,2,0.6666666666666666,0,1,0,0,3,0,1.5,0,3,1.5,0,2,0,0,2,1,0,2,41,13.666666666666666,3,25,1,0,1,1.4142135623730951,0.3333333333333333,0,0.3333333333333333,0,1,0,0,0,1,3,0,3,2,0,2,0,1,0,2,2,1,1,0,1,0,1,0,1,1,1,113.22807743445301,88.06001324968199,66.21524996166842 greeter.py,__init__,function,3,4,0,0,0,0,1,1,1,1,1,1,1,1,4,4,3,6,10,12.754887502163468,1.2754887502163468,7,28.07354922057604,4,0.25,112.29419688230416,6.2385664934613425,0.007758604623869012,2,2,1,0,0,2,2,1,0,0,2,2,0,0,2,2,1,1,0,0,1,0,1,0,1,1,1,1,0,0,2,0,2,0,2,2,2,2,0,0,0,0,0,0,13,13,13,13,1,0,0,1,1,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,142.19991098932817,129.5520742665475,83.15784268381765 greeter.py,greet,function,6,9,1,1,1,1,2,2,2,2,2,2,2,2,4,6,4,7,13,16,1.2307692307692308,8,39,3.5,0.2857142857142857,136.5,7.583333333333333,0.008836911647049637,4,4,3,0,0,4,4,3,0,0,4,4,0,0,4,4,3,3,0,0,1,0,1,0,1,1,1,1,0,0,1,0,1,0,1,1,1,1,0,0,2,2,2,2,25,25,25,25,0,0,1,1,0,0,1,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,129.0315107899836,110.65590846191628,75.45702385379158 diff --git a/tests/output_formats/snapshots/output_formats__csv_test__csv_rust_counter.snap b/tests/output_formats/snapshots/output_formats__csv_test__csv_rust_counter.snap index 5608fbd45..94d5d5114 100644 --- a/tests/output_formats/snapshots/output_formats__csv_test__csv_rust_counter.snap +++ b/tests/output_formats/snapshots/output_formats__csv_test__csv_rust_counter.snap @@ -3,6 +3,6 @@ source: tests/output_formats/csv_test.rs expression: out --- path,space_name,space_kind,start_line,end_line,cognitive.sum,cognitive.average,cognitive.min,cognitive.max,cyclomatic.sum,cyclomatic.average,cyclomatic.min,cyclomatic.max,cyclomatic.modified.sum,cyclomatic.modified.average,cyclomatic.modified.min,cyclomatic.modified.max,halstead.unique_operators,halstead.total_operators,halstead.unique_operands,halstead.total_operands,halstead.length,halstead.estimated_program_length,halstead.purity_ratio,halstead.vocabulary,halstead.volume,halstead.difficulty,halstead.level,halstead.effort,halstead.time,halstead.bugs,loc.sloc,loc.ploc,loc.lloc,loc.cloc,loc.blank,loc.sloc_average,loc.ploc_average,loc.lloc_average,loc.cloc_average,loc.blank_average,loc.sloc_min,loc.sloc_max,loc.cloc_min,loc.cloc_max,loc.ploc_min,loc.ploc_max,loc.lloc_min,loc.lloc_max,loc.blank_min,loc.blank_max,nom.functions,nom.closures,nom.functions_average,nom.closures_average,nom.total,nom.average,nom.functions_min,nom.functions_max,nom.closures_min,nom.closures_max,nargs.function_args,nargs.closure_args,nargs.function_args_average,nargs.closure_args_average,nargs.total,nargs.average,nargs.function_args_min,nargs.function_args_max,nargs.closure_args_min,nargs.closure_args_max,nexits.sum,nexits.average,nexits.min,nexits.max,tokens.sum,tokens.average,tokens.min,tokens.max,abc.assignments,abc.branches,abc.conditions,abc.magnitude,abc.assignments_average,abc.branches_average,abc.conditions_average,abc.assignments_min,abc.assignments_max,abc.branches_min,abc.branches_max,abc.conditions_min,abc.conditions_max,wmc.class_wmc_sum,wmc.interface_wmc_sum,wmc.total,npm.class_npm_sum,npm.interface_npm_sum,npm.class_methods,npm.interface_methods,npm.class_coa,npm.interface_coa,npm.total,npm.total_methods,npm.coa,npa.class_npa_sum,npa.interface_npa_sum,npa.class_attributes,npa.interface_attributes,npa.class_cda,npa.interface_cda,npa.total,npa.total_attributes,npa.cda,mi.original,mi.sei,mi.visual_studio -counter.rs,counter.rs,unit,2,13,2,2,0,2,4,4,1,2,4,4,1,2,15,23,6,15,38,74.11313393845472,1.950345629959335,21,166.9080620655929,18.75,0.05333333333333334,3129.5261637298668,173.86256465165926,0.07131777790263222,12,11,3,0,1,4,3.6666666666666665,1,0,0.3333333333333333,8,12,0,0,8,11,3,3,0,1,1,0,0.3333333333333333,0,1,0.3333333333333333,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,45,15,4,34,1,0,2,2.23606797749979,0.3333333333333333,0,0.6666666666666666,0,1,0,0,0,2,2,0,2,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,103.21380797524063,73.61247636275048,60.358952032304465 +counter.rs,counter.rs,unit,1,13,2,2,0,2,4,4,1,2,4,4,1,2,15,23,6,15,38,74.11313393845472,1.950345629959335,21,166.9080620655929,18.75,0.05333333333333334,3129.5261637298668,173.86256465165926,0.07131777790263222,12,11,3,0,1,4,3.6666666666666665,1,0,0.3333333333333333,8,12,0,0,8,11,3,3,0,1,1,0,0.3333333333333333,0,1,0.3333333333333333,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,45,15,4,34,1,0,2,2.23606797749979,0.3333333333333333,0,0.6666666666666666,0,1,0,0,0,2,2,0,2,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,103.21380797524063,73.61247636275048,60.358952032304465 counter.rs,Counter,impl,4,13,2,2,0,2,3,3,1,2,3,3,1,2,14,20,6,13,33,68.81274391313339,2.085234664034345,20,142.62362713128297,15.166666666666666,0.06593406593406594,2163.125011491125,120.17361174950695,0.05575277534828428,10,10,3,0,0,5,5,1.5,0,0,8,10,0,0,8,10,3,3,0,0,1,0,0.5,0,1,0.5,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,38,19,4,34,1,0,2,2.23606797749979,0.5,0,1,0,1,0,0,0,2,2,0,2,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,107.21503374494837,79.28320507878054,62.69885014324466 counter.rs,bump,function,5,12,2,2,2,2,2,2,2,2,2,2,2,2,13,18,5,12,30,59.715356810271004,1.9905118936757,18,125.09775004326937,15.6,0.06410256410256411,1951.524900675002,108.41805003750011,0.05205488323759921,8,8,3,0,0,8,8,3,0,0,8,8,0,0,8,8,3,3,0,0,1,0,1,0,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,34,34,34,34,1,0,2,2.23606797749979,1,0,2,1,1,0,0,2,2,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,111.74175077815008,85.71205743468377,65.34605308663747 diff --git a/tests/parity/space_span_containment.rs b/tests/parity/space_span_containment.rs index b9e01decc..ead80e97d 100644 --- a/tests/parity/space_span_containment.rs +++ b/tests/parity/space_span_containment.rs @@ -137,6 +137,42 @@ fn every_space_lies_within_its_parent_in_every_language() { ); } +/// The degenerate sources every language must still span correctly +/// (#1195). +/// +/// The sweep above only ever feeds `check_source` a real code fixture, +/// so the `(1, line_count)` rule it asserts was never tested against a +/// file that opens with blank lines or holds nothing but whitespace — +/// exactly the inputs where it did not hold. A whitespace-only file +/// parses to a childless root, which tree-sitter collapses to a point at +/// the *end* of the whitespace; the unit reported `0..0` for every one +/// of these before the fix. +/// +/// An empty file is deliberately absent: it has no lines at all, so the +/// `(1, line_count)` rule would demand the inverted `1..0`. That carve-out +/// is pinned as a unit test beside `line_span` instead. +#[test] +fn degenerate_sources_still_span_their_lines_in_every_language() { + let mut checked = 0; + + for lang in LANG::into_enum_iter() { + if !lang.is_enabled() { + continue; + } + checked += 1; + + let (_, ext) = fixture(lang); + for source in ["\n", "\n\n", " \n \n", "\t\n\n\n"] { + check_source(lang, source, ext); + } + } + + assert!( + checked > 0, + "at least one language feature must be enabled for this test to mean anything" + ); +} + /// The #1163 reproducer and its two controls, with the spans spelled out /// rather than only checked for containment — the invariant above says /// the tree is *representable*, these say it is *right*. From 2b953897d0b3c66708c83fe3f8663f8757d9bb7c Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Sun, 2 Aug 2026 23:37:53 -0700 Subject: [PATCH 04/27] fix(abc): address ternary slots by grammar field, not by index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A comment inside a ternary changed its ABC conditions in thirteen languages. tree-sitter counts a comment among a node's children, so it is both the operand's previous sibling and a shift in every positional index — one cause, two opposite symptoms: - **Over-count** (C, C++, Objective-C, Mozcpp, PHP, Perl, JavaScript, TypeScript, TSX, MozJS). The boolean-context seed asked "is my previous sibling `?` or `:`". A comment answers no, flipping the seed on for a *branch* slot: `a ? /*n*/ (b) : c` scored 3 where `a ? (b) : c` scores 2. This is the defect #1181 describes. - **Under-count** (Java, C#, Groovy). Not mentioned in the issue, and found by a control test written to check the first fix had not leaked. These walk branches by `child(2)` / `child(4)`, so the comment *is* child 2 and the real operand is never inspected: `a ? /*n*/ !b : c` scored 2 where `a ? !b : c` scores 3. Both slots now resolve through `child_by_field_name`, testing identity against the `condition` field rather than negating a neighbour-token test. All three grammars in the second group already expose `condition` / `consequence` / `alternative`, so the positional form was never necessary. This also closes the inverts-on-failure weakness the issue gives as its real reason: an unmatched sibling meant "boolean context", so a future extra token id would have turned every parenthesised alternative into a condition. The issue scopes this to three modules; it is seven, covering thirteen languages — `cpp_inspect_container` is shared by the C, Objective-C and Mozcpp impls, and `js_family` by four. Ruby was already on the field form (#1161) and is unchanged; its comment describing the C-family defect as live has been updated. Java, C# and Groovy needed both halves in one change: fixing the branch walk alone would have fed correctly-located operands to a seed that still flipped on for them, converting their under-count into the C family's over-count. Tests: `ternary_comment_invariance` in `abc.rs` sweeps every enabled ternary language and asserts a comment changes nothing, for both a parenthesised and a negated branch operand — the two inputs that discriminate the two defects, neither of which existing fixtures use. A companion test pins the absolute values 2 and 3 so a regression moving both sides equally still fails. Verified by perturbation: restoring the C++ seed fails the over-count direction and restoring Java's positional walk fails the under-count direction, each naming its own language. Removes `Node::previous_sibling_under` and its test. The fix deleted its last seven callers, and its doc said it existed for "every ABC condition walker" — with none left it was dead code. `Node::previous_sibling` and `Ancestors::previous_sibling` keep their `checker.rs` callers. No corpus snapshot moves: a comment between `?` and a parenthesised or negated operand does not occur in the integration corpora. Fixes #1181 --- src/metrics/abc.rs | 130 +++++++++++++++++++++++++++++++++++ src/metrics/abc/cpp.rs | 6 +- src/metrics/abc/csharp.rs | 18 +++-- src/metrics/abc/groovy.rs | 18 +++-- src/metrics/abc/java.rs | 28 +++++--- src/metrics/abc/js_family.rs | 6 +- src/metrics/abc/perl.rs | 6 +- src/metrics/abc/php.rs | 6 +- src/metrics/abc/ruby.rs | 19 +++-- src/node.rs | 72 +------------------ 10 files changed, 195 insertions(+), 114 deletions(-) diff --git a/src/metrics/abc.rs b/src/metrics/abc.rs index 8a53c33fe..653f2fca8 100644 --- a/src/metrics/abc.rs +++ b/src/metrics/abc.rs @@ -8948,3 +8948,133 @@ function f(int $a, int $b): int { ); } } + +/// A comment inside a ternary must not change its ABC conditions +/// (#1181). +/// +/// Two opposite defects, one cause: tree-sitter counts a comment among a +/// node's children, so it is the operand's previous sibling *and* it +/// shifts every positional index. +/// +/// * Languages whose seed asked "is my previous sibling `?` or `:`" +/// (C family, PHP, Perl, JS family) read the comment as "not a +/// ternary token", flipped the boolean-context seed on for a *branch* +/// slot, and **over**-counted: `a ? /*n*/ (b) : c` scored 3 where +/// `a ? (b) : c` scores 2. +/// * Languages whose branch walk read `child(2)` / `child(4)` (Java, +/// C#, Groovy) landed on the comment instead of the operand, never +/// inspected it, and **under**-counted: `a ? /*n*/ !b : c` scored 2 +/// where `a ? !b : c` scores 3. +/// +/// Both slots are now addressed by grammar field. The parenthesised +/// operand is the only input that discriminates the first defect and +/// the negated operand the only one that discriminates the second — +/// existing ternary fixtures use neither. +#[cfg(test)] +mod ternary_comment_invariance { + use crate::test_support::metrics_verbatim; + use crate::{LANG, MetricsOptions}; + + fn conditions(lang: LANG, source: &str) -> u64 { + metrics_verbatim(lang, source.as_bytes(), MetricsOptions::default()) + .abc + .conditions_sum() + } + + /// `(base, with_comment)` for a parenthesised and a negated branch + /// operand, per language. + fn cases(lang: LANG) -> Option<[(String, String); 2]> { + // `{}` marks the consequence slot; `/*n*/` the inserted comment. + let (template, paren, negated, comment) = match lang { + LANG::Cpp | LANG::C | LANG::Objc | LANG::Mozcpp => { + ("int f(){ int x = a ? {} : c; }", "(b)", "!b", "/*n*/ ") + } + LANG::Java => ( + "class K{ void f(){ int x = a ? {} : c; } }", + "(b)", + "!b", + "/*n*/ ", + ), + LANG::Csharp => ( + "class K{ void f(){ var x = a ? {} : c; } }", + "(b)", + "!b", + "/*n*/ ", + ), + LANG::Groovy => ("def f(){ def x = a ? {} : c }", "(b)", "!b", "/*n*/ "), + LANG::Javascript | LANG::Typescript | LANG::Tsx | LANG::Mozjs => { + ("function f(){ var x = a ? {} : c; }", "(b)", "!b", "/*n*/ ") + } + LANG::Php => ( + " ("sub f { my $x = $a ? {} : $c; }", "($b)", "!$b", "# n\n "), + _ => return None, + }; + let build = |operand: &str, with_comment: bool| { + let slot = if with_comment { + format!("{comment}{operand}") + } else { + operand.to_owned() + }; + template.replace("{}", &slot) + }; + Some([ + (build(paren, false), build(paren, true)), + (build(negated, false), build(negated, true)), + ]) + } + + #[test] + fn a_comment_before_a_branch_operand_changes_nothing() { + let mut checked = 0; + for lang in LANG::into_enum_iter() { + if !lang.is_enabled() { + continue; + } + let Some(pairs) = cases(lang) else { continue }; + checked += 1; + for (base, commented) in pairs { + assert_eq!( + conditions(lang, &commented), + conditions(lang, &base), + "{lang:?}: a comment changed the ABC conditions of a ternary\n \ + without: {base}\n with: {commented}" + ); + } + } + assert!( + checked > 0, + "no ternary language enabled; this test asserted nothing" + ); + } + + /// The absolute values the invariance test compares against, so a + /// regression that moved *both* sides equally still fails. + /// + /// expected: `a ? (b) : c` counts the `?` marker plus the condition + /// `a` in boolean context = 2. Negating the consequence adds one + /// more, since `!b` establishes boolean content for that slot = 3. + #[test] + fn the_baseline_values_are_two_and_three() { + for lang in LANG::into_enum_iter() { + if !lang.is_enabled() { + continue; + } + let Some([(paren, _), (negated, _)]) = cases(lang) else { + continue; + }; + assert_eq!( + conditions(lang, &paren), + 2, + "{lang:?}: parenthesised branch" + ); + assert_eq!(conditions(lang, &negated), 3, "{lang:?}: negated branch"); + } + } +} diff --git a/src/metrics/abc/cpp.rs b/src/metrics/abc/cpp.rs index ae919876d..7dc4f8ec6 100644 --- a/src/metrics/abc/cpp.rs +++ b/src/metrics/abc/cpp.rs @@ -39,9 +39,9 @@ pub(super) fn cpp_inspect_container(container_node: &Node, parent: &Node, condit parent_kind, "binary_expression" | "if_statement" | "while_statement" | "do_statement" | "for_statement" ) || (parent_kind == "conditional_expression" - && node - .previous_sibling_under(parent) - .is_none_or(|prev| !matches!(prev.kind(), "?" | ":"))); + && parent + .child_by_field_name("condition") + .is_some_and(|condition| condition.id() == node.id())); loop { // `condition_clause` is the C++-grammar wrapper around an diff --git a/src/metrics/abc/csharp.rs b/src/metrics/abc/csharp.rs index cacc796c7..f1ea14e12 100644 --- a/src/metrics/abc/csharp.rs +++ b/src/metrics/abc/csharp.rs @@ -26,9 +26,9 @@ fn csharp_inspect_container(container_node: &Node, parent: &Node, conditions: &m // expression evaluates as a condition. let mut has_boolean_content = match parent.kind_id().into() { BinaryExpression | IfStatement | WhileStatement | DoStatement | ForStatement => true, - ConditionalExpression => node - .previous_sibling_under(parent) - .is_none_or(|prev| !matches!(prev.kind_id().into(), QMARK | COLON)), + ConditionalExpression => parent + .child_by_field_name("condition") + .is_some_and(|condition| condition.id() == node.id()), _ => false, }; @@ -268,11 +268,17 @@ fn csharp_walk_for_conditions<'a>( // invocation/identifier/boolean would. fn csharp_walk_conditional(node: &Node, stats: &mut Stats) { let conds = &mut stats.conditions; - if let Some(condition) = node.child(0) { + // By grammar FIELD, not index — see `java_walk_ternary` for why the + // positional form dropped a negated branch operand behind a comment + // (#1181). + if let Some(condition) = node.child_by_field_name("condition") { csharp_count_condition(&condition, node, conds); } - csharp_inspect_child(node, 2, conds); - csharp_inspect_child(node, 4, conds); + for field in ["consequence", "alternative"] { + if let Some(branch) = node.child_by_field_name(field) { + csharp_inspect_container(&branch, node, conds); + } + } } // Counts unary / single-token conditions inside `for` statements. The diff --git a/src/metrics/abc/groovy.rs b/src/metrics/abc/groovy.rs index cc6f968fb..5a266dd5b 100644 --- a/src/metrics/abc/groovy.rs +++ b/src/metrics/abc/groovy.rs @@ -21,9 +21,9 @@ fn groovy_inspect_container(container_node: &Node, parent: &Node, conditions: &m let mut has_boolean_content = match parent.kind_id().into() { BinaryExpression | IfStatement | WhileStatement | DoWhileStatement | ForStatement => true, - TernaryExpression => node - .previous_sibling_under(parent) - .is_none_or(|prev_node| !matches!(prev_node.kind_id().into(), QMARK | COLON)), + TernaryExpression => parent + .child_by_field_name("condition") + .is_some_and(|condition| condition.id() == node.id()), _ => false, }; @@ -220,11 +220,17 @@ fn groovy_walk_for_conditions<'a>( fn groovy_walk_ternary(node: &Node, stats: &mut Stats) { let conds = &mut stats.conditions; - if let Some(condition) = node.child(0) { + // By grammar FIELD, not index — see `java_walk_ternary` for why the + // positional form dropped a negated branch operand behind a comment + // (#1181). + if let Some(condition) = node.child_by_field_name("condition") { groovy_count_condition(&condition, node, conds); } - groovy_inspect_child(node, 2, conds); - groovy_inspect_child(node, 4, conds); + for field in ["consequence", "alternative"] { + if let Some(branch) = node.child_by_field_name(field) { + groovy_inspect_container(&branch, node, conds); + } + } } // Two shapes: a present condition lives at child(3); an empty condition diff --git a/src/metrics/abc/java.rs b/src/metrics/abc/java.rs index f875d1e9b..215a2d69d 100644 --- a/src/metrics/abc/java.rs +++ b/src/metrics/abc/java.rs @@ -24,9 +24,9 @@ fn java_inspect_container(container_node: &Node, parent: &Node, conditions: &mut // Initializes the flag to true if the container is known to contain a boolean value let mut has_boolean_content = match parent.kind_id().into() { BinaryExpression | IfStatement | WhileStatement | DoStatement | ForStatement => true, - TernaryExpression => node - .previous_sibling_under(parent) - .is_none_or(|prev_node| !matches!(prev_node.kind_id().into(), QMARK | COLON)), + TernaryExpression => parent + .child_by_field_name("condition") + .is_some_and(|condition| condition.id() == node.id()), _ => false, }; @@ -238,9 +238,17 @@ fn java_walk_for_conditions<'a>(node: &Node<'a>, ancestors: Ancestors<'a, '_>, s fn java_walk_ternary(node: &Node, stats: &mut Stats) { use Java::*; let conds = &mut stats.conditions; - // Child 0: condition itself. The terminal set mirrors the one in - // `java_inspect_container` (issue #372 / lesson #19). - if let Some(condition) = node.child(0) { + // Slots are addressed by grammar FIELD, not by index. The positional + // form read children 0 / 2 / 4, and tree-sitter counts comments among + // a node's children, so `a ? /*n*/ !b : c` put the comment at index 2 + // and the negated operand went uninspected — the ternary scored 2 + // where the same expression without the comment scores 3 (#1181). + // That is the mirror image of the over-count the token-based seed + // produced in the C family, from the same cause. + // + // The terminal set mirrors the one in `java_inspect_container` + // (issue #372 / lesson #19). + if let Some(condition) = node.child_by_field_name("condition") { match condition.kind_id().into() { java_bool_terminal_kinds!() => *conds += 1., ParenthesizedExpression | UnaryExpression => { @@ -249,9 +257,11 @@ fn java_walk_ternary(node: &Node, stats: &mut Stats) { _ => {} } } - // Children 2 and 4: the two branch expressions. - java_inspect_child(node, 2, conds); - java_inspect_child(node, 4, conds); + for field in ["consequence", "alternative"] { + if let Some(branch) = node.child_by_field_name(field) { + java_inspect_container(&branch, node, conds); + } + } } // Handles the `for (...)` multi-shape positional cascade: the loop diff --git a/src/metrics/abc/js_family.rs b/src/metrics/abc/js_family.rs index 792e180db..075d49a8c 100644 --- a/src/metrics/abc/js_family.rs +++ b/src/metrics/abc/js_family.rs @@ -40,9 +40,9 @@ macro_rules! impl_js_family_unary_walker { parent_kind, BinaryExpression | IfStatement | WhileStatement | DoStatement | ForStatement ) || (matches!(parent_kind, TernaryExpression) - && node - .previous_sibling_under(parent) - .is_none_or(|prev| !matches!(prev.kind_id().into(), QMARK | COLON))); + && parent + .child_by_field_name("condition") + .is_some_and(|condition| condition.id() == node.id())); loop { let is_parens = matches!(node_kind, ParenthesizedExpression); diff --git a/src/metrics/abc/perl.rs b/src/metrics/abc/perl.rs index 281e74226..b75f630ea 100644 --- a/src/metrics/abc/perl.rs +++ b/src/metrics/abc/perl.rs @@ -69,9 +69,9 @@ fn perl_inspect_container(container_node: &Node, parent: &Node, conditions: &mut | P::WhileStatement | P::UntilStatement ) || (matches!(parent_kind, P::TernaryExpression) - && node - .previous_sibling_under(parent) - .is_none_or(|prev| !matches!(prev.kind_id().into(), P::QMARK | P::COLON))); + && parent + .child_by_field_name("condition") + .is_some_and(|condition| condition.id() == node.id())); loop { // `Array` is tree-sitter-perl's name for the `(...)` shape diff --git a/src/metrics/abc/php.rs b/src/metrics/abc/php.rs index 61d91cf6e..26079d406 100644 --- a/src/metrics/abc/php.rs +++ b/src/metrics/abc/php.rs @@ -33,9 +33,9 @@ fn php_inspect_container(container_node: &Node, parent: &Node, conditions: &mut parent_kind, BinaryExpression | IfStatement | WhileStatement | DoStatement | ForStatement ) || (matches!(parent_kind, ConditionalExpression) - && node - .previous_sibling_under(parent) - .is_none_or(|prev| !matches!(prev.kind_id().into(), QMARK | COLON))); + && parent + .child_by_field_name("condition") + .is_some_and(|condition| condition.id() == node.id())); loop { let is_parens = matches!(node_kind, ParenthesizedExpression); diff --git a/src/metrics/abc/ruby.rs b/src/metrics/abc/ruby.rs index e3a38fc64..3f1864d61 100644 --- a/src/metrics/abc/ruby.rs +++ b/src/metrics/abc/ruby.rs @@ -55,16 +55,15 @@ fn ruby_inspect_container(container_node: &Node, parent: &Node, conditions: &mut // contribute nothing — see `ruby_walk_ternary` (#1161). // // The slot is identified by grammar FIELD, not by the neighbouring - // `?` / `:` token. `cpp_inspect_container` uses the token form, and - // it has two weaknesses this avoids: a comment between the token and - // the operand is the previous sibling instead, which flips the seed - // on for a branch slot; and the token test inverts on failure, so - // Ruby's second `:` id (`COLON2`, unreachable at tree-sitter-ruby - // 0.23.1 but one grammar bump away) would silently turn every - // parenthesised alternative into a condition. Both are live in the - // C family — tracked in #1181, not fixed here. Reproducer for the - // first: `x = a ? /*n*/ (b) : c;` reports 3 conditions where the - // same line without the comment reports 2. + // `?` / `:` token. The token form had two weaknesses this avoids: a + // comment between the token and the operand is the previous sibling + // instead, which flips the seed on for a branch slot; and the test + // inverts on failure, so Ruby's second `:` id (`COLON2`, unreachable + // at tree-sitter-ruby 0.23.1 but one grammar bump away) would + // silently turn every parenthesised alternative into a condition. + // Both were live across the C family, PHP, Perl and the JS family + // until #1181 moved them all onto this form; the cross-language + // regression test is `ternary_comment_invariance` in `abc.rs`. let mut has_boolean_content = matches!( parent_kind, Binary | Binary2 | Binary3 | If | Unless | While | Until diff --git a/src/node.rs b/src/node.rs index 016c37ebe..630fdc278 100644 --- a/src/node.rs +++ b/src/node.rs @@ -235,32 +235,12 @@ impl<'a> Node<'a> { /// /// **`O(depth)`, not `O(1)`**, for [`Node::parent`]'s reason: /// `ts_node__prev_sibling` opens with `ts_node_parent`. Callers on a - /// walk should use [`previous_sibling_under`] or - /// [`Ancestors::previous_sibling`] instead (#1096). - /// - /// [`previous_sibling_under`]: Self::previous_sibling_under + /// walk should use [`Ancestors::previous_sibling`] instead (#1096). pub(crate) fn previous_sibling(&self) -> Option> { node_resolved_sibling_lookups::record(); self.0.prev_sibling().map(Node) } - /// The sibling immediately before this node among `parent`'s - /// children, or `None` when this node is `parent`'s first child. - /// - /// [`Node::previous_sibling`] would resolve the parent first and pay - /// its `O(depth)` cost; callers that already hold the parent — every - /// ABC condition walker does, because it descended from it — pay a - /// cursor walk over the siblings instead (#1096). - /// - /// A one-element chain is all [`Ancestors::previous_sibling`] reads, - /// so this delegates rather than repeating the scan — including its - /// fallback for a node that is not among `parent`'s children, which - /// is a caller error here but a legitimate chain/node mismatch - /// there. - pub(crate) fn previous_sibling_under(&self, parent: &Node<'a>) -> Option> { - Ancestors::known(std::slice::from_ref(parent)).previous_sibling(self) - } - /// Returns `true` if any direct child has the given grammar /// `kind_id`. See #217 for the motivating perf finding from the /// JS/TS template-literal hot path. @@ -1984,56 +1964,6 @@ mod tests { ); } - /// `previous_sibling_under` must answer exactly what the - /// authoritative `Node::previous_sibling` does, for every child of - /// the parent — including the first, whose answer is `None` for a - /// reason (no earlier sibling) rather than by accident. The ABC - /// container walkers depend on this: they seed their - /// boolean-context flag from whether a ternary's `?` / `:` precedes - /// the operand (#1096). - #[test] - fn previous_sibling_under_agrees_with_the_authoritative_lookup() { - // Anonymous tokens (`(`, `,`, `)`) sit between the named - // arguments, so the sequence exercises both kinds of sibling. - let code = b"int main() { f(a, b, c); }"; - let tree = Tree::new::(code); - let arguments = tree - .get_root() - .preorder() - .find(|n| n.kind() == "argument_list") - .expect("fixture has an argument list"); - let children: Vec> = arguments.children().collect(); - assert!( - children.len() > 3, - "fixture must have several siblings, got {}", - children.len() - ); - - let mut first_is_none = false; - for child in &children { - let expected = child.previous_sibling().map(|p| p.id()); - assert_eq!( - child.previous_sibling_under(&arguments).map(|p| p.id()), - expected, - "disagreed on the sibling before a {} node", - child.kind() - ); - first_is_none |= expected.is_none(); - } - assert!( - first_is_none, - "the first child's `None` is part of what this pins" - ); - // One break this cannot see: a scan that never finds `node` - // among the children falls back to the authoritative lookup and - // so still answers correctly, just at `Node::parent`'s cost. - // That failure mode is a perf regression, not a wrong answer, - // and the `abc/nested-if` probe is what covers it. Every - // *wrong-answer* break does fail here — returning the following - // sibling, or the parent's first child, fails on the first - // child alone. - } - /// `count_specific_ancestors` must return the same count whichever /// way it reaches the ancestors. Uses `loc`'s real C predicate pair /// (`while`/`for`/`if` header, stopping at the enclosing block), so From 2062a383283078dc533238350cd10e7041893aa1 Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Sun, 2 Aug 2026 23:44:35 -0700 Subject: [PATCH 05/27] fix(abc): count the `not` keyword like `!` in Ruby and Perl MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ruby's and Perl's ABC unary-conditional walkers tested only the `!` token, so the two spellings of one negation scored differently: if not b 0 vs if !b 1 x = a ? (not b) : (not c) 2 vs x = a ? (!b) : (!c) 4 `not` and `!` differ in precedence but not in meaning, and ABC counts the negation rather than the parse. Both now read the grammar's `operator` field — present on `unary` / `unary_expression` in both grammars — and accept either token, matching the field-based addressing the ternary slots moved to in #1181. Sibling sweep, per grammar-dispatch item 7. The issue nominates Perl and Lua; measured, it is Perl only: - **Perl** — same gap, same fix. Its `operator` field types are `! + ++ - -- and not ~`. The hidden `_unary_not` supertype (`P::UnaryNot`) is deliberately not matched: the parser never emits it. - **Lua** — already correct. `not` is its *only* negation keyword and was already the token being tested, so there was nothing to widen. - **Elixir** — not named in the issue, checked because it has a `not` keyword and a `Not` variant. Already correct; it reaches the same count by another path. - **Python** — counts `not` through its own dispatcher arm and has no `!` spelling to compare against. Every one of those is exercised by the new test rather than only noted, so a future edit cannot regress the already-correct three silently. Tests: `keyword_negation_parity` in `abc.rs` asserts the two spellings score identically across the guard, both-branch-ternary and single-branch-ternary shapes, plus a companion pinning the absolute values 1 and 4 so a regression moving both spellings equally still fails. Verified by perturbation: narrowing either language back to `BANG` alone fails two tests naming that language. No corpus snapshot moves. Fixes #1182 --- src/metrics/abc.rs | 115 ++++++++++++++++++++++++++++++++++++++++ src/metrics/abc/perl.rs | 10 +++- src/metrics/abc/ruby.rs | 10 +++- 3 files changed, 133 insertions(+), 2 deletions(-) diff --git a/src/metrics/abc.rs b/src/metrics/abc.rs index 653f2fca8..5a96c733d 100644 --- a/src/metrics/abc.rs +++ b/src/metrics/abc.rs @@ -9078,3 +9078,118 @@ mod ternary_comment_invariance { } } } + +/// A keyword negation must score like its symbolic twin (#1182). +/// +/// `not b` and `!b` are the same negation — they differ in precedence, +/// not in meaning, and ABC counts the negation rather than the parse. +/// Ruby and Perl tested only the `!` token, so `if not b` scored 0 +/// against `if !b`'s 1, and a `not` ternary scored 2 against the `!` +/// form's 4. +/// +/// Lua and Elixir were checked in the same sweep and were already +/// correct: Lua's only negation keyword *is* `not` and it was the token +/// being tested, and Elixir reaches the same count by another path. +/// They are exercised here so a future edit cannot regress them +/// silently. Python counts `not` through its own dispatcher arm and has +/// no `!` spelling to compare against. +#[cfg(test)] +mod keyword_negation_parity { + use crate::test_support::metrics_verbatim; + use crate::{LANG, MetricsOptions}; + + fn conditions(lang: LANG, source: &str) -> u64 { + metrics_verbatim(lang, source.as_bytes(), MetricsOptions::default()) + .abc + .conditions_sum() + } + + /// `(bang_form, keyword_form)` pairs that must score identically. + fn pairs(lang: LANG) -> Option> { + let build = + |t: &str| -> (String, String) { (t.replace("{NOT}", "!"), t.replace("{NOT}", "not ")) }; + let templates: &[&str] = match lang { + LANG::Ruby => &[ + "def f(b)\n if {NOT}b\n 1\n end\nend\n", + "def f(a, b, c)\n x = a ? ({NOT}b) : ({NOT}c)\nend\n", + "def f(a, b, c)\n x = a ? b : ({NOT}c)\nend\n", + ], + LANG::Perl => &[ + "sub f { if ({NOT}$b) { 1; } }", + "sub f { my $x = $a ? ({NOT}$b) : ({NOT}$c); }", + "sub f { my $x = $a ? $b : ({NOT}$c); }", + ], + // Already correct before #1182; pinned so they stay that way. + LANG::Elixir => &["def f(b) do\n if {NOT}b do\n 1\n end\nend\n"], + _ => return None, + }; + Some(templates.iter().map(|t| build(t)).collect()) + } + + #[test] + fn the_not_keyword_scores_like_bang() { + let mut checked = 0; + for lang in LANG::into_enum_iter() { + if !lang.is_enabled() { + continue; + } + let Some(pairs) = pairs(lang) else { continue }; + checked += 1; + for (bang, keyword) in pairs { + assert_eq!( + conditions(lang, &keyword), + conditions(lang, &bang), + "{lang:?}: `not` and `!` scored differently\n bang: {bang}\n keyword: {keyword}" + ); + } + } + assert!( + checked > 0, + "no language enabled; this test asserted nothing" + ); + } + + /// The absolute values, so a regression that moved both spellings + /// equally still fails. + /// + /// expected: `if !b` is one condition — the negated bare operand. + /// `a ? (!b) : (!c)` is four: the `?` marker, the condition `a` in + /// boolean context, and one per negated branch operand. + #[test] + fn the_baseline_values_are_one_and_four() { + for (lang, guard, ternary) in [ + ( + LANG::Ruby, + "def f(b)\n if not b\n 1\n end\nend\n", + "def f(a, b, c)\n x = a ? (not b) : (not c)\nend\n", + ), + ( + LANG::Perl, + "sub f { if (not $b) { 1; } }", + "sub f { my $x = $a ? (not $b) : (not $c); }", + ), + ] { + if !lang.is_enabled() { + continue; + } + assert_eq!(conditions(lang, guard), 1, "{lang:?}: `if not b`"); + assert_eq!(conditions(lang, ternary), 4, "{lang:?}: `not` ternary"); + } + } + + /// Lua's only negation keyword is `not`, so it has no `!` twin to + /// compare against — its guard is that the keyword counts at all. + #[test] + fn lua_counts_its_only_negation_keyword() { + if !LANG::Lua.is_enabled() { + return; + } + assert_eq!( + conditions( + LANG::Lua, + "function f(b)\n if not b then return 1 end\nend\n" + ), + 1 + ); + } +} diff --git a/src/metrics/abc/perl.rs b/src/metrics/abc/perl.rs index b75f630ea..4782f3105 100644 --- a/src/metrics/abc/perl.rs +++ b/src/metrics/abc/perl.rs @@ -86,8 +86,16 @@ fn perl_inspect_container(container_node: &Node, parent: &Node, conditions: &mut // has only one inner expression, so child(1) and last-named // are equivalent. let is_parens = matches!(node_kind, P::ParenthesizedArgument | P::Array); + // Both spellings of the same negation — see `ruby_inspect_container` + // for the rationale; Perl has the identical gap (#1182). Read + // through the grammar's `operator` field, whose type list is + // `! + ++ - -- and not ~`. Do NOT match the hidden `_unary_not` + // supertype (`P::UnaryNot`), which the parser never emits + // (grammar-dispatch item 2). let is_not = matches!(node_kind, P::UnaryExpression) - && node.child(0).is_some_and(|c| c.kind_id() == P::BANG as u16); + && node + .child_by_field_name("operator") + .is_some_and(|op| matches!(op.kind_id().into(), P::BANG | P::Not)); if !is_parens && !is_not { break; diff --git a/src/metrics/abc/ruby.rs b/src/metrics/abc/ruby.rs index 3f1864d61..5a87b9243 100644 --- a/src/metrics/abc/ruby.rs +++ b/src/metrics/abc/ruby.rs @@ -74,8 +74,16 @@ fn ruby_inspect_container(container_node: &Node, parent: &Node, conditions: &mut loop { let is_parens = matches!(node_kind, ParenthesizedStatements); + // Both spellings of the same negation. `not` and `!` differ in + // precedence but not in meaning, and ABC counts the negation, + // not the parse — testing `BANG` alone scored `if not b` as 0 + // where `if !b` scores 1, and a `not` ternary as 2 where the `!` + // form scores 4 (#1182). Read through the grammar's `operator` + // field rather than child(0), matching the ternary slots (#1181). let is_not = matches!(node_kind, Unary | Unary2 | Unary3 | Unary4 | Unary5) - && node.child(0).is_some_and(|c| c.kind_id() == BANG as u16); + && node + .child_by_field_name("operator") + .is_some_and(|op| matches!(op.kind_id().into(), BANG | Not)); if !is_parens && !is_not { break; From 9039894e092d52b6f4da533490f31bd6f0773d61 Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Mon, 3 Aug 2026 00:05:25 -0700 Subject: [PATCH 06/27] fix(abc): wire Phase 2B slot routing for Tcl and iRules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tcl and iRules had none of the Phase 2B slot routing, so a bare-truthy predicate scored zero and a ternary scored only its `?` marker. Both now match the value every other language reports for the same expression: if {$a} 0 -> 1 (C's `if (a)` is 1) if {!$a} 0 -> 1 while {$a} 0 -> 1 if/elseif/else 2 -> 4 (C++ is 4) expr {$a ? !$b : !$c} 1 -> 4 (C++ is 4) The fix the issue prescribes does not transfer. Its model is #1102/#1161's `child_by_field_name("condition")`, but `ternary_expr` in both grammars exposes **no fields at all**, and `while` has no `condition` field either (`seq('while', $.expr, $._word)`). Slots are therefore located relative to the `?` and `:` tokens, never by index: `_expr` inlines `seq('(', $._expr, ')')`, so `($a) ? !$b : !$c` puts anonymous parens directly under `ternary_expr` and shifts every operand right. Three further gaps had to close for the routing to work at all, none of them ternary-specific: - The seed listed one parent kind (`BinopExpr`), so only a `&&` / `||` chain ever established boolean context. It now also admits the `if` / `elseif` / `while` predicate and a ternary's condition slot. - `has_boolean_content` was not `mut` and lacked the `if !has_boolean_content && is_not` line every sibling carries, so a `!` outside a chain could never establish context for itself. - There was no wrapper peel. The `expr` node is this grammar's `condition_clause`; peeling it is what reaches the operand. Grammar-dispatch item 1 bites twice here and both traps are commented at the point of use: `Expr2` is the `expr` *command keyword* and `Expr3` the hidden `_expr` supertype, and both render to the same name as the `Expr` node the code wants. `If`/`While`/`Elseif` have the same node-vs-keyword split. Item 9 does *not* bite for `if`/`while` — contrary to the issue's warning, both have dedicated productions rather than parsing as a generic `command`. A predicate written as a command substitution is a truthy test of that command's result, so `if {[somecmd]}` reports 1 exactly as `if {$a}` does. The redundant `if {[expr {…}]}` idiom therefore reports the chain plus one, which is what moves `tcl_if_multiple_conditions` 7 -> 9 and `tcl_while_conditions` 4 -> 6. Scope: this delivers the issue's three numbered Scope items. The argument and `return` slots named in its *Where* section stay unrouted, and the book row now says so precisely rather than claiming the whole of Phase 2B is missing. Wiring them needs a decision the issue does not make — in these grammars a negation is reachable only through a standalone `expr {…}` command, and routing that double-counts against the command-substitution treatment above. Tests: `irules_abc_bare_truthy_reports_zero` pinned the *absence* of this routing and is rewritten as `..._counts_one_condition`; six new tests cover the negated bare predicate, the ternary slots, the #1161 comparison control (`$a > 0 ? 1 : 0`, which must not move and does not), and a parenthesised condition in both languages. That last one matters: a first pass without it left the whole fixed-index revert passing every test, since indices 0/2/4 are accidentally right for an unparenthesised ternary. Verified by perturbation — reverting the seed fails two tests, reverting either language's slot location fails exactly that language's parenthesised test. Book: the Tcl and iRules deviation rows are rewritten, and both languages join the ternary-slots row. No corpus snapshot moves; three in-tree Tcl snapshots move by value only. Fixes #1180 --- big-code-analysis-book/src/metrics.md | 6 +- src/metrics/abc.rs | 174 ++++++++++++++++-- src/metrics/abc/irules.rs | 127 ++++++++++++- src/metrics/abc/tcl.rs | 137 +++++++++++++- ...bc__tests__tcl_if_multiple_conditions.snap | 8 +- ...__tests__tcl_ternary_counts_condition.snap | 8 +- ...ics__abc__tests__tcl_while_conditions.snap | 8 +- 7 files changed, 427 insertions(+), 41 deletions(-) diff --git a/big-code-analysis-book/src/metrics.md b/big-code-analysis-book/src/metrics.md index 52e2e9f2b..a33e3f8fa 100644 --- a/big-code-analysis-book/src/metrics.md +++ b/big-code-analysis-book/src/metrics.md @@ -143,10 +143,10 @@ application would over-count. | C, Go, Rust | `try` / `catch` omitted | No `try`/`catch` keyword in the grammar; error-handling uses `errno` / `Result` / `Result`-like sums. | | Ruby | `Rescue` substitutes for `catch` | Ruby's exception-handling keyword is `rescue`; the AST node `Rescue` plays the role of Java's `catch`. | | All languages | `default` / `_` wildcard arm excluded from the condition set | Fitzpatrick's Figure 2 lists `default`, but it falls through unconditionally — counting it would inflate `C` on every `switch` / `match` regardless of body. big-code-analysis omits it for every language (the Rust `_ =>` and Java `default:` arms included). | -| Tcl | Chain-operand unary conditions wired; bare-truthy / argument / `return` slots are not | Each operand of a `&&` / `\|\|` chain inside `expr {…}` counts as one condition, so `if {$a && $b}` reports two. The broader Phase 2B slot routing is not wired, so a bare-truthy `if {$a}` still reports zero. | -| iRules | Chain-operand unary conditions wired (unlike its Tcl sibling); bare-truthy / argument / `return` slots are not | Each operand of a `&&` / `\|\|` / `and` / `or` chain counts as one condition (Rule 9), so `if {!$a && !$b}` reports two. iRules also recognises the word-form string-match comparators (`contains`, `starts_with`, `ends_with`, `equals`, `matches`, …) that Tcl lacks (Tcl's `eq` / `ne` / `in` / `ni` are shared). The broader Phase 2B slot routing is not wired, so a bare-truthy `if {$a}` still reports zero. | +| Tcl | Chain-operand, bare-truthy and ternary slots wired; argument and `return` slots are not | Each operand of a `&&` / `\|\|` chain inside `expr {…}` counts as one condition, so `if {$a && $b}` reports two. `if` / `elseif` / `while` route their `expr {…}` predicate, so a bare-truthy `if {$a}` reports one and `if {!$a}` likewise — matching C's `if (a)` (#1180). A predicate written as a command substitution is a truthy test of that command's result, so `if {[somecmd]}` also reports one, and the redundant `if {[expr {$a \|\| $b}]}` idiom reports three (the chain's two operands plus the substitution). The argument and `return` slots remain unrouted: a negation reached only through a standalone `expr {…}` command still reports zero. | +| iRules | Chain-operand, bare-truthy and ternary slots wired; argument and `return` slots are not | Each operand of a `&&` / `\|\|` / `and` / `or` chain counts as one condition (Rule 9), so `if {!$a && !$b}` reports two. iRules also recognises the word-form string-match comparators (`contains`, `starts_with`, `ends_with`, `equals`, `matches`, …) that Tcl lacks (Tcl's `eq` / `ne` / `in` / `ni` are shared). Bare-truthy and ternary slot routing matches its Tcl sibling exactly (#1180) — see that row for the shared detail, including the argument and `return` slots that remain unrouted. | | All Phase 2 languages (Java, Groovy, C#, Rust, Go, JavaScript, TypeScript, TSX, Mozjs, PHP, C, C++, Objective-C, Mozcpp, Python, Perl, Lua) | `if (true) {}`, `m(!a, !b)`, `return !x` count their operand(s) | Phase 2B routes `if` / `while` / `do-while` / argument-list / `return` slots through the same walker, so the rule applies uniformly across decision-bearing positions. A bare `return x` continues to report zero — Fitzpatrick treats an identifier in a return slot as a value, not a unary conditional. | -| Ternary slots: Java, Groovy, C#, C, C++, Objective-C, Mozcpp, JavaScript, TypeScript, TSX, Mozjs, PHP, Perl, Ruby, Python | `a ? !b : !c` counts its condition and both branch operands | The same walker also runs over a ternary's three operand slots, so `a ? !b : !c` reports 4 (the `?` plus three unary conditions) rather than 1. Python arrives at the same total by a different route: `not` operands are counted by the `NotOperator` rule wherever they appear, so only the condition slot is routed through the walker and `(not b) if a else (not c)` likewise reports 4. Languages with no ternary (Rust, Go, Kotlin, Lua, Elixir) are unaffected. Tcl and iRules do have one but do not yet route it — one more instance of the unwired Phase 2B slot routing described in their rows above (issue #1180). | +| Ternary slots: Java, Groovy, C#, C, C++, Objective-C, Mozcpp, JavaScript, TypeScript, TSX, Mozjs, PHP, Perl, Ruby, Python, Tcl, iRules | `a ? !b : !c` counts its condition and both branch operands | The same walker also runs over a ternary's three operand slots, so `a ? !b : !c` reports 4 (the `?` plus three unary conditions) rather than 1. Tcl and iRules reach the same 4 without grammar fields: their `ternary_expr` exposes none, and `_expr` inlines `( … )`, so the slots are located relative to the `?` and `:` tokens instead of by index (#1180). Python arrives at the same total by a different route: `not` operands are counted by the `NotOperator` rule wherever they appear, so only the condition slot is routed through the walker and `(not b) if a else (not c)` likewise reports 4. Languages with no ternary (Rust, Go, Kotlin, Lua, Elixir) are unaffected. | | Ruby | Bare-predicate `if` / `unless` / `while` / `until` (block and modifier forms) count one condition | Idiomatic Ruby favours bare predicates (`if flag`, `x if flag`); counting the condition slot keeps ABC conditions at or above Ruby's cyclomatic decision count (the alignment enforced across the other languages). A comparison (`if a == b`) or `&&` / `\|\|` chain in the predicate is counted by its own operator / walker arm and is not double-counted. | | Bash | `if` / `elif` / `while` and each non-wildcard `case` arm count one condition | A Bash predicate is a *command*, so the branch keyword itself — not an embedded boolean expression — is the condition signal. Each matches a Bash cyclomatic decision; the bare `*)` case arm (the analogue of `default:`) is excluded, mirroring the cyclomatic standard count. The arithmetic ternary `$(( a ? b : c ))` therefore contributes nothing: it carries no branch keyword, so it falls outside the rule set rather than through a gap in it. | | Kotlin | `try` counts a condition alongside `catch` | Fitzpatrick counts both keywords, and Java / C# / C++ / Groovy already count both; Kotlin previously counted only the catch block. | diff --git a/src/metrics/abc.rs b/src/metrics/abc.rs index 5a96c733d..c35d9cc44 100644 --- a/src/metrics/abc.rs +++ b/src/metrics/abc.rs @@ -8671,8 +8671,10 @@ function f(int $a, int $b): int { #[test] fn tcl_ternary_counts_condition() { - // `$a ? $b : $c` inside an `expr` is one `ternary_expr` - // node → 1 condition. + // The `ternary_expr` node is one condition and its condition + // slot `$a` — a bare truthy test — is another, matching C++'s + // `int r = a ? b : c;` (also 2). The two branch operands are + // unnegated and so contribute nothing (#1180). check_metrics::( "proc f {a b c} {\n\ set r [expr {$a ? $b : $c}]\n\ @@ -8681,12 +8683,59 @@ function f(int $a, int $b): int { |metric| { assert_eq!(metric.abc.assignments_sum(), 1); assert_eq!(metric.abc.branches_sum(), 0); - assert_eq!(metric.abc.conditions_sum(), 1); + assert_eq!(metric.abc.conditions_sum(), 2); insta::assert_json_snapshot!(metric.abc); }, ); } + /// The Tcl half of the ternary slot-location guard (#1180). + /// + /// `ternary_expr` exposes no grammar fields, so the slots are found + /// relative to the `?` and `:` tokens. A *parenthesised* condition is + /// the input that discriminates that from a fixed-index reading: + /// `_expr` inlines `( … )` as anonymous children of `ternary_expr`, + /// so `($a) ? !$b : !$c` shifts every operand right by one and + /// `child(0)` / `child(2)` / `child(4)` land on `(`, `)` and `?`. + /// Without this case the whole fixed-index revert passes. + #[test] + fn tcl_parenthesised_ternary_condition_matches_the_bare_form() { + let conditions = |source: &str| { + crate::test_support::metrics_verbatim( + crate::LANG::Tcl, + source.as_bytes(), + crate::MetricsOptions::default(), + ) + .abc + .conditions_sum() + }; + let bare = conditions("proc f {a b c} {\n set r [expr {$a ? !$b : !$c}]\n}"); + assert_eq!(bare, 4, "the bare form is the documented reference value"); + assert_eq!( + conditions("proc f {a b c} {\n set r [expr {($a) ? !$b : !$c}]\n}"), + bare, + "parenthesising the condition must not change the count" + ); + } + + #[test] + fn tcl_bare_truthy_and_negated_predicates_count_one_condition() { + // The headline #1180 fix, on the Tcl side: both were 0 before. + let conditions = |source: &str| { + crate::test_support::metrics_verbatim( + crate::LANG::Tcl, + source.as_bytes(), + crate::MetricsOptions::default(), + ) + .abc + .conditions_sum() + }; + assert_eq!(conditions("proc f {a} {\n if {$a} { puts x }\n}"), 1); + assert_eq!(conditions("proc f {a} {\n if {!$a} { puts x }\n}"), 1); + assert_eq!(conditions("proc f {a} {\n while {$a} { puts x }\n}"), 1); + assert_eq!(conditions("proc f {a} {\n while {!$a} { puts x }\n}"), 1); + } + #[test] fn tcl_elseif_and_else_count_conditions() { // `if` / `elseif` / `else` clause productions each @@ -8728,8 +8777,14 @@ function f(int $a, int $b): int { }", "foo.tcl", |metric| { - // The two `expr` predicates feed the walker: 4 + 3 = 7. - assert_eq!(metric.abc.conditions_sum(), 7); + // The two chains feed the walker: 4 + 3 = 7. Each `if` + // predicate is additionally a bare truthy test of a + // command substitution — `{[expr {…}]}` is structurally + // `if {[somecmd]}`, which counts 1 exactly as `if {$a}` + // does — so 7 + 2 = 9 (#1180). Written without the + // redundant `[expr …]` wrapper, `if {$a || $b}` scores + // 2, matching C++'s `if (a || b)`. + assert_eq!(metric.abc.conditions_sum(), 9); insta::assert_json_snapshot!(metric.abc); }, ); @@ -8747,7 +8802,10 @@ function f(int $a, int $b): int { }", "foo.tcl", |metric| { - assert_eq!(metric.abc.conditions_sum(), 4); + // 2 + 2 from the chains, plus one bare truthy test per + // `while` predicate — see `tcl_if_multiple_conditions` + // (#1180). + assert_eq!(metric.abc.conditions_sum(), 6); insta::assert_json_snapshot!(metric.abc); }, ); @@ -8895,7 +8953,11 @@ function f(int $a, int $b): int { "foo.irule", |metric| { assert_eq!(metric.abc.assignments_sum(), 3); - assert_eq!(metric.abc.conditions_sum(), 2); + // The `elseif` and `else` clauses are one condition each, + // as before; #1180 adds the two bare truthy predicates + // (`{ $a }`, `{ $b }`). C++'s + // `if(a){} else if(b){} else {}` also scores 4. + assert_eq!(metric.abc.conditions_sum(), 4); }, ); } @@ -8930,23 +8992,103 @@ function f(int $a, int $b): int { ); } - /// A bare-truthy `if {$a}` condition carries no comparison, ternary, - /// or short-circuit operator, so the unary-conditional walker is - /// never invoked and conditions stay 0 — iRules does not wire the - /// broader Phase 2B slot routing that counts bare-truthy operands. - /// The `log` command is the single branch. Pins the metrics-book - /// claim in the ABC per-language deviation table. - #[test] - fn irules_abc_bare_truthy_reports_zero() { + /// A bare-truthy `if {$a}` predicate is one condition (#1180). + /// + /// It carries no comparison, ternary or short-circuit operator, so + /// before the Phase 2B slot routing landed nothing invoked the + /// unary-conditional walker and the whole predicate scored 0 — this + /// test previously pinned that absence, and the metrics book's ABC + /// deviation table said so. Now the `if` node routes its `expr` + /// predicate and the count matches C++'s `if (a)`, which is also 1. + /// The `log` command remains the single branch. + #[test] + fn irules_abc_bare_truthy_counts_one_condition() { check_metrics::( "when X {\n if { $a } { log local0. hi }\n}\n", "foo.irule", |metric| { assert_eq!(metric.abc.branches_sum(), 1); - assert_eq!(metric.abc.conditions_sum(), 0); + assert_eq!(metric.abc.conditions_sum(), 1); + }, + ); + } + + /// The negated form of the same predicate, which was *also* 0 before + /// #1180: `!$a` reached the walker but no parent seeded boolean + /// context, so the terminal operand was never counted. Distinct from + /// `irules_abc_negated_operands_in_chain`, whose `&&` supplied the + /// seed the bare form lacked. + #[test] + fn irules_abc_negated_bare_truthy_counts_one_condition() { + check_metrics::( + "when X {\n if { !$a } { log local0. hi }\n}\n", + "foo.irule", + |metric| { + assert_eq!(metric.abc.branches_sum(), 1); + assert_eq!(metric.abc.conditions_sum(), 1); + }, + ); + } + + /// The ternary's three operand slots, located relative to the `?` + /// and `:` tokens because the grammar exposes no fields (#1180). + /// + /// `$a ? !$b : !$c` is four: the `ternary_expr` node, the bare + /// truthy condition, and one per negated branch — the same value + /// Java, C#, Groovy, the C family, the JS family, PHP, Perl, Ruby + /// and Python report for the identical expression. + #[test] + fn irules_abc_ternary_routes_its_operand_slots() { + check_metrics::( + "when X {\n set y [expr { $a ? !$b : !$c }]\n}\n", + "foo.irule", + |metric| { + assert_eq!(metric.abc.conditions_sum(), 4); + }, + ); + } + + /// The #1161 control: a ternary whose condition is a *comparison* + /// must not move. The `>` already supplied its condition and the + /// branches are unnegated, so routing the slots adds nothing. + #[test] + fn irules_abc_comparison_ternary_is_unchanged_by_slot_routing() { + check_metrics::( + "when X {\n set y [expr { $a > 0 ? 1 : 0 }]\n}\n", + "foo.irule", + |metric| { + assert_eq!(metric.abc.conditions_sum(), 2); }, ); } + + /// A parenthesised ternary condition scores the same as a bare one. + /// + /// The grammar inlines `( … )` as anonymous children of + /// `ternary_expr` rather than wrapping them in a node, so a + /// fixed-index reading of the slots would shift right by one and + /// mis-assign every operand. This is the input that discriminates + /// the token-relative location the fix uses. + #[test] + fn irules_abc_parenthesised_ternary_condition_matches_the_bare_form() { + // `check_metrics` takes a bare `fn`, so it cannot carry the + // first measurement into the second comparison; `metrics_verbatim` + // returns a value instead. + let conditions = |source: &str| { + crate::test_support::metrics_verbatim( + crate::LANG::Irules, + source.as_bytes(), + crate::MetricsOptions::default(), + ) + .abc + .conditions_sum() + }; + assert_eq!( + conditions("when X {\n set y [expr { ($a) ? !$b : !$c }]\n}\n"), + conditions("when X {\n set y [expr { $a ? !$b : !$c }]\n}\n"), + "parenthesising the condition must not change the count" + ); + } } /// A comment inside a ternary must not change its ABC conditions diff --git a/src/metrics/abc/irules.rs b/src/metrics/abc/irules.rs index fafd73b58..bd7d61cb5 100644 --- a/src/metrics/abc/irules.rs +++ b/src/metrics/abc/irules.rs @@ -13,6 +13,76 @@ use super::{Abc, Stats}; use crate::macros::irules_bool_terminal_kinds; use crate::*; +/// The three operand slots of a `ternary_expr`, located relative to the +/// `?` and `:` tokens rather than by fixed index. +/// +/// The grammar exposes **no fields** on `ternary_expr` (verified against +/// `node-types.json`: `fields: []`), so the `child_by_field_name` model +/// every other language's ABC ternary uses does not transfer here. Nor +/// does a fixed index: `_expr` inlines `seq('(', $._expr, ')')`, so +/// `($a) ? $b : $c` puts the anonymous parens directly under +/// `ternary_expr` and shifts every operand right (#1180, +/// grammar-dispatch item 3). +/// +/// Slots are therefore the *named* children adjacent to the two marker +/// tokens, which is stable under both parenthesisation and comments. +fn irules_ternary_slots<'a>( + node: &Node<'a>, +) -> (Option>, Option>, Option>) { + let (mut condition, mut consequence, mut alternative) = (None, None, None); + let mut seen_question = false; + let mut seen_colon = false; + + for child in node.children() { + match child.kind_id().into() { + Irules::QMARK => seen_question = true, + Irules::COLON => seen_colon = true, + _ if !child.is_named() => {} + _ if seen_colon => alternative = alternative.or(Some(child)), + _ if seen_question => consequence = consequence.or(Some(child)), + // The condition is the *last* named child before `?`, so a + // parenthesised condition resolves to the inner expression + // rather than to whatever preceded it. + _ => condition = Some(child), + } + } + (condition, consequence, alternative) +} + +/// Routes the three ternary slots (#1180). +/// +/// Mirrors `cpp_walk_ternary`: the condition is counted directly when it +/// is already a terminal bool, and otherwise handed to the wrapper-peeling +/// walker; each branch goes to the walker, which counts it only if a `!` +/// establishes boolean content for that slot. +fn irules_walk_ternary(node: &Node, conditions: &mut f64) { + let (condition, consequence, alternative) = irules_ternary_slots(node); + if let Some(condition) = condition { + if matches!(condition.kind_id().into(), irules_bool_terminal_kinds!()) { + *conditions += 1.; + } else { + irules_inspect_container(&condition, node, conditions); + } + } + for branch in [consequence, alternative].into_iter().flatten() { + irules_inspect_container(&branch, node, conditions); + } +} + +/// The `expr` wrapper holding an `if` / `elseif` / `while` predicate. +/// +/// `while` exposes no `condition` field at all — the grammar is +/// `seq('while', $.expr, $._word)` — so the slot is found by kind. Match +/// `Irules::Expr` (the braced `{ … }` expression node) and not +/// `Irules::Expr2`, which is the `expr` *command keyword* under +/// `expr_cmd` and renders to the same name (grammar-dispatch item 1). +/// `Irules::Expr3` is the hidden `_expr` supertype the parser never emits +/// (item 2). +fn irules_condition_expr<'a>(node: &Node<'a>) -> Option> { + node.children() + .find(|child| matches!(child.kind_id().into(), Irules::Expr)) +} + impl Abc for IrulesCode { fn compute<'a>( node: &Node<'a>, @@ -57,11 +127,26 @@ impl Abc for IrulesCode { | Irules::MatchesGlob | Irules::In | Irules::Ni - | Irules::TernaryExpr - | Irules::Elseif | Irules::Else => { stats.conditions += 1.; } + // Phase 2B slot routing (#1180) — see `tcl.rs`, which this + // mirrors arm for arm. + Irules::If | Irules::While => { + if let Some(expr) = irules_condition_expr(node) { + irules_inspect_container(&expr, node, &mut stats.conditions); + } + } + Irules::Elseif => { + stats.conditions += 1.; + if let Some(expr) = irules_condition_expr(node) { + irules_inspect_container(&expr, node, &mut stats.conditions); + } + } + Irules::TernaryExpr => { + stats.conditions += 1.; + irules_walk_ternary(node, &mut stats.conditions); + } // Fitzpatrick Rule 9: the short-circuit operators are not counted // directly (cross-language policy, #395); instead each operand of // a `&&`/`||`/`and`/`or` chain is one condition (#403). iRules' @@ -99,19 +184,51 @@ fn irules_command_is_assignment(node: &Node, code: &[u8]) -> bool { fn irules_inspect_container(container_node: &Node, parent: &Node, conditions: &mut f64) { let mut node = *container_node; let mut node_kind = node.kind_id().into(); - let has_boolean_content = matches!(parent.kind_id().into(), Irules::BinopExpr); + let parent_kind = parent.kind_id().into(); + // Phase 2B slot routing (#1180). Before it, only a `&&` / `||` chain + // seeded boolean context, so `if {!$a}` and every ternary operand + // scored zero. The `if` / `elseif` / `while` predicate is a boolean + // context by construction; a ternary seeds only its *condition* + // slot, since the two branches are type-free and an unnegated branch + // must contribute nothing. + let mut has_boolean_content = matches!( + parent_kind, + Irules::BinopExpr | Irules::If | Irules::Elseif | Irules::While + ) || (matches!(parent_kind, Irules::TernaryExpr) + && irules_ternary_slots(parent) + .0 + .is_some_and(|condition| condition.id() == node.id())); loop { + // The `expr` wrapper is this grammar's `{ … }` predicate node — + // the analogue of the C family's `condition_clause`, and peeled + // the same way. + let is_parens = matches!(node_kind, Irules::Expr); let is_not = matches!(node_kind, Irules::UnaryExpr) && node .child(0) .is_some_and(|c| c.kind_id() == Irules::BANG as u16); - if !is_not { + if !is_parens && !is_not { break; } + // A `!` proves the operand is boolean even where the parent did + // not — every sibling language carries this line; its absence was + // why a negated operand outside a `&&` chain could never count. + if !has_boolean_content && is_not { + has_boolean_content = true; + } - let Some(child) = node.child(1) else { break }; + // `!` stores its operand at child 1 (after the token). The `expr` + // wrapper holds `{`, the expression, `}`, and `_expr` inlines + // parens, so take the first *named* child instead of a fixed + // index — that resolves `{($a)}` as well as `{$a}`. + let next = if is_not { + node.child(1) + } else { + node.children().find(Node::is_named) + }; + let Some(child) = next else { break }; node = child; node_kind = node.kind_id().into(); diff --git a/src/metrics/abc/tcl.rs b/src/metrics/abc/tcl.rs index ccb015868..ef186b5c3 100644 --- a/src/metrics/abc/tcl.rs +++ b/src/metrics/abc/tcl.rs @@ -55,19 +55,51 @@ const TCL_ASSIGNMENT_COMMANDS: &[&[u8]] = &[b"incr", b"append", b"lappend"]; fn tcl_inspect_container(container_node: &Node, parent: &Node, conditions: &mut f64) { let mut node = *container_node; let mut node_kind = node.kind_id().into(); - let has_boolean_content = matches!(parent.kind_id().into(), Tcl::BinopExpr); + let parent_kind = parent.kind_id().into(); + // Phase 2B slot routing (#1180). Before it, only a `&&` / `||` chain + // seeded boolean context, so `if {!$a}` and every ternary operand + // scored zero. The `if` / `elseif` / `while` predicate is a boolean + // context by construction; a ternary seeds only its *condition* + // slot, since the two branches are type-free and an unnegated branch + // must contribute nothing. + let mut has_boolean_content = matches!( + parent_kind, + Tcl::BinopExpr | Tcl::If | Tcl::Elseif | Tcl::While + ) || (matches!(parent_kind, Tcl::TernaryExpr) + && tcl_ternary_slots(parent) + .0 + .is_some_and(|condition| condition.id() == node.id())); loop { + // The `expr` wrapper is this grammar's `{ … }` predicate node — + // the analogue of the C family's `condition_clause`, and peeled + // the same way. + let is_parens = matches!(node_kind, Tcl::Expr); let is_not = matches!(node_kind, Tcl::UnaryExpr) && node .child(0) .is_some_and(|c| c.kind_id() == Tcl::BANG as u16); - if !is_not { + if !is_parens && !is_not { break; } + // A `!` proves the operand is boolean even where the parent did + // not — every sibling language carries this line; its absence was + // why a negated operand outside a `&&` chain could never count. + if !has_boolean_content && is_not { + has_boolean_content = true; + } - let Some(child) = node.child(1) else { break }; + // `!` stores its operand at child 1 (after the token). The `expr` + // wrapper holds `{`, the expression, `}`, and `_expr` inlines + // parens, so take the first *named* child instead of a fixed + // index — that resolves `{($a)}` as well as `{$a}`. + let next = if is_not { + node.child(1) + } else { + node.children().find(Node::is_named) + }; + let Some(child) = next else { break }; node = child; node_kind = node.kind_id().into(); @@ -104,6 +136,76 @@ fn tcl_count_unary_conditions(list_node: &Node, conditions: &mut f64) { } } +/// The three operand slots of a `ternary_expr`, located relative to the +/// `?` and `:` tokens rather than by fixed index. +/// +/// The grammar exposes **no fields** on `ternary_expr` (verified against +/// `node-types.json`: `fields: []`), so the `child_by_field_name` model +/// every other language's ABC ternary uses does not transfer here. Nor +/// does a fixed index: `_expr` inlines `seq('(', $._expr, ')')`, so +/// `($a) ? $b : $c` puts the anonymous parens directly under +/// `ternary_expr` and shifts every operand right (#1180, +/// grammar-dispatch item 3). +/// +/// Slots are therefore the *named* children adjacent to the two marker +/// tokens, which is stable under both parenthesisation and comments. +fn tcl_ternary_slots<'a>( + node: &Node<'a>, +) -> (Option>, Option>, Option>) { + let (mut condition, mut consequence, mut alternative) = (None, None, None); + let mut seen_question = false; + let mut seen_colon = false; + + for child in node.children() { + match child.kind_id().into() { + Tcl::QMARK => seen_question = true, + Tcl::COLON => seen_colon = true, + _ if !child.is_named() => {} + _ if seen_colon => alternative = alternative.or(Some(child)), + _ if seen_question => consequence = consequence.or(Some(child)), + // The condition is the *last* named child before `?`, so a + // parenthesised condition resolves to the inner expression + // rather than to whatever preceded it. + _ => condition = Some(child), + } + } + (condition, consequence, alternative) +} + +/// Routes the three ternary slots (#1180). +/// +/// Mirrors `cpp_walk_ternary`: the condition is counted directly when it +/// is already a terminal bool, and otherwise handed to the wrapper-peeling +/// walker; each branch goes to the walker, which counts it only if a `!` +/// establishes boolean content for that slot. +fn tcl_walk_ternary(node: &Node, conditions: &mut f64) { + let (condition, consequence, alternative) = tcl_ternary_slots(node); + if let Some(condition) = condition { + if matches!(condition.kind_id().into(), tcl_bool_terminal_kinds!()) { + *conditions += 1.; + } else { + tcl_inspect_container(&condition, node, conditions); + } + } + for branch in [consequence, alternative].into_iter().flatten() { + tcl_inspect_container(&branch, node, conditions); + } +} + +/// The `expr` wrapper holding an `if` / `elseif` / `while` predicate. +/// +/// `while` exposes no `condition` field at all — the grammar is +/// `seq('while', $.expr, $._word)` — so the slot is found by kind. Match +/// `Tcl::Expr` (the braced `{ … }` expression node) and not +/// `Tcl::Expr2`, which is the `expr` *command keyword* under +/// `expr_cmd` and renders to the same name (grammar-dispatch item 1). +/// `Tcl::Expr3` is the hidden `_expr` supertype the parser never emits +/// (item 2). +fn tcl_condition_expr<'a>(node: &Node<'a>) -> Option> { + node.children() + .find(|child| matches!(child.kind_id().into(), Tcl::Expr)) +} + impl Abc for TclCode { fn compute<'a>( node: &Node<'a>, @@ -138,11 +240,36 @@ impl Abc for TclCode { | Tcl::Ne | Tcl::In | Tcl::Ni - | Tcl::TernaryExpr - | Tcl::Elseif | Tcl::Else => { stats.conditions += 1.; } + // Phase 2B slot routing (#1180). `if` / `while` / `elseif` + // carry their predicate in an `expr` wrapper; routing it is + // what makes a bare truthy test (`if {$a}`) and a negated one + // (`if {!$a}`) count at all. A comparison predicate is + // unaffected: its `binop_expr` is neither a wrapper nor a + // negation, so the walker breaks without counting and the + // operator token arm above still supplies the one condition. + Tcl::If | Tcl::While => { + if let Some(expr) = tcl_condition_expr(node) { + tcl_inspect_container(&expr, node, &mut stats.conditions); + } + } + // `elseif` is both a clause (one condition, as before) and a + // predicate owner, matching the C family, where + // `if (a) {} else if (b) {}` scores 3. + Tcl::Elseif => { + stats.conditions += 1.; + if let Some(expr) = tcl_condition_expr(node) { + tcl_inspect_container(&expr, node, &mut stats.conditions); + } + } + // The `?` marker is one condition, as before; its three + // operand slots are new. + Tcl::TernaryExpr => { + stats.conditions += 1.; + tcl_walk_ternary(node, &mut stats.conditions); + } // Fitzpatrick Rule 9 walker: each operand of a `&&` / `||` // chain inside an `expr` slot is one condition (issue #403). Tcl::AMPAMP | Tcl::PIPEPIPE => { diff --git a/src/metrics/snapshots/big_code_analysis__metrics__abc__tests__tcl_if_multiple_conditions.snap b/src/metrics/snapshots/big_code_analysis__metrics__abc__tests__tcl_if_multiple_conditions.snap index 4d98b6353..3e45850ce 100644 --- a/src/metrics/snapshots/big_code_analysis__metrics__abc__tests__tcl_if_multiple_conditions.snap +++ b/src/metrics/snapshots/big_code_analysis__metrics__abc__tests__tcl_if_multiple_conditions.snap @@ -5,16 +5,16 @@ expression: metric.abc { "assignments": 0, "branches": 3, - "conditions": 7, - "magnitude": 7.615773105863909, + "conditions": 9, + "magnitude": 9.486832980505138, "value": 0.0, "assignments_average": 0.0, "branches_average": 1.5, - "conditions_average": 3.5, + "conditions_average": 4.5, "assignments_min": 0, "assignments_max": 0, "branches_min": 0, "branches_max": 3, "conditions_min": 0, - "conditions_max": 7 + "conditions_max": 9 } diff --git a/src/metrics/snapshots/big_code_analysis__metrics__abc__tests__tcl_ternary_counts_condition.snap b/src/metrics/snapshots/big_code_analysis__metrics__abc__tests__tcl_ternary_counts_condition.snap index 16d0bb81a..159a4ffea 100644 --- a/src/metrics/snapshots/big_code_analysis__metrics__abc__tests__tcl_ternary_counts_condition.snap +++ b/src/metrics/snapshots/big_code_analysis__metrics__abc__tests__tcl_ternary_counts_condition.snap @@ -5,16 +5,16 @@ expression: metric.abc { "assignments": 1, "branches": 0, - "conditions": 1, - "magnitude": 1.4142135623730951, + "conditions": 2, + "magnitude": 2.23606797749979, "value": 0.0, "assignments_average": 0.5, "branches_average": 0.0, - "conditions_average": 0.5, + "conditions_average": 1.0, "assignments_min": 0, "assignments_max": 1, "branches_min": 0, "branches_max": 0, "conditions_min": 0, - "conditions_max": 1 + "conditions_max": 2 } diff --git a/src/metrics/snapshots/big_code_analysis__metrics__abc__tests__tcl_while_conditions.snap b/src/metrics/snapshots/big_code_analysis__metrics__abc__tests__tcl_while_conditions.snap index dff1ee049..f8c4c3838 100644 --- a/src/metrics/snapshots/big_code_analysis__metrics__abc__tests__tcl_while_conditions.snap +++ b/src/metrics/snapshots/big_code_analysis__metrics__abc__tests__tcl_while_conditions.snap @@ -5,16 +5,16 @@ expression: metric.abc { "assignments": 0, "branches": 2, - "conditions": 4, - "magnitude": 4.47213595499958, + "conditions": 6, + "magnitude": 6.324555320336759, "value": 0.0, "assignments_average": 0.0, "branches_average": 1.0, - "conditions_average": 2.0, + "conditions_average": 3.0, "assignments_min": 0, "assignments_max": 0, "branches_min": 0, "branches_max": 2, "conditions_min": 0, - "conditions_max": 4 + "conditions_max": 6 } From 89f86be71dc25b7cf675225af02250750b8ba4f7 Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Mon, 3 Aug 2026 00:24:05 -0700 Subject: [PATCH 07/27] fix(walk): anchor manifest excludes under a directory seed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A manifest `exclude` glob is written against the `bca.toml` directory, but `WalkFilters::passes` matched it against the *walk root*. The two coincide only for the canonical `paths = ["."]`, so a directory seed silently stopped excluding anything: $ bca metrics -O json | jq -r .name # ./sub/kept.rs — correct $ bca metrics -p sub -O json | jq -r .name sub/kept.rs sub/skipme/a.rs # leaks The same leak appears from inside the subdirectory (`cd sub && bca metrics -p .`), where the walk root, the working directory and the manifest root are three different places. Both are fixed. Pre-existing, not a #1164 regression: that change anchored the explicitly-named-file path and the `bca check` gate, and left this one as it was. The rule — *a path is excluded when the CLI set matches at the working directory, or the manifest set matches at the manifest directory* — was spelled three times with three different answers about anchoring. It now lives once, in `walk_seed::AnchoredExcludes`, which `WalkFilters::passes`, `WalkFilters::warn_exclude_overridden` and `CheckExcludes::exempts` all delegate to, so the walker and the gate cannot drift about which files a manifest glob describes. The one deliberate exception — an explicitly named file seed bypasses excludes entirely (#726) — stays a separate method on the caller (`WalkFilters::includes`) and is documented on the shared type, rather than hiding as a divergence inside one match expression. Cost, which the issue flags as the thing to check: anchoring allocates and `passes` runs for every walked file, so the working directory is resolved once per walk rather than per entry, and the manifest branch is guarded by `is_empty()` — a run with no manifest excludes pays nothing, and one with them pays the normalising allocation only after the CLI set declines. The issue's claim that `manifest_match_path` costs a `current_dir()` syscall per call is not accurate: `ManifestAnchor` already carried a caller-resolved `cwd`. The real cost is the join, lexical-normalise and strip. Tests: three added to `explicit_path_excludes.rs`, which had no directory-seed coverage at all — a subdirectory seed from the manifest root, the same from inside it, and a control pinning that the canonical root walk (always correct) stays correct. Verified by perturbation: matching the manifest set at the walk root again fails both new directory-seed tests and two pre-existing #1164 tests, while the control passes, which is exactly the asymmetry the bug had. Fixes #1189 --- big-code-analysis-cli/src/commands/check.rs | 42 ++++----- big-code-analysis-cli/src/walk.rs | 85 +++++++++---------- big-code-analysis-cli/src/walk_seed.rs | 82 ++++++++++++++++++ big-code-analysis-cli/src/walk_tests.rs | 10 ++- .../tests/discovery/explicit_path_excludes.rs | 77 +++++++++++++++++ 5 files changed, 225 insertions(+), 71 deletions(-) diff --git a/big-code-analysis-cli/src/commands/check.rs b/big-code-analysis-cli/src/commands/check.rs index 7099338b7..452079637 100644 --- a/big-code-analysis-cli/src/commands/check.rs +++ b/big-code-analysis-cli/src/commands/check.rs @@ -356,11 +356,13 @@ pub(crate) fn write_check_baseline( /// `bca.toml` sits at the root of. One list can carry only one anchor, /// and merging them silently gave the manifest half the caller's. /// -/// The mirroring is in the *split*, not in the matching: this type -/// anchors the manifest set always, whereas `WalkFilters` does so only -/// in its override warning, because a directory walk's root and the -/// manifest's coincide for the canonical `paths = ["."]`. Do not assume -/// the two apply the same rule. +/// The matching rule itself lives in +/// [`crate::walk_seed::AnchoredExcludes`], which the walker's filters +/// also use. It did not always: this type anchored the manifest set +/// always and `WalkFilters::passes` never did, on the reasoning that a +/// directory walk's root and the manifest's coincide — true only for +/// the canonical `paths = ["."]`, and the source of #1189. The two now +/// share one implementation and cannot drift again. struct CheckExcludes<'a> { cli: crate::ExcludeGlobs, manifest: crate::ExcludeGlobs, @@ -372,6 +374,11 @@ struct CheckExcludes<'a> { /// decide whether the file lies under the manifest, and `exempts` /// runs for every violation — so reading it there cost a syscall /// per offender on exactly the runs #1164 exists to serve. + /// + /// Owned rather than borrowed because `AnchoredExcludes` borrows it, + /// and a self-referential struct is not worth the alternative here; + /// `exempts` rebuilds the borrowed view per call, which is three + /// pointer copies. cwd: std::path::PathBuf, } @@ -408,23 +415,16 @@ impl<'a> CheckExcludes<'a> { /// Whether a violation at `path` — `walk_form` being its /// walk-root-anchored spelling — is exempt from the threshold gate. /// - /// The `is_empty()` arms are not redundant with the matches, for the - /// same reason [`crate::walk::WalkFilters::passes`] spells them out: - /// `GlobSet::is_match` builds its `Candidate` *before* its own empty - /// check. Only one of the two sets is configured in the usual case, - /// so the other should cost nothing. The manifest guard buys more - /// than that — it also skips `manifest_match_path`, which - /// normalises the path per violation. + /// Delegates to the shared rule, so the gate and the walker cannot + /// disagree about which files a manifest glob describes. fn exempts(&self, path: &Path, walk_form: crate::walk_seed::CwdForm<'_>) -> bool { - (!self.cli.is_empty() && self.cli.is_match(walk_form.0)) - || (!self.manifest.is_empty() - && self - .manifest - .is_match(crate::walk_seed::manifest_match_path( - crate::walk_seed::ManifestAnchor::resolve(self.manifest_dir, &self.cwd), - path, - walk_form, - ))) + crate::walk_seed::AnchoredExcludes::new( + &self.cli, + &self.manifest, + self.manifest_dir, + &self.cwd, + ) + .excludes(path, walk_form) } } diff --git a/big-code-analysis-cli/src/walk.rs b/big-code-analysis-cli/src/walk.rs index a89b3b764..3a0bee940 100644 --- a/big-code-analysis-cli/src/walk.rs +++ b/big-code-analysis-cli/src/walk.rs @@ -105,18 +105,12 @@ pub(crate) fn valid_languages() -> String { /// convention (empty globset = no-op) with the patterns it applies to. pub(crate) struct WalkFilters<'a> { include: &'a GlobSet, - exclude: &'a ExcludeGlobs, - /// The manifest's own `exclude` globs, compiled separately from the - /// caller's so they can keep the manifest directory as their anchor - /// (#1164). A directory walk matches both against the walk-root - /// form — for the canonical `paths = ["."]` the two roots are the - /// same directory — but an explicitly-named file seed has no walk - /// root, so [`Self::warn_exclude_overridden`] anchors this set at - /// `manifest_dir` instead of at the caller's working directory. - manifest_exclude: &'a ExcludeGlobs, - /// Directory holding the `bca.toml` that supplied - /// `manifest_exclude`; `None` when no manifest applied. - manifest_dir: Option<&'a Path>, + /// Both exclude sets plus the roots they resolve against. One type + /// rather than a `{cli, manifest, manifest_dir}` triple per call + /// site: the three sites that spell this rule used to give three + /// different answers about anchoring, which is what let a manifest + /// glob stop applying under a directory seed (#1189). + excludes: walk_seed::AnchoredExcludes<'a>, /// Whether `--language` forces a language, which makes every named /// file analyzable regardless of its extension. Only the /// exclude-override warning consults it — see @@ -132,23 +126,22 @@ impl WalkFilters<'_> { /// to the file's walk root (#489), so `./`-anchored patterns match /// regardless of how the seed was spelled. /// - /// Both exclude sets are matched against that same walk-root form. - /// Only an explicitly-named seed needs the manifest anchoring - /// [`Self::warn_exclude_overridden`] applies — see the - /// `manifest_exclude` field for why the two roots coincide on a - /// directory walk. + /// Each exclude set is matched at its own root — see + /// [`walk_seed::AnchoredExcludes`]. Matching the manifest set at the + /// walk root instead is correct only when the two coincide, which + /// they do for the canonical `paths = ["."]` and do not for a + /// directory seed: `bca metrics -p sub` moved the walk root to `sub` + /// and every manifest glob stopped applying (#1189). /// - /// The `is_empty()` arms are not redundant with the match itself: - /// `GlobSet::is_match` builds its `Candidate` *before* its own - /// empty check, and on Windows that allocates. Every walked file - /// reaches this, so an unconfigured set should cost nothing. - fn passes(&self, match_path: &Path) -> bool { + /// `path` is the file as the walk found it, needed because manifest + /// anchoring resolves against the `bca.toml` directory rather than + /// against the walk root the `match_path` form carries. + fn passes(&self, path: &Path, match_path: &Path) -> bool { // Strip a leading `./` so bare-relative patterns (`dir/**`) match // the `./`-anchored walk-root form just like `./dir/**` does (#726). let match_path = walk_seed::strip_cur_dir(match_path); (self.include.is_empty() || self.include.is_match(match_path)) - && (self.exclude.is_empty() || !self.exclude.is_match(match_path)) - && (self.manifest_exclude.is_empty() || !self.manifest_exclude.is_match(match_path)) + && !self.excludes.excludes(path, walk_seed::CwdForm(match_path)) } /// Does `match_path` satisfy only the include allow-list (empty = @@ -198,22 +191,12 @@ impl WalkFilters<'_> { return; } let cwd_form = walk_seed::CwdForm(walk_seed::strip_cur_dir(match_path)); - // `match_path` is anchored to the working directory, which is - // the right root only for the globs the caller typed there. A - // manifest glob is written against the manifest's directory, so - // matching it against the CWD form left this warning silent for - // every caller standing anywhere but the project root — silent - // exactly when the override it exists to announce happens - // (#1164). - // Computed inside the `or_else` because anchoring costs a - // `current_dir()` syscall and a normalising allocation, and the - // CLI set answering first makes both unnecessary. - if let Some(glob) = self.exclude.first_match(cwd_form.0).or_else(|| { - let cwd = std::env::current_dir().unwrap_or_default(); - let anchor = walk_seed::ManifestAnchor::resolve(self.manifest_dir, &cwd); - let manifest_form = walk_seed::manifest_match_path(anchor, seed, cwd_form); - self.manifest_exclude.first_match(&manifest_form) - }) { + // Each set at its own root, via the same rule `passes` applies — + // a manifest glob is written against the manifest's directory, + // so matching it against the CWD form left this warning silent + // for every caller standing anywhere but the project root, which + // is exactly when the override it announces happens (#1164). + if let Some(glob) = self.excludes.first_match(seed, cwd_form) { eprintln!( "bca: warning: {} matches an exclude pattern ({glob}) \ but was named explicitly; analyzing anyway", @@ -491,7 +474,7 @@ fn visit_walk_entry( // Anchor the glob match to the walk root rather than the emitted (possibly // absolute) path, so `./`-anchored excludes match regardless of how the // seed resolved — including a manifest root above the CWD (#489). - if filters.passes(&walk_seed::match_path_for(seed, &path)) { + if filters.passes(&path, &walk_seed::match_path_for(seed, &path)) { // The receiver outlives the walk (it is drained by the caller), so // this cannot fail. let _ = tx.send(path); @@ -605,14 +588,22 @@ pub(crate) fn resolve_walk_files(globals: GlobalOpts) -> (ResolvedFiles, usize) "bca.toml exclude_from", ); let num_jobs = globals.num_jobs.resolve(); + // Read once, not per walked file: `passes` runs for every entry and + // `current_dir()` is a syscall. It cannot be cached process-wide + // either — `bca diff`'s directory guard moves the cwd — so once per + // walk is the right granularity (#1189). + let cwd = std::env::current_dir().unwrap_or_default(); let filters = WalkFilters { include: &include, - exclude: &exclude, - manifest_exclude: &manifest_exclude, - // An empty set never matches, so there is no anchor worth - // supplying — and the no-manifest default above has no - // directory to offer in the first place. - manifest_dir: (!manifest_exclude.is_empty()).then_some(manifest_excludes.dir.as_path()), + excludes: walk_seed::AnchoredExcludes::new( + &exclude, + &manifest_exclude, + // An empty set never matches, so there is no anchor worth + // supplying — and the no-manifest default above has no + // directory to offer in the first place. + (!manifest_exclude.is_empty()).then_some(manifest_excludes.dir.as_path()), + &cwd, + ), language_forced: globals.language.is_some(), }; let resolved = expand_seed_paths( diff --git a/big-code-analysis-cli/src/walk_seed.rs b/big-code-analysis-cli/src/walk_seed.rs index 7a8998cac..468648233 100644 --- a/big-code-analysis-cli/src/walk_seed.rs +++ b/big-code-analysis-cli/src/walk_seed.rs @@ -271,6 +271,88 @@ pub(crate) fn manifest_match_path<'a>( ) } +/// The two exclude sets a run carries, each matched at its own root. +/// +/// The rule is one sentence — *a path is excluded when the CLI set +/// matches it at the working directory, or the manifest set matches it +/// at the manifest directory* — and before #1189 it was spelled three +/// separate times with three different answers. [`WalkFilters::passes`] +/// matched both sets at the walk root, which is only the manifest root +/// for the canonical `paths = ["."]`; a directory seed +/// (`bca metrics -p sub`) moved the walk root and the manifest globs +/// silently stopped applying. The other two sites anchored correctly. +/// +/// Stating it once is the point. There is exactly one deliberate +/// exception, and it is a *separate method on the caller* rather than a +/// divergence inside this rule: [`crate::walk::WalkFilters::includes`] +/// consults the include allow-list and skips excludes entirely, because +/// a project's ignore rules shape *directory-walk* scope and must not +/// silently discard a file the user named on the command line (#726). +pub(crate) struct AnchoredExcludes<'a> { + /// Globs the caller typed, resolved against the working directory. + cli: &'a crate::ExcludeGlobs, + /// Globs from a `bca.toml`, resolved against that file's directory. + manifest: &'a crate::ExcludeGlobs, + anchor: ManifestAnchor<'a>, +} + +impl<'a> AnchoredExcludes<'a> { + /// `manifest_dir` is `None` when no manifest applied, which makes + /// every manifest match fall back to the cwd-anchored form. + pub(crate) fn new( + cli: &'a crate::ExcludeGlobs, + manifest: &'a crate::ExcludeGlobs, + manifest_dir: Option<&'a std::path::Path>, + cwd: &'a PathBuf, + ) -> Self { + Self { + cli, + manifest, + anchor: ManifestAnchor::resolve(manifest_dir, cwd), + } + } + + /// The first pattern `path` matches in either set, or `None`. + /// + /// `path` is the file as the run knows it; `cwd_form` its + /// working-directory-anchored spelling. The CLI set is consulted + /// first and the manifest anchoring computed only if it declines, + /// because [`manifest_match_path`] allocates and this runs for every + /// walked file. + pub(crate) fn first_match( + &self, + path: &std::path::Path, + cwd_form: CwdForm<'_>, + ) -> Option<&'a str> { + if !self.cli.is_empty() + && let Some(glob) = self.cli.first_match(cwd_form.0) + { + return Some(glob); + } + if self.manifest.is_empty() { + return None; + } + self.manifest + .first_match(manifest_match_path(self.anchor, path, cwd_form)) + } + + /// Whether either set excludes `path`. + /// + /// The `is_empty` arms are not redundant with the match itself: + /// `GlobSet::is_match` builds its `Candidate` *before* its own empty + /// check, and on Windows that allocates. Every walked file reaches + /// this, so an unconfigured set must cost nothing. + pub(crate) fn excludes(&self, path: &std::path::Path, cwd_form: CwdForm<'_>) -> bool { + if !self.cli.is_empty() && self.cli.is_match(cwd_form.0) { + return true; + } + !self.manifest.is_empty() + && self + .manifest + .is_match(manifest_match_path(self.anchor, path, cwd_form)) + } +} + /// The path to match `--include` globs against for an *explicitly named /// file seed*: its CWD-relative tail when the file lies under the CWD, /// otherwise the seed as spelled. diff --git a/big-code-analysis-cli/src/walk_tests.rs b/big-code-analysis-cli/src/walk_tests.rs index 2ae4e520b..7dbd16f31 100644 --- a/big-code-analysis-cli/src/walk_tests.rs +++ b/big-code-analysis-cli/src/walk_tests.rs @@ -40,11 +40,15 @@ fn walk_directory_seed_returns_sorted_paths() { let empty_include = mk_globset(Vec::new()).expect("empty globset"); let empty_exclude = build_exclude_globset(Vec::new(), None, "--exclude-from"); + let cwd = std::env::current_dir().unwrap_or_default(); let filters = WalkFilters { include: &empty_include, - exclude: &empty_exclude, - manifest_exclude: &empty_exclude, - manifest_dir: None, + excludes: crate::walk_seed::AnchoredExcludes::new( + &empty_exclude, + &empty_exclude, + None, + &cwd, + ), language_forced: false, }; let mut errors = WalkErrors::default(); diff --git a/big-code-analysis-cli/tests/discovery/explicit_path_excludes.rs b/big-code-analysis-cli/tests/discovery/explicit_path_excludes.rs index 2804d5f24..6e8d9ac90 100644 --- a/big-code-analysis-cli/tests/discovery/explicit_path_excludes.rs +++ b/big-code-analysis-cli/tests/discovery/explicit_path_excludes.rs @@ -109,6 +109,83 @@ fn same_manifest_exclude_still_drops_the_file_on_a_directory_walk() { .stderr(predicate::str::contains("named explicitly").not()); } +/// A manifest exclude must keep applying when the walk root is a +/// *subdirectory* of the manifest root (#1189). +/// +/// The glob is written against the `bca.toml` directory, but a directory +/// seed moves the walk root: `bca check nested` emits `nested/skipme/a.rs` +/// where `./nested/skipme/**` was authored for the project root, so +/// matching the manifest set against the walk-root form silently stopped +/// excluding anything. `same_manifest_exclude_still_drops_the_file_on_a_directory_walk` +/// above cannot detect this — it runs from the fixture root with no +/// seed, where the two roots coincide. +/// +/// `nested/kept.rs` is the positive half: without a surviving offender, +/// "the excluded file is absent" is indistinguishable from a run that +/// resolved no files at all. +fn nested_fixture() -> TempDir { + let dir = fixture("exclude = [\"./nested/skipme/**\"]\n"); + fs::create_dir_all(dir.path().join("nested").join("skipme")).unwrap(); + fs::write( + dir.path().join("nested").join("skipme").join("a.rs"), + branchy("nested_skipme_offender"), + ) + .unwrap(); + fs::write( + dir.path().join("nested").join("kept.rs"), + branchy("nested_kept_offender"), + ) + .unwrap(); + dir +} + +#[test] +fn manifest_exclude_applies_under_a_directory_seed() { + let dir = nested_fixture(); + + cli(dir.path()) + .args(["check", "nested", "--no-summary", "--no-remediation"]) + .assert() + .code(2) + .stdout(predicate::str::contains("nested_kept_offender")) + .stdout(predicate::str::contains("nested_skipme_offender").not()); +} + +/// The same seed named from *inside* the subdirectory, where the walk +/// root, the working directory and the manifest root are three different +/// places. This is the harder half: the manifest is discovered upwards, +/// so the glob still carries the project root's spelling while every +/// path the walk emits is relative to `nested`. +#[test] +fn manifest_exclude_applies_to_a_directory_seed_from_inside_it() { + let dir = nested_fixture(); + + cli(&dir.path().join("nested")) + .args(["check", ".", "--no-summary", "--no-remediation"]) + .assert() + .code(2) + .stdout(predicate::str::contains("nested_kept_offender")) + .stdout(predicate::str::contains("nested_skipme_offender").not()); +} + +/// The control: the canonical `paths = ["."]` walk from the manifest +/// root, which was always correct, must stay correct. Anchoring the +/// manifest set at the manifest directory rather than the walk root is a +/// no-op here precisely because the two coincide — and if that stopped +/// holding, this is the test that says so. +#[test] +fn manifest_exclude_still_applies_to_the_canonical_root_walk() { + let dir = nested_fixture(); + + cli(dir.path()) + .args(["check", "--no-summary", "--no-remediation"]) + .assert() + .code(2) + .stdout(predicate::str::contains("nested_kept_offender")) + .stdout(predicate::str::contains("kept_offender")) + .stdout(predicate::str::contains("nested_skipme_offender").not()); +} + /// The `-X` / `--exclude` CLI surface behaves as the manifest key does, /// and the reported glob is the *matching* pattern rather than whichever /// one happens to sit first. From 4a814d612d36cfaeca0e6d66104eb52a1f9e1956 Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Mon, 3 Aug 2026 00:29:59 -0700 Subject: [PATCH 08/27] feat(check): report which exclude globs are manifest-anchored MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `--print-effective-config` reported one flattened `check_exclude` array, so a reader could not tell which globs resolve against which root. After #1164 that is what decides whether a glob matches at all: a CLI glob resolves against the caller's working directory, a manifest glob against the `bca.toml` directory. This is the surface consulted to answer "which exemptions are in effect?", which is the question #1164 exists to make answerable. Takes option 1 — additive sibling keys naming the manifest-origin subset, leaving the resolved arrays where they are. The walker's `exclude` surface gets the same treatment as the gate's, since it flattens two anchors identically; the issue names only the gate. check_exclude = ["tests/**", "./generated/**"] manifest_check_exclude = ["./generated/**"] manifest = "/repo/bca.toml" The anchor is the `manifest` key's directory, which is populated exactly when the new keys are. The round-trip constraint the issue says shapes the fix turns out not to bind: `--config` deserializes `ThresholdConfig`, which reads only the `[thresholds]` table and has no `deny_unknown_fields`, so `[check]` is parsed and discarded — and `check_print_effective_config_toml_roundtrips_through_config` asserts only two `[thresholds]` substrings. Verified by round-tripping a captured config through `--config` with the new keys present. One correction to the issue. It reports `display_globs_from` as dropping the manifest's `exclude_from` file when a CLI `--exclude-from` is also set. The file is not dropped from the report — it is not in effect: a CLI `--exclude-from` *replaces* the manifest's rather than unioning with it, by the documented `replaced_by` rule in `Manifest::merge` ("the inline list unions, the file does not"). Reporting it would assert something false. `manifest_exclude_from` is therefore present exactly when the manifest's file is the one in force, which is the provenance question for that key. Tests: `print_effective_config_unions_the_manifest_check_exclude` gains the new-shape assertions the issue nominates it for, plus a check that an unconfigured surface emits no empty provenance key. A new test pins the replace-not-union rule from both sides — the manifest's file goes absent while its inline glob stays — so the two rules cannot be collapsed into a single "CLI wins" story by a later edit. Book: the `check` page documents both key pairs and the union-vs-replace difference between the glob lists and the files. Fixes #1194 --- big-code-analysis-book/src/commands/check.md | 16 +++ .../src/commands/check/effective_config.rs | 58 +++++++++++ big-code-analysis-cli/src/commands_tests.rs | 8 ++ .../tests/check/check_exclude.rs | 99 +++++++++++++++++++ 4 files changed, 181 insertions(+) diff --git a/big-code-analysis-book/src/commands/check.md b/big-code-analysis-book/src/commands/check.md index 4156f369b..979eee109 100644 --- a/big-code-analysis-book/src/commands/check.md +++ b/big-code-analysis-book/src/commands/check.md @@ -508,6 +508,22 @@ Most-specific to least, `bca check` resolves exemptions in this order: `--print-effective-config` reports the resolved `check_exclude` globs alongside the other gate inputs. +Globs resolve against different roots depending on where they came +from: one you pass on the command line resolves against your working +directory, one from a `bca.toml` against that file's directory. The +resolved `check_exclude` array is the union of both and cannot express +that split, so a `manifest_check_exclude` key names the manifest-origin +subset, with the anchor being the reported `manifest` file's directory. +The walker's own `exclude` surface reports the same pair. Both keys are +omitted when no manifest contributed a glob. + +`manifest_exclude_from` and `manifest_check_exclude_from` follow the +same idea for the `exclude_from` *files*, with one difference worth +knowing: a command-line `--exclude-from` **replaces** the manifest's +file rather than adding to it, so the manifest key is present only when +the manifest's file is the one actually in effect. The inline glob +lists union; the files do not. + ## Baselines When you adopt thresholds on an existing codebase you typically face a diff --git a/big-code-analysis-cli/src/commands/check/effective_config.rs b/big-code-analysis-cli/src/commands/check/effective_config.rs index 3726b8f69..8671e0e9b 100644 --- a/big-code-analysis-cli/src/commands/check/effective_config.rs +++ b/big-code-analysis-cli/src/commands/check/effective_config.rs @@ -14,11 +14,46 @@ fn reported_globs(cli: &[String], manifest: Option<&ManifestExcludes>) -> Vec, manifest: Option<&ManifestExcludes>) -> Option { cli.or_else(|| manifest.and_then(|m| m.globs_from.as_deref())) .map(|p| p.display().to_string()) } +/// The manifest-origin subset of a reported exclude list (#1194). +/// +/// `--print-effective-config` is the surface consulted to answer "which +/// exemptions are in effect?", and after #1164 the answer depends on +/// where each glob came from: a CLI glob resolves against the caller's +/// working directory, a manifest glob against the `bca.toml` directory. +/// The flattened array cannot express that, so this names the subset +/// carrying the manifest anchor. The anchor itself is the `manifest` +/// key's parent directory, which is populated exactly when these are. +/// +/// Additive rather than a split, so the resolved list stays where it is +/// and the TOML form keeps round-tripping through `--config`. +fn manifest_globs(manifest: Option<&ManifestExcludes>) -> Vec { + manifest.map(|m| m.globs.clone()).unwrap_or_default() +} + +/// The manifest's own `exclude_from` file, when one is in effect. +/// +/// Present exactly when `exclude_from` above resolved to the manifest's +/// file, absent when a CLI flag replaced it — which is the provenance +/// question for this key: the two together say whether the effective +/// file came from the manifest or the command line. +fn manifest_globs_from(manifest: Option<&ManifestExcludes>) -> Option { + manifest + .and_then(|m| m.globs_from.as_deref()) + .map(|p| p.display().to_string()) +} + /// Serialize the resolved threshold/check configuration to stdout. /// Used by `--print-effective-config` to surface the post-merge view /// of every layer (`--config` TOML + repeated `--threshold` CLI @@ -128,16 +163,35 @@ pub(crate) struct EffectiveCheck { pub(crate) paths: Vec, pub(crate) include: Vec, pub(crate) exclude: Vec, + /// The subset of `exclude` that came from the manifest, and so + /// resolves against the `manifest` key's directory rather than the + /// caller's working directory (#1194). Empty when no manifest + /// contributed any glob. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub(crate) manifest_exclude: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub(crate) exclude_from: Option, + /// Set when `exclude_from` above came from the manifest rather than + /// from `--exclude-from`; absent when a CLI flag replaced it. The + /// pair answers where the effective file came from (#1194). + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) manifest_exclude_from: Option, /// Resolved `[check.exclude]` globs (#378): files analysed and /// reported but exempt from the gate. Empty when unset. #[serde(skip_serializing_if = "Vec::is_empty")] pub(crate) check_exclude: Vec, + /// The manifest-origin subset of `check_exclude` — the gate's half + /// of `manifest_exclude` above, and the case #1194 was filed about. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub(crate) manifest_check_exclude: Vec, /// Source file for additional `check_exclude` globs /// (`--check-exclude-from` / `[check] exclude_from`), if any. #[serde(skip_serializing_if = "Option::is_none")] pub(crate) check_exclude_from: Option, + /// The manifest's own `[check] exclude_from` file; see + /// `manifest_exclude_from`. + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) manifest_check_exclude_from: Option, #[serde(skip_serializing_if = "Option::is_none")] pub(crate) paths_from: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -232,18 +286,22 @@ impl EffectiveConfig { // reader wants reported is the resolved set both halves add // up to. exclude: reported_globs(&globals.exclude, globals.manifest_excludes.as_ref()), + manifest_exclude: manifest_globs(globals.manifest_excludes.as_ref()), exclude_from: display_globs_from( globals.exclude_from.as_deref(), globals.manifest_excludes.as_ref(), ), + manifest_exclude_from: manifest_globs_from(globals.manifest_excludes.as_ref()), check_exclude: reported_globs( &args.check_exclude, args.manifest_check_exclude.as_ref(), ), + manifest_check_exclude: manifest_globs(args.manifest_check_exclude.as_ref()), check_exclude_from: display_globs_from( args.check_exclude_from.as_deref(), args.manifest_check_exclude.as_ref(), ), + manifest_check_exclude_from: manifest_globs_from(args.manifest_check_exclude.as_ref()), paths_from: globals.paths_from.as_ref().map(|p| p.display().to_string()), baseline: args.baseline.as_ref().map(|p| p.display().to_string()), config: args.config.as_ref().map(|p| p.display().to_string()), diff --git a/big-code-analysis-cli/src/commands_tests.rs b/big-code-analysis-cli/src/commands_tests.rs index f34389bd7..b00b1b6c6 100644 --- a/big-code-analysis-cli/src/commands_tests.rs +++ b/big-code-analysis-cli/src/commands_tests.rs @@ -571,9 +571,13 @@ fn effective_config_toml_roundtrips_through_threshold_config_schema() { paths: vec!["src/".to_owned()], include: vec!["*.rs".to_owned()], exclude: vec!["target/".to_owned()], + manifest_exclude: Vec::new(), exclude_from: None, + manifest_exclude_from: None, check_exclude: Vec::new(), + manifest_check_exclude: Vec::new(), check_exclude_from: None, + manifest_check_exclude_from: None, paths_from: None, baseline: None, config: None, @@ -625,9 +629,13 @@ fn effective_config_json_serializes_threshold_overrides() { paths: Vec::new(), include: Vec::new(), exclude: Vec::new(), + manifest_exclude: Vec::new(), exclude_from: None, + manifest_exclude_from: None, check_exclude: Vec::new(), + manifest_check_exclude: Vec::new(), check_exclude_from: None, + manifest_check_exclude_from: None, paths_from: None, baseline: None, config: None, diff --git a/big-code-analysis-cli/tests/check/check_exclude.rs b/big-code-analysis-cli/tests/check/check_exclude.rs index 3e12b2122..f3e6fe73b 100644 --- a/big-code-analysis-cli/tests/check/check_exclude.rs +++ b/big-code-analysis-cli/tests/check/check_exclude.rs @@ -544,4 +544,103 @@ fn print_effective_config_unions_the_manifest_check_exclude() { !check.contains_key("exclude_from"), "no walker exclude_from was configured", ); + + // The flattened `check_exclude` above cannot say which of its two + // globs resolves against which root, and after #1164 that is what + // decides whether a glob matches at all. The sibling key names the + // manifest-origin subset, so the resolved list stays where it is — + // and keeps round-tripping through `--config` — while the + // provenance becomes answerable (#1194). Its anchor is the + // `manifest` key's directory, which is populated whenever this is. + let manifest_check_exclude: Vec<&str> = check["manifest_check_exclude"] + .as_array() + .expect("manifest_check_exclude is an array") + .iter() + .map(|v| v.as_str().expect("glob is a string")) + .collect(); + assert_eq!( + manifest_check_exclude, + ["./generated/**"], + "only the manifest's glob is manifest-anchored; `tests/**` came from the CLI", + ); + assert!( + check.contains_key("manifest"), + "the manifest-origin subset is meaningless without the anchor it resolves against", + ); + + // The effective `check_exclude_from` came from the manifest here, + // so the provenance key mirrors it. + assert_eq!( + check["manifest_check_exclude_from"].as_str(), + check["check_exclude_from"].as_str(), + "no CLI --check-exclude-from was passed, so the manifest's file is the one in effect", + ); + + // The walker's exclude surface configured nothing, so its + // provenance key is absent rather than empty. + assert!( + !check.contains_key("manifest_exclude"), + "an unconfigured surface must not emit an empty provenance key", + ); +} + +/// A CLI `--check-exclude-from` *replaces* the manifest's file rather +/// than unioning with it, so the provenance key must go absent — saying +/// the manifest's file is in effect when it is not would be worse than +/// saying nothing. +/// +/// #1194 reads the manifest's file as being "dropped entirely" here. +/// It is not dropped in the report; it is not in effect, by the +/// documented `replaced_by` rule that governs the *file* while the +/// inline glob list unions. +#[test] +fn print_effective_config_omits_a_replaced_manifest_exclude_from() { + let dir = TempDir::new().unwrap(); + fs::write(dir.path().join("kept.rs"), branchy("kept_offender")).unwrap(); + fs::write(dir.path().join("more-globs.txt"), "vendor/**\n").unwrap(); + fs::write(dir.path().join("cli-globs.txt"), "cli-vendor/**\n").unwrap(); + fs::write( + dir.path().join("bca.toml"), + "paths = [\".\"]\n\ + [check]\n\ + exclude = [\"./generated/**\"]\n\ + exclude_from = \"more-globs.txt\"\n", + ) + .unwrap(); + + let assert = cli(dir.path()) + .args([ + "check", + "--paths", + dir.path().to_str().unwrap(), + "--threshold", + "cyclomatic=1", + "--check-exclude-from", + "cli-globs.txt", + "--print-effective-config", + "toml", + ]) + .assert() + .success(); + let stdout = String::from_utf8(assert.get_output().stdout.clone()).expect("utf8 stdout"); + let parsed: toml::Table = toml::from_str(&stdout).expect("effective config is valid TOML"); + let check = parsed["check"].as_table().expect("[check] is a table"); + + assert_eq!( + check["check_exclude_from"].as_str(), + Some("cli-globs.txt"), + "the CLI file is the one in effect", + ); + assert!( + !check.contains_key("manifest_check_exclude_from"), + "the manifest's file was replaced, so it must not be reported as in effect", + ); + // The inline glob list still unions, which is what makes the two + // rules distinguishable rather than a single "CLI wins" story. + assert!( + check["manifest_check_exclude"] + .as_array() + .is_some_and(|a| a.len() == 1), + "the manifest's inline glob is still in effect", + ); } From a52fea7c0827076ae255d26817bca357b1728bc1 Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Mon, 3 Aug 2026 00:42:05 -0700 Subject: [PATCH 09/27] refactor(report): hoist hotspot selection out of both renderers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `write_language_section` existed twice, and its format-independent half — the `Source` match, the `cc_note` select split, and the `fully_suppressed_count` call — was line-for-line identical. Both copies carried a comment claiming the two formats could not diverge, which a comment cannot enforce. That half moves into `markdown_report::hotspot` as `select_for`, returning a `SpecOutcome` of either rows (with the CC stats a `cc_note` spec needs) or a fully-suppressed count. Each renderer's loop becomes a two-arm match over its own four emit functions, which really are the only format-specific part. The issue's sketch names two types that do not exist: there is no `Row` (rows are `Vec<&FunctionSummary>` throughout) and no `CcStats` (it is `CyclomaticStats`). The signature here is written against the real ones. Per-format rationale stays at the call sites, per `.claude/rules/macro-comments.md`: why HTML wraps the MI note in a styled `

` while Markdown has a dedicated emitter, and why the suppressed caption takes the spec in one format and the rendered title in the other. What moved into `select_for` is the shared reasoning — select-first, and why an empty table is not the same as a suppressed one. Folds in the second duplicate the issue flags: `partition_by_kind` and `split_units_and_functions` were the same function with the same signature and two implementations (one-pass with `with_capacity` vs two filter passes). The one-pass version survives, now shared. Tests: `both_formats_select_the_same_sections` is new, and it is the point of the change. The existing cross-format guard checks column membership only, so it could not have noticed the two selection copies disagreeing — a structural guard should not rest on a property nothing covers. It sweeps `SPECS` asserting the formats agree on both which sections render a table and which are fully suppressed, with a fixture carrying both outcomes and `seen_table > 0` / `seen_suppressed > 0` so neither arm can pass vacuously. Verified by perturbation: dropping the fully-suppressed arm from the HTML renderer alone fails it, naming the section the formats disagreed about. Behaviour-preserving: all 5105 tests pass unchanged, including the rich report snapshots. Fixes #1190 --- big-code-analysis-cli/src/html_report.rs | 2 +- .../src/html_report/sections.rs | 72 +++-------- big-code-analysis-cli/src/markdown_report.rs | 120 +++++++++++++----- .../src/markdown_report/hotspot.rs | 81 ++++++++++++ .../src/markdown_report/sections.rs | 21 +-- 5 files changed, 194 insertions(+), 102 deletions(-) diff --git a/big-code-analysis-cli/src/html_report.rs b/big-code-analysis-cli/src/html_report.rs index d86bc95d0..130bc10e5 100644 --- a/big-code-analysis-cli/src/html_report.rs +++ b/big-code-analysis-cli/src/html_report.rs @@ -36,7 +36,7 @@ use std::fmt::Write; use big_code_analysis::{SpaceKind, SuppressionPolicy}; use crate::markdown_report::advisory::{AdvisoryCounts, AdvisoryThresholds}; -use crate::markdown_report::hotspot::{self, Align, Cell, HotspotSpec, SPECS, Source}; +use crate::markdown_report::hotspot::{self, Align, Cell, HotspotSpec, SPECS}; use crate::markdown_report::{ FunctionSummary, is_class_like, language_display_name, mi_rating, mi_weight_numerator, sloc_weighted_avg_mi, thousands, diff --git a/big-code-analysis-cli/src/html_report/sections.rs b/big-code-analysis-cli/src/html_report/sections.rs index 9efe1f35f..22f364507 100644 --- a/big-code-analysis-cli/src/html_report/sections.rs +++ b/big-code-analysis-cli/src/html_report/sections.rs @@ -389,24 +389,6 @@ pub(crate) fn write_legend_html(out: &mut String, entries: &[(&str, &str)]) { let _ = out.write_str("\n\n"); } -/// Split a per-language slice into its unit (file) and function buckets -/// in a single pass. Class-likes are intentionally dropped from both -/// buckets — the WMC hotspot sources them straight from `entries`. -fn partition_by_kind<'a>( - entries: &[&'a FunctionSummary], -) -> (Vec<&'a FunctionSummary>, Vec<&'a FunctionSummary>) { - let mut units: Vec<&FunctionSummary> = Vec::with_capacity(entries.len()); - let mut funcs: Vec<&FunctionSummary> = Vec::with_capacity(entries.len()); - for &s in entries { - match s.kind { - SpaceKind::Unit => units.push(s), - SpaceKind::Function => funcs.push(s), - _ => {} - } - } - (units, funcs) -} - /// File-level roll-up backing one language's `

Summary

` note. /// Only `SpaceKind::Unit` summaries feed it. struct LanguageTotals { @@ -675,7 +657,7 @@ pub(crate) fn write_language_section( // table deep-links to e.g. `#rust-cyclomatic-complexity-hotspots`. Built // from the raw language slug, matching the `h2` section id. let id_prefix = slugify(lang_name); - let (units, funcs) = partition_by_kind(entries); + let (units, funcs) = hotspot::partition_by_kind(entries); write_language_summary(out, headings, &id_prefix, &units); // The Actionable Summary leads the section (directly after Summary, @@ -685,39 +667,27 @@ pub(crate) fn write_language_section( // Drive every hotspot section from the shared `SPECS` table so the HTML // and Markdown reports cannot diverge in membership/order/suppression. - // WMC draws from the full slice, MI from units, the rest from functions. + // `select_for` owns the whole format-independent half, leaving the four + // emit calls below as the only thing this renderer decides (#1190). for spec in SPECS { - let base: &[&FunctionSummary] = match spec.source { - Source::Units => &units, - Source::Funcs => &funcs, - Source::All => entries, - }; - // Select first, emit second — same shape as the Markdown writer, and - // for the same reason: both selectors feed identical empty-table - // handling, so hoisting it leaves one copy of that rule instead of two. - let (rows, cc_stats) = if spec.cc_note { - let (rows, stats) = hotspot::select_cc(spec, base, top_n, policy, advisory); - (rows, Some(stats)) - } else { - (hotspot::select(spec, base, top_n, policy, advisory), None) - }; - if rows.is_empty() { - // An empty table is either a genuinely-absent metric or a table - // whose every matching row was suppressed; only the latter earns - // a caption, or the Actionable Summary's bullets dangle (#616). - let suppressed = hotspot::fully_suppressed_count(spec, base, policy, advisory); - emit_fully_suppressed_note_html(out, headings, &id_prefix, spec, top_n, suppressed); - continue; - } - emit_html_section(out, headings, &id_prefix, spec, top_n, &rows); - if let Some(stats) = cc_stats { - emit_cc_note_html(out, &stats, policy); - } else if spec.mi_note { - let _ = writeln!( - out, - "

{}

", - escape_html(hotspot::MI_NOTE) - ); + match hotspot::select_for(spec, &units, &funcs, entries, top_n, policy, advisory) { + hotspot::SpecOutcome::FullySuppressed(suppressed) => { + emit_fully_suppressed_note_html(out, headings, &id_prefix, spec, top_n, suppressed); + } + hotspot::SpecOutcome::Rows { rows, cc_stats } => { + emit_html_section(out, headings, &id_prefix, spec, top_n, &rows); + if let Some(stats) = cc_stats { + emit_cc_note_html(out, &stats, policy); + } else if spec.mi_note { + // HTML wraps the note in a styled `

`; the Markdown + // writer has a dedicated emitter for the same text. + let _ = writeln!( + out, + "

{}

", + escape_html(hotspot::MI_NOTE) + ); + } + } } } diff --git a/big-code-analysis-cli/src/markdown_report.rs b/big-code-analysis-cli/src/markdown_report.rs index 6fd147f47..2829a5c5c 100644 --- a/big-code-analysis-cli/src/markdown_report.rs +++ b/big-code-analysis-cli/src/markdown_report.rs @@ -1000,7 +1000,7 @@ fn write_language_section( let display_name = language_display_name(lang_name); let _ = writeln!(out, "\n## {display_name}\n"); - let (units, funcs) = sections::split_units_and_functions(entries); + let (units, funcs) = hotspot::partition_by_kind(entries); sections::write_summary(out, &units); // The Actionable Summary is the highest-altitude block (the @@ -1012,36 +1012,24 @@ fn write_language_section( // Drive every hotspot section from the shared `SPECS` table (the same // table the HTML report uses) so the two formats cannot diverge in - // membership/order/suppression. WMC draws from the full slice, MI from - // units, the rest from functions. + // membership/order/suppression. `select_for` owns the whole + // format-independent half, leaving the four emit calls below as the + // only thing this renderer decides (#1190). for spec in hotspot::SPECS { - let base: &[&FunctionSummary] = match spec.source { - hotspot::Source::Units => &units, - hotspot::Source::Funcs => &funcs, - hotspot::Source::All => entries, - }; - // Select first, emit second. Both selectors feed the same - // empty-table handling, so hoisting it out of the CC/non-CC split - // leaves one copy of that rule instead of two identical ones. - let (rows, cc_stats) = if spec.cc_note { - let (rows, stats) = hotspot::select_cc(spec, base, top_n, policy, advisory); - (rows, Some(stats)) - } else { - (hotspot::select(spec, base, top_n, policy, advisory), None) - }; - if rows.is_empty() { - // An empty table is either a genuinely-absent metric or a table - // whose every matching row was suppressed; only the latter earns - // a caption, or the Actionable Summary's bullets dangle (#616). - let suppressed = hotspot::fully_suppressed_count(spec, base, policy, advisory); - sections::emit_fully_suppressed_note_md(out, &spec.title.render(top_n), suppressed); - continue; - } - sections::emit_section_md(out, spec, top_n, &rows); - if let Some(stats) = cc_stats { - sections::emit_cc_note_md(out, &stats, policy); - } else if spec.mi_note { - sections::emit_mi_note_md(out); + match hotspot::select_for(spec, &units, &funcs, entries, top_n, policy, advisory) { + hotspot::SpecOutcome::FullySuppressed(suppressed) => { + // Markdown renders the caption from the title text; the + // HTML writer needs the spec itself, for the heading id. + sections::emit_fully_suppressed_note_md(out, &spec.title.render(top_n), suppressed); + } + hotspot::SpecOutcome::Rows { rows, cc_stats } => { + sections::emit_section_md(out, spec, top_n, &rows); + if let Some(stats) = cc_stats { + sections::emit_cc_note_md(out, &stats, policy); + } else if spec.mi_note { + sections::emit_mi_note_md(out); + } + } } } } @@ -2468,6 +2456,78 @@ mod tests { ); } + /// The two renderers must agree on *which* sections a language gets + /// and which are fully suppressed — the selection half, which + /// `hotspot::select_for` now owns (#1190). + /// + /// The existing cross-format guard below checks column membership + /// only. That would not have noticed the two copies of the selection + /// logic diverging, which is the divergence the extraction removes + /// the possibility of; a structural guard should not rest on a + /// property no test covers. + /// + /// Both outcomes have to appear in one fixture, or the assertion + /// holds for a renderer that emits every section or none: `hot` + /// surfaces in the function tables, while `quiet` is suppressed and + /// is the sole member of its own, so that section comes back + /// `FullySuppressed`. + #[test] + fn both_formats_select_the_same_sections() { + use crate::html_report::generate_html_report; + + let unit = make_summary("lib.rs", "src/lib.rs", SpaceKind::Unit, LANG::Rust); + let mut hot = make_summary("hot", "src/lib.rs", SpaceKind::Function, LANG::Rust); + hot.halstead_effort = 200.0; + hot.nargs = 4; + hot.cyclomatic = 12.0; + let mut quiet = make_summary("quiet", "src/lib.rs", SpaceKind::Function, LANG::Rust); + quiet.nexits = 9; + quiet.suppressed = + big_code_analysis::SuppressionScope::Some(std::collections::BTreeSet::from([ + big_code_analysis::Metric::Nexits, + ])); + let fixture = [unit, hot, quiet]; + + let md = generate_report(&fixture, 20, SuppressionPolicy::Honor); + let html = generate_html_report(&fixture, 20, SuppressionPolicy::Honor); + + let mut seen_table = 0; + let mut seen_suppressed = 0; + for spec in hotspot::SPECS { + let title = spec.title.render(20); + let md_has_table = md.contains(&format!("### {title}")); + // The HTML heading id is derived per language, so match the + // rendered title inside an `h3` close rather than the id. + let html_has_table = html.match_indices(&format!(">{title}")).any(|(i, _)| { + html[i..] + .find('<') + .is_some_and(|lt| html[i + lt..].starts_with("")) + }); + assert_eq!( + md_has_table, html_has_table, + "formats disagree on whether {title:?} renders a table" + ); + + let caption = hotspot::fully_suppressed_caption(&title, 1); + let md_suppressed = md.contains(&caption); + let html_suppressed = html.contains(&caption); + assert_eq!( + md_suppressed, html_suppressed, + "formats disagree on whether {title:?} is fully suppressed" + ); + + seen_table += usize::from(md_has_table); + seen_suppressed += usize::from(md_suppressed); + } + // Without both outcomes present the comparisons above are + // vacuously equal for a renderer that emits nothing at all. + assert!(seen_table > 0, "fixture produced no hotspot table"); + assert!( + seen_suppressed > 0, + "fixture produced no fully-suppressed section, so that arm is untested" + ); + } + /// Issue #628: the Halstead Effort and Many-Parameters tables were the /// only per-function hotspots missing the `Line` column. Both renderers /// draw from the shared `hotspot::SPECS`, so one fix covers both — assert diff --git a/big-code-analysis-cli/src/markdown_report/hotspot.rs b/big-code-analysis-cli/src/markdown_report/hotspot.rs index cfdaaa1fe..cb973e6c4 100644 --- a/big-code-analysis-cli/src/markdown_report/hotspot.rs +++ b/big-code-analysis-cli/src/markdown_report/hotspot.rs @@ -967,6 +967,87 @@ pub(crate) fn suppressed_metric_breakdown( .collect() } +/// Partition `entries` by `SpaceKind` into (units, functions) in a +/// single pass. +/// +/// Class-likes land in neither bucket — the WMC hotspot sources them +/// straight from `entries` via [`Source::All`]. Lived twice, once per +/// renderer, with the same signature and semantics and two different +/// implementations (#1190). +pub(crate) fn partition_by_kind<'a>( + entries: &[&'a FunctionSummary], +) -> (Vec<&'a FunctionSummary>, Vec<&'a FunctionSummary>) { + let mut units: Vec<&FunctionSummary> = Vec::with_capacity(entries.len()); + let mut funcs: Vec<&FunctionSummary> = Vec::with_capacity(entries.len()); + for &s in entries { + match s.kind { + big_code_analysis::SpaceKind::Unit => units.push(s), + big_code_analysis::SpaceKind::Function => funcs.push(s), + _ => {} + } + } + (units, funcs) +} + +/// What one [`HotspotSpec`] resolved to for a language: rows to emit, or +/// nothing to emit and why. +/// +/// The distinction is the whole reason this is an enum rather than an +/// empty `Vec`. An empty table is either a genuinely-absent metric or a +/// table whose every matching row was suppressed, and only the latter +/// earns a caption — without it the Actionable Summary's bullets dangle +/// (#616). +pub(crate) enum SpecOutcome<'a> { + /// Rows survived selection. `cc_stats` is `Some` exactly for a spec + /// carrying `cc_note`, computed over the full suppression-filtered + /// set before truncation. + Rows { + rows: Vec<&'a FunctionSummary>, + cc_stats: Option, + }, + /// Every matching row was suppressed; the payload is how many. + FullySuppressed(usize), +} + +/// Resolve one spec against a language's entries — the whole +/// format-independent half of rendering a hotspot section. +/// +/// Both renderers ran this identically: pick the source slice, run the +/// CC or plain selector, and on an empty result count the suppressed +/// rows. A comment in each claimed the two could not diverge; this makes +/// that structural rather than asserted (#1190). What stays per-format +/// is the emit half, which really is the only part that differs. +pub(crate) fn select_for<'a>( + spec: &HotspotSpec, + units: &[&'a FunctionSummary], + funcs: &[&'a FunctionSummary], + entries: &[&'a FunctionSummary], + top_n: usize, + policy: SuppressionPolicy, + advisory: AdvisoryThresholds, +) -> SpecOutcome<'a> { + // WMC draws from the full slice, MI from units, the rest from + // functions. + let base: &[&FunctionSummary] = match spec.source { + Source::Units => units, + Source::Funcs => funcs, + Source::All => entries, + }; + // Select first, emit second: both selectors feed the same empty-table + // handling, so hoisting it out of the CC/non-CC split leaves one copy + // of that rule rather than two identical ones. + let (rows, cc_stats) = if spec.cc_note { + let (rows, stats) = select_cc(spec, base, top_n, policy, advisory); + (rows, Some(stats)) + } else { + (select(spec, base, top_n, policy, advisory), None) + }; + if rows.is_empty() { + return SpecOutcome::FullySuppressed(fully_suppressed_count(spec, base, policy, advisory)); + } + SpecOutcome::Rows { rows, cc_stats } +} + /// Like [`select`] but also returns the cyclomatic stats over the FULL /// suppression-filtered set (before truncation), for the CC note. pub(crate) fn select_cc<'a>( diff --git a/big-code-analysis-cli/src/markdown_report/sections.rs b/big-code-analysis-cli/src/markdown_report/sections.rs index 33f3a410c..1d1dacff9 100644 --- a/big-code-analysis-cli/src/markdown_report/sections.rs +++ b/big-code-analysis-cli/src/markdown_report/sections.rs @@ -9,7 +9,7 @@ use std::fmt::Write as _; -use big_code_analysis::{SpaceKind, SuppressionPolicy}; +use big_code_analysis::SuppressionPolicy; use super::advisory::AdvisoryThresholds; use super::hotspot::{Cell, CyclomaticStats, HotspotSpec}; @@ -231,22 +231,3 @@ pub(super) fn emit_fully_suppressed_note_md(out: &mut String, title: &str, count super::hotspot::fully_suppressed_caption(title, count) ); } - -/// Partition `entries` by `SpaceKind` into (units, functions). The -/// `units` slice drives the file-level summary and MI section; the -/// `funcs` slice drives all per-function hotspot tables. -pub(super) fn split_units_and_functions<'a>( - entries: &[&'a FunctionSummary], -) -> (Vec<&'a FunctionSummary>, Vec<&'a FunctionSummary>) { - let units: Vec<&FunctionSummary> = entries - .iter() - .filter(|s| s.kind == SpaceKind::Unit) - .copied() - .collect(); - let funcs: Vec<&FunctionSummary> = entries - .iter() - .filter(|s| s.kind == SpaceKind::Function) - .copied() - .collect(); - (units, funcs) -} From 6eab5b7c68629de057bbc91e449eac578e80de93 Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Mon, 3 Aug 2026 00:51:29 -0700 Subject: [PATCH 10/27] fix(nargs): count a lambda's bare parameter in Java and C# MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A lambda written without the optional parentheses reported zero arguments: Function f = x -> x + 1; // nargs 0, should be 1 Function g = (x) -> x + 1; // nargs 1 The two spellings are the same lambda — the parens are optional in the grammar and carry no meaning — so this breaks the same "byte-equivalent constructs score identically" contract the book states for cognitive. `compute_args` walks the children of the `parameters` field. Java's `lambda_expression` puts a list there for `(x) -> …` and `() -> …`, but for the bare form it puts a childless `identifier`, so the walk yields nothing. The singular-`parameter` fallback beside it would have handled exactly this, but it is unreachable: the `if let` already matched. Sibling sweep, per grammar-dispatch item 7. The issue nominates Groovy and Kotlin; measured, it is **C#** — which the issue does not mention. Its bare `x => x + 1` puts a childless `implicit_parameter` in the same field, with the same result. Groovy has no `lambda_expression` node at all and Kotlin routes lambdas through `compute_kotlin_lambda_args`; both were already correct, and both are now covered by the test so they cannot regress silently. The JS family reaches the right answer through the singular `parameter` field. The fix is a gated `Checker::is_bare_param` hook (default `false`, so no other language changes) rather than an ungated `child_count() == 0`. Honest caveat: no test distinguishes the two — the ungated form passes the whole suite — because the difference only appears on a MISSING or zero-width ERROR node under `parameters`, which no fixture produces. The gate is chosen so a broken parse cannot silently conjure an argument, and so as not to repeat the not-gated-in-code shape the issue complains about in the existing fallback. A Java `NArgs::compute` override was not an option: the trait doc makes `compute` and `params_owner` mutually exclusive, and `JavaCode` already overrides `params_owner` for #1160's compact constructor. Tests: `lambda_parenthesisation_parity` asserts the two spellings agree across Java, C# and the JS family, plus the absolute value 1 so a regression zeroing both spellings still fails, `() -> 1` staying 0 (the new branch must not mistake an empty list for a parameter), and `(x, y) -> …` staying 2 (the plural path undisturbed). A companion pins that the argument is billed to `closure_args`, per language rather than globally — the JS arrow's channel depends on binding, which is #1188. Verified by perturbation: removing the branch fails both tests. Two C# corpus snapshots move, `closure_args` 0 -> 1 with its derived aggregates and no `fn_args` field touched. Submodule bumped and pushed in this commit. Fixes #1185 --- src/checker.rs | 20 +++ src/checker/csharp.rs | 7 ++ src/checker/java.rs | 7 ++ src/metrics/nargs.rs | 127 ++++++++++++++++++++ tests/repositories/big-code-analysis-output | 2 +- 5 files changed, 162 insertions(+), 1 deletion(-) diff --git a/src/checker.rs b/src/checker.rs index d51fb2ad2..a3beb897b 100644 --- a/src/checker.rs +++ b/src/checker.rs @@ -292,6 +292,26 @@ pub(crate) trait Checker { fn is_non_arg(_: &Node) -> bool { false } + /// Whether `node` — the value of a function's `parameters` field — + /// is itself a *single formal parameter* rather than a parameter + /// *list*. + /// + /// `compute_args` walks the field node's children, which is right + /// for a list and yields zero for a lone parameter that has none. + /// Java's `lambda_expression` spells its `parameters` field three + /// ways — `formal_parameters` and `inferred_parameters` are lists, + /// but the un-parenthesised `x -> …` form puts a bare, childless + /// `identifier` there — and C#'s puts a childless + /// `implicit_parameter`. Both reported `nargs = 0` for a lambda + /// that plainly has one argument (#1185). + /// + /// Gated per language rather than inferred from `child_count() == 0` + /// so a MISSING or zero-width ERROR node under `parameters` on a + /// broken parse cannot silently conjure an argument. + #[inline] + fn is_bare_param(_: &Node) -> bool { + false + } #[inline] fn is_string(_: &Node) -> bool { false diff --git a/src/checker/csharp.rs b/src/checker/csharp.rs index 19fa451ba..c16870c26 100644 --- a/src/checker/csharp.rs +++ b/src/checker/csharp.rs @@ -75,6 +75,13 @@ impl Checker for CsharpCode { matches!(node.kind_id().into(), csharp_invocation_expr_kinds!()) } + /// `x => x + 1` puts a childless `implicit_parameter` in the + /// `parameters` field, where `(x) => …` puts a `parameter_list` + /// (#1185). Same shape as Java's bare-identifier lambda. + fn is_bare_param(node: &Node) -> bool { + node.kind_id() == Csharp::ImplicitParameter as u16 + } + fn is_non_arg(node: &Node) -> bool { matches!( node.kind_id().into(), diff --git a/src/checker/java.rs b/src/checker/java.rs index 8cba3eaa7..29624af69 100644 --- a/src/checker/java.rs +++ b/src/checker/java.rs @@ -66,6 +66,13 @@ impl Checker for JavaCode { node.kind_id() == Java::MethodInvocation } + /// `x -> x + 1` puts a bare `identifier` in the `parameters` field; + /// `(x) -> …` and `(x, y) -> …` put an `inferred_parameters` list + /// and `() -> …` a `formal_parameters` one (#1185). + fn is_bare_param(node: &Node) -> bool { + node.kind_id() == Java::Identifier as u16 + } + fn is_non_arg(node: &Node) -> bool { // Java's explicit receiver parameter (`void m(S this, int a)`, JLS // 8.4.1) parses as a `receiver_parameter` child of diff --git a/src/metrics/nargs.rs b/src/metrics/nargs.rs index 31c8d3d9b..5fce27457 100644 --- a/src/metrics/nargs.rs +++ b/src/metrics/nargs.rs @@ -211,6 +211,13 @@ impl Stats { #[inline] fn compute_args(node: &Node, nargs: &mut usize) { if let Some(params) = node.child_by_field_name("parameters") { + // The field can hold a lone parameter rather than a list, in + // which case there are no children to walk and the loop below + // yields zero — see `Checker::is_bare_param` (#1185). + if T::is_bare_param(¶ms) { + *nargs += 1; + return; + } let node_params = params; node_params.act_on_child(&mut |n| { if !T::is_non_arg(n) { @@ -4056,3 +4063,123 @@ when HTTP_REQUEST { log local0. \"hit\" } ); } } + +/// A lambda's parameter count must not depend on optional parentheses +/// (#1185). +/// +/// `x -> x + 1` and `(x) -> x + 1` are the same lambda — the parens are +/// optional in the grammar and carry no meaning — so they must score +/// alike, the same "byte-equivalent constructs score identically" +/// contract the book states for cognitive. +/// +/// The cause is shared: the `parameters` field holds a lone, childless +/// parameter node rather than a list, and `compute_args` walks the +/// field's children. The issue names Java; the sweep found **C#** has +/// the identical defect via `implicit_parameter`. Kotlin and Groovy +/// were checked and are correct — each overrides `compute` with its own +/// closure-parameter shape — and the JS family reaches the right answer +/// through the singular `parameter` field. +#[cfg(test)] +mod lambda_parenthesisation_parity { + use crate::test_support::metrics_verbatim; + use crate::{LANG, MetricsOptions}; + + /// `(closure_args, function_args)` — the split matters as much as + /// the count: a lambda must stay in the closure channel. + fn args(lang: LANG, source: &str) -> (u64, u64) { + let m = metrics_verbatim(lang, source.as_bytes(), MetricsOptions::default()); + (m.nargs.closure_args_sum(), m.nargs.function_args_sum()) + } + + /// `(bare, parenthesised, two_params, zero_params)`. + fn cases(lang: LANG) -> Option<[&'static str; 4]> { + Some(match lang { + LANG::Java => [ + "class K{ void f(){ Function a = x -> x + 1; } }", + "class K{ void f(){ Function a = (x) -> x + 1; } }", + "class K{ void f(){ BiFunction c = (x, y) -> x + y; } }", + "class K{ void f(){ Supplier d = () -> 1; } }", + ], + LANG::Csharp => [ + "class K{ void f(){ Func a = x => x + 1; } }", + "class K{ void f(){ Func a = (x) => x + 1; } }", + "class K{ void f(){ Func c = (x, y) => x + y; } }", + "class K{ void f(){ Func d = () => 1; } }", + ], + LANG::Javascript | LANG::Typescript | LANG::Tsx | LANG::Mozjs => [ + "function f(){ var a = x => x + 1; }", + "function f(){ var a = (x) => x + 1; }", + "function f(){ var c = (x, y) => x + y; }", + "function f(){ var d = () => 1; }", + ], + _ => return None, + }) + } + + #[test] + fn optional_parentheses_do_not_change_the_count() { + let mut checked = 0; + for lang in LANG::into_enum_iter() { + if !lang.is_enabled() { + continue; + } + let Some([bare, paren, two, zero]) = cases(lang) else { + continue; + }; + checked += 1; + + let (bare_args, paren_args) = (args(lang, bare), args(lang, paren)); + assert_eq!( + bare_args, paren_args, + "{lang:?}: the parentheses changed the argument count\n bare: {bare}\n paren: {paren}" + ); + // The absolute value, so a regression that zeroed *both* + // spellings would still fail. + assert_eq!( + bare_args.0 + bare_args.1, + 1, + "{lang:?}: a one-parameter lambda must report one argument" + ); + // A zero-parameter lambda must stay 0: the bare-parameter + // branch must not mistake an empty list for a parameter. + assert_eq!( + args(lang, zero), + (0, 0), + "{lang:?}: `() -> …` has no arguments" + ); + // And the plural path must be undisturbed. + let two_args = args(lang, two); + assert_eq!( + two_args.0 + two_args.1, + 2, + "{lang:?}: a two-parameter lambda must report two arguments" + ); + } + assert!( + checked > 0, + "no lambda language enabled; this test asserted nothing" + ); + } + + /// The lambda stays in the *closure* channel, not the function one. + /// + /// Java and C# route it through `is_closure`; the JS family's arrow + /// is classified by `check_if_arrow_func!` and lands in `fn_args` + /// when bound to a variable, which is a separate question (#1188). + /// Asserting the channel per language rather than globally keeps + /// this test from encoding that as a bug. + #[test] + fn a_bare_lambda_stays_in_the_closure_channel() { + for lang in [LANG::Java, LANG::Csharp] { + if !lang.is_enabled() { + continue; + } + let [bare, ..] = cases(lang).expect("both languages have cases"); + assert_eq!( + args(lang, bare), + (1, 0), + "{lang:?}: the lambda's argument must be billed to closure_args" + ); + } + } +} diff --git a/tests/repositories/big-code-analysis-output b/tests/repositories/big-code-analysis-output index d71252eb4..552d457c9 160000 --- a/tests/repositories/big-code-analysis-output +++ b/tests/repositories/big-code-analysis-output @@ -1 +1 @@ -Subproject commit d71252eb413c2fe048e0467649e1d6bcdbe5d8bd +Subproject commit 552d457c9aa308d06a868a07e1746072685de1c0 From a7c296e4a6f3b24d9adcbadc51d88bcf82419d6b Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Mon, 3 Aug 2026 06:25:02 -0700 Subject: [PATCH 11/27] fix(spaces): open a function space for five nameless constructs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kotlin property accessors (`get()` / `set()`), Kotlin `init { … }`, Java and Groovy `static { … }`, and the JS family's ES2022 `class_static_block` were each referenced nowhere outside the generated language enum. None opened a `FuncSpace`, so its control flow was charged to the enclosing class space, and `bca check` could never flag one however complex it got. Each now appears in `Checker::is_func_space` and `Getter::get_space_kind`, and is named by a new `get_func_space_name` override: ``, ``, ``, ``. `get_func_space_name` returns `Option<&'a str>` borrowed from `code`, so a `&'static str` is the only synthesised name possible — a per-property `` would need a signature change. The angle brackets follow the existing `` convention and cannot collide with a real identifier in any of these grammars. Deliberately **not** in `is_func`. None is a callable named at a call site — in Kotlin you write `p.foo`, not `p.getFoo()` — and putting an accessor there would have `Npm` bill the same property once as an attribute and again as a method, skewing the NPA/NPM ratio the OOP metrics exist to report. This is the split the JS family already uses for `FunctionExpression`. That choice has a consequence the issue does not anticipate, confirmed by measurement and settled deliberately: `Nom` keys on `is_func`, so an accessor-only Kotlin file still reports `nom.functions == 0` — the symptom the issue calls "worst of the set". What the missing *space* caused is fixed (own metric scope, complexity off the class, flaggable by `bca check`); the `nom` count is not, and the book and the tests now say so rather than leaving it to be rediscovered. WMC does move: it keys on `SpaceKind::Function` rather than on `is_func`, so an initialiser's complexity now rolls into its class's WMC. That is correct — WMC is the sum of a class's method-body complexities and an initialiser body is real class complexity — but it is a published-metric change. `kotlin_init_block` is updated from 1 to 2 with the reasoning inline. Prerequisite, and the reason this lands before the rest of the group: `functions_metrics_parity` asserted that every *named* `SpaceKind::Function` space is reported by `Ast::functions()`, with the literal `""` as its only escape hatch. A named space that is `is_func_space` but not `is_func` fails that. The hatch is now a predicate over the angle-bracket convention, and `get_func_space_name`'s default is extracted as `default_func_space_name` so an override can special-case a few kinds and delegate the rest. Two grammar-dispatch traps, both commented at the point of use: `src/getter/kotlin.rs` glob-imports `Kotlin::*` inside the function while the `Getter` *trait* is in scope from `use super::*`, so the `Getter` / `Setter` variants are written fully qualified; `src/getter/groovy.rs` imports an explicit list, so a bare `StaticInitializer` parses as a binding that matches everything. Tests: four in `nameless_construct_spaces`, each asserting on **both** the new space and the space it took the value from — asserting only the new one passes even if the parent kept a duplicate count, which is what #1160 found in the first draft of the equivalent Java fix. One pins that two `static { }` blocks in a class produce two identically-named spaces, so the collision reads as a decision rather than an oversight. No corpus snapshot moves: the integration corpora contain no `.kt`, `.java` or `.groovy` files and no JS class static block. Refs #1184 --- big-code-analysis-book/src/metrics.md | 12 ++ src/checker/groovy.rs | 3 + src/checker/java.rs | 7 + src/checker/javascript.rs | 5 + src/checker/kotlin.rs | 19 ++ src/checker/mozjs.rs | 5 + src/checker/tsx.rs | 5 + src/checker/typescript.rs | 5 + src/getter.rs | 29 ++- src/getter/groovy.rs | 39 +++- src/getter/java.rs | 36 +++- src/getter/javascript.rs | 10 +- src/getter/kotlin.rs | 40 +++++ src/getter/mozjs.rs | 10 +- src/getter/tsx.rs | 10 +- src/getter/typescript.rs | 10 +- ...etrics__wmc__tests__kotlin_init_block.snap | 4 +- src/metrics/wmc.rs | 18 +- src/spaces_tests.rs | 167 ++++++++++++++++++ tests/parity/functions_metrics_parity.rs | 25 ++- 20 files changed, 431 insertions(+), 28 deletions(-) diff --git a/big-code-analysis-book/src/metrics.md b/big-code-analysis-book/src/metrics.md index a33e3f8fa..436eda652 100644 --- a/big-code-analysis-book/src/metrics.md +++ b/big-code-analysis-book/src/metrics.md @@ -830,6 +830,18 @@ crate with many closures and few functions is typical of iterator-heavy code; a Python module with many functions and few closures is typical of script-style code. +Some constructs that carry executable code are deliberately counted by +neither field: Kotlin property accessors (`get()` / `set()`) and `init` +blocks, Java and Groovy `static { … }` initialisers, and JavaScript +class static blocks. Each opens a function *space* — so it has its own +complexity scores, `bca check` can flag it, and it contributes to WMC — +but none is a callable you name at a call site, and counting an accessor +as a method would make NPM bill the same property once as an attribute +and again as a method, skewing the NPA/NPM ratio the OOP metrics exist +to report. A Kotlin file of nothing but property accessors therefore +reports `nom.functions == 0` while still reporting their complexity +(#1184). + ### How to read it NOM is the input to several other metrics — WMC sums *cyclomatic* diff --git a/src/checker/groovy.rs b/src/checker/groovy.rs index b69e0f8ce..62cc67f15 100644 --- a/src/checker/groovy.rs +++ b/src/checker/groovy.rs @@ -30,6 +30,9 @@ impl Checker for GroovyCode { | Groovy::EnumDeclaration | Groovy::RecordDeclaration | Groovy::AnnotationTypeDeclaration + // `static { … }` — see `JavaCode::is_func_space`, which + // this mirrors (#1184). + | Groovy::StaticInitializer ) } diff --git a/src/checker/java.rs b/src/checker/java.rs index 29624af69..048607fc9 100644 --- a/src/checker/java.rs +++ b/src/checker/java.rs @@ -36,6 +36,13 @@ impl Checker for JavaCode { | Java::EnumDeclaration | Java::RecordDeclaration | Java::AnnotationTypeDeclaration + // `static { … }` carries executable code — conventionally + // the class's initialisation logic — but was referenced + // nowhere outside the generated enum, so it opened no + // space and its control flow was charged to the class + // (#1184). `is_func_space` rather than `is_func`: it is + // not a callable anyone names at a call site. + | Java::StaticInitializer ) } diff --git a/src/checker/javascript.rs b/src/checker/javascript.rs index 3d97f36d6..c3ad1d4ae 100644 --- a/src/checker/javascript.rs +++ b/src/checker/javascript.rs @@ -25,6 +25,11 @@ impl Checker for JavascriptCode { | Javascript::GeneratorFunctionDeclaration | Javascript::ClassDeclaration | Javascript::ArrowFunction + // ES2022 `static { … }`. Referenced nowhere + // outside the generated enum before #1184, so a + // class static block opened no space and its + // control flow was charged to the class. + | Javascript::ClassStaticBlock ) } diff --git a/src/checker/kotlin.rs b/src/checker/kotlin.rs index 0e3f69bcb..e75271287 100644 --- a/src/checker/kotlin.rs +++ b/src/checker/kotlin.rs @@ -19,6 +19,25 @@ impl Checker for KotlinCode { | Kotlin::ObjectDeclaration | Kotlin::CompanionObject | Kotlin::ObjectLiteral + // Property accessors and `init { … }` carry executable + // code but were referenced nowhere outside the generated + // enum, so they opened no space at all: their control + // flow was charged to the enclosing class and `bca check` + // could never flag one however complex it got. A file of + // nothing but accessors reported `nom.functions == 0` + // (#1184). + // + // `is_func_space` and not `is_func`: none of the three is + // a callable a user names at a call site — in Kotlin you + // write `p.foo`, not `p.getFoo()` — and putting an + // accessor in `is_func` would have `npm` count the same + // property once as an attribute and again as a method, + // skewing the NPA/NPM ratio the OOP metrics exist to + // report. This is the same split the JS family already + // uses for `FunctionExpression`. + | Kotlin::Getter + | Kotlin::Setter + | Kotlin::AnonymousInitializer ) } diff --git a/src/checker/mozjs.rs b/src/checker/mozjs.rs index 89140f08b..b52ccd960 100644 --- a/src/checker/mozjs.rs +++ b/src/checker/mozjs.rs @@ -24,6 +24,11 @@ impl Checker for MozjsCode { | Mozjs::GeneratorFunctionDeclaration | Mozjs::ClassDeclaration | Mozjs::ArrowFunction + // ES2022 `static { … }`. Referenced nowhere + // outside the generated enum before #1184, so a + // class static block opened no space and its + // control flow was charged to the class. + | Mozjs::ClassStaticBlock ) } diff --git a/src/checker/tsx.rs b/src/checker/tsx.rs index eb8a256d6..5badd65f6 100644 --- a/src/checker/tsx.rs +++ b/src/checker/tsx.rs @@ -24,6 +24,11 @@ impl Checker for TsxCode { | Tsx::AbstractClassDeclaration | Tsx::InterfaceDeclaration | Tsx::ArrowFunction + // ES2022 `static { … }`. Referenced nowhere + // outside the generated enum before #1184, so a + // class static block opened no space and its + // control flow was charged to the class. + | Tsx::ClassStaticBlock ) } diff --git a/src/checker/typescript.rs b/src/checker/typescript.rs index 99b3a3687..148561143 100644 --- a/src/checker/typescript.rs +++ b/src/checker/typescript.rs @@ -27,6 +27,11 @@ impl Checker for TypescriptCode { | Typescript::AbstractClassDeclaration | Typescript::InterfaceDeclaration | Typescript::ArrowFunction + // ES2022 `static { … }`. Referenced nowhere + // outside the generated enum before #1184, so a + // class static block opened no space and its + // control flow was charged to the class. + | Typescript::ClassStaticBlock ) } diff --git a/src/getter.rs b/src/getter.rs index 94791a269..c59248134 100644 --- a/src/getter.rs +++ b/src/getter.rs @@ -165,6 +165,26 @@ macro_rules! impl_js_family_get_op_type { }; } +/// The default space name: the node's `name` field, else ``. +/// +/// A free function as well as the trait default so a language that needs +/// to name a *few* kinds specially can delegate the rest rather than +/// restate the rule — `` / `` / `` / `` in +/// Kotlin, Java and Groovy all do (#1184). Calling `Self::…` there would +/// recurse. +pub(crate) fn default_func_space_name<'a, 'tree>( + node: &Node<'tree>, + code: &'a [u8], + _ancestors: Ancestors<'tree, '_>, +) -> Option<&'a str> { + // we're in a function or in a class + if let Some(name) = node.child_by_field_name("name") { + node_text(code, &name) + } else { + Some("") + } +} + /// Per-language accessors the space walker and the Halstead /// operator/operand classification dispatch through. /// @@ -199,14 +219,9 @@ pub(crate) trait Getter { fn get_func_space_name<'a, 'tree>( node: &Node<'tree>, code: &'a [u8], - _ancestors: Ancestors<'tree, '_>, + ancestors: Ancestors<'tree, '_>, ) -> Option<&'a str> { - // we're in a function or in a class - if let Some(name) = node.child_by_field_name("name") { - node_text(code, &name) - } else { - Some("") - } + default_func_space_name(node, code, ancestors) } fn get_space_kind(_node: &Node) -> SpaceKind { diff --git a/src/getter/groovy.rs b/src/getter/groovy.rs index 6db7794ed..5537ad124 100644 --- a/src/getter/groovy.rs +++ b/src/getter/groovy.rs @@ -4,6 +4,38 @@ use super::*; impl Getter for GroovyCode { + /// Names the space, synthesising one for constructs that carry no + /// name token (#1184). + /// + /// `get_func_space_name` returns `Option<&'a str>` borrowed from + /// `code`, so the only name available for a nameless construct is a + /// `&'static str` — a per-property spelling like `` would + /// need a signature change. Angle brackets follow the existing + /// `` convention and cannot collide with a real + /// identifier in any of these grammars. + /// + /// Sibling collisions are accepted, exactly as multiple + /// `` siblings already are: two properties each with a + /// getter, or two `static { }` blocks in one class, produce two + /// spaces with the same name. Nothing enforces name uniqueness + /// among siblings, and inventing an index would make the name + /// unstable under an unrelated edit. + /// + /// `` rather than the JVM's ``: the same + /// construct exists in JavaScript, where `` would mean + /// nothing, and one spelling across languages is worth more here + /// than JVM precision. + fn get_func_space_name<'a, 'tree>( + node: &Node<'tree>, + code: &'a [u8], + ancestors: Ancestors<'tree, '_>, + ) -> Option<&'a str> { + if node.kind_id() == Groovy::StaticInitializer as u16 { + return Some(""); + } + crate::getter::default_func_space_name(node, code, ancestors) + } + fn get_space_kind(node: &Node) -> SpaceKind { use Groovy::{ AnnotationTypeDeclaration, ClassDeclaration, Closure, ConstructorDeclaration, @@ -37,7 +69,12 @@ impl Getter for GroovyCode { InterfaceDeclaration | TraitDeclaration | AnnotationTypeDeclaration => { SpaceKind::Interface } - MethodDeclaration | ConstructorDeclaration | Closure => SpaceKind::Function, + // `Groovy::` qualified: this module imports an explicit list + // rather than glob-importing, so a bare name here would parse + // as a binding and match everything. + MethodDeclaration | ConstructorDeclaration | Closure | Groovy::StaticInitializer => { + SpaceKind::Function + } SourceFile => SpaceKind::Unit, _ => SpaceKind::Unknown, } diff --git a/src/getter/java.rs b/src/getter/java.rs index 0e161633b..f2601bb0b 100644 --- a/src/getter/java.rs +++ b/src/getter/java.rs @@ -4,6 +4,38 @@ use super::*; impl Getter for JavaCode { + /// Names the space, synthesising one for constructs that carry no + /// name token (#1184). + /// + /// `get_func_space_name` returns `Option<&'a str>` borrowed from + /// `code`, so the only name available for a nameless construct is a + /// `&'static str` — a per-property spelling like `` would + /// need a signature change. Angle brackets follow the existing + /// `` convention and cannot collide with a real + /// identifier in any of these grammars. + /// + /// Sibling collisions are accepted, exactly as multiple + /// `` siblings already are: two properties each with a + /// getter, or two `static { }` blocks in one class, produce two + /// spaces with the same name. Nothing enforces name uniqueness + /// among siblings, and inventing an index would make the name + /// unstable under an unrelated edit. + /// + /// `` rather than the JVM's ``: the same + /// construct exists in JavaScript, where `` would mean + /// nothing, and one spelling across languages is worth more here + /// than JVM precision. + fn get_func_space_name<'a, 'tree>( + node: &Node<'tree>, + code: &'a [u8], + ancestors: Ancestors<'tree, '_>, + ) -> Option<&'a str> { + if node.kind_id() == Java::StaticInitializer as u16 { + return Some(""); + } + crate::getter::default_func_space_name(node, code, ancestors) + } + fn get_space_kind(node: &Node) -> SpaceKind { use Java::*; @@ -25,7 +57,9 @@ impl Getter for JavaCode { MethodDeclaration | ConstructorDeclaration | CompactConstructorDeclaration - | LambdaExpression => SpaceKind::Function, + | LambdaExpression + // `static { … }` (#1184). + | StaticInitializer => SpaceKind::Function, InterfaceDeclaration | AnnotationTypeDeclaration => SpaceKind::Interface, // An anonymous class (`new Runnable() { ... }`) is an // `object_creation_expression` carrying a `class_body` child; diff --git a/src/getter/javascript.rs b/src/getter/javascript.rs index 6bcb884ab..09a01d3f8 100644 --- a/src/getter/javascript.rs +++ b/src/getter/javascript.rs @@ -13,7 +13,9 @@ impl Getter for JavascriptCode { | GeneratorFunction | FunctionDeclaration | GeneratorFunctionDeclaration - | ArrowFunction => SpaceKind::Function, + | ArrowFunction + // ES2022 `static { … }` (#1184). + | ClassStaticBlock => SpaceKind::Function, Class | ClassDeclaration => SpaceKind::Class, Program => SpaceKind::Unit, _ => SpaceKind::Unknown, @@ -25,6 +27,12 @@ impl Getter for JavascriptCode { code: &'a [u8], ancestors: Ancestors<'tree, '_>, ) -> Option<&'a str> { + // A class static block has no name token and no naming parent to + // fall back on, so it would otherwise land on `` + // alongside every arrow and function expression (#1184). + if node.kind_id() == Javascript::ClassStaticBlock as u16 { + return Some(""); + } if let Some(name) = node.child_by_field_name("name") { node_text(code, &name) } else { diff --git a/src/getter/kotlin.rs b/src/getter/kotlin.rs index 0d7110af9..b4ba8623b 100644 --- a/src/getter/kotlin.rs +++ b/src/getter/kotlin.rs @@ -123,6 +123,39 @@ fn kotlin_string_has_interp(node: &Node, code: &[u8]) -> bool { } impl Getter for KotlinCode { + /// Names the space, synthesising one for constructs that carry no + /// name token (#1184). + /// + /// `get_func_space_name` returns `Option<&'a str>` borrowed from + /// `code`, so the only name available for a nameless construct is a + /// `&'static str` — a per-property spelling like `` would + /// need a signature change. Angle brackets follow the existing + /// `` convention and cannot collide with a real + /// identifier in any of these grammars. + /// + /// Sibling collisions are accepted, exactly as multiple + /// `` siblings already are: two properties each with a + /// getter, or two `static { }` blocks in one class, produce two + /// spaces with the same name. Nothing enforces name uniqueness + /// among siblings, and inventing an index would make the name + /// unstable under an unrelated edit. + /// + /// `` / `` are Kotlin's own spelling of the accessor + /// keywords; `` is what the JVM calls the constructor an + /// `init` block compiles into. + fn get_func_space_name<'a, 'tree>( + node: &Node<'tree>, + code: &'a [u8], + ancestors: Ancestors<'tree, '_>, + ) -> Option<&'a str> { + match node.kind_id().into() { + Kotlin::Getter => Some(""), + Kotlin::Setter => Some(""), + Kotlin::AnonymousInitializer => Some(""), + _ => crate::getter::default_func_space_name(node, code, ancestors), + } + } + fn get_space_kind(node: &Node) -> SpaceKind { use Kotlin::*; @@ -165,6 +198,13 @@ impl Getter for KotlinCode { FunctionDeclaration | SecondaryConstructor | LambdaLiteral | AnonymousFunction => { SpaceKind::Function } + // Property accessors and `init { … }` (#1184). Fully + // qualified deliberately: the enclosing `use Kotlin::*` would + // otherwise put the `Getter` *variant* and the `Getter` + // *trait* (in scope from `use super::*`) in the same + // namespace. `src/getter/ruby.rs` hit the same collision and + // solved it with `use Ruby as R;`. + Kotlin::Getter | Kotlin::Setter | Kotlin::AnonymousInitializer => SpaceKind::Function, SourceFile => SpaceKind::Unit, _ => SpaceKind::Unknown, } diff --git a/src/getter/mozjs.rs b/src/getter/mozjs.rs index 90b1bccab..aa6ae6968 100644 --- a/src/getter/mozjs.rs +++ b/src/getter/mozjs.rs @@ -13,7 +13,9 @@ impl Getter for MozjsCode { | GeneratorFunction | FunctionDeclaration | GeneratorFunctionDeclaration - | ArrowFunction => SpaceKind::Function, + | ArrowFunction + // ES2022 `static { … }` (#1184). + | ClassStaticBlock => SpaceKind::Function, Class | ClassDeclaration => SpaceKind::Class, Program => SpaceKind::Unit, _ => SpaceKind::Unknown, @@ -25,6 +27,12 @@ impl Getter for MozjsCode { code: &'a [u8], ancestors: Ancestors<'tree, '_>, ) -> Option<&'a str> { + // A class static block has no name token and no naming parent to + // fall back on, so it would otherwise land on `` + // alongside every arrow and function expression (#1184). + if node.kind_id() == Mozjs::ClassStaticBlock as u16 { + return Some(""); + } if let Some(name) = node.child_by_field_name("name") { node_text(code, &name) } else { diff --git a/src/getter/tsx.rs b/src/getter/tsx.rs index b57fd88ea..bca2ca3d3 100644 --- a/src/getter/tsx.rs +++ b/src/getter/tsx.rs @@ -13,7 +13,9 @@ impl Getter for TsxCode { | GeneratorFunction | FunctionDeclaration | GeneratorFunctionDeclaration - | ArrowFunction => SpaceKind::Function, + | ArrowFunction + // ES2022 `static { … }` (#1184). + | ClassStaticBlock => SpaceKind::Function, Class | ClassDeclaration | AbstractClassDeclaration => SpaceKind::Class, InterfaceDeclaration => SpaceKind::Interface, Program => SpaceKind::Unit, @@ -26,6 +28,12 @@ impl Getter for TsxCode { code: &'a [u8], ancestors: Ancestors<'tree, '_>, ) -> Option<&'a str> { + // A class static block has no name token and no naming parent to + // fall back on, so it would otherwise land on `` + // alongside every arrow and function expression (#1184). + if node.kind_id() == Tsx::ClassStaticBlock as u16 { + return Some(""); + } if let Some(name) = node.child_by_field_name("name") { node_text(code, &name) } else { diff --git a/src/getter/typescript.rs b/src/getter/typescript.rs index bf06e51c3..b86d35359 100644 --- a/src/getter/typescript.rs +++ b/src/getter/typescript.rs @@ -13,7 +13,9 @@ impl Getter for TypescriptCode { | GeneratorFunction | FunctionDeclaration | GeneratorFunctionDeclaration - | ArrowFunction => SpaceKind::Function, + | ArrowFunction + // ES2022 `static { … }` (#1184). + | ClassStaticBlock => SpaceKind::Function, Class | ClassDeclaration | AbstractClassDeclaration => SpaceKind::Class, InterfaceDeclaration => SpaceKind::Interface, Program => SpaceKind::Unit, @@ -26,6 +28,12 @@ impl Getter for TypescriptCode { code: &'a [u8], ancestors: Ancestors<'tree, '_>, ) -> Option<&'a str> { + // A class static block has no name token and no naming parent to + // fall back on, so it would otherwise land on `` + // alongside every arrow and function expression (#1184). + if node.kind_id() == Typescript::ClassStaticBlock as u16 { + return Some(""); + } if let Some(name) = node.child_by_field_name("name") { node_text(code, &name) } else { diff --git a/src/metrics/snapshots/big_code_analysis__metrics__wmc__tests__kotlin_init_block.snap b/src/metrics/snapshots/big_code_analysis__metrics__wmc__tests__kotlin_init_block.snap index 568878a3e..9c8b477b8 100644 --- a/src/metrics/snapshots/big_code_analysis__metrics__wmc__tests__kotlin_init_block.snap +++ b/src/metrics/snapshots/big_code_analysis__metrics__wmc__tests__kotlin_init_block.snap @@ -3,7 +3,7 @@ source: src/metrics/wmc.rs expression: metric.wmc --- { - "class_wmc_sum": 1, + "class_wmc_sum": 2, "interface_wmc_sum": 0, - "total": 1 + "total": 2 } diff --git a/src/metrics/wmc.rs b/src/metrics/wmc.rs index 5c5f6f9b9..c60abfc7a 100644 --- a/src/metrics/wmc.rs +++ b/src/metrics/wmc.rs @@ -2515,19 +2515,27 @@ mod tests { #[test] fn kotlin_init_block() { - // `init` blocks are anonymous initializers, not function spaces; - // they do not add to WMC directly. The class still has whatever - // methods it declares. + // An `init` block opens a function space since #1184, so its + // complexity rolls into the class's WMC — the metric is the sum + // of a class's method-body complexities, and an initializer body + // is real class complexity a reader has to account for. Before + // #1184 the block opened no space at all: its control flow was + // charged to the enclosing class space without WMC ever seeing + // it, and `bca check` could not flag one however complex it got. + // + // WMC keys on `SpaceKind::Function` rather than on `is_func`, + // which is why this moves even though the block is deliberately + // *not* in `is_func` (it is not a callable anyone names). check_metrics::( "class C(val n: Int) { - init { // not counted + init { // +1 since #1184 require(n >= 0) { \"n must be non-negative\" } } fun get(): Int = n // +1 }", "foo.kt", |metric| { - assert_eq!(metric.wmc.class_wmc_sum(), 1); + assert_eq!(metric.wmc.class_wmc_sum(), 2); assert_eq!(metric.wmc.interface_wmc_sum(), 0); insta::assert_json_snapshot!(metric.wmc); }, diff --git a/src/spaces_tests.rs b/src/spaces_tests.rs index 455b79a05..35e2c937c 100644 --- a/src/spaces_tests.rs +++ b/src/spaces_tests.rs @@ -2109,3 +2109,170 @@ mod childless_root_span { assert_eq!(span("// c\nfn a() {}\n"), (1, 2)); } } + +/// Constructs that carry executable code but no name token now open a +/// function space of their own (#1184). +/// +/// Each was referenced nowhere outside the generated language enum, so +/// it opened no `FuncSpace` at all: its control flow was charged to the +/// enclosing class, `nom` / `wmc` under-counted, and `bca check` could +/// never flag one however complex it got. A Kotlin file of nothing but +/// property accessors reported `nom.functions == 0`. +/// +/// Every assertion here is made on **both** the new space and the space +/// it took the value from. Asserting only the new one passes even if the +/// parent kept a duplicate count, which is the defect #1160 found in the +/// first draft of the equivalent Java fix. +#[cfg(test)] +mod nameless_construct_spaces { + use crate::test_support::space_verbatim; + use crate::{FuncSpace, LANG, MetricsOptions, SpaceKind}; + + fn analyse(lang: LANG, source: &str) -> FuncSpace { + space_verbatim(lang, source.as_bytes(), MetricsOptions::default()) + } + + /// The `(name, kind)` of every descendant space, in preorder. + fn shape(space: &FuncSpace) -> Vec<(Option<&str>, SpaceKind)> { + let mut out = vec![(space.name.as_deref(), space.kind)]; + for child in &space.spaces { + out.extend(shape(child)); + } + out + } + + fn child<'a>(space: &'a FuncSpace, name: &str) -> &'a FuncSpace { + fn find<'a>(s: &'a FuncSpace, name: &str) -> Option<&'a FuncSpace> { + if s.name.as_deref() == Some(name) { + return Some(s); + } + s.spaces.iter().find_map(|c| find(c, name)) + } + find(space, name).unwrap_or_else(|| panic!("no space named {name:?} in {:?}", shape(space))) + } + + #[test] + #[cfg(feature = "kotlin")] + fn kotlin_accessors_and_init_open_named_spaces() { + let root = analyse( + LANG::Kotlin, + "class C {\n\ + \x20 var p: Int = 0\n\ + \x20 get() { if (field > 0) { return field } else { return 0 } }\n\ + \x20 set(v) { if (v > 0) { field = v } }\n\ + \x20 init { if (p > 0) { println(\"x\") } }\n\ + }\n", + ); + assert_eq!( + shape(&root), + vec![ + (None, SpaceKind::Unit), + (Some("C"), SpaceKind::Class), + (Some(""), SpaceKind::Function), + (Some(""), SpaceKind::Function), + (Some(""), SpaceKind::Function), + ], + ); + + // The value moved rather than being duplicated: the accessor + // owns its branch, and the class no longer owns it directly. + let class = child(&root, "C"); + assert_eq!( + class.metrics.cognitive.cognitive(), + 0, + "class own cognitive" + ); + assert_eq!(child(&root, "").metrics.cognitive.cognitive(), 2); + assert_eq!(child(&root, "").metrics.cognitive.cognitive(), 1); + assert_eq!(child(&root, "").metrics.cognitive.cognitive(), 1); + + // `nom.functions` deliberately stays 0. The issue calls an + // accessor-only file reporting `nom.functions == 0` the worst of + // the set, and this fix does *not* change that number: `Nom` + // keys on `is_func`, and these constructs are `is_func_space` + // only, because a Kotlin accessor is not a callable anyone names + // at a call site (`p.foo`, not `p.getFoo()`) and counting it as + // a method would have `npm` bill the same property once as an + // attribute and again as a method. + // + // What the fix does change is everything the missing *space* + // caused: each accessor now has its own metric scope, so its + // complexity is no longer charged to the class and `bca check` + // can flag it. WMC picks them up because it keys on the space + // kind rather than on `is_func`. + assert_eq!( + root.metrics.nom.functions_sum(), + 0, + "accessors are is_func_space, not is_func" + ); + assert_eq!( + root.metrics.wmc.class_wmc_sum(), + 6, + "WMC keys on SpaceKind::Function, so it does pick them up" + ); + } + + #[test] + #[cfg(feature = "java")] + fn java_static_initializer_opens_a_named_space() { + let root = analyse( + LANG::Java, + "class C { static int x; static { if (x > 0) { x = 1; } else { x = 2; } } }\n", + ); + assert_eq!( + shape(&root), + vec![ + (None, SpaceKind::Unit), + (Some("C"), SpaceKind::Class), + (Some(""), SpaceKind::Function), + ], + ); + assert_eq!(child(&root, "C").metrics.cognitive.cognitive(), 0); + assert_eq!( + child(&root, "").metrics.cognitive.cognitive(), + 2 + ); + } + + #[test] + #[cfg(feature = "javascript")] + fn javascript_class_static_block_opens_a_named_space() { + let root = analyse( + LANG::Javascript, + "class C { static f; static { if (C.f) { C.f = 1; } else { C.f = 2; } } }\n", + ); + assert_eq!( + shape(&root), + vec![ + (None, SpaceKind::Unit), + (Some("C"), SpaceKind::Class), + (Some(""), SpaceKind::Function), + ], + ); + assert_eq!(child(&root, "C").metrics.cognitive.cognitive(), 0); + assert_eq!( + child(&root, "").metrics.cognitive.cognitive(), + 2 + ); + } + + /// Sibling constructs with the same synthesised name are allowed to + /// collide, exactly as multiple `` siblings already do. + /// Pinned so the collision reads as a decision rather than an + /// oversight — inventing an index would make the name unstable under + /// an unrelated edit. + #[test] + #[cfg(feature = "java")] + fn two_static_initializers_produce_two_identically_named_spaces() { + let root = analyse( + LANG::Java, + "class C { static int x; static { x = 1; } static { x = 2; } }\n", + ); + let names: Vec<_> = shape(&root) + .into_iter() + .filter(|(_, k)| *k == SpaceKind::Function) + .map(|(n, _)| n) + .collect(); + assert_eq!(names, vec![Some(""), Some("")]); + } +} diff --git a/tests/parity/functions_metrics_parity.rs b/tests/parity/functions_metrics_parity.rs index df770d585..be6a62207 100644 --- a/tests/parity/functions_metrics_parity.rs +++ b/tests/parity/functions_metrics_parity.rs @@ -30,8 +30,8 @@ use big_code_analysis::{Ast, FuncSpace, LANG, MetricsOptions, Source, SpaceKind, use super::ops_metrics_space_parity::fixture; -/// The name `Getter::get_func_space_name` reports for a space whose AST -/// node carries no name field — a closure, lambda, or block. +/// Whether `name` is one `Getter::get_func_space_name` synthesised for a +/// space whose AST node carries no name of its own. /// /// These are the one legitimate asymmetry between the two sides. Every /// language promotes such a node to a `SpaceKind::Function` space, but @@ -39,11 +39,20 @@ use super::ops_metrics_space_parity::fixture; /// `FuncLiteral`, Perl's and PHP's anonymous `sub`/`function`, Ruby's /// `lambda` and Lua's `function` expression are `is_func_space` without /// being `is_func`, while a JavaScript arrow function and an iRules -/// `when` block are both. `bca functions` therefore reports some -/// anonymous spaces and not others, which is a per-grammar judgement -/// this test has no business pinning — so the coverage claim below is -/// scoped to spaces that carry a real name. -const ANONYMOUS: &str = ""; +/// `when` block are both. `bca functions` therefore reports some such +/// spaces and not others, which is a per-grammar judgement this test has +/// no business pinning — so the coverage claim below is scoped to spaces +/// that carry a real name. +/// +/// A predicate over the angle-bracket convention rather than the single +/// literal `""` it used to be: #1184 added ``, ``, +/// `` and `` for constructs that carry executable +/// code but no name token, and each is `is_func_space` without being +/// `is_func` for exactly the reason above. No language here permits `<` +/// in an identifier, so the convention cannot collide with a real name. +fn is_synthesised_name(name: &str) -> bool { + name.starts_with('<') && name.ends_with('>') +} /// A space or span, reduced to the fields all three seams report. type Fun = (Option, usize, usize); @@ -122,7 +131,7 @@ fn every_named_function_space_is_reported_by_functions_and_find() { // back out of `functions()`. This is the #1162 direction: before // the fix, Elixir's `bar` was here and nowhere else. for fun in &from_metrics { - if fun.0.as_deref() == Some(ANONYMOUS) { + if fun.0.as_deref().is_some_and(is_synthesised_name) { continue; } assert!( From 7fa91436d10307342e9423ce9f3f72a90a6df092 Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Mon, 3 Aug 2026 06:42:49 -0700 Subject: [PATCH 12/27] fix(js): classify generator functions as functions, not closures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `GeneratorFunction` and `GeneratorFunctionDeclaration` were `SpaceKind::Function` to the `Getter` and *closures* to the `Checker`, and every metric keying on one or the other inherited the disagreement. `function* g() {}` is lexically a function declaration — the `*` is a modifier, not a different binding form — so the Getter was right. function outer(a,b){ if(a){ if(b){ function* gen(c){ if(c){…} } }}} // cognitive 3 -> 2, matching the byte-equivalent non-generator // nom: functions 0 / closures 1 -> functions 1 / closures 0 // nargs: the parameter moves from closure_args to fn_args The two kinds are **not** symmetrical, which the issue's "move both" framing misses and which the grammar settles: `generator_function_declaration` has a *required* `name` field and mirrors `function_declaration`, so it is unconditionally a function; `generator_function` has an *optional* one and mirrors `function_expression`, so it routes through `check_if_func!`. Making it unconditional would classify `run(function*(){})` as a function while `run(function(){})` stays a closure — a new inconsistency in place of the old one. Both cognitive halves move together, as the issue's comment requires, because they are independent: - the **boundary arm** decides whether the generator resets its own inherited nesting (`outer2`'s `gen`: 3 -> 2); - the **`stops` list** decides whether a plain `function` nested *inside* a generator gets a depth surcharge (`wrapper`'s `inner`: 1 -> 2). Fixing only one leaves the other reproducer wrong. The 14-line comment describing generators as a knowing omission is replaced with what the arm now does and why the two kinds are gated differently. This also repairs a latent parity violation: `function* g(){}` opened a Function space named `g` while `is_func` said false, so `bca functions` and `bca find --type function` did not report it. No fixture reached it, which is the only reason `functions_metrics_parity` was green — the JS fixture now carries a generator, and reverting the classification fails that test by name. Tests: `check_js_function_boundary` gains generator cases in both its loops, so all four instantiating languages cover them — #1159's tests covered none, which the issue's comment calls out. Verified by perturbation: restoring the old classification fails all four per-language boundary tests plus the parity test. No corpus snapshot moves, and worth stating precisely rather than as a bare claim: exactly one file across all three corpora contains a generator (`pdf.js/src/core/xfa/template.js`), and it is in `pdf_js_test.rs`'s exclude list under the #84 parse-error FIXME. Its checked-in snapshot is therefore stale with respect to this change but is never compared. Fixes #1186 --- src/checker.rs | 23 +++++++-- src/metrics/cognitive.rs | 63 ++++++++++++++++++------ tests/parity/ops_metrics_space_parity.rs | 9 +++- 3 files changed, 75 insertions(+), 20 deletions(-) diff --git a/src/checker.rs b/src/checker.rs index a3beb897b..0bddf52e7 100644 --- a/src/checker.rs +++ b/src/checker.rs @@ -95,11 +95,27 @@ macro_rules! check_if_arrow_func { }; } +// A generator is a function, not a closure (#1186). `function* g() {}` +// is lexically a function *declaration* — the `*` is a modifier on the +// declaration, not a different binding form — and `get_space_kind` +// already calls both generator kinds `SpaceKind::Function`. The Checker +// disagreeing with the Getter is what made `nom` report a generator as a +// closure, bill its parameters to `closure_args`, and (reaching no +// cognitive boundary arm) let it inherit the enclosing nesting. +// +// The two kinds are **not** symmetrical, which the issue's "move both" +// framing misses. `generator_function_declaration` has a required `name` +// field and mirrors `function_declaration`, so it is unconditionally a +// function. `generator_function` has an *optional* one and mirrors +// `function_expression`, so it routes through the same +// binding-site walk: making it unconditional would classify +// `run(function*(){})` as a function while `run(function(){})` stays a +// closure. macro_rules! is_js_func { ($node: ident, $ancestors: ident) => { match $node.kind_id().into() { - FunctionDeclaration | MethodDefinition => true, - FunctionExpression => check_if_func!($node, $ancestors), + FunctionDeclaration | GeneratorFunctionDeclaration | MethodDefinition => true, + FunctionExpression | GeneratorFunction => check_if_func!($node, $ancestors), ArrowFunction => check_if_arrow_func!($node, $ancestors), _ => false, } @@ -109,8 +125,7 @@ macro_rules! is_js_func { macro_rules! is_js_closure { ($node: ident, $ancestors: ident) => { match $node.kind_id().into() { - GeneratorFunction | GeneratorFunctionDeclaration => true, - FunctionExpression => !check_if_func!($node, $ancestors), + FunctionExpression | GeneratorFunction => !check_if_func!($node, $ancestors), ArrowFunction => !check_if_arrow_func!($node, $ancestors), _ => false, } diff --git a/src/metrics/cognitive.rs b/src/metrics/cognitive.rs index 28cf7ff0c..2068ed181 100644 --- a/src/metrics/cognitive.rs +++ b/src/metrics/cognitive.rs @@ -424,7 +424,11 @@ macro_rules! js_cognitive { matches!(id.into(), AMPAMPEQ | PIPEPIPEEQ | QMARKQMARKEQ) }); } - FunctionDeclaration | MethodDefinition | FunctionExpression + FunctionDeclaration + | MethodDefinition + | FunctionExpression + | GeneratorFunctionDeclaration + | GeneratorFunction if Self::is_func(node, ancestors) => { // The JS family takes the shared function-boundary @@ -475,26 +479,31 @@ macro_rules! js_cognitive { // across every language — the deferred half of #1159 — // rather than settling it in one arm. // - // Generators are the other knowing omission, and the - // argument above applies to them verbatim: - // `GeneratorFunction` / `GeneratorFunctionDeclaration` - // are `SpaceKind::Function` to the Getter too. They - // are absent from both this list and the boundary arm - // because `is_js_func!` excludes them — the Checker - // calls them closures — so `function* g() {}` still - // inherits enclosing nesting and still gives a nested - // `function` no depth surcharge. Fixing that means - // moving them between the two Checker predicates, - // which also moves `nom`'s function/closure split and - // `nargs`' `fn_nargs`/`closure_nargs`; tracked in - // #1186, deliberately not settled here. + // Both generator kinds are in the arm and in `stops` + // since #1186, which moved them from `is_js_closure!` + // to `is_js_func!`. The two halves are independent + // and had to move together: the arm decides whether + // `function* g()` resets its own inherited nesting, + // while `stops` decides whether a plain `function` + // nested *inside* a generator gets a depth surcharge. + // `GeneratorFunction` is gated by `Self::is_func` for + // the same reason `FunctionExpression` is — it has an + // optional name and covers both a function and a + // closure — while `GeneratorFunctionDeclaration`, + // like `FunctionDeclaration`, is unconditional. nesting.conditional = 0; nesting.lambda = 0; increment_function_depth( &mut nesting.function_depth, node, ancestors, - &[FunctionDeclaration, MethodDefinition, FunctionExpression], + &[ + FunctionDeclaration, + MethodDefinition, + FunctionExpression, + GeneratorFunctionDeclaration, + GeneratorFunction, + ], ); } ArrowFunction => { @@ -9445,6 +9454,17 @@ end", "function_expression", "const inner = function (c) { if (c) { return 1; } };", ), + // Generators reach this arm since #1186. Before it, + // `is_js_func!` called them closures, so neither form + // reached the boundary and each scored 3. + ( + "generator_function_declaration", + "function* inner(c) { if (c) { yield 1; } }", + ), + ( + "generator_function", + "const inner = function* (c) { if (c) { yield 1; } };", + ), ] { let source = format!("function outer(a, b) {{ if (a) {{ if (b) {{ {definition} }} }} }}"); @@ -9476,6 +9496,19 @@ end", "function_expression", "const m = function () { function inner(c) { if (c) { return 1; } } };", ), + // The independent half of #1186: a plain `function` nested + // inside a *generator* got no depth surcharge, because the + // generator was excluded from `stops` by the same + // `is_js_func!` gate. This scored 1 before the fix while the + // non-generator control above scored 2. + ( + "generator_function_declaration", + "function* m() { function inner(c) { if (c) { return 1; } } }", + ), + ( + "generator_function", + "const m = function* () { function inner(c) { if (c) { return 1; } } };", + ), ] { check_func_space::(source, filename, |space| { assert_eq!( diff --git a/tests/parity/ops_metrics_space_parity.rs b/tests/parity/ops_metrics_space_parity.rs index 4efce90d1..ef324e5b4 100644 --- a/tests/parity/ops_metrics_space_parity.rs +++ b/tests/parity/ops_metrics_space_parity.rs @@ -45,8 +45,15 @@ pub(super) fn fixture(lang: LANG) -> (&'static str, &'static str) { // per-edit `bca check` hook rather than for the self-scan gate. // bca: suppress(cyclomatic) match lang { + // The generator is deliberate: `function* h()` opened a + // `SpaceKind::Function` space named `h` while `is_func` said + // false, so `functions()` did not report it — a live violation of + // `functions_metrics_parity`'s coverage claim that no fixture + // reached. #1186 fixed the classification; this makes the fixture + // able to catch a regression of it. LANG::Javascript | LANG::Mozjs => ( - "function f(a) {\n const g = (b) => b * 2;\n return g(a) + 1;\n}\n", + "function f(a) {\n const g = (b) => b * 2;\n return g(a) + 1;\n}\n\ + function* h(n) {\n yield n;\n}\n", "js", ), LANG::Typescript => ( From 9aa6bcf4f456dfada9c5835c22deed6518dfe746 Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Mon, 3 Aug 2026 07:01:47 -0700 Subject: [PATCH 13/27] fix(cognitive): reset the lambda surcharge at every function boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `nesting.lambda` was reset only by the JS macro, so every other language carried an enclosing closure's surcharge into a function *declared inside* it. The same body scored differently depending on whether something two levels up happened to be a closure: fn outer(a: bool, b: bool) { let f = || { if a { fn g(b: bool) { if b { … } } g(b); } }; f(); } // `g` scored 3 inside the closure, 2 outside it Measured at 3-vs-2 in Rust, Java, C++, PHP — the four the issue names — and in **C#**, which it does not, and where a `LocalFunctionStatement` inside a lambda is idiomatic rather than contrived. A function declaration opens a fresh lexical scope whatever encloses it, so the reset belongs to every boundary. It moves into `enter_function_boundary`, which is what stops a language opting out by accident — the way the gap arose. The `js_cognitive!` arm becomes a plain caller and its 8-line justification for spelling the statements out longhand is deleted rather than edited: it is now false. Audited all 18 callers. No language is made wrong and none double-fixes: in every module the lambda-increment kind set and the boundary kind set are disjoint, so a single node can only take one arm and the order of operations inside the helper cannot matter. Three near-misses worth naming because the identifiers are close but the `kind_id`s differ — Lua's `FunctionDefinition` (lambda) vs `FunctionDeclaration*` (boundary), Kotlin's `AnonymousFunction` vs `FunctionDeclaration`, PHP's `AnonymousFunction`/`ArrowFunction` vs `FunctionDefinition`/`MethodDeclaration`. Elixir is the one boundary that cannot use the helper — it takes the resets without the depth bump, for the documented `Call`-ancestor reason — so it gains the line by hand, with a note that staying in step is now its own responsibility. The JS tail from #1159 lands here too: `ArrowFunction` joins the `stops` list, so `(function(){ function g(){…} })()` and `(() => { function g(){…} })()` both charge `g` a depth of 1 where the arrow form charged 0. It cannot double-charge, since the sole caller resets `lambda` first. `javascript_function_depth_and_lambda_are_distinguishable` moves 1 -> 2 and its rationale is re-derived: the doubled arrow is still load-bearing for its original parity reason, and the test still discriminates a field transposition — writing `function_depth = 0` in place of `lambda = 0` scores 4 against the expected 2. `javascript_arrow_contributes_lambda_nesting` is unaffected. Python's boundary comment claimed only the JS macro carried the extra line; rewritten. Tests: `a_function_declared_inside_a_closure_scores_the_same_as_outside` in the cross-language parity suite pins the rule once rather than per module, asserting the paired fixtures agree *and* that the baseline is still 2 — an equality alone would survive a regression that moved both halves. One corpus snapshot moves, from the arrow-in-`stops` half: `accessibility_spec.js`'s `getSpans` sits inside a `describe(..., () => {…})`, so it gains depth 1 and the arrow nested in it inherits it. Cognitive fields only. Submodule bumped and pushed here. Fixes #1187 --- big-code-analysis-book/src/metrics.md | 9 ++ src/metrics/cognitive.rs | 81 ++++++++-------- src/metrics/cognitive/elixir.rs | 11 ++- src/metrics/cognitive/python.rs | 18 ++-- .../parity/cognitive_cross_language_parity.rs | 97 +++++++++++++++++++ tests/repositories/big-code-analysis-output | 2 +- 6 files changed, 171 insertions(+), 47 deletions(-) diff --git a/big-code-analysis-book/src/metrics.md b/big-code-analysis-book/src/metrics.md index 436eda652..2740c8058 100644 --- a/big-code-analysis-book/src/metrics.md +++ b/big-code-analysis-book/src/metrics.md @@ -336,6 +336,15 @@ Sonar ecosystem. sense. It adds a surcharge *on top of* the enclosing nesting instead of replacing it, so a decision inside a lambda written inside an `if` is charged for both. +- That surcharge stops at the next function boundary. A function + *declared inside* a closure body opens a fresh lexical scope, so it + does not inherit the closure's surcharge — `fn g` inside a `|| { … }` + scores what it would score outside one. Until #1187 only the + JavaScript family applied this, so the same body scored differently + in Rust, Java, C++, PHP and C# depending on whether something two + levels up happened to be a closure. Python cannot express the shape + at all: a `def` is a statement and a lambda body is a single + expression. - **Python** charges a boolean operator an extra `+1` for each enclosing `lambda`, on top of the `+1` the boolean sequence itself earns. No other language does this. Only the outermost operator diff --git a/src/metrics/cognitive.rs b/src/metrics/cognitive.rs index 2068ed181..e43d42ed7 100644 --- a/src/metrics/cognitive.rs +++ b/src/metrics/cognitive.rs @@ -309,20 +309,29 @@ fn increment_function_depth<'a, T: PartialEq + From>( /// Applies the function-boundary rule at `node`, which every language /// with a syntactic function-definition kind shares (#696). /// -/// It moves two of [`Nesting`]'s three channels. Structural nesting -/// restarts at zero, so control flow written inside this function is -/// charged against its own depth rather than the enclosing function's; -/// and the function-depth surcharge rises when this definition is -/// itself lexically nested in one of `stops`. Byte-equivalent -/// constructs therefore score the same across languages, which is the -/// property the book's per-language deviations list states. +/// It moves all three of [`Nesting`]'s channels. Structural nesting and +/// the lambda surcharge restart at zero, so control flow written inside +/// this function is charged against its own depth rather than against +/// whatever enclosed the definition; and the function-depth surcharge +/// rises when this definition is itself lexically nested in one of +/// `stops`. Byte-equivalent constructs therefore score the same across +/// languages, which is the property the book's per-language deviations +/// list states. +/// +/// The lambda reset was the JS macro's alone until #1187. Every other +/// language carried the enclosing closure's surcharge into a function +/// *declared inside* it, so the same body scored 3 or 2 depending on +/// whether something two levels up happened to be a closure — measured +/// in Rust, Java, C++, PHP and C#, where a `LocalFunctionStatement` +/// inside a lambda is idiomatic. A function declaration is a new lexical +/// scope whatever encloses it, so the reset belongs to every boundary, +/// and living here is what stops a language opting out by accident — +/// which is how the gap arose. /// /// The two statements were spelled out longhand in eighteen modules -/// before #1103. Two callers still spell them out, for opposite -/// reasons: `elixir.rs` takes only the reset and deliberately skips the -/// depth bump, while the `js_cognitive!` macro takes the pair *plus* a -/// `nesting.lambda` reset no other language performs. Each says why at -/// its own site. +/// before #1103. One caller still spells them out: `elixir.rs` takes the +/// resets and deliberately skips the depth bump, and says why at its own +/// site. fn enter_function_boundary<'a, T: PartialEq + From>( nesting: &mut Nesting, node: &Node<'a>, @@ -330,6 +339,7 @@ fn enter_function_boundary<'a, T: PartialEq + From>( stops: &[T], ) { nesting.conditional = 0; + nesting.lambda = 0; increment_function_depth(&mut nesting.function_depth, node, ancestors, stops); } @@ -431,15 +441,6 @@ macro_rules! js_cognitive { | GeneratorFunction if Self::is_func(node, ancestors) => { - // The JS family takes the shared function-boundary - // rule plus one extra channel: `nesting.lambda` is - // reset too, which no other language does. A `function` - // declaration written inside an arrow function starts a - // fresh lexical scope, so it should not inherit that - // arrow's lambda surcharge. That third statement is why - // this arm spells the pair out rather than calling - // `enter_function_boundary` (#1103). - // // The kind set is `is_js_func!` minus `ArrowFunction`, // and the `function_expression` half is re-derived by // asking `Self::is_func` rather than copied flat, because @@ -491,10 +492,8 @@ macro_rules! js_cognitive { // optional name and covers both a function and a // closure — while `GeneratorFunctionDeclaration`, // like `FunctionDeclaration`, is unconditional. - nesting.conditional = 0; - nesting.lambda = 0; - increment_function_depth( - &mut nesting.function_depth, + enter_function_boundary( + &mut nesting, node, ancestors, &[ @@ -503,6 +502,7 @@ macro_rules! js_cognitive { FunctionExpression, GeneratorFunctionDeclaration, GeneratorFunction, + ArrowFunction, ], ); } @@ -10161,27 +10161,32 @@ end", /// Pins that `function_depth` and `lambda` are distinguishable. /// /// They are summed symmetrically almost everywhere, so most inputs - /// cannot tell them apart. The one asymmetric operation in the whole - /// cognitive family is the JS `FunctionDeclaration` arm's - /// `lambda = 0`, which clears one field alone. + /// cannot tell them apart. The asymmetric operation is + /// `enter_function_boundary`'s `lambda = 0`, which clears one field + /// while `increment_function_depth` raises the other — since #1187 + /// that pair runs for every language, not only the JS macro. /// - /// Whether that separates them depends on the *parity* of the - /// ancestor chain between the innermost arrow and the - /// `function_declaration`, because a swap at the write site - /// transposes the pair at every node on the way down. The plain + /// The doubled arrow is still load-bearing, for the reason it always + /// was: a swap at the write site transposes the pair at every node + /// on the way down, and the plain /// `arrow -> statement_block -> function_declaration` chain has odd - /// parity and stays at the same total either way; a second chained - /// arrow flips it. Hence the doubled arrow here — without it this - /// test passes with the two fields transposed and guards nothing. + /// parity and totals the same either way. A mutant writing + /// `function_depth = 0` in place of `lambda = 0` leaves `lambda 2, + /// function_depth 1` here and charges the `if` 4 rather than 2. #[test] fn javascript_function_depth_and_lambda_are_distinguishable() { - // expected: the inner `function` resets lambda, so only its own - // function-depth level survives and the `if` costs 1. + // expected: `inner` takes the boundary, so `conditional` and + // `lambda` both reset to 0. `ArrowFunction` joined the `stops` + // list in #1187, so `inner` earns a function-depth surcharge of + // 1 — `increment_function_depth` asks whether *any* ancestor is a + // stop, not how many, so two arrows still give 1. The `if` costs + // 1 base + 1 depth = 2, up from 1 before the arrow entered + // `stops`. check_metrics::( "const f = () => () => { function inner() { if (a) { } } };", "nest.js", |metric| { - assert_eq!(metric.cognitive.cognitive_sum(), 1); + assert_eq!(metric.cognitive.cognitive_sum(), 2); }, ); } diff --git a/src/metrics/cognitive/elixir.rs b/src/metrics/cognitive/elixir.rs index 80f67f37e..f2c7d9d01 100644 --- a/src/metrics/cognitive/elixir.rs +++ b/src/metrics/cognitive/elixir.rs @@ -99,11 +99,20 @@ impl Cognitive for ElixirCode { // concern — the lambda channel via // `AnonymousFunction` handles the analogous // higher-order case. Elixir is therefore the one - // language that takes the reset without the depth + // language that takes the resets without the depth // bump, so it resets inline instead of calling the // shared `enter_function_boundary` the other // eighteen modules use (#1103). + // + // Both channels, not just the conditional one. `def` + // inside `fn` is invalid Elixir but the grammar + // parses it, and skipping the lambda reset here is + // exactly the accidental opt-out that #1187 moved + // the line into the shared helper to prevent — so + // the one language that cannot use the helper has to + // stay in step by hand. nesting.conditional = 0; + nesting.lambda = 0; } _ => {} }, diff --git a/src/metrics/cognitive/python.rs b/src/metrics/cognitive/python.rs index 82e19fbc7..39ff9b6b7 100644 --- a/src/metrics/cognitive/python.rs +++ b/src/metrics/cognitive/python.rs @@ -222,13 +222,17 @@ impl Cognitive for PythonCode { // this definition is itself nested inside another, so a `def` // written inside an `if` is scored against its own depth rather // than the enclosing function's — matching Java, Rust, and - // every other conforming family (#696, #1149). Python is the - // one family that provably needs no `nesting.lambda = 0` - // companion to go with it — a `def` is a statement and a - // lambda body is a single expression, so no - // `function_definition` can sit under a `lambda`. Elsewhere - // that shape is legal (`let f = || { fn g() {} };`) and only - // the JS macro currently carries the extra line. + // every other conforming family (#696, #1149). + // + // Python is the one family for which the helper's + // `nesting.lambda = 0` is provably a no-op rather than a + // correction: a `def` is a statement and a lambda body is a + // single expression, so no `function_definition` can sit + // under a `lambda`. Elsewhere that shape is legal + // (`let f = || { fn g() {} };`), and until #1187 only the JS + // macro reset the channel — which is why the same body + // scored 3 or 2 in Rust, Java, C++, PHP and C# depending on + // whether something two levels up was a closure. FunctionDefinition => { enter_function_boundary(&mut nesting, node, ancestors, &[FunctionDefinition]); } diff --git a/tests/parity/cognitive_cross_language_parity.rs b/tests/parity/cognitive_cross_language_parity.rs index f3e229787..302e3ad4b 100644 --- a/tests/parity/cognitive_cross_language_parity.rs +++ b/tests/parity/cognitive_cross_language_parity.rs @@ -238,3 +238,100 @@ func f(x int) string { assert_eq!(irules, expected, "irules"); assert_eq!(objc, expected, "objc"); } + +/// A function *declared inside a closure* must score the same as the +/// same function declared outside one (#1187). +/// +/// The lambda surcharge is a property of where the closure's *body* sits, +/// not of a new function boundary opened inside it. Until #1187 only the +/// JS macro reset `nesting.lambda` at a boundary, so every other language +/// carried the enclosing closure's surcharge into the nested declaration +/// and the same body scored 3 or 2 depending on whether something two +/// levels up happened to be a closure. +/// +/// The paired fixtures are what make this discriminate: an absolute +/// value alone would move with any unrelated re-tuning, whereas the +/// *difference* between the two is exactly the defect. Both halves are +/// asserted so a regression that changed both equally still fails. +/// +/// Python is deliberately absent: a `def` is a statement and a lambda +/// body is a single expression, so the shape is unconstructible. +#[test] +fn a_function_declared_inside_a_closure_scores_the_same_as_outside() { + /// The innermost `g`'s own cognitive score. + fn inner_g(lang: LANG, source: &str, ext: &str) -> f64 { + fn find(space: &big_code_analysis::FuncSpace) -> Option { + if space.name.as_deref() == Some("g") { + return Some(space.metrics.cognitive.cognitive() as f64); + } + space.spaces.iter().find_map(find) + } + let name = format!("parity.{ext}"); + let space = analyze( + Source::new(lang, source.as_bytes()).with_name(Some(name)), + MetricsOptions::default(), + ) + .expect("parser produced no FuncSpace for parity fixture"); + find(&space).unwrap_or_else(|| panic!("{lang:?}: fixture has no function named `g`")) + } + + // (language, extension, inside a closure, outside one). The inner + // body is byte-identical between the two halves of each pair. + let cases: &[(LANG, &str, &str, &str)] = &[ + ( + LANG::Rust, + "rs", + "fn outer(a: bool, b: bool) { let f = || { if a { fn g(b: bool) { if b { println!(\"x\"); } } g(b); } }; f(); }", + "fn outer(a: bool, b: bool) { if a { fn g(b: bool) { if b { println!(\"x\"); } } g(b); } }", + ), + ( + LANG::Java, + "java", + "class K { void outer(boolean a, boolean b){ Runnable r = () -> { if(a){ class L { void g(boolean b){ if(b){ System.out.print(1); } } } } }; } }", + "class K { void outer(boolean a, boolean b){ if(a){ class L { void g(boolean b){ if(b){ System.out.print(1); } } } } } }", + ), + ( + LANG::Cpp, + "cpp", + "void outer(bool a, bool b){ auto f = [&]{ if(a){ struct L { void g(bool b){ if(b){ } } }; } }; f(); }", + "void outer(bool a, bool b){ if(a){ struct L { void g(bool b){ if(b){ } } }; } }", + ), + ( + LANG::Php, + "php", + " { if(a){ void g(bool b){ if(b){ } } } }; f(); } }", + "class K{ void outer(bool a, bool b){ if(a){ void g(bool b){ if(b){ } } } } }", + ), + ]; + + let mut checked = 0; + for &(lang, ext, inside, outside) in cases { + if !lang.is_enabled() { + continue; + } + checked += 1; + let (inside_score, outside_score) = + (inner_g(lang, inside, ext), inner_g(lang, outside, ext)); + assert_eq!( + inside_score, outside_score, + "{lang:?}: `g` scored {inside_score} inside a closure and {outside_score} outside it", + ); + // expected: `g`'s own `if` is +1 base, plus +1 for `g` being + // lexically nested in another function. The enclosing closure + // must contribute nothing. + assert_eq!( + outside_score, 2.0, + "{lang:?}: the baseline itself moved, so the equality above proves nothing", + ); + } + assert!( + checked > 0, + "at least one language feature must be enabled for this test to mean anything" + ); +} diff --git a/tests/repositories/big-code-analysis-output b/tests/repositories/big-code-analysis-output index 552d457c9..a4e7ccef5 160000 --- a/tests/repositories/big-code-analysis-output +++ b/tests/repositories/big-code-analysis-output @@ -1 +1 @@ -Subproject commit 552d457c9aa308d06a868a07e1746072685de1c0 +Subproject commit a4e7ccef5f75b238a39c2f474116589a58604cc2 From db5e9e62980247696811b715d2f3d713db35b069 Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Mon, 3 Aug 2026 07:20:58 -0700 Subject: [PATCH 14/27] fix(js): classify an anonymous function by one rule in both spellings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check_if_func!` and `check_if_arrow_func!` both answer "is this expression bound to a name, or used positionally?", and they answered it with two different ancestor walks. The issue reports one divergence; there are four, each measured: 1. **`$stop`**: `Arguments` for functions, `CallExpression` for arrows — different tree levels, since `call_expression > arguments > function_expression`. An IIFE's chain is `parenthesized_expression -> call_expression` with no `arguments` node on it, so the function walk ran past the call and reached the binding: `(function(c){})(1)` was a closure and `const v = (function(c){})(1)` a function. The same construct, classified by what happened one level up past the call. This is the reported bug. 2. **`$up`**: `Pair` in the function list only, so `({ "k": function(){} })` was a function and `({ "k": () => {} })` a closure. The arrow agreed for a *bare* key alone, and by a different mechanism — `$extra`'s `property_identifier` sibling — which a string, number or computed key does not provide. 3. **`$extra`**: `has_sibling(PropertyIdentifier)` in the arrow only, the exact mirror: `class C { p = () => {}; }` was a function and `class C { p = function(){}; }` a closure. 4. **`$extra`**: `is_child(Identifier)` in the function only — and this one **must stay**. For a function expression the only possible `identifier` child is its optional name, so `run(function g(){})` is rightly a function; an arrow stores its un-parenthesised single parameter in exactly that position, so unifying it would make `run(x => x)` a function. That is the commonest callback shape in any JS corpus. The first three are unified, which makes an IIFE a closure in both spellings — matching the arrow behaviour, and matching how a reader reads it. `$up` and `$stop` are now token-identical; only `$extra` differs, for the reason in (4), recorded at the macros. Knock-on, taken deliberately: the cognitive boundary arm is gated on `Self::is_func`, so a reclassified IIFE no longer resets its own `nesting.conditional`. That coupling is the documented invariant — the Checker and cognitive disagreeing would be the larger bug — so it stays. Tests: `check_js_binding_site_parity` runs in all four instantiating languages (the macros are shared but the token ids are not) and asserts the two spellings agree for each divergence, plus both directions of the one that must not unify. Verified by perturbation: reverting each of the four independently fails the case that names it, in every language. Reinstating `is_child(Identifier)` on the arrow fails `run(x => x)` — the guard against a future over-eager unification. `javascript_nom` / `mozjs_nom` move: their fixture is the D1 case (`var bar = (function(){…})()`), now two functions and two closures rather than three and one. Four pdf.js corpus files move, all pure reclassification — `functions + closures` constant in every pair, no field outside nom / nargs / cognitive touched. Submodule bumped and pushed here. Fixes #1188 --- src/checker.rs | 43 +++++- src/metrics/nom.rs | 149 ++++++++++++++++++-- tests/repositories/big-code-analysis-output | 2 +- 3 files changed, 178 insertions(+), 16 deletions(-) diff --git a/src/checker.rs b/src/checker.rs index 0bddf52e7..a69605644 100644 --- a/src/checker.rs +++ b/src/checker.rs @@ -71,14 +71,49 @@ macro_rules! js_ancestor_walk { }; } +// Is this expression *bound* to a name, or used positionally? +// +// `check_if_func!` and `check_if_arrow_func!` answer that question for +// the two anonymous-function forms, and until #1188 they answered it +// with two different ancestor walks. Four divergences, each measured: +// +// 1. `$stop` was `Arguments` for one and `CallExpression` for the other +// — different tree levels, since `call_expression > arguments > +// function_expression`. An IIFE's chain is `parenthesized_expression +// -> call_expression` with no `arguments` on it at all, so the func +// walk ran past the call and reached the binding: `(function(c){})(1)` +// was a closure while `const v = (function(c){})(1)` was a function. +// The same construct, classified by what happened one level up past +// the call. +// 2. `Pair` was in the func `$up` only, so `({ "k": function(){} })` was +// a function and `({ "k": () => {} })` a closure. The arrow reached +// the same verdict for a *bare* key only, and by a different +// mechanism — `$extra`'s `property_identifier` sibling — which a +// string, number or computed key does not provide. +// 3. `has_sibling(PropertyIdentifier)` was in the arrow `$extra` only, +// the exact mirror: `class C { p = () => {}; }` was a function and +// `class C { p = function(){}; }` a closure. +// 4. `is_child(Identifier)` is in the func `$extra` only, and **must +// stay there**. For a function expression the only possible +// `identifier` *child* is its optional name, so it means "carries its +// own name" and `run(function g(){})` is rightly a function. An arrow +// stores its un-parenthesised single parameter in exactly that +// position, so unifying it would make `run(x => x)` — the commonest +// callback shape in any JS corpus — a function. +// +// The first three are unified below, which makes an IIFE a closure in +// both spellings: that is what a reader sees, and it matches the +// behaviour the arrow form already had. `$up` and `$stop` are now +// token-identical and only `$extra` differs, for the reason in (4). macro_rules! check_if_func { ($node: ident, $ancestors: ident) => { js_ancestor_walk!( $node, $ancestors, [VariableDeclarator | AssignmentExpression | LabeledStatement | Pair], - [StatementBlock | ReturnStatement | NewExpression | Arguments], - $node.is_child(Identifier as u16), + [StatementBlock | ReturnStatement | NewExpression | CallExpression | Arguments], + $node.is_child(Identifier as u16) + || $node.has_sibling($ancestors, PropertyIdentifier as u16), ) }; } @@ -88,8 +123,8 @@ macro_rules! check_if_arrow_func { js_ancestor_walk!( $node, $ancestors, - [VariableDeclarator | AssignmentExpression | LabeledStatement], - [StatementBlock | ReturnStatement | NewExpression | CallExpression], + [VariableDeclarator | AssignmentExpression | LabeledStatement | Pair], + [StatementBlock | ReturnStatement | NewExpression | CallExpression | Arguments], $node.has_sibling($ancestors, PropertyIdentifier as u16), ) }; diff --git a/src/metrics/nom.rs b/src/metrics/nom.rs index 6954d49a1..1fa548d6f 100644 --- a/src/metrics/nom.rs +++ b/src/metrics/nom.rs @@ -612,17 +612,24 @@ mod tests { }", "foo.js", |metric| { - // Number of spaces = 5 - // functions: f, foo, bar - // closures: return function () + // Number of spaces = 5. + // functions: f, foo + // closures: the IIFE assigned to `bar`, and the + // function it returns + // + // The IIFE moved to the closure side in #1188. It used to + // count as the function `bar` purely because its *result* + // was bound to a name: written `(function(){…})()` with + // the assignment dropped, the identical construct was + // already a closure. Both spellings are closures now. insta::assert_json_snapshot!( metric.nom, @r#" { - "functions": 3, - "closures": 1, - "functions_average": 0.6, - "closures_average": 0.2, + "functions": 2, + "closures": 2, + "functions_average": 0.4, + "closures_average": 0.4, "total": 4, "average": 0.8, "functions_min": 0, @@ -636,6 +643,126 @@ mod tests { ); } + /// The two anonymous-function forms must be classified by the same + /// rule (#1188). + /// + /// `check_if_func!` and `check_if_arrow_func!` walk the ancestor + /// chain to decide whether an expression is *bound to a name* or used + /// positionally, and they used to disagree in three ways. Each case + /// below is one measured divergence; the function-expression and + /// arrow spellings must now agree. + /// + /// Uses `metrics_verbatim` rather than the `check_metrics` shim, + /// whose bare-`fn` callback cannot capture the case's label or its + /// expectation. + fn check_js_binding_site_parity(lang: crate::LANG) { + let split = |source: &str| { + let m = crate::test_support::metrics_verbatim( + lang, + source.as_bytes(), + crate::MetricsOptions::default(), + ); + (m.nom.functions_sum(), m.nom.closures_sum()) + }; + + // (label, function-expression form, arrow form, is it a function?) + let cases: &[(&str, &str, &str, bool)] = &[ + // An IIFE is the same construct whether or not its *result* + // is bound. It used to be a function in the bound spelling + // only, because `$stop` was `Arguments` for functions and + // `CallExpression` for arrows — different tree levels, and an + // IIFE's chain carries no `arguments` node at all. + ( + "bare IIFE", + "(function (c) { return c; })(1);", + "(() => 1)(1);", + false, + ), + ( + "bound IIFE", + "const v = (function (c) { return c; })(1);", + "const v = (() => 1)(1);", + false, + ), + // A pair with a *non-identifier* key. The arrow reached the + // right verdict for a bare key only, through `$extra`'s + // `property_identifier` sibling, which a string key does not + // provide; `Pair` was in the function `$up` alone. + ( + "string-keyed pair", + "({ \"k\": function () { return 1; } });", + "({ \"k\": () => 1 });", + true, + ), + // The mirror: `has_sibling(PropertyIdentifier)` was in the + // arrow `$extra` alone, so a class field initialiser was a + // function as an arrow and a closure as a function expression. + ( + "class field initialiser", + "class C { p = function () { return 1; }; }", + "class C { p = () => 1; }", + true, + ), + // Plain positional callbacks, unchanged by all of the above. + ( + "positional callback", + "run(function () { return 1; });", + "run(() => 1);", + false, + ), + ]; + + for &(label, func_form, arrow_form, expected) in cases { + for (form, source) in [("function", func_form), ("arrow", arrow_form)] { + let (functions, closures) = split(source); + assert_eq!( + functions == 1, + expected, + "{lang:?} {label} / {form}: expected a {}, got functions {functions} closures {closures}", + if expected { "function" } else { "closure" }, + ); + } + } + + // The divergence that must **survive**: for a function expression + // an `identifier` child is its optional name, so + // `run(function g(){})` is a function; for an arrow it is the + // un-parenthesised parameter, so `run(x => x)` must stay a + // closure. Unifying that one would reclassify the commonest + // callback shape in any JS corpus. Asserted in both directions so + // a future "unify the last one too" cannot pass quietly. + assert_eq!( + split("run(function g() { return 1; });").0, + 1, + "{lang:?}: a named function expression carries its own name", + ); + assert_eq!( + split("run(x => x);").1, + 1, + "{lang:?}: an arrow's identifier child is its parameter, not a name", + ); + } + + #[test] + fn javascript_binding_site_parity() { + check_js_binding_site_parity(crate::LANG::Javascript); + } + + #[test] + fn mozjs_binding_site_parity() { + check_js_binding_site_parity(crate::LANG::Mozjs); + } + + #[test] + fn typescript_binding_site_parity() { + check_js_binding_site_parity(crate::LANG::Typescript); + } + + #[test] + fn tsx_binding_site_parity() { + check_js_binding_site_parity(crate::LANG::Tsx); + } + #[test] fn javascript_call_nom() { check_metrics::( @@ -1594,10 +1721,10 @@ mod tests { metric.nom, @r#" { - "functions": 3, - "closures": 1, - "functions_average": 0.6, - "closures_average": 0.2, + "functions": 2, + "closures": 2, + "functions_average": 0.4, + "closures_average": 0.4, "total": 4, "average": 0.8, "functions_min": 0, diff --git a/tests/repositories/big-code-analysis-output b/tests/repositories/big-code-analysis-output index a4e7ccef5..9e4090850 160000 --- a/tests/repositories/big-code-analysis-output +++ b/tests/repositories/big-code-analysis-output @@ -1 +1 @@ -Subproject commit a4e7ccef5f75b238a39c2f474116589a58604cc2 +Subproject commit 9e4090850df164f1208ad50ccba5419ee03f3216 From d87db12cd63173f8b55735e5637893ed1fca947a Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Mon, 3 Aug 2026 07:38:29 -0700 Subject: [PATCH 15/27] fix(cognitive): treat the #1184 constructs as function boundaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The five nameless constructs open a `FuncSpace` since #1184a, but reached no cognitive boundary arm, so each inherited the enclosing conditional nesting instead of restarting it. A Kotlin property accessor nested two `if`s deep scored 7 where the ordinary method beside it — same body, same position — scored 5. This is the second half of #1160's pattern: `is_func` / `get_space_kind` fix the space, the cognitive arm and the `stops` list fix the score. Held until last in this group because it edits the same `js_cognitive!` region #1186, #1187 and #1188 rewrote. Kotlin, Java and Groovy match kinds directly, so the constructs join the existing arm and its `stops` list. JavaScript needs its own *ungated* arm: the class static block is deliberately outside `is_func` (#1184a), and the JS boundary arm is gated on `Self::is_func`, so adding the kind there would never fire. It is in `stops` for the same reason a `function_expression` is — a `function` declared inside a `static { … }` really is nested in one. Tests: `nameless_construct_boundaries` compares each construct against a *sibling method* with a byte-identical body in the same position, so the assertion states the property rather than a number that moves with any unrelated re-tuning; the absolute 5 is pinned alongside so a regression moving both equally still fails. Two levels of `if` are load-bearing, and the issue's own checklist says so. A first draft used one level and reported the same number with and without the fix — it could not discriminate, and I only noticed because the perturbation run came back green. A second false negative followed: the first perturbation attempt silently matched nothing, because rustfmt had collapsed the arm onto one line after I wrote the pattern. Both are worth recording, since each looked exactly like a passing verification. With the perturbation actually applied, the Kotlin accessor scores 7 against the method's 5 and the test names it. Fixes #1184 --- src/metrics/cognitive.rs | 125 ++++++++++++++++++++++++++++++++ src/metrics/cognitive/groovy.rs | 5 +- src/metrics/cognitive/java.rs | 10 ++- src/metrics/cognitive/kotlin.rs | 18 ++++- 4 files changed, 153 insertions(+), 5 deletions(-) diff --git a/src/metrics/cognitive.rs b/src/metrics/cognitive.rs index e43d42ed7..c3d01c330 100644 --- a/src/metrics/cognitive.rs +++ b/src/metrics/cognitive.rs @@ -503,6 +503,30 @@ macro_rules! js_cognitive { GeneratorFunctionDeclaration, GeneratorFunction, ArrowFunction, + ClassStaticBlock, + ], + ); + } + // A class static block is a function boundary but is + // deliberately *not* in `is_func` (#1184), so it needs + // its own ungated arm rather than joining the gated one + // above — gated, it would never fire. It is in the + // `stops` list below for the same reason a + // `function_expression` is: a `function` declared inside + // a `static { … }` really is nested in one. + ClassStaticBlock => { + enter_function_boundary( + &mut nesting, + node, + ancestors, + &[ + FunctionDeclaration, + MethodDefinition, + FunctionExpression, + GeneratorFunctionDeclaration, + GeneratorFunction, + ArrowFunction, + ClassStaticBlock, ], ); } @@ -10515,3 +10539,104 @@ end", ); } } + +/// The nameless constructs from #1184 are function boundaries, so a +/// deeply-nested one must score what the same body scores as an +/// ordinary method in the same position (#1184). +/// +/// Each opens a `FuncSpace`, and without a cognitive boundary arm it +/// reached none and inherited the enclosing conditional nesting: a +/// Kotlin accessor nested two `if`s deep scored 7 where the method +/// beside it scored 5. +/// +/// **Two levels of nesting are load-bearing.** At one level the fixture +/// reports the same number either way, which is the trap the issue's own +/// checklist warns about — a first draft of this test used one `if` and +/// could not discriminate the fix from its absence. +/// +/// The comparison is against a *sibling method* rather than an absolute +/// number, so the assertion states the property (these are ordinary +/// function boundaries) rather than a value that moves with any +/// unrelated re-tuning. The absolute is pinned too, so a regression +/// moving both equally still fails. +#[cfg(test)] +mod nameless_construct_boundaries { + use crate::test_support::space_verbatim; + use crate::{FuncSpace, LANG, MetricsOptions}; + + fn score(lang: LANG, source: &str, name: &str) -> u64 { + fn find(s: &FuncSpace, name: &str) -> Option { + if s.name.as_deref() == Some(name) { + return Some(s.metrics.cognitive.cognitive()); + } + s.spaces.iter().find_map(|c| find(c, name)) + } + let root = space_verbatim(lang, source.as_bytes(), MetricsOptions::default()); + find(&root, name) + .unwrap_or_else(|| panic!("{lang:?}: no space named {name:?} in the fixture")) + } + + /// `(language, source, construct name, sibling method name)`. Each + /// fixture nests a class two `if`s deep and gives it both the + /// nameless construct and an ordinary method with a byte-identical + /// body. + fn cases() -> Vec<(LANG, &'static str, &'static str, &'static str)> { + vec![ + ( + LANG::Kotlin, + "fun outer(a: Boolean) { if (a) { if (a) { class D {\n\ + \x20 var q: Int = 0\n\ + \x20 get() { if (q > 0) { if (q > 1) { return 2 } }; return 0 }\n\ + \x20 fun m(): Int { if (q > 0) { if (q > 1) { return 2 } }; return 0 }\n\ + } } } }\n", + "", + "m", + ), + ( + LANG::Java, + "class K { void outer(boolean a) { if (a) { if (a) { class D {\n\ + \x20 static int x;\n\ + \x20 static { if (x > 0) { if (x > 1) { x = 2; } } }\n\ + \x20 void m() { if (x > 0) { if (x > 1) { x = 2; } } }\n\ + } } } } }\n", + "", + "m", + ), + ( + LANG::Javascript, + "function outer(a) { if (a) { if (a) { class D {\n\ + \x20 static x;\n\ + \x20 static { if (D.x > 0) { if (D.x > 1) { D.x = 2; } } }\n\ + \x20 m() { if (D.x > 0) { if (D.x > 1) { D.x = 2; } } }\n\ + } } } }\n", + "", + "m", + ), + ] + } + + #[test] + fn a_nested_nameless_construct_scores_like_a_sibling_method() { + let mut checked = 0; + for (lang, source, construct, method) in cases() { + if !lang.is_enabled() { + continue; + } + checked += 1; + let (got, want) = (score(lang, source, construct), score(lang, source, method)); + assert_eq!( + got, want, + "{lang:?}: {construct} scored {got} where the sibling method scored {want}; \ + the construct is inheriting the enclosing nesting", + ); + // expected: two `if`s at +1 and +2 = 3, plus +1 each for the + // function-depth surcharge from `outer` = 5. Pinned so a + // regression that moved both sides equally still fails. + assert_eq!(want, 5, "{lang:?}: the baseline itself moved"); + } + assert!( + checked > 0, + "no language enabled; this test asserted nothing" + ); + } +} diff --git a/src/metrics/cognitive/groovy.rs b/src/metrics/cognitive/groovy.rs index 72cf03341..beb7a6d9d 100644 --- a/src/metrics/cognitive/groovy.rs +++ b/src/metrics/cognitive/groovy.rs @@ -80,12 +80,13 @@ impl Cognitive for GroovyCode { // families. Groovy methods can nest inside inner classes; a // nested method previously inherited the enclosing nesting and // missed the SonarSource B-nesting amplification (#696). - MethodDeclaration | ConstructorDeclaration => { + // `static { … }` — see `java.rs`, which this mirrors (#1184). + MethodDeclaration | ConstructorDeclaration | StaticInitializer => { enter_function_boundary( &mut nesting, node, ancestors, - &[MethodDeclaration, ConstructorDeclaration], + &[MethodDeclaration, ConstructorDeclaration, StaticInitializer], ); } _ => {} diff --git a/src/metrics/cognitive/java.rs b/src/metrics/cognitive/java.rs index 8f3ae1e11..e4db1ce84 100644 --- a/src/metrics/cognitive/java.rs +++ b/src/metrics/cognitive/java.rs @@ -65,7 +65,14 @@ impl Cognitive for JavaCode { // `constructor_declaration`, so it needs listing in both the // arm and the `stops` set — otherwise its body's control flow // is charged to the enclosing class space (#1160). - MethodDeclaration | ConstructorDeclaration | CompactConstructorDeclaration => { + // `static { … }` joins them for the same reason (#1184): it + // opens a `FuncSpace`, so without this arm an initialiser + // written inside a nested class inherited the enclosing + // nesting. + MethodDeclaration + | ConstructorDeclaration + | CompactConstructorDeclaration + | StaticInitializer => { enter_function_boundary( &mut nesting, node, @@ -74,6 +81,7 @@ impl Cognitive for JavaCode { MethodDeclaration, ConstructorDeclaration, CompactConstructorDeclaration, + StaticInitializer, ], ); } diff --git a/src/metrics/cognitive/kotlin.rs b/src/metrics/cognitive/kotlin.rs index 7351488d8..959fee00b 100644 --- a/src/metrics/cognitive/kotlin.rs +++ b/src/metrics/cognitive/kotlin.rs @@ -83,12 +83,26 @@ impl Cognitive for KotlinCode { matches!(id.into(), AMPAMP | PIPEPIPE | QMARKCOLON) }); } - FunctionDeclaration | SecondaryConstructor => { + // Property accessors and `init { … }` are function + // boundaries too (#1184). They open a `FuncSpace`, so without + // this arm an accessor written inside an `if` inherited that + // `if`'s nesting and scored more than the byte-identical + // accessor at top level — the same defect #1160 fixed for + // Java's compact constructor. They are `is_func_space` + // without being `is_func`, which does not matter here: this + // arm matches kinds directly. + FunctionDeclaration | SecondaryConstructor | Getter | Setter | AnonymousInitializer => { enter_function_boundary( &mut nesting, node, ancestors, - &[FunctionDeclaration, SecondaryConstructor], + &[ + FunctionDeclaration, + SecondaryConstructor, + Getter, + Setter, + AnonymousInitializer, + ], ); } LambdaLiteral | AnonymousFunction => { From bb9a0d1db29da9c11a5463881fc6455852f1371d Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Mon, 3 Aug 2026 07:48:56 -0700 Subject: [PATCH 16/27] docs(self-scan): record why nargs stays at 7 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #1183 proposed converging this repository's `nargs` limit from 7 onto the shipped default of 5, and prescribed sampling a dozen offenders before committing to the refactors. Measured all 76 that a limit of 5 would newly gate, and the result is a third answer — neither "threaded context" nor "genuine god-functions": | | count | |---|---| | newly gated at `nargs = 5` | 76 | | with **6+ parameters of their own** | **17** | | carrying closure args | 59 | | with four or fewer own parameters | 44 | The gate extracts `nargs.total()`, which sums a function's own parameters with every nested closure's. `Parser::filters` has one parameter and five from closures in its body; `write_top_offenders` has three and scores 6 through a sort comparator and a format closure. Converging would therefore pressure contributors to delete idiomatic inline closures for 59 of the 76, which is not what the metric is for. That is a property of the extractor rather than of this codebase, so it is not a case for the per-language override in #1141 either. Filed as #1196, since every adopting project inherits the same semantics through the shipped default, and #1140's per-language corpus figures were derived from the same summed value. Replaces the previous rationale, which framed the choice as "stay at 7 or do the real 6 -> 5 work" — the evidence supersedes both halves. Refs #1183, #1196 --- bca.toml | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/bca.toml b/bca.toml index bdfd63265..1b4cba470 100644 --- a/bca.toml +++ b/bca.toml @@ -149,10 +149,22 @@ exclude = [ # Re-measure with `bca check --explain-threshold nargs=6` (#1169) rather # than a bare `--threshold`, which has no soft tier by design. What makes # `nargs` different from the `cognitive` convergence above is where the -# limit falls in the distribution: nothing in this repo exceeds 6, so a +# limit falls in the distribution: nothing *unbaselined* exceeds 6, so a # limit of 6 sits on the population's ceiling and buys no hard-tier gate, -# whereas `cognitive` runs to 20 with 15 inside the distribution. The -# 6 -> 5 step is tracked separately in #1183. +# whereas `cognitive` runs to 20 with 15 inside the distribution. +# +# The 6 -> 5 step (#1183) was measured and rejected, and the reason is +# about the metric rather than about this codebase. The gate extracts +# `nargs.total()`, which sums a function's own parameters with every +# nested closure's. Of the 76 functions a limit of 5 would newly gate, +# only 17 have 6 or more parameters of their own; 59 carry closure args +# and 44 have four or fewer own parameters. The extreme is +# `Parser::filters` — one parameter, plus five from the closures in +# its body. Converging would therefore pressure contributors to delete +# idiomatic inline closures rather than to simplify signatures, which is +# not what the metric is for. Whether the gate should key on +# `function_args` instead is tracked in #1196; until that is settled a +# lower limit here would be measuring the wrong thing. cognitive = 15 cyclomatic = 15 "halstead.effort" = 50000 From a66365eff90a7a3a1cee953de337850f1e441379 Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Mon, 3 Aug 2026 08:14:16 -0700 Subject: [PATCH 17/27] refactor: absorb the threshold regressions this batch introduced MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `bca check` over the batch's changed files reported four offenders that are mine. Each is resolved on its own terms rather than by baselining the lot. **`get_func_space_name`, cognitive 16 (limit 15), four JS-family modules.** #1184a added an early return and pushed it over. Genuinely flattened instead: the `Pair` and `VariableDeclarator` arms differed only in *which field* carries the name, so they collapse to one lookup, which removes a nesting level and the duplication together. The `if let … else { if let … { match … } }` staircase becomes two early returns and a `map_or`. **`EffectiveConfig::from_resolved`, halstead.effort 52857 (limit 50000).** #1194 added four fields to a 27-field struct literal. The effort is entirely operand vocabulary — 71 unique operands, one per field name, and no decisions at all — so the number is an artifact of flat initialisation rather than of anything a reader must follow. Split on the boundary a reader would already draw: the function built two independent types, and `EffectiveCheck` now has its own constructor. **`tcl_inspect_container` / `irules_inspect_container`, cognitive 19.** Not refactored. These are the same wrapper-peeling state machine as `cpp_inspect_container`, which already carries `bca: suppress(cognitive)` with the rationale that the boolean-context flag must be readable at every step so any split has to thread it back out. #1180 pushed the two siblings over the limit by giving them the seed and the wrapper peel every other language already had. They get the same marker, pointing at the same reasoning. Behaviour is unchanged throughout: 5117 tests pass, including the 450 getter / nom / cognitive tests that cover the flattened function. The remaining offender is `markdown_report/hotspot.rs`'s file-level `loc.ploc`, which is a baseline refresh rather than a code question — handled separately with the rest of the baseline. --- .../src/commands/check/effective_config.rs | 54 +++++++++++++------ src/getter/javascript.rs | 34 +++++------- src/getter/mozjs.rs | 34 +++++------- src/getter/tsx.rs | 34 +++++------- src/getter/typescript.rs | 34 +++++------- src/metrics/abc/irules.rs | 9 ++++ src/metrics/abc/tcl.rs | 9 ++++ 7 files changed, 112 insertions(+), 96 deletions(-) diff --git a/big-code-analysis-cli/src/commands/check/effective_config.rs b/big-code-analysis-cli/src/commands/check/effective_config.rs index 8671e0e9b..fea21d611 100644 --- a/big-code-analysis-cli/src/commands/check/effective_config.rs +++ b/big-code-analysis-cli/src/commands/check/effective_config.rs @@ -253,28 +253,25 @@ fn resolved_limits(set: &ThresholdSet) -> BTreeMap { .collect() } -impl EffectiveConfig { - /// Project the resolved `ThresholdSet` + the original CLI args into - /// a serializable view. Paths are rendered with [`Path::display`] - /// because the printed config is informational; `--config` only - /// reads the `[thresholds]` table back, where keys/values are pure - /// ASCII metric names + numbers and round-trip exactly. - pub(crate) fn from_resolved( +impl EffectiveCheck { + /// Project the CLI args and any applied manifest into the + /// serializable `[check]` view. + /// + /// Its own constructor rather than a block inside + /// [`EffectiveConfig::from_resolved`]: that function built two + /// independent types, and this half is a flat projection whose + /// Halstead effort is entirely operand vocabulary — one unique + /// operand per field name, and no decisions at all. Splitting on + /// the boundary a reader would already draw puts each type’s + /// construction with its own type. + fn from_resolved( globals: &GlobalOpts, args: &CheckArgs, - resolved: &LanguageThresholds, manifest: Option<&Manifest>, tier: TierSpec, tiered_exit_codes: bool, ) -> Self { - let thresholds = EffectiveThresholds { - global: resolved_limits(resolved.global()), - lang: resolved - .languages() - .map(|(slug, set)| (slug, resolved_limits(set))) - .collect(), - }; - let check = EffectiveCheck { + Self { paths: globals .paths .iter() @@ -323,7 +320,32 @@ impl EffectiveConfig { }, baseline_line_tolerance: args.baseline_line_tolerance, baseline_fuzzy_match: args.baseline_fuzzy_match.unwrap_or(false), + } + } +} + +impl EffectiveConfig { + /// Project the resolved `ThresholdSet` + the original CLI args into + /// a serializable view. Paths are rendered with [`Path::display`] + /// because the printed config is informational; `--config` only + /// reads the `[thresholds]` table back, where keys/values are pure + /// ASCII metric names + numbers and round-trip exactly. + pub(crate) fn from_resolved( + globals: &GlobalOpts, + args: &CheckArgs, + resolved: &LanguageThresholds, + manifest: Option<&Manifest>, + tier: TierSpec, + tiered_exit_codes: bool, + ) -> Self { + let thresholds = EffectiveThresholds { + global: resolved_limits(resolved.global()), + lang: resolved + .languages() + .map(|(slug, set)| (slug, resolved_limits(set))) + .collect(), }; + let check = EffectiveCheck::from_resolved(globals, args, manifest, tier, tiered_exit_codes); Self { thresholds, check } } } diff --git a/src/getter/javascript.rs b/src/getter/javascript.rs index 09a01d3f8..cc24354f4 100644 --- a/src/getter/javascript.rs +++ b/src/getter/javascript.rs @@ -34,27 +34,21 @@ impl Getter for JavascriptCode { return Some(""); } if let Some(name) = node.child_by_field_name("name") { - node_text(code, &name) - } else { - // We can be in a pair: foo: function() {} - // Or in a variable declaration: var aFun = function() {} - if let Some(parent) = ancestors.parent(node) { - match parent.kind_id().into() { - Javascript::Pair => { - if let Some(name) = parent.child_by_field_name("key") { - return node_text(code, &name); - } - } - Javascript::VariableDeclarator => { - if let Some(name) = parent.child_by_field_name("name") { - return node_text(code, &name); - } - } - _ => {} - } - } - Some("") + return node_text(code, &name); } + // Otherwise the name comes from the binding site: a pair + // (`foo: function () {}`) or a variable declaration + // (`var aFun = function () {}`). The two differ only in which + // field carries the name, so they collapse to one lookup. + let bound_name = ancestors.parent(node).and_then(|parent| { + let field = match parent.kind_id().into() { + Javascript::Pair => "key", + Javascript::VariableDeclarator => "name", + _ => return None, + }; + parent.child_by_field_name(field) + }); + bound_name.map_or(Some(""), |name| node_text(code, &name)) } impl_js_family_get_op_type!( diff --git a/src/getter/mozjs.rs b/src/getter/mozjs.rs index aa6ae6968..310413c9c 100644 --- a/src/getter/mozjs.rs +++ b/src/getter/mozjs.rs @@ -34,27 +34,21 @@ impl Getter for MozjsCode { return Some(""); } if let Some(name) = node.child_by_field_name("name") { - node_text(code, &name) - } else { - // We can be in a pair: foo: function() {} - // Or in a variable declaration: var aFun = function() {} - if let Some(parent) = ancestors.parent(node) { - match parent.kind_id().into() { - Mozjs::Pair => { - if let Some(name) = parent.child_by_field_name("key") { - return node_text(code, &name); - } - } - Mozjs::VariableDeclarator => { - if let Some(name) = parent.child_by_field_name("name") { - return node_text(code, &name); - } - } - _ => {} - } - } - Some("") + return node_text(code, &name); } + // Otherwise the name comes from the binding site: a pair + // (`foo: function () {}`) or a variable declaration + // (`var aFun = function () {}`). The two differ only in which + // field carries the name, so they collapse to one lookup. + let bound_name = ancestors.parent(node).and_then(|parent| { + let field = match parent.kind_id().into() { + Mozjs::Pair => "key", + Mozjs::VariableDeclarator => "name", + _ => return None, + }; + parent.child_by_field_name(field) + }); + bound_name.map_or(Some(""), |name| node_text(code, &name)) } impl_js_family_get_op_type!( diff --git a/src/getter/tsx.rs b/src/getter/tsx.rs index bca2ca3d3..1f6033f9a 100644 --- a/src/getter/tsx.rs +++ b/src/getter/tsx.rs @@ -35,27 +35,21 @@ impl Getter for TsxCode { return Some(""); } if let Some(name) = node.child_by_field_name("name") { - node_text(code, &name) - } else { - // We can be in a pair: foo: function() {} - // Or in a variable declaration: var aFun = function() {} - if let Some(parent) = ancestors.parent(node) { - match parent.kind_id().into() { - Tsx::Pair => { - if let Some(name) = parent.child_by_field_name("key") { - return node_text(code, &name); - } - } - Tsx::VariableDeclarator => { - if let Some(name) = parent.child_by_field_name("name") { - return node_text(code, &name); - } - } - _ => {} - } - } - Some("") + return node_text(code, &name); } + // Otherwise the name comes from the binding site: a pair + // (`foo: function () {}`) or a variable declaration + // (`var aFun = function () {}`). The two differ only in which + // field carries the name, so they collapse to one lookup. + let bound_name = ancestors.parent(node).and_then(|parent| { + let field = match parent.kind_id().into() { + Tsx::Pair => "key", + Tsx::VariableDeclarator => "name", + _ => return None, + }; + parent.child_by_field_name(field) + }); + bound_name.map_or(Some(""), |name| node_text(code, &name)) } // TSX exposes two anonymous `"string"` aliases: `String2` (the diff --git a/src/getter/typescript.rs b/src/getter/typescript.rs index b86d35359..9627d7bbf 100644 --- a/src/getter/typescript.rs +++ b/src/getter/typescript.rs @@ -35,27 +35,21 @@ impl Getter for TypescriptCode { return Some(""); } if let Some(name) = node.child_by_field_name("name") { - node_text(code, &name) - } else { - // We can be in a pair: foo: function() {} - // Or in a variable declaration: var aFun = function() {} - if let Some(parent) = ancestors.parent(node) { - match parent.kind_id().into() { - Typescript::Pair => { - if let Some(name) = parent.child_by_field_name("key") { - return node_text(code, &name); - } - } - Typescript::VariableDeclarator => { - if let Some(name) = parent.child_by_field_name("name") { - return node_text(code, &name); - } - } - _ => {} - } - } - Some("") + return node_text(code, &name); } + // Otherwise the name comes from the binding site: a pair + // (`foo: function () {}`) or a variable declaration + // (`var aFun = function () {}`). The two differ only in which + // field carries the name, so they collapse to one lookup. + let bound_name = ancestors.parent(node).and_then(|parent| { + let field = match parent.kind_id().into() { + Typescript::Pair => "key", + Typescript::VariableDeclarator => "name", + _ => return None, + }; + parent.child_by_field_name(field) + }); + bound_name.map_or(Some(""), |name| node_text(code, &name)) } // TS exposes `String2` as the anonymous `"string"` alias for the diff --git a/src/metrics/abc/irules.rs b/src/metrics/abc/irules.rs index bd7d61cb5..6fed24c84 100644 --- a/src/metrics/abc/irules.rs +++ b/src/metrics/abc/irules.rs @@ -182,6 +182,15 @@ fn irules_command_is_assignment(node: &Node, code: &[u8]) -> bool { // iRules counterpart of `tcl_inspect_container` (Fitzpatrick Rule 9): a // negated bare operand (`!$flag`) inside a boolean chain is one condition. fn irules_inspect_container(container_node: &Node, parent: &Node, conditions: &mut f64) { + // bca: suppress(cognitive) — wrapper-peeling state machine, clearest whole + // The same shape as `cpp_inspect_container`, and it carries the same + // marker for the same reason: one loop peels the `expr` / `!` layers + // while carrying a single boolean-context flag, the flag must be + // readable at every step so any split would have to thread it back + // out, and the parts have no names a reader would draw. It crossed + // the limit when #1180 wired the Phase 2B slot routing — the seed + // grew a ternary-slot disjunct and the loop gained the wrapper peel + // every sibling already had. let mut node = *container_node; let mut node_kind = node.kind_id().into(); let parent_kind = parent.kind_id().into(); diff --git a/src/metrics/abc/tcl.rs b/src/metrics/abc/tcl.rs index ef186b5c3..4d3c47ad1 100644 --- a/src/metrics/abc/tcl.rs +++ b/src/metrics/abc/tcl.rs @@ -53,6 +53,15 @@ const TCL_ASSIGNMENT_COMMANDS: &[&[u8]] = &[b"incr", b"append", b"lappend"]; // (`$x`), command substitutions (`[cmd]`), the boolean keyword, and // the numeric literal. fn tcl_inspect_container(container_node: &Node, parent: &Node, conditions: &mut f64) { + // bca: suppress(cognitive) — wrapper-peeling state machine, clearest whole + // The same shape as `cpp_inspect_container`, and it carries the same + // marker for the same reason: one loop peels the `expr` / `!` layers + // while carrying a single boolean-context flag, the flag must be + // readable at every step so any split would have to thread it back + // out, and the parts have no names a reader would draw. It crossed + // the limit when #1180 wired the Phase 2B slot routing — the seed + // grew a ternary-slot disjunct and the loop gained the wrapper peel + // every sibling already had. let mut node = *container_node; let mut node_kind = node.kind_id().into(); let parent_kind = parent.kind_id().into(); From 085b79f9585bd6a5a26753f1e391e45f639c88b0 Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Mon, 3 Aug 2026 08:55:27 -0700 Subject: [PATCH 18/27] fix: resolve the findings from the batch's own review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four read-only reviewers (correctness over the library, over the CLI and tooling, a test audit, and a reuse/modernisation pass) went over `main..HEAD`. Their findings, resolved. **Two regressions this batch introduced, both caught by review rather than by its tests, both now pinned.** `!($a)` scored zero in Tcl and iRules. #1180's negation branch kept a fixed `child(1)`, and `_expr` inlines `( … )` as anonymous children, so the index landed on `(` and the walk stopped. The bare form counted and the parenthesised one did not — an inconsistency the fix itself created, since neither counted before it. Both branches now take the first *named* child. The parenthesised fixtures added with #1180 covered the ternary *condition* slot only, which is why this passed. `function () {…}.bind(this)` became a function. #1188's fix for the class-field divergence used `has_sibling(PropertyIdentifier)`, which tests every sibling rather than a binding position, so a member expression's property name supplied one — re-creating the func/arrow divergence #1188 exists to remove, in a new shape, and moving real pdf.js counts. The binding site is now named structurally: the field-definition kind joins `$up`, threaded per language because JS and TS spell it differently (`FieldDefinition` / `PublicFieldDefinition`). Two corpus snapshots return to their pre-batch values exactly. **A gate this branch was failing.** `make rustfmt-bail` was red: five comments sat *inside* a match pattern, which makes rustfmt emit the enclosing match verbatim while `cargo fmt --check` still exits 0 — the exact defect `.claude/rules/formatting.md` documents, introduced while adding the #1184 arms. Hoisted above the arms, as the rule requires. **Documentation that contradicted the code.** `GlobalOpts::exclude`'s clap doc still described the #1189 bug as current behaviour, and clap renders it into `--help` and 14 man pages; corrected and `cargo xtask` re-run in this commit. The `js_cognitive!` rationale still said `ArrowFunction` was "knowingly absent" from `stops` two lines above the slice containing it. `bca.toml` still offered "do the real 6 -> 5 work" as an option the paragraph below it rules out. `benchmarking.md` named `Node::previous_sibling_under` in the present tense after #1181 removed it. **CHANGELOG.** `STABILITY.md` requires every metric-value drift to be called out; nothing was recorded. `[Unreleased]` now carries all eight drifts and the two additive changes. **A performance regression in #1189.** `relative_tail` canonicalised the manifest root per call, and the exclude match runs per *walked file* — so a project reached through a symlink, or `bca diff`'s before-side (which moves the cwd away from the manifest), paid two syscalls per file. The root is fixed for a run, so it is canonicalised once and passed down; the per-file `path.canonicalize()` remains only as the last resort it always was. **Test quality.** `check_js_binding_site_parity` asserted only the function count, so a regression dropping a construct from `is_func_space` entirely — zero functions *and* zero closures — passed every negative row; it now asserts the pair. `is_synthesised_name` matched any `<…>`-shaped name, which would have exempted Ruby's `def <=>`; the five names are enumerated. Two assertions that could not fail independently (`assertIn("1", …)` against a header containing "1192", and `kept_offender` as a substring of `nested_kept_offender`) are replaced with ones that can. Not fixed here, filed instead: the #1184 spaces emit `npm`/`npa` blocks their sibling methods do not (#1197). The enable predicate reads `is_func_space` as "is a container", and correcting it needs the same per-language threading as above — not a rushed edit at the end of a large batch. --- CHANGELOG.md | 81 +++++++++++++++++++ bca.toml | 3 +- big-code-analysis-cli/src/cli_args/mod.rs | 21 +++-- big-code-analysis-cli/src/commands/check.rs | 6 ++ big-code-analysis-cli/src/walk.rs | 14 +++- big-code-analysis-cli/src/walk_seed.rs | 66 +++++++++++++-- big-code-analysis-cli/src/walk_seed_tests.rs | 4 +- big-code-analysis-cli/src/walk_tests.rs | 1 + .../tests/discovery/explicit_path_excludes.rs | 6 +- docs/development/benchmarking.md | 11 ++- man/bca-check.1 | 2 +- man/bca-count.1 | 2 +- man/bca-diff.1 | 2 +- man/bca-dump.1 | 2 +- man/bca-exemptions.1 | 2 +- man/bca-find.1 | 2 +- man/bca-functions.1 | 2 +- man/bca-init.1 | 2 +- man/bca-metrics.1 | 2 +- man/bca-ops.1 | 2 +- man/bca-preproc.1 | 2 +- man/bca-report.1 | 2 +- man/bca-strip-comments.1 | 2 +- man/bca-vcs.1 | 2 +- src/checker.rs | 29 ++++--- src/checker/javascript.rs | 2 +- src/checker/mozjs.rs | 2 +- src/checker/tsx.rs | 2 +- src/checker/typescript.rs | 2 +- src/getter/java.rs | 2 +- src/getter/javascript.rs | 2 +- src/getter/mozjs.rs | 2 +- src/getter/tsx.rs | 2 +- src/getter/typescript.rs | 2 +- src/metrics/abc.rs | 64 +++++++++++++++ src/metrics/abc/irules.rs | 17 ++-- src/metrics/abc/tcl.rs | 17 ++-- src/metrics/cognitive.rs | 17 ++-- src/metrics/nom.rs | 16 ++++ tests/parity/functions_metrics_parity.rs | 21 +++-- tests/repositories/big-code-analysis-output | 2 +- utils/check-snapshot-anchors-test.py | 5 +- 42 files changed, 346 insertions(+), 101 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c4c271095..edf59870b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,32 @@ for historical reference. ### Added +- `bca check --print-effective-config` reports which exclude globs are + manifest-anchored (#1194). After #1164 a glob's meaning depends on its + origin — a `--check-exclude` pattern resolves against the caller's + working directory, a `bca.toml` one against the manifest's directory — + and a single flattened array cannot express that. `manifest_exclude`, + `manifest_check_exclude`, `manifest_exclude_from` and + `manifest_check_exclude_from` name the manifest-origin subset + alongside the resolved lists, which stay where they were so the TOML + form keeps round-tripping through `--config`. The anchor is the + reported `manifest` file's directory. Each key is omitted when the + manifest contributed nothing; the `*_from` pair is present only when + the manifest's file is the one actually in effect, since a CLI + `--exclude-from` *replaces* rather than unions with it. +- Kotlin property accessors (`get()` / `set()`) and `init { … }`, Java + and Groovy `static { … }`, and JavaScript class static blocks now open + a function space of their own (#1184). Each carries executable code but + was referenced nowhere outside the generated language enum, so its + control flow was charged to the enclosing class and `bca check` could + never flag one however complex it got. They are reported under + synthesised names — ``, ``, ``, `` — + following the existing `` convention. **They are + deliberately absent from `nom.functions`, `nargs` and `bca functions`**: + none is a callable named at a call site, and counting an accessor as a + method would make `npm` bill the same property once as an attribute and + again as a method. See the NOM section of the metrics guide. + - `bca check --explain-threshold =`: preview what a candidate threshold would cost at **both** tiers without editing `bca.toml` or running a gate (#1169). Reports hard-tier offenders, the @@ -682,6 +708,61 @@ for historical reference. ### Fixed +- **Metric drift.** ABC `conditions` moves wherever a comment sits inside + a ternary (#1181). Slots are now addressed by grammar field rather than + by neighbouring token or fixed index, which fixes two opposite errors + from one cause: C, C++, Objective-C, Mozcpp, PHP, Perl, JavaScript, + TypeScript, TSX and MozJS **over**-counted (`a ? /*n*/ (b) : c` scored + 3 against `a ? (b) : c`'s 2), while Java, C# and Groovy **under**-counted + (`a ? /*n*/ !b : c` scored 2 against `a ? !b : c`'s 3). +- **Metric drift.** ABC `conditions` counts Ruby's and Perl's `not` + keyword like `!` (#1182). `if not b` scored 0 against `if !b`'s 1, and a + `not` ternary scored 2 against the `!` form's 4. Lua and Elixir were + already correct. +- **Metric drift.** Tcl and iRules gained the Phase 2B slot routing every + other language already had (#1180): `if {$a}` and `while {$a}` move 0 → + 1, `if`/`elseif`/`else` 2 → 4, and `expr {$a ? !$b : !$c}` 1 → 4, + matching the value the other languages report for the same expression. + The argument and `return` slots remain unrouted. +- **Metric drift.** A lambda written without its optional parentheses + reports its parameter in Java and C# (#1185). `x -> x + 1` scored + `nargs` 0 where `(x) -> x + 1` scored 1; the parameters are billed to + `closure_args` as before. +- **Metric drift.** JavaScript-family generator functions are classified + as functions rather than closures (#1186), so `nom`'s function/closure + split, `nargs`' `fn_args`/`closure_args` split and cognitive nesting all + move for `function*`. `bca functions` and `bca find --type function` + now report a named generator, which they previously omitted. +- **Metric drift.** An immediately-invoked function expression is a + closure whether or not its result is bound (#1188). `nom` and `nargs` + previously classified `(function(){…})()` and + `const v = (function(){…})()` differently. A class field initialiser and + a non-identifier-keyed object property are now classified the same way + whether written as a function expression or an arrow. +- **Metric drift.** Cognitive complexity resets the lambda surcharge at + every function boundary, not only in the JavaScript family (#1187). A + function *declared inside* a closure scored 3 where the same body + outside one scored 2, in Rust, Java, C++, PHP and C#. Separately, + `(function(){ function g(){…} })()` and `(() => { function g(){…} })()` + now charge `g` the same function depth. +- **Metric drift.** The file-level unit's line span is anchored at line 1 + (#1195). A whitespace-only file reported `0..0`, and any file opening + with blank lines reported a span that omitted them — `"\n\n\nfn a(){}\n"` + gave `4..4` of a 4-line file. An empty file still reports `0..0`, having + no lines. +- **Metric drift.** Kotlin `init { … }` complexity now contributes to its + class's WMC, which follows from the new function space (#1184). +- A `bca.toml` `exclude` glob keeps applying under a directory seed + (#1189). `bca metrics -p sub` moved the walk root, and manifest globs — + written against the manifest's directory — silently stopped matching. + The rule is now stated once and shared by the walker and the `bca check` + gate. +- `utils/check-snapshot-anchors.py` lexes char literals, byte-raw strings + and the whole of `src/metrics/` (#1192). A `b'"'` opened a string span + that hid every later snapshot call, and the scan was non-recursive so + the 126 files under the per-language subdirectories were never checked. + Latent — no live count changed. + - **Metric values move.** A Java record's compact constructor (`record R(int a) { R { … } }`) now opens its own function space instead of charging its body to the enclosing class (#1160). diff --git a/bca.toml b/bca.toml index 1b4cba470..e0afb94dd 100644 --- a/bca.toml +++ b/bca.toml @@ -143,8 +143,7 @@ exclude = [ # them 0.3 below the band at once — they cannot ever clear it, because # they are the limit. That is 74 baseline entries bought for no # hard-tier gain, which is the "reads as debt rather than as a decision" -# outcome #1143 exists to avoid. The honest options are to stay at 7 or -# to do the real 6 -> 5 work. +# outcome #1143 exists to avoid. # # Re-measure with `bca check --explain-threshold nargs=6` (#1169) rather # than a bare `--threshold`, which has no soft tier by design. What makes diff --git a/big-code-analysis-cli/src/cli_args/mod.rs b/big-code-analysis-cli/src/cli_args/mod.rs index 3426139f7..b8a27995b 100644 --- a/big-code-analysis-cli/src/cli_args/mod.rs +++ b/big-code-analysis-cli/src/cli_args/mod.rs @@ -113,19 +113,16 @@ pub(crate) struct WalkSelectionArgs { /// named, use `--check-exclude` / `[check] exclude` instead. /// /// A relative glob given here is resolved against the directory you - /// ran `bca` from. One written in `bca.toml` is matched against - /// each file's path relative to the *walk root*, which is the - /// manifest's own directory only when the walk starts there — as it - /// does for the usual `paths = ["."]`. Naming a subdirectory - /// explicitly (`bca metrics -p sub`) moves the walk root and can - /// stop a manifest glob matching. `[check] exclude` does not have - /// this caveat: it anchors at the manifest whatever the caller - /// names. + /// ran `bca` from. One written in `bca.toml` is resolved against the + /// manifest's own directory, whatever path you name and wherever you + /// run from — so `bca metrics -p sub` honours the same exclusions the + /// bare `bca metrics` does. `[check] exclude` anchors the same way. // The per-file agent hooks in the book's agent-feedback recipe are - // the caller this bit exists for; see #1146. The walk-root caveat - // above is #1189 — #1164 anchored the explicit-path warning and the - // `[check] exclude` gate at the manifest, but left the directory - // walk matching manifest globs against the walk root. + // the caller this bit exists for; see #1146. #1164 anchored the + // explicit-path warning and the `[check] exclude` gate at the + // manifest; #1189 did the same for the directory walk, which until + // then matched manifest globs against the walk root and so stopped + // excluding anything once a directory seed moved it. #[clap(long, short = 'X', num_args(1), action = clap::ArgAction::Append, help_heading = "Input selection")] pub(crate) exclude: Vec, /// Force a language instead of inferring from extension. Accepts a diff --git a/big-code-analysis-cli/src/commands/check.rs b/big-code-analysis-cli/src/commands/check.rs index 452079637..32190f99c 100644 --- a/big-code-analysis-cli/src/commands/check.rs +++ b/big-code-analysis-cli/src/commands/check.rs @@ -380,6 +380,10 @@ struct CheckExcludes<'a> { /// `exempts` rebuilds the borrowed view per call, which is three /// pointer copies. cwd: std::path::PathBuf, + /// `manifest_dir` resolved through symlinks, once per run — see + /// [`crate::walk_seed::ManifestAnchor`]'s `canonical_root`. `exempts` + /// runs per violation, so this must not be recomputed there. + canonical_manifest_dir: Option, } impl<'a> CheckExcludes<'a> { @@ -408,6 +412,7 @@ impl<'a> CheckExcludes<'a> { "bca.toml [check] exclude_from", ), manifest_dir: manifest.map(|m| m.dir.as_path()), + canonical_manifest_dir: manifest.and_then(|m| m.dir.canonicalize().ok()), cwd: std::env::current_dir().unwrap_or_default(), }) } @@ -423,6 +428,7 @@ impl<'a> CheckExcludes<'a> { &self.manifest, self.manifest_dir, &self.cwd, + self.canonical_manifest_dir.as_deref(), ) .excludes(path, walk_form) } diff --git a/big-code-analysis-cli/src/walk.rs b/big-code-analysis-cli/src/walk.rs index 3a0bee940..3b505acf2 100644 --- a/big-code-analysis-cli/src/walk.rs +++ b/big-code-analysis-cli/src/walk.rs @@ -593,16 +593,22 @@ pub(crate) fn resolve_walk_files(globals: GlobalOpts) -> (ResolvedFiles, usize) // either — `bca diff`'s directory guard moves the cwd — so once per // walk is the right granularity (#1189). let cwd = std::env::current_dir().unwrap_or_default(); + // An empty set never matches, so there is no anchor worth supplying — + // and the no-manifest default above has no directory to offer in the + // first place. + let manifest_dir = (!manifest_exclude.is_empty()).then_some(manifest_excludes.dir.as_path()); + // Once per walk, not once per file: the exclude match runs for every + // entry, and a project reached through a symlink would otherwise pay + // two `canonicalize` syscalls per file inside `relative_tail`. + let canonical_manifest_dir = manifest_dir.and_then(|dir| dir.canonicalize().ok()); let filters = WalkFilters { include: &include, excludes: walk_seed::AnchoredExcludes::new( &exclude, &manifest_exclude, - // An empty set never matches, so there is no anchor worth - // supplying — and the no-manifest default above has no - // directory to offer in the first place. - (!manifest_exclude.is_empty()).then_some(manifest_excludes.dir.as_path()), + manifest_dir, &cwd, + canonical_manifest_dir.as_deref(), ), language_forced: globals.language.is_some(), }; diff --git a/big-code-analysis-cli/src/walk_seed.rs b/big-code-analysis-cli/src/walk_seed.rs index 468648233..e88ea987d 100644 --- a/big-code-analysis-cli/src/walk_seed.rs +++ b/big-code-analysis-cli/src/walk_seed.rs @@ -155,14 +155,43 @@ pub(crate) fn reanchor_seed(seed: PathBuf) -> PathBuf { /// remainder, and a `bca.toml` directory for /// [`root_relative_match_path`]. fn relative_tail(path: &std::path::Path, root: &std::path::Path) -> Option { + relative_tail_with(path, root, None) +} + +/// [`relative_tail`], with the caller's already-canonicalised `root`. +/// +/// The plain form canonicalises `root` per call, which is per *walked +/// file* on the exclude path — and `root` is fixed for a whole run. A +/// caller that can hoist it (see [`ManifestAnchor::resolve`]) passes it +/// here, which turns the common symlinked-root case — a macOS +/// `/var/folders/…` `TempDir`, or any project reached through a symlink +/// — into a second lexical `strip_prefix` rather than two `canonicalize` +/// syscalls per file. +/// +/// The `path.canonicalize()` fallback stays last: only it can rescue a +/// symlink *inside* the path, and it is now reached only when both +/// lexical attempts fail. +fn relative_tail_with( + path: &std::path::Path, + root: &std::path::Path, + canonical_root: Option<&std::path::Path>, +) -> Option { if let Ok(rel) = path.strip_prefix(root) { return Some(rel.to_path_buf()); } + if let Some(canonical_root) = canonical_root + && let Ok(rel) = path.strip_prefix(canonical_root) + { + return Some(rel.to_path_buf()); + } let canonical = path.canonicalize().ok()?; if let Ok(rel) = canonical.strip_prefix(root) { return Some(rel.to_path_buf()); } - let canonical_root = root.canonicalize().ok()?; + let canonical_root = match canonical_root { + Some(root) => root.to_path_buf(), + None => root.canonicalize().ok()?, + }; canonical .strip_prefix(&canonical_root) .ok() @@ -203,8 +232,12 @@ fn root_relative_match_path(anchor: ManifestAnchor<'_>, path: &std::path::Path) } else { path.to_path_buf() }; - relative_tail(&crate::baseline::lexical_normalize(&absolute), anchor.root?) - .filter(|rel| !rel.as_os_str().is_empty()) + relative_tail_with( + &crate::baseline::lexical_normalize(&absolute), + anchor.root?, + anchor.canonical_root, + ) + .filter(|rel| !rel.as_os_str().is_empty()) } /// Where a manifest-anchored glob set resolves from: the `bca.toml` @@ -224,14 +257,32 @@ fn root_relative_match_path(anchor: ManifestAnchor<'_>, path: &std::path::Path) pub(crate) struct ManifestAnchor<'a> { pub(crate) root: Option<&'a std::path::Path>, pub(crate) cwd: &'a std::path::Path, + /// `root` resolved through symlinks, when a caller hoisted it. + /// + /// `root` is fixed for a whole run while the exclude match runs per + /// *walked file*, so canonicalising it here rather than inside + /// [`relative_tail`] keeps two syscalls per file off the path a + /// symlinked project root would otherwise take. + pub(crate) canonical_root: Option<&'a std::path::Path>, } impl<'a> ManifestAnchor<'a> { /// Resolve the working directory once. `None` when it cannot be /// read, which makes every manifest match fall back to the /// cwd-anchored form exactly as an absent manifest does. - pub(crate) fn resolve(root: Option<&'a std::path::Path>, cwd: &'a PathBuf) -> Self { - Self { root, cwd } + /// `root` may be pre-canonicalised by the caller — see the + /// `canonical_root` field for why that matters on the per-walked-file + /// path. + pub(crate) fn resolve( + root: Option<&'a std::path::Path>, + cwd: &'a std::path::Path, + canonical_root: Option<&'a std::path::Path>, + ) -> Self { + Self { + root, + cwd, + canonical_root, + } } } @@ -303,12 +354,13 @@ impl<'a> AnchoredExcludes<'a> { cli: &'a crate::ExcludeGlobs, manifest: &'a crate::ExcludeGlobs, manifest_dir: Option<&'a std::path::Path>, - cwd: &'a PathBuf, + cwd: &'a std::path::Path, + canonical_manifest_dir: Option<&'a std::path::Path>, ) -> Self { Self { cli, manifest, - anchor: ManifestAnchor::resolve(manifest_dir, cwd), + anchor: ManifestAnchor::resolve(manifest_dir, cwd, canonical_manifest_dir), } } diff --git a/big-code-analysis-cli/src/walk_seed_tests.rs b/big-code-analysis-cli/src/walk_seed_tests.rs index c1206bc02..24ef7d4cd 100644 --- a/big-code-analysis-cli/src/walk_seed_tests.rs +++ b/big-code-analysis-cli/src/walk_seed_tests.rs @@ -50,8 +50,8 @@ fn relative_tail_canonicalizes_both_sides_when_forms_diverge() { /// Build a [`super::ManifestAnchor`] for a test. Exists so the tests /// read as `(root, cwd)` at each call rather than repeating the struct /// literal, and so a future field lands in one place. -fn anchor<'a>(root: Option<&'a Path>, cwd: &'a std::path::PathBuf) -> super::ManifestAnchor<'a> { - super::ManifestAnchor::resolve(root, cwd) +fn anchor<'a>(root: Option<&'a Path>, cwd: &'a Path) -> super::ManifestAnchor<'a> { + super::ManifestAnchor::resolve(root, cwd, None) } #[test] diff --git a/big-code-analysis-cli/src/walk_tests.rs b/big-code-analysis-cli/src/walk_tests.rs index 7dbd16f31..625fdef1d 100644 --- a/big-code-analysis-cli/src/walk_tests.rs +++ b/big-code-analysis-cli/src/walk_tests.rs @@ -48,6 +48,7 @@ fn walk_directory_seed_returns_sorted_paths() { &empty_exclude, None, &cwd, + None, ), language_forced: false, }; diff --git a/big-code-analysis-cli/tests/discovery/explicit_path_excludes.rs b/big-code-analysis-cli/tests/discovery/explicit_path_excludes.rs index 6e8d9ac90..5848338a2 100644 --- a/big-code-analysis-cli/tests/discovery/explicit_path_excludes.rs +++ b/big-code-analysis-cli/tests/discovery/explicit_path_excludes.rs @@ -182,7 +182,11 @@ fn manifest_exclude_still_applies_to_the_canonical_root_walk() { .assert() .code(2) .stdout(predicate::str::contains("nested_kept_offender")) - .stdout(predicate::str::contains("kept_offender")) + // The root fixture's own survivor, matched on its file rather + // than its function name: `kept_offender` is a substring of + // `nested_kept_offender`, so asserting the name alone is implied + // by the line above and cannot fail independently. + .stdout(predicate::str::contains("kept.rs")) .stdout(predicate::str::contains("nested_skipme_offender").not()); } diff --git a/docs/development/benchmarking.md b/docs/development/benchmarking.md index cb9a92669..593c6729d 100644 --- a/docs/development/benchmarking.md +++ b/docs/development/benchmarking.md @@ -199,12 +199,17 @@ finds at the call site. All four probes fitted 1.99-2.06 before and That change also confirmed #1088's lesson a second time from the other direction. The ABC condition walkers reach a slot's parent to decide -whether it sits in boolean context, and the same walkers ask +whether it sits in boolean context, and the same walkers used to ask `Node::previous_sibling` whether a ternary's `?` / `:` precedes the operand — and `ts_node__prev_sibling` opens with `ts_node_parent`, so it carries the same `O(depth)`. Passing the parent in and scanning its -children (`Node::previous_sibling_under`) was needed for the fix to -hold on a ternary shape, not only on the shapes the probes render. +children (`Node::previous_sibling_under`) was what made the fix hold on +a ternary shape, not only on the shapes the probes render. + +That helper no longer exists: #1181 moved every ternary slot onto +`child_by_field_name`, which needs no sibling scan at all, and the +method was removed with its last caller. The sibling cost described +above is still the reason to reach for a field lookup first. The `Ancestors::unknown()` call sites that remain are deliberate rather than deferred: the two synthetic-`Unit`-root pushes hand it a node that diff --git a/man/bca-check.1 b/man/bca-check.1 index 3a3175f29..03f6f26f1 100644 --- a/man/bca-check.1 +++ b/man/bca-check.1 @@ -175,7 +175,7 @@ Glob to exclude files. Repeat the flag to add multiple globs (`\-X \*(Aq*.tmp\*( Shapes directory\-walk scope only: a file named directly on the command line overrides every exclude glob and is analyzed anyway (the ripgrep/fd convention), with a warning on stderr naming the glob it overrode. `\-I` / `\-\-include` is not overridden. To exempt something from `bca check`\*(Aqs threshold gate whichever way it is named, use `\-\-check\-exclude` / `[check] exclude` instead. -A relative glob given here is resolved against the directory you ran `bca` from. One written in `bca.toml` is matched against each file\*(Aqs path relative to the *walk root*, which is the manifest\*(Aqs own directory only when the walk starts there — as it does for the usual `paths = ["."]`. Naming a subdirectory explicitly (`bca metrics \-p sub`) moves the walk root and can stop a manifest glob matching. `[check] exclude` does not have this caveat: it anchors at the manifest whatever the caller names. +A relative glob given here is resolved against the directory you ran `bca` from. One written in `bca.toml` is resolved against the manifest\*(Aqs own directory, whatever path you name and wherever you run from — so `bca metrics \-p sub` honours the same exclusions the bare `bca metrics` does. `[check] exclude` anchors the same way. .TP \fB\-l\fR, \fB\-\-language\fR \fI\fR Force a language instead of inferring from extension. Accepts a canonical language name (`rust`, `python`, `cpp`, …) or a file extension (`rs`, `py`, …). An unrecognized value is a hard error diff --git a/man/bca-count.1 b/man/bca-count.1 index a560695bf..79546d57b 100644 --- a/man/bca-count.1 +++ b/man/bca-count.1 @@ -27,7 +27,7 @@ Glob to exclude files. Repeat the flag to add multiple globs (`\-X \*(Aq*.tmp\*( Shapes directory\-walk scope only: a file named directly on the command line overrides every exclude glob and is analyzed anyway (the ripgrep/fd convention), with a warning on stderr naming the glob it overrode. `\-I` / `\-\-include` is not overridden. To exempt something from `bca check`\*(Aqs threshold gate whichever way it is named, use `\-\-check\-exclude` / `[check] exclude` instead. -A relative glob given here is resolved against the directory you ran `bca` from. One written in `bca.toml` is matched against each file\*(Aqs path relative to the *walk root*, which is the manifest\*(Aqs own directory only when the walk starts there — as it does for the usual `paths = ["."]`. Naming a subdirectory explicitly (`bca metrics \-p sub`) moves the walk root and can stop a manifest glob matching. `[check] exclude` does not have this caveat: it anchors at the manifest whatever the caller names. +A relative glob given here is resolved against the directory you ran `bca` from. One written in `bca.toml` is resolved against the manifest\*(Aqs own directory, whatever path you name and wherever you run from — so `bca metrics \-p sub` honours the same exclusions the bare `bca metrics` does. `[check] exclude` anchors the same way. .TP \fB\-l\fR, \fB\-\-language\fR \fI\fR Force a language instead of inferring from extension. Accepts a canonical language name (`rust`, `python`, `cpp`, …) or a file extension (`rs`, `py`, …). An unrecognized value is a hard error diff --git a/man/bca-diff.1 b/man/bca-diff.1 index 2438bfae1..e73a97741 100644 --- a/man/bca-diff.1 +++ b/man/bca-diff.1 @@ -65,7 +65,7 @@ Glob to exclude files. Repeat the flag to add multiple globs (`\-X \*(Aq*.tmp\*( Shapes directory\-walk scope only: a file named directly on the command line overrides every exclude glob and is analyzed anyway (the ripgrep/fd convention), with a warning on stderr naming the glob it overrode. `\-I` / `\-\-include` is not overridden. To exempt something from `bca check`\*(Aqs threshold gate whichever way it is named, use `\-\-check\-exclude` / `[check] exclude` instead. -A relative glob given here is resolved against the directory you ran `bca` from. One written in `bca.toml` is matched against each file\*(Aqs path relative to the *walk root*, which is the manifest\*(Aqs own directory only when the walk starts there — as it does for the usual `paths = ["."]`. Naming a subdirectory explicitly (`bca metrics \-p sub`) moves the walk root and can stop a manifest glob matching. `[check] exclude` does not have this caveat: it anchors at the manifest whatever the caller names. +A relative glob given here is resolved against the directory you ran `bca` from. One written in `bca.toml` is resolved against the manifest\*(Aqs own directory, whatever path you name and wherever you run from — so `bca metrics \-p sub` honours the same exclusions the bare `bca metrics` does. `[check] exclude` anchors the same way. .TP \fB\-l\fR, \fB\-\-language\fR \fI\fR Force a language instead of inferring from extension. Accepts a canonical language name (`rust`, `python`, `cpp`, …) or a file extension (`rs`, `py`, …). An unrecognized value is a hard error diff --git a/man/bca-dump.1 b/man/bca-dump.1 index 469d6981b..cc2ac5765 100644 --- a/man/bca-dump.1 +++ b/man/bca-dump.1 @@ -30,7 +30,7 @@ Glob to exclude files. Repeat the flag to add multiple globs (`\-X \*(Aq*.tmp\*( Shapes directory\-walk scope only: a file named directly on the command line overrides every exclude glob and is analyzed anyway (the ripgrep/fd convention), with a warning on stderr naming the glob it overrode. `\-I` / `\-\-include` is not overridden. To exempt something from `bca check`\*(Aqs threshold gate whichever way it is named, use `\-\-check\-exclude` / `[check] exclude` instead. -A relative glob given here is resolved against the directory you ran `bca` from. One written in `bca.toml` is matched against each file\*(Aqs path relative to the *walk root*, which is the manifest\*(Aqs own directory only when the walk starts there — as it does for the usual `paths = ["."]`. Naming a subdirectory explicitly (`bca metrics \-p sub`) moves the walk root and can stop a manifest glob matching. `[check] exclude` does not have this caveat: it anchors at the manifest whatever the caller names. +A relative glob given here is resolved against the directory you ran `bca` from. One written in `bca.toml` is resolved against the manifest\*(Aqs own directory, whatever path you name and wherever you run from — so `bca metrics \-p sub` honours the same exclusions the bare `bca metrics` does. `[check] exclude` anchors the same way. .TP \fB\-l\fR, \fB\-\-language\fR \fI\fR Force a language instead of inferring from extension. Accepts a canonical language name (`rust`, `python`, `cpp`, …) or a file extension (`rs`, `py`, …). An unrecognized value is a hard error diff --git a/man/bca-exemptions.1 b/man/bca-exemptions.1 index 302d4160f..74ccd7d55 100644 --- a/man/bca-exemptions.1 +++ b/man/bca-exemptions.1 @@ -63,7 +63,7 @@ Glob to exclude files. Repeat the flag to add multiple globs (`\-X \*(Aq*.tmp\*( Shapes directory\-walk scope only: a file named directly on the command line overrides every exclude glob and is analyzed anyway (the ripgrep/fd convention), with a warning on stderr naming the glob it overrode. `\-I` / `\-\-include` is not overridden. To exempt something from `bca check`\*(Aqs threshold gate whichever way it is named, use `\-\-check\-exclude` / `[check] exclude` instead. -A relative glob given here is resolved against the directory you ran `bca` from. One written in `bca.toml` is matched against each file\*(Aqs path relative to the *walk root*, which is the manifest\*(Aqs own directory only when the walk starts there — as it does for the usual `paths = ["."]`. Naming a subdirectory explicitly (`bca metrics \-p sub`) moves the walk root and can stop a manifest glob matching. `[check] exclude` does not have this caveat: it anchors at the manifest whatever the caller names. +A relative glob given here is resolved against the directory you ran `bca` from. One written in `bca.toml` is resolved against the manifest\*(Aqs own directory, whatever path you name and wherever you run from — so `bca metrics \-p sub` honours the same exclusions the bare `bca metrics` does. `[check] exclude` anchors the same way. .TP \fB\-l\fR, \fB\-\-language\fR \fI\fR Force a language instead of inferring from extension. Accepts a canonical language name (`rust`, `python`, `cpp`, …) or a file extension (`rs`, `py`, …). An unrecognized value is a hard error diff --git a/man/bca-find.1 b/man/bca-find.1 index 173fe19a6..5d3384396 100644 --- a/man/bca-find.1 +++ b/man/bca-find.1 @@ -33,7 +33,7 @@ Glob to exclude files. Repeat the flag to add multiple globs (`\-X \*(Aq*.tmp\*( Shapes directory\-walk scope only: a file named directly on the command line overrides every exclude glob and is analyzed anyway (the ripgrep/fd convention), with a warning on stderr naming the glob it overrode. `\-I` / `\-\-include` is not overridden. To exempt something from `bca check`\*(Aqs threshold gate whichever way it is named, use `\-\-check\-exclude` / `[check] exclude` instead. -A relative glob given here is resolved against the directory you ran `bca` from. One written in `bca.toml` is matched against each file\*(Aqs path relative to the *walk root*, which is the manifest\*(Aqs own directory only when the walk starts there — as it does for the usual `paths = ["."]`. Naming a subdirectory explicitly (`bca metrics \-p sub`) moves the walk root and can stop a manifest glob matching. `[check] exclude` does not have this caveat: it anchors at the manifest whatever the caller names. +A relative glob given here is resolved against the directory you ran `bca` from. One written in `bca.toml` is resolved against the manifest\*(Aqs own directory, whatever path you name and wherever you run from — so `bca metrics \-p sub` honours the same exclusions the bare `bca metrics` does. `[check] exclude` anchors the same way. .TP \fB\-l\fR, \fB\-\-language\fR \fI\fR Force a language instead of inferring from extension. Accepts a canonical language name (`rust`, `python`, `cpp`, …) or a file extension (`rs`, `py`, …). An unrecognized value is a hard error diff --git a/man/bca-functions.1 b/man/bca-functions.1 index 03f275105..7c7e5bb93 100644 --- a/man/bca-functions.1 +++ b/man/bca-functions.1 @@ -24,7 +24,7 @@ Glob to exclude files. Repeat the flag to add multiple globs (`\-X \*(Aq*.tmp\*( Shapes directory\-walk scope only: a file named directly on the command line overrides every exclude glob and is analyzed anyway (the ripgrep/fd convention), with a warning on stderr naming the glob it overrode. `\-I` / `\-\-include` is not overridden. To exempt something from `bca check`\*(Aqs threshold gate whichever way it is named, use `\-\-check\-exclude` / `[check] exclude` instead. -A relative glob given here is resolved against the directory you ran `bca` from. One written in `bca.toml` is matched against each file\*(Aqs path relative to the *walk root*, which is the manifest\*(Aqs own directory only when the walk starts there — as it does for the usual `paths = ["."]`. Naming a subdirectory explicitly (`bca metrics \-p sub`) moves the walk root and can stop a manifest glob matching. `[check] exclude` does not have this caveat: it anchors at the manifest whatever the caller names. +A relative glob given here is resolved against the directory you ran `bca` from. One written in `bca.toml` is resolved against the manifest\*(Aqs own directory, whatever path you name and wherever you run from — so `bca metrics \-p sub` honours the same exclusions the bare `bca metrics` does. `[check] exclude` anchors the same way. .TP \fB\-l\fR, \fB\-\-language\fR \fI\fR Force a language instead of inferring from extension. Accepts a canonical language name (`rust`, `python`, `cpp`, …) or a file extension (`rs`, `py`, …). An unrecognized value is a hard error diff --git a/man/bca-init.1 b/man/bca-init.1 index c17596dcf..91b897218 100644 --- a/man/bca-init.1 +++ b/man/bca-init.1 @@ -33,7 +33,7 @@ Glob to exclude files. Repeat the flag to add multiple globs (`\-X \*(Aq*.tmp\*( Shapes directory\-walk scope only: a file named directly on the command line overrides every exclude glob and is analyzed anyway (the ripgrep/fd convention), with a warning on stderr naming the glob it overrode. `\-I` / `\-\-include` is not overridden. To exempt something from `bca check`\*(Aqs threshold gate whichever way it is named, use `\-\-check\-exclude` / `[check] exclude` instead. -A relative glob given here is resolved against the directory you ran `bca` from. One written in `bca.toml` is matched against each file\*(Aqs path relative to the *walk root*, which is the manifest\*(Aqs own directory only when the walk starts there — as it does for the usual `paths = ["."]`. Naming a subdirectory explicitly (`bca metrics \-p sub`) moves the walk root and can stop a manifest glob matching. `[check] exclude` does not have this caveat: it anchors at the manifest whatever the caller names. +A relative glob given here is resolved against the directory you ran `bca` from. One written in `bca.toml` is resolved against the manifest\*(Aqs own directory, whatever path you name and wherever you run from — so `bca metrics \-p sub` honours the same exclusions the bare `bca metrics` does. `[check] exclude` anchors the same way. .TP \fB\-l\fR, \fB\-\-language\fR \fI\fR Force a language instead of inferring from extension. Accepts a canonical language name (`rust`, `python`, `cpp`, …) or a file extension (`rs`, `py`, …). An unrecognized value is a hard error diff --git a/man/bca-metrics.1 b/man/bca-metrics.1 index 76f6f0811..e831be74f 100644 --- a/man/bca-metrics.1 +++ b/man/bca-metrics.1 @@ -63,7 +63,7 @@ Glob to exclude files. Repeat the flag to add multiple globs (`\-X \*(Aq*.tmp\*( Shapes directory\-walk scope only: a file named directly on the command line overrides every exclude glob and is analyzed anyway (the ripgrep/fd convention), with a warning on stderr naming the glob it overrode. `\-I` / `\-\-include` is not overridden. To exempt something from `bca check`\*(Aqs threshold gate whichever way it is named, use `\-\-check\-exclude` / `[check] exclude` instead. -A relative glob given here is resolved against the directory you ran `bca` from. One written in `bca.toml` is matched against each file\*(Aqs path relative to the *walk root*, which is the manifest\*(Aqs own directory only when the walk starts there — as it does for the usual `paths = ["."]`. Naming a subdirectory explicitly (`bca metrics \-p sub`) moves the walk root and can stop a manifest glob matching. `[check] exclude` does not have this caveat: it anchors at the manifest whatever the caller names. +A relative glob given here is resolved against the directory you ran `bca` from. One written in `bca.toml` is resolved against the manifest\*(Aqs own directory, whatever path you name and wherever you run from — so `bca metrics \-p sub` honours the same exclusions the bare `bca metrics` does. `[check] exclude` anchors the same way. .TP \fB\-l\fR, \fB\-\-language\fR \fI\fR Force a language instead of inferring from extension. Accepts a canonical language name (`rust`, `python`, `cpp`, …) or a file extension (`rs`, `py`, …). An unrecognized value is a hard error diff --git a/man/bca-ops.1 b/man/bca-ops.1 index 5d4544fbc..06fd4a3a3 100644 --- a/man/bca-ops.1 +++ b/man/bca-ops.1 @@ -54,7 +54,7 @@ Glob to exclude files. Repeat the flag to add multiple globs (`\-X \*(Aq*.tmp\*( Shapes directory\-walk scope only: a file named directly on the command line overrides every exclude glob and is analyzed anyway (the ripgrep/fd convention), with a warning on stderr naming the glob it overrode. `\-I` / `\-\-include` is not overridden. To exempt something from `bca check`\*(Aqs threshold gate whichever way it is named, use `\-\-check\-exclude` / `[check] exclude` instead. -A relative glob given here is resolved against the directory you ran `bca` from. One written in `bca.toml` is matched against each file\*(Aqs path relative to the *walk root*, which is the manifest\*(Aqs own directory only when the walk starts there — as it does for the usual `paths = ["."]`. Naming a subdirectory explicitly (`bca metrics \-p sub`) moves the walk root and can stop a manifest glob matching. `[check] exclude` does not have this caveat: it anchors at the manifest whatever the caller names. +A relative glob given here is resolved against the directory you ran `bca` from. One written in `bca.toml` is resolved against the manifest\*(Aqs own directory, whatever path you name and wherever you run from — so `bca metrics \-p sub` honours the same exclusions the bare `bca metrics` does. `[check] exclude` anchors the same way. .TP \fB\-l\fR, \fB\-\-language\fR \fI\fR Force a language instead of inferring from extension. Accepts a canonical language name (`rust`, `python`, `cpp`, …) or a file extension (`rs`, `py`, …). An unrecognized value is a hard error diff --git a/man/bca-preproc.1 b/man/bca-preproc.1 index ba03aadf4..9396b0bc9 100644 --- a/man/bca-preproc.1 +++ b/man/bca-preproc.1 @@ -27,7 +27,7 @@ Glob to exclude files. Repeat the flag to add multiple globs (`\-X \*(Aq*.tmp\*( Shapes directory\-walk scope only: a file named directly on the command line overrides every exclude glob and is analyzed anyway (the ripgrep/fd convention), with a warning on stderr naming the glob it overrode. `\-I` / `\-\-include` is not overridden. To exempt something from `bca check`\*(Aqs threshold gate whichever way it is named, use `\-\-check\-exclude` / `[check] exclude` instead. -A relative glob given here is resolved against the directory you ran `bca` from. One written in `bca.toml` is matched against each file\*(Aqs path relative to the *walk root*, which is the manifest\*(Aqs own directory only when the walk starts there — as it does for the usual `paths = ["."]`. Naming a subdirectory explicitly (`bca metrics \-p sub`) moves the walk root and can stop a manifest glob matching. `[check] exclude` does not have this caveat: it anchors at the manifest whatever the caller names. +A relative glob given here is resolved against the directory you ran `bca` from. One written in `bca.toml` is resolved against the manifest\*(Aqs own directory, whatever path you name and wherever you run from — so `bca metrics \-p sub` honours the same exclusions the bare `bca metrics` does. `[check] exclude` anchors the same way. .TP \fB\-l\fR, \fB\-\-language\fR \fI\fR Force a language instead of inferring from extension. Accepts a canonical language name (`rust`, `python`, `cpp`, …) or a file extension (`rs`, `py`, …). An unrecognized value is a hard error diff --git a/man/bca-report.1 b/man/bca-report.1 index bac2e2e5c..6d60796c7 100644 --- a/man/bca-report.1 +++ b/man/bca-report.1 @@ -62,7 +62,7 @@ Glob to exclude files. Repeat the flag to add multiple globs (`\-X \*(Aq*.tmp\*( Shapes directory\-walk scope only: a file named directly on the command line overrides every exclude glob and is analyzed anyway (the ripgrep/fd convention), with a warning on stderr naming the glob it overrode. `\-I` / `\-\-include` is not overridden. To exempt something from `bca check`\*(Aqs threshold gate whichever way it is named, use `\-\-check\-exclude` / `[check] exclude` instead. -A relative glob given here is resolved against the directory you ran `bca` from. One written in `bca.toml` is matched against each file\*(Aqs path relative to the *walk root*, which is the manifest\*(Aqs own directory only when the walk starts there — as it does for the usual `paths = ["."]`. Naming a subdirectory explicitly (`bca metrics \-p sub`) moves the walk root and can stop a manifest glob matching. `[check] exclude` does not have this caveat: it anchors at the manifest whatever the caller names. +A relative glob given here is resolved against the directory you ran `bca` from. One written in `bca.toml` is resolved against the manifest\*(Aqs own directory, whatever path you name and wherever you run from — so `bca metrics \-p sub` honours the same exclusions the bare `bca metrics` does. `[check] exclude` anchors the same way. .TP \fB\-l\fR, \fB\-\-language\fR \fI\fR Force a language instead of inferring from extension. Accepts a canonical language name (`rust`, `python`, `cpp`, …) or a file extension (`rs`, `py`, …). An unrecognized value is a hard error diff --git a/man/bca-strip-comments.1 b/man/bca-strip-comments.1 index c449bf9d3..69fab0835 100644 --- a/man/bca-strip-comments.1 +++ b/man/bca-strip-comments.1 @@ -30,7 +30,7 @@ Glob to exclude files. Repeat the flag to add multiple globs (`\-X \*(Aq*.tmp\*( Shapes directory\-walk scope only: a file named directly on the command line overrides every exclude glob and is analyzed anyway (the ripgrep/fd convention), with a warning on stderr naming the glob it overrode. `\-I` / `\-\-include` is not overridden. To exempt something from `bca check`\*(Aqs threshold gate whichever way it is named, use `\-\-check\-exclude` / `[check] exclude` instead. -A relative glob given here is resolved against the directory you ran `bca` from. One written in `bca.toml` is matched against each file\*(Aqs path relative to the *walk root*, which is the manifest\*(Aqs own directory only when the walk starts there — as it does for the usual `paths = ["."]`. Naming a subdirectory explicitly (`bca metrics \-p sub`) moves the walk root and can stop a manifest glob matching. `[check] exclude` does not have this caveat: it anchors at the manifest whatever the caller names. +A relative glob given here is resolved against the directory you ran `bca` from. One written in `bca.toml` is resolved against the manifest\*(Aqs own directory, whatever path you name and wherever you run from — so `bca metrics \-p sub` honours the same exclusions the bare `bca metrics` does. `[check] exclude` anchors the same way. .TP \fB\-l\fR, \fB\-\-language\fR \fI\fR Force a language instead of inferring from extension. Accepts a canonical language name (`rust`, `python`, `cpp`, …) or a file extension (`rs`, `py`, …). An unrecognized value is a hard error diff --git a/man/bca-vcs.1 b/man/bca-vcs.1 index d35e7700d..af72a0904 100644 --- a/man/bca-vcs.1 +++ b/man/bca-vcs.1 @@ -124,7 +124,7 @@ Glob to exclude files. Repeat the flag to add multiple globs (`\-X \*(Aq*.tmp\*( Shapes directory\-walk scope only: a file named directly on the command line overrides every exclude glob and is analyzed anyway (the ripgrep/fd convention), with a warning on stderr naming the glob it overrode. `\-I` / `\-\-include` is not overridden. To exempt something from `bca check`\*(Aqs threshold gate whichever way it is named, use `\-\-check\-exclude` / `[check] exclude` instead. -A relative glob given here is resolved against the directory you ran `bca` from. One written in `bca.toml` is matched against each file\*(Aqs path relative to the *walk root*, which is the manifest\*(Aqs own directory only when the walk starts there — as it does for the usual `paths = ["."]`. Naming a subdirectory explicitly (`bca metrics \-p sub`) moves the walk root and can stop a manifest glob matching. `[check] exclude` does not have this caveat: it anchors at the manifest whatever the caller names. +A relative glob given here is resolved against the directory you ran `bca` from. One written in `bca.toml` is resolved against the manifest\*(Aqs own directory, whatever path you name and wherever you run from — so `bca metrics \-p sub` honours the same exclusions the bare `bca metrics` does. `[check] exclude` anchors the same way. .TP \fB\-l\fR, \fB\-\-language\fR \fI\fR Force a language instead of inferring from extension. Accepts a canonical language name (`rust`, `python`, `cpp`, …) or a file extension (`rs`, `py`, …). An unrecognized value is a hard error diff --git a/src/checker.rs b/src/checker.rs index a69605644..3bbef4e0c 100644 --- a/src/checker.rs +++ b/src/checker.rs @@ -106,14 +106,17 @@ macro_rules! js_ancestor_walk { // behaviour the arrow form already had. `$up` and `$stop` are now // token-identical and only `$extra` differs, for the reason in (4). macro_rules! check_if_func { - ($node: ident, $ancestors: ident) => { + ($node: ident, $ancestors: ident, $field_definition: ident) => { js_ancestor_walk!( $node, $ancestors, - [VariableDeclarator | AssignmentExpression | LabeledStatement | Pair], + [VariableDeclarator + | AssignmentExpression + | LabeledStatement + | Pair + | $field_definition], [StatementBlock | ReturnStatement | NewExpression | CallExpression | Arguments], - $node.is_child(Identifier as u16) - || $node.has_sibling($ancestors, PropertyIdentifier as u16), + $node.is_child(Identifier as u16), ) }; } @@ -147,10 +150,12 @@ macro_rules! check_if_arrow_func { // `run(function*(){})` as a function while `run(function(){})` stays a // closure. macro_rules! is_js_func { - ($node: ident, $ancestors: ident) => { + ($node: ident, $ancestors: ident, $field_definition: ident) => { match $node.kind_id().into() { FunctionDeclaration | GeneratorFunctionDeclaration | MethodDefinition => true, - FunctionExpression | GeneratorFunction => check_if_func!($node, $ancestors), + FunctionExpression | GeneratorFunction => { + check_if_func!($node, $ancestors, $field_definition) + } ArrowFunction => check_if_arrow_func!($node, $ancestors), _ => false, } @@ -158,9 +163,11 @@ macro_rules! is_js_func { } macro_rules! is_js_closure { - ($node: ident, $ancestors: ident) => { + ($node: ident, $ancestors: ident, $field_definition: ident) => { match $node.kind_id().into() { - FunctionExpression | GeneratorFunction => !check_if_func!($node, $ancestors), + FunctionExpression | GeneratorFunction => { + !check_if_func!($node, $ancestors, $field_definition) + } ArrowFunction => !check_if_arrow_func!($node, $ancestors), _ => false, } @@ -168,17 +175,17 @@ macro_rules! is_js_closure { } macro_rules! is_js_func_and_closure_checker { - ($language: ident) => { + ($language: ident, $field_definition: ident) => { #[inline] fn is_func<'a>(node: &Node<'a>, ancestors: Ancestors<'a, '_>) -> bool { use $language::*; - is_js_func!(node, ancestors) + is_js_func!(node, ancestors, $field_definition) } #[inline] fn is_closure<'a>(node: &Node<'a>, ancestors: Ancestors<'a, '_>) -> bool { use $language::*; - is_js_closure!(node, ancestors) + is_js_closure!(node, ancestors, $field_definition) } }; } diff --git a/src/checker/javascript.rs b/src/checker/javascript.rs index c3ad1d4ae..638ae0b57 100644 --- a/src/checker/javascript.rs +++ b/src/checker/javascript.rs @@ -33,7 +33,7 @@ impl Checker for JavascriptCode { ) } - is_js_func_and_closure_checker!(Javascript); + is_js_func_and_closure_checker!(Javascript, FieldDefinition); fn is_call(node: &Node) -> bool { node.kind_id() == Javascript::CallExpression diff --git a/src/checker/mozjs.rs b/src/checker/mozjs.rs index b52ccd960..65546ebc2 100644 --- a/src/checker/mozjs.rs +++ b/src/checker/mozjs.rs @@ -32,7 +32,7 @@ impl Checker for MozjsCode { ) } - is_js_func_and_closure_checker!(Mozjs); + is_js_func_and_closure_checker!(Mozjs, FieldDefinition); fn is_call(node: &Node) -> bool { node.kind_id() == Mozjs::CallExpression diff --git a/src/checker/tsx.rs b/src/checker/tsx.rs index 5badd65f6..19e288aef 100644 --- a/src/checker/tsx.rs +++ b/src/checker/tsx.rs @@ -32,7 +32,7 @@ impl Checker for TsxCode { ) } - is_js_func_and_closure_checker!(Tsx); + is_js_func_and_closure_checker!(Tsx, PublicFieldDefinition); fn is_call(node: &Node) -> bool { node.kind_id() == Tsx::CallExpression diff --git a/src/checker/typescript.rs b/src/checker/typescript.rs index 148561143..decf0a824 100644 --- a/src/checker/typescript.rs +++ b/src/checker/typescript.rs @@ -35,7 +35,7 @@ impl Checker for TypescriptCode { ) } - is_js_func_and_closure_checker!(Typescript); + is_js_func_and_closure_checker!(Typescript, PublicFieldDefinition); fn is_call(node: &Node) -> bool { node.kind_id() == Typescript::CallExpression diff --git a/src/getter/java.rs b/src/getter/java.rs index f2601bb0b..672b7fdcc 100644 --- a/src/getter/java.rs +++ b/src/getter/java.rs @@ -54,11 +54,11 @@ impl Getter for JavaCode { // `name` field holding the record's simple name, so the // default `get_func_space_name` already names the space `R` — // identical to what the canonical spelling would produce. + // `StaticInitializer` is `static { … }` (#1184). MethodDeclaration | ConstructorDeclaration | CompactConstructorDeclaration | LambdaExpression - // `static { … }` (#1184). | StaticInitializer => SpaceKind::Function, InterfaceDeclaration | AnnotationTypeDeclaration => SpaceKind::Interface, // An anonymous class (`new Runnable() { ... }`) is an diff --git a/src/getter/javascript.rs b/src/getter/javascript.rs index cc24354f4..34a85b3cf 100644 --- a/src/getter/javascript.rs +++ b/src/getter/javascript.rs @@ -8,13 +8,13 @@ impl Getter for JavascriptCode { use Javascript::*; match node.kind_id().into() { + // `ClassStaticBlock` is ES2022 `static { … }` (#1184). FunctionExpression | MethodDefinition | GeneratorFunction | FunctionDeclaration | GeneratorFunctionDeclaration | ArrowFunction - // ES2022 `static { … }` (#1184). | ClassStaticBlock => SpaceKind::Function, Class | ClassDeclaration => SpaceKind::Class, Program => SpaceKind::Unit, diff --git a/src/getter/mozjs.rs b/src/getter/mozjs.rs index 310413c9c..2b4e6a370 100644 --- a/src/getter/mozjs.rs +++ b/src/getter/mozjs.rs @@ -8,13 +8,13 @@ impl Getter for MozjsCode { use Mozjs::*; match node.kind_id().into() { + // `ClassStaticBlock` is ES2022 `static { … }` (#1184). FunctionExpression | MethodDefinition | GeneratorFunction | FunctionDeclaration | GeneratorFunctionDeclaration | ArrowFunction - // ES2022 `static { … }` (#1184). | ClassStaticBlock => SpaceKind::Function, Class | ClassDeclaration => SpaceKind::Class, Program => SpaceKind::Unit, diff --git a/src/getter/tsx.rs b/src/getter/tsx.rs index 1f6033f9a..2c56b4df6 100644 --- a/src/getter/tsx.rs +++ b/src/getter/tsx.rs @@ -8,13 +8,13 @@ impl Getter for TsxCode { use Tsx::*; match node.kind_id().into() { + // `ClassStaticBlock` is ES2022 `static { … }` (#1184). FunctionExpression | MethodDefinition | GeneratorFunction | FunctionDeclaration | GeneratorFunctionDeclaration | ArrowFunction - // ES2022 `static { … }` (#1184). | ClassStaticBlock => SpaceKind::Function, Class | ClassDeclaration | AbstractClassDeclaration => SpaceKind::Class, InterfaceDeclaration => SpaceKind::Interface, diff --git a/src/getter/typescript.rs b/src/getter/typescript.rs index 9627d7bbf..f7facaa33 100644 --- a/src/getter/typescript.rs +++ b/src/getter/typescript.rs @@ -8,13 +8,13 @@ impl Getter for TypescriptCode { use Typescript::*; match node.kind_id().into() { + // `ClassStaticBlock` is ES2022 `static { … }` (#1184). FunctionExpression | MethodDefinition | GeneratorFunction | FunctionDeclaration | GeneratorFunctionDeclaration | ArrowFunction - // ES2022 `static { … }` (#1184). | ClassStaticBlock => SpaceKind::Function, Class | ClassDeclaration | AbstractClassDeclaration => SpaceKind::Class, InterfaceDeclaration => SpaceKind::Interface, diff --git a/src/metrics/abc.rs b/src/metrics/abc.rs index c35d9cc44..8f15d0d86 100644 --- a/src/metrics/abc.rs +++ b/src/metrics/abc.rs @@ -8718,6 +8718,70 @@ function f(int $a, int $b): int { ); } + /// A parenthesised operand under `!` counts the same as a bare one. + /// + /// `_expr` inlines `( … )` as anonymous children, so `!($a)` puts + /// `(` where a positional read expects the operand. The walker's + /// negation branch kept a fixed `child(1)` through the first draft of + /// #1180 and scored these 0 while the unparenthesised forms scored 1 + /// — an inconsistency the fix itself introduced, since before it + /// neither form counted. Found in review, not by the tests: the + /// parenthesised fixtures added with #1180 covered the ternary + /// *condition* slot only. + #[test] + fn tcl_parenthesised_negated_operands_match_the_bare_form() { + let conditions = |source: &str| { + crate::test_support::metrics_verbatim( + crate::LANG::Tcl, + source.as_bytes(), + crate::MetricsOptions::default(), + ) + .abc + .conditions_sum() + }; + for (bare, parenthesised) in [ + ( + "proc f {a} {\n if {!$a} { puts x }\n}", + "proc f {a} {\n if {!($a)} { puts x }\n}", + ), + ( + "proc f {a b} {\n if {$a && !$b} { puts x }\n}", + "proc f {a b} {\n if {$a && !($b)} { puts x }\n}", + ), + ( + "proc f {a b c} {\n set r [expr {$a ? !$b : !$c}]\n}", + "proc f {a b c} {\n set r [expr {$a ? !($b) : !$c}]\n}", + ), + ] { + let want = conditions(bare); + assert!(want > 0, "the bare form must count something: {bare}"); + assert_eq!( + conditions(parenthesised), + want, + "parenthesising the negated operand changed the count\n bare: {bare}\n paren: {parenthesised}" + ); + } + } + + #[test] + fn irules_abc_parenthesised_negated_operands_match_the_bare_form() { + let conditions = |source: &str| { + crate::test_support::metrics_verbatim( + crate::LANG::Irules, + source.as_bytes(), + crate::MetricsOptions::default(), + ) + .abc + .conditions_sum() + }; + let want = conditions("when X {\n if { !$a } { log local0. hi }\n}\n"); + assert_eq!(want, 1); + assert_eq!( + conditions("when X {\n if { !($a) } { log local0. hi }\n}\n"), + want + ); + } + #[test] fn tcl_bare_truthy_and_negated_predicates_count_one_condition() { // The headline #1180 fix, on the Tcl side: both were 0 before. diff --git a/src/metrics/abc/irules.rs b/src/metrics/abc/irules.rs index 6fed24c84..90e073eba 100644 --- a/src/metrics/abc/irules.rs +++ b/src/metrics/abc/irules.rs @@ -228,16 +228,15 @@ fn irules_inspect_container(container_node: &Node, parent: &Node, conditions: &m has_boolean_content = true; } - // `!` stores its operand at child 1 (after the token). The `expr` - // wrapper holds `{`, the expression, `}`, and `_expr` inlines - // parens, so take the first *named* child instead of a fixed - // index — that resolves `{($a)}` as well as `{$a}`. - let next = if is_not { - node.child(1) - } else { - node.children().find(Node::is_named) + // The first *named* child, for both wrappers. `!` and the `{` + // / `(` delimiters are all anonymous, so this is the operand in + // every shape — and unlike a fixed index it survives `_expr` + // inlining its parens: `!($a)` puts `(` at child 1, where a + // positional read lands on the delimiter and the walk stops + // without counting the negation at all. + let Some(child) = node.children().find(Node::is_named) else { + break; }; - let Some(child) = next else { break }; node = child; node_kind = node.kind_id().into(); diff --git a/src/metrics/abc/tcl.rs b/src/metrics/abc/tcl.rs index 4d3c47ad1..4b083cd4a 100644 --- a/src/metrics/abc/tcl.rs +++ b/src/metrics/abc/tcl.rs @@ -99,16 +99,15 @@ fn tcl_inspect_container(container_node: &Node, parent: &Node, conditions: &mut has_boolean_content = true; } - // `!` stores its operand at child 1 (after the token). The `expr` - // wrapper holds `{`, the expression, `}`, and `_expr` inlines - // parens, so take the first *named* child instead of a fixed - // index — that resolves `{($a)}` as well as `{$a}`. - let next = if is_not { - node.child(1) - } else { - node.children().find(Node::is_named) + // The first *named* child, for both wrappers. `!` and the `{` + // / `(` delimiters are all anonymous, so this is the operand in + // every shape — and unlike a fixed index it survives `_expr` + // inlining its parens: `!($a)` puts `(` at child 1, where a + // positional read lands on the delimiter and the walk stops + // without counting the negation at all. + let Some(child) = node.children().find(Node::is_named) else { + break; }; - let Some(child) = next else { break }; node = child; node_kind = node.kind_id().into(); diff --git a/src/metrics/cognitive.rs b/src/metrics/cognitive.rs index c3d01c330..6b4f4ba75 100644 --- a/src/metrics/cognitive.rs +++ b/src/metrics/cognitive.rs @@ -470,15 +470,14 @@ macro_rules! js_cognitive { // anonymous IIFE is a lexical function scope — // `get_space_kind` maps every `function_expression` to // `SpaceKind::Function` — so a `function` declared inside - // one really is nested in a function. `ArrowFunction` is - // knowingly absent, and because the `nesting.lambda = 0` - // above also wipes the arrow's surcharge, an arrow - // ancestor contributes nothing at all: `(function () { - // function g() {…} })()` charges `g` a depth of 1 where - // `(() => { function g() {…} })()` charges 0. Closing - // that gap means deciding what a lambda ancestor is worth - // across every language — the deferred half of #1159 — - // rather than settling it in one arm. + // one really is nested in a function. + // + // `ArrowFunction` is in the list since #1187, which is + // what makes `(function () { function g() {…} })()` and + // `(() => { function g() {…} })()` both charge `g` a + // depth of 1; the arrow form charged 0 while the kind + // was absent. It cannot double-charge, because the sole + // caller resets `nesting.lambda` first. // // Both generator kinds are in the arm and in `stops` // since #1186, which moved them from `is_js_closure!` diff --git a/src/metrics/nom.rs b/src/metrics/nom.rs index 1fa548d6f..43df81a5a 100644 --- a/src/metrics/nom.rs +++ b/src/metrics/nom.rs @@ -710,6 +710,22 @@ mod tests { "run(() => 1);", false, ), + // A positional callback that is the *object* of a member + // expression. The first fix for the class-field divergence + // used `has_sibling(PropertyIdentifier)`, which tests every + // sibling rather than a binding position — so `.bind(this)` + // supplied the sibling and made this a function while the + // arrow spelling (which needs parentheses, interposing a + // node) stayed a closure. That re-created the very func/arrow + // divergence this test exists to forbid, in a new shape, and + // it moved real pdf.js corpus counts before review caught it. + // The binding site is now named structurally, by kind. + ( + "member-expression object", + "run(function () { return 1; }.bind(this));", + "run((() => 1).bind(this));", + false, + ), ]; for &(label, func_form, arrow_form, expected) in cases { diff --git a/tests/parity/functions_metrics_parity.rs b/tests/parity/functions_metrics_parity.rs index be6a62207..56aa699a0 100644 --- a/tests/parity/functions_metrics_parity.rs +++ b/tests/parity/functions_metrics_parity.rs @@ -44,14 +44,21 @@ use super::ops_metrics_space_parity::fixture; /// no business pinning — so the coverage claim below is scoped to spaces /// that carry a real name. /// -/// A predicate over the angle-bracket convention rather than the single -/// literal `""` it used to be: #1184 added ``, ``, -/// `` and `` for constructs that carry executable -/// code but no name token, and each is `is_func_space` without being -/// `is_func` for exactly the reason above. No language here permits `<` -/// in an identifier, so the convention cannot collide with a real name. +/// The five names are enumerated rather than matched by shape. A +/// `starts_with('<') && ends_with('>')` predicate reads as equivalent +/// but is not: Ruby's spaceship operator is a real method, and +/// `def <=>(other)` produces a space named literally `<=>`, which such a +/// predicate would exempt from the coverage claim below. Enumerating +/// also makes a sixth synthesised name fail loudly rather than inherit +/// the exemption silently. +/// +/// `` is the trait default; #1184 added the other four for +/// constructs that carry executable code but no name token, each +/// `is_func_space` without being `is_func` for exactly the reason above. +const SYNTHESISED_NAMES: &[&str] = &["", "", "", "", ""]; + fn is_synthesised_name(name: &str) -> bool { - name.starts_with('<') && name.ends_with('>') + SYNTHESISED_NAMES.contains(&name) } /// A space or span, reduced to the fields all three seams report. diff --git a/tests/repositories/big-code-analysis-output b/tests/repositories/big-code-analysis-output index 9e4090850..7a9c5ec8f 160000 --- a/tests/repositories/big-code-analysis-output +++ b/tests/repositories/big-code-analysis-output @@ -1 +1 @@ -Subproject commit 9e4090850df164f1208ad50ccba5419ee03f3216 +Subproject commit 7a9c5ec8fb06d185d09588e8ca7af9468104410a diff --git a/utils/check-snapshot-anchors-test.py b/utils/check-snapshot-anchors-test.py index 570f20e61..f23a231c3 100644 --- a/utils/check-snapshot-anchors-test.py +++ b/utils/check-snapshot-anchors-test.py @@ -201,7 +201,10 @@ def test_pass_regress_and_ratchet(self) -> None: code, _, _ = self._run([str(target), "--baseline", str(baseline), "--update"]) self.assertEqual(code, 0) - self.assertIn("1", baseline.read_text(encoding="utf-8")) + # The parsed dict, not a substring: the written header carries + # "#1192" and "126", so `assertIn("1", …)` holds for an empty + # baseline too and pins nothing. + self.assertEqual(list(gate.load_baseline(baseline).values()), [1]) code, _, _ = self._run([str(target), "--baseline", str(baseline)]) self.assertEqual(code, 0, "unchanged tree must pass") From 2aa4f8c597daa2d85c52fe982bb8f1e76fe3c366 Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Mon, 3 Aug 2026 09:59:33 -0700 Subject: [PATCH 19/27] chore(self-scan): refresh the baseline for this batch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `make pre-commit` was failing at `_pc-self-scan`: `markdown_report/hotspot.rs` grew past its recorded `loc.ploc` of 650 to 696 when #1190 moved the shared selection half into it from the two renderers. `AGENTS.md` requires the refresh in the same change as the metric move, and it was outstanding. Refreshed with the headroom variant, so functions in the 95-100% band are recorded and the soft tier does not re-fire on untouched files. The file shrinks: 183 entries to 181. Several recorded values drop because this batch's refactors genuinely simplified their subjects — `EffectiveConfig::from_resolved`'s nargs 12 to 7 after the `EffectiveCheck` constructor was split out, and two `loc.ploc` entries by 23 and 9 lines. Three entries are new, and all three are the cost of an extraction rather than new complexity: `EffectiveCheck::from_resolved` (nargs 10), `select_for` (7) and `relative_tail_with` (5). Extracting a helper moves a parameter list into existence — the file-level artifact `AGENTS.md` warns about under "Responding to bca metric feedback" — and in each case the alternative was leaving the duplication the reviews asked to remove. `select_for` is the clearest instance: seven parameters exist because it owns the whole format-independent half that both renderers previously spelled out. --- .bca-baseline.toml | 60 +++++++++++++++++++--------------------------- 1 file changed, 24 insertions(+), 36 deletions(-) diff --git a/.bca-baseline.toml b/.bca-baseline.toml index 2d1ea23d7..9fa923140 100644 --- a/.bca-baseline.toml +++ b/.bca-baseline.toml @@ -89,11 +89,17 @@ qualified = "run_check" metric = "halstead.effort" value = 60852.18615151166 +[[entry]] +path = "big-code-analysis-cli/src/commands/check/effective_config.rs" +qualified = "EffectiveCheck::from_resolved" +metric = "nargs" +value = 10.0 + [[entry]] path = "big-code-analysis-cli/src/commands/check/effective_config.rs" qualified = "EffectiveConfig::from_resolved" metric = "nargs" -value = 12.0 +value = 7.0 [[entry]] path = "big-code-analysis-cli/src/commands/check/effective_config.rs" @@ -195,7 +201,7 @@ value = 8.0 path = "big-code-analysis-cli/src/html_report/sections.rs" qualified = "" metric = "loc.ploc" -value = 547.0 +value = 524.0 [[entry]] path = "big-code-analysis-cli/src/html_report/sections.rs" @@ -213,7 +219,7 @@ value = 59351.805921378844 path = "big-code-analysis-cli/src/markdown_report.rs" qualified = "" metric = "loc.ploc" -value = 597.0 +value = 588.0 [[entry]] path = "big-code-analysis-cli/src/markdown_report.rs" @@ -225,7 +231,13 @@ value = 39.153543900903784 path = "big-code-analysis-cli/src/markdown_report/hotspot.rs" qualified = "" metric = "loc.ploc" -value = 650.0 +value = 696.0 + +[[entry]] +path = "big-code-analysis-cli/src/markdown_report/hotspot.rs" +qualified = "select_for" +metric = "nargs" +value = 7.0 [[entry]] path = "big-code-analysis-cli/src/markdown_report/sections.rs" @@ -287,6 +299,12 @@ qualified = "write_html_body" metric = "nargs" value = 8.0 +[[entry]] +path = "big-code-analysis-cli/src/walk_seed.rs" +qualified = "relative_tail_with" +metric = "nexits" +value = 5.0 + [[entry]] path = "big-code-analysis-py/python/big_code_analysis/_flatten.py" qualified = "_flatten_metrics" @@ -503,24 +521,12 @@ qualified = "IrulesCode::get_op_type" metric = "halstead.effort" value = 53163.06867422894 -[[entry]] -path = "src/getter/javascript.rs" -qualified = "JavascriptCode::get_func_space_name" -metric = "cognitive" -value = 15.0 - [[entry]] path = "src/getter/mozcpp.rs" qualified = "MozcppCode::get_func_space_name" metric = "cognitive" value = 15.0 -[[entry]] -path = "src/getter/mozjs.rs" -qualified = "MozjsCode::get_func_space_name" -metric = "cognitive" -value = 15.0 - [[entry]] path = "src/getter/perl.rs" qualified = "PerlCode::get_op_type" @@ -545,18 +551,6 @@ qualified = "RubyCode::get_op_type" metric = "halstead.effort" value = 84039.86210807812 -[[entry]] -path = "src/getter/tsx.rs" -qualified = "TsxCode::get_func_space_name" -metric = "cognitive" -value = 15.0 - -[[entry]] -path = "src/getter/typescript.rs" -qualified = "TypescriptCode::get_func_space_name" -metric = "cognitive" -value = 15.0 - [[entry]] path = "src/metrics/abc/csharp.rs" qualified = "csharp_inspect_container" @@ -593,12 +587,6 @@ qualified = "KotlinCode::compute" metric = "cyclomatic" value = 16.0 -[[entry]] -path = "src/metrics/abc/perl.rs" -qualified = "perl_inspect_container" -metric = "halstead.effort" -value = 49178.00338181524 - [[entry]] path = "src/metrics/abc/rust.rs" qualified = "RustCode::compute" @@ -681,7 +669,7 @@ value = 7.0 path = "src/metrics/nargs.rs" qualified = "" metric = "loc.ploc" -value = 542.0 +value = 547.0 [[entry]] path = "src/metrics/nargs.rs" @@ -753,7 +741,7 @@ value = 15.0 path = "src/node.rs" qualified = "Node<'a>" metric = "nom" -value = 35.0 +value = 34.0 [[entry]] path = "src/ops.rs" From 176e90432d39fe01d1646bb359e4db94dad40d55 Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Mon, 3 Aug 2026 10:18:36 -0700 Subject: [PATCH 20/27] fix(check): gate nargs on a callable's own parameters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `bca check --threshold nargs=N` read `nargs.total()`, which is `function_args_sum() + closure_args_sum()` — subtree sums — so a function was gated on its own parameters *plus every nested closure's*. `write_top_offenders` declares three parameters and was reported at 6, because a sort comparator and a format closure contributed three more. The remediation the number implied, fewer parameters, was not the one that would clear it. Measured on this repository while working #1183: of the 76 functions a limit of 5 would have newly gated, only **17** had six or more parameters of their own. 59 carried closure args and 44 had four or fewer own parameters; the extreme was `Parser::filters` — one parameter, plus five from the closures in its body. Converging the limit would have pressured contributors to delete idiomatic inline closures rather than simplify signatures. The survey on #1196 found no comparable tool doing this. RuboCop `Metrics/ParameterLists` (verified in source: `on_args` returns early for lambdas and procs), ESLint `max-params`, Clippy `too_many_arguments`, lizard, SonarQube S107 and Pylint `R0913` all count one callable's own formal parameters. Two of those are the anchors `default_thresholds.rs` derives the shipped limit of 5 from, so the default was calibrated against a different quantity than the gate enforced. The book's own NArgs definition — "arguments declared by *a* function, method, or closure" — already agreed with the precedent rather than with the gate. Nothing escapes the narrower rule. In the ten grammars whose closures open their own space a closure is gated on its own offender row, which is where its fix belongs — verified: a six-parameter Rust closure inside a one-parameter function is flagged as `small::`, not as `small`. In Python, Java, Kotlin and C++/Mozcpp a lambda opens no space, so its arguments can only be attributed to the enclosing function; there the row now carries the split, `nargs = 8 (1 own + 7 lambda)`, so the reader can tell whether the lever is the signature or the lambda. The split is appended rather than substituted, so the value stays where tooling parsing the row expects it, and it is omitted when there is no lambda term to disclose. `NargsSplit` is a struct rather than a `(u64, u64)` because the two fields are same-typed, not interchangeable, and transposing them would print a fluent lie. Effect on this project: refreshing the baseline retired 45 of its 61 recorded `nargs` entries (181 entries to 137 overall). Those were never real debt. Tests: four end-to-end cases in `check_thresholds.rs` covering own-args gating, the closure-on-its-own-row guarantee, the split where a lambda opens no space, and its absence for a plain parameter list. Verified by perturbation — restoring `total()` fails the two that assert gating, while the two that assert rendering correctly do not move. Note that the old behaviour had **no** test at all: the whole suite passed unchanged when the extractor was first swapped. Docs: the book's NArgs section gains a "what the threshold gate measures" subsection distinguishing the gate's reading from the serialized subtree sums; the threshold recipe keeps its #1143 cluster-trap narrative but records that those figures predate this change and re-measures the same tree (7 -> 5 now costs 17 new hard-tier offenders rather than 77). The `MetricScope::Function` doc no longer claims every metric in that scope includes nested closures — `nargs` now does not, while `halstead.*`, `nexits` and `tokens` still do. Scope note: #1196 and my report on it both said the #1140 per-language percentile table would need re-deriving. It would not — that table carries `cognitive`, `cyclomatic`, `abc`, `halstead.effort` and `loc.ploc`, and no `nargs` column. The one corpus-derived `nargs` claim in the book is annotated instead, since re-measuring needs the 20-language corpus that is not in this repository; the claim's direction of change (down) only strengthens its conclusion. Fixes #1196 --- .bca-baseline.toml | 280 +----------------- CHANGELOG.md | 36 +++ big-code-analysis-book/src/metrics.md | 31 ++ .../src/recipes/thresholds.md | 22 +- big-code-analysis-cli/src/baseline_tests.rs | 5 + big-code-analysis-cli/src/check_format.rs | 10 + big-code-analysis-cli/src/commands_tests.rs | 1 + big-code-analysis-cli/src/thresholds.rs | 80 ++++- big-code-analysis-cli/src/thresholds_tests.rs | 5 + .../tests/check/check_thresholds.rs | 118 ++++++++ src/metric_catalog.rs | 13 +- 11 files changed, 324 insertions(+), 277 deletions(-) diff --git a/.bca-baseline.toml b/.bca-baseline.toml index 9fa923140..6e4e6493d 100644 --- a/.bca-baseline.toml +++ b/.bca-baseline.toml @@ -17,36 +17,12 @@ qualified = "Baseline::from_str" metric = "halstead.effort" value = 58410.89968953587 -[[entry]] -path = "big-code-analysis-cli/src/baseline.rs" -qualified = "Baseline::match_in_group" -metric = "nargs" -value = 7.0 - -[[entry]] -path = "big-code-analysis-cli/src/baseline_diff.rs" -qualified = "BaselineDiff::column_widths" -metric = "nargs" -value = 8.0 - [[entry]] path = "big-code-analysis-cli/src/baseline_diff.rs" qualified = "BaselineDiff::compute" metric = "halstead.effort" value = 118801.67275912332 -[[entry]] -path = "big-code-analysis-cli/src/baseline_diff.rs" -qualified = "BaselineDiff::compute" -metric = "nargs" -value = 8.0 - -[[entry]] -path = "big-code-analysis-cli/src/check_format.rs" -qualified = "AggregatedFormat::dump" -metric = "nargs" -value = 7.0 - [[entry]] path = "big-code-analysis-cli/src/check_format.rs" qualified = "write_github_annotations" @@ -71,58 +47,16 @@ qualified = "write_aggregate" metric = "halstead.effort" value = 48544.21441032752 -[[entry]] -path = "big-code-analysis-cli/src/commands/analyze.rs" -qualified = "write_aggregate" -metric = "nargs" -value = 7.0 - -[[entry]] -path = "big-code-analysis-cli/src/commands/check.rs" -qualified = "filter_by_baseline" -metric = "nargs" -value = 8.0 - [[entry]] path = "big-code-analysis-cli/src/commands/check.rs" qualified = "run_check" metric = "halstead.effort" value = 60852.18615151166 -[[entry]] -path = "big-code-analysis-cli/src/commands/check/effective_config.rs" -qualified = "EffectiveCheck::from_resolved" -metric = "nargs" -value = 10.0 - -[[entry]] -path = "big-code-analysis-cli/src/commands/check/effective_config.rs" -qualified = "EffectiveConfig::from_resolved" -metric = "nargs" -value = 7.0 - [[entry]] path = "big-code-analysis-cli/src/commands/check/effective_config.rs" qualified = "print_effective_config" metric = "nargs" -value = 9.0 - -[[entry]] -path = "big-code-analysis-cli/src/commands/diff_cmd.rs" -qualified = "compute_since_diff" -metric = "nargs" -value = 8.0 - -[[entry]] -path = "big-code-analysis-cli/src/commands/init.rs" -qualified = "run_command_init" -metric = "nargs" -value = 7.0 - -[[entry]] -path = "big-code-analysis-cli/src/commands/init.rs" -qualified = "scaffold_baseline" -metric = "nargs" value = 7.0 [[entry]] @@ -143,12 +77,6 @@ qualified = "materialize_tree" metric = "halstead.effort" value = 60390.01093335322 -[[entry]] -path = "big-code-analysis-cli/src/diff.rs" -qualified = "materialize_tree" -metric = "nargs" -value = 10.0 - [[entry]] path = "big-code-analysis-cli/src/diff.rs" qualified = "materialize_tree" @@ -171,19 +99,13 @@ value = 7.0 path = "big-code-analysis-cli/src/dispatch.rs" qualified = "dispatch_ops" metric = "nargs" -value = 8.0 - -[[entry]] -path = "big-code-analysis-cli/src/html_report.rs" -qualified = "write_table_classed" -metric = "nargs" value = 7.0 [[entry]] path = "big-code-analysis-cli/src/html_report.rs" qualified = "write_table_classed_with_tooltips" metric = "nargs" -value = 9.0 +value = 7.0 [[entry]] path = "big-code-analysis-cli/src/html_report.rs" @@ -191,12 +113,6 @@ qualified = "write_table_core" metric = "nargs" value = 7.0 -[[entry]] -path = "big-code-analysis-cli/src/html_report.rs" -qualified = "write_table_with_tooltips" -metric = "nargs" -value = 8.0 - [[entry]] path = "big-code-analysis-cli/src/html_report/sections.rs" qualified = "" @@ -239,30 +155,12 @@ qualified = "select_for" metric = "nargs" value = 7.0 -[[entry]] -path = "big-code-analysis-cli/src/markdown_report/sections.rs" -qualified = "emit_section_md" -metric = "nargs" -value = 8.0 - -[[entry]] -path = "big-code-analysis-cli/src/metric_diff.rs" -qualified = "MetricDiff::from_sets" -metric = "nargs" -value = 8.0 - [[entry]] path = "big-code-analysis-cli/src/threshold_suggestion.rs" qualified = "closest_names" metric = "halstead.effort" value = 55785.628204736706 -[[entry]] -path = "big-code-analysis-cli/src/threshold_suggestion.rs" -qualified = "closest_names" -metric = "nargs" -value = 9.0 - [[entry]] path = "big-code-analysis-cli/src/threshold_suggestion.rs" qualified = "edit_distance_with_cutoff" @@ -270,22 +168,16 @@ metric = "halstead.effort" value = 66737.04848699087 [[entry]] -path = "big-code-analysis-cli/src/vcs_command.rs" +path = "big-code-analysis-cli/src/thresholds.rs" qualified = "" metric = "loc.ploc" -value = 532.0 - -[[entry]] -path = "big-code-analysis-cli/src/vcs_command.rs" -qualified = "build_options" -metric = "nargs" -value = 8.0 +value = 543.0 [[entry]] path = "big-code-analysis-cli/src/vcs_command.rs" -qualified = "rank" -metric = "nargs" -value = 7.0 +qualified = "" +metric = "loc.ploc" +value = 532.0 [[entry]] path = "big-code-analysis-cli/src/vcs_command.rs" @@ -293,12 +185,6 @@ qualified = "write_table" metric = "nexits" value = 5.0 -[[entry]] -path = "big-code-analysis-cli/src/vcs_report.rs" -qualified = "write_html_body" -metric = "nargs" -value = 8.0 - [[entry]] path = "big-code-analysis-cli/src/walk_seed.rs" qualified = "relative_tail_with" @@ -333,7 +219,7 @@ value = 6.0 path = "big-code-analysis-py/src/batch.rs" qualified = "analyze_paths" metric = "nargs" -value = 12.0 +value = 11.0 [[entry]] path = "big-code-analysis-py/src/batch.rs" @@ -437,12 +323,6 @@ qualified = "run_parse" metric = "halstead.effort" value = 82478.1543040951 -[[entry]] -path = "big-code-analysis-web/src/web/server.rs" -qualified = "run_with_timeout" -metric = "nargs" -value = 7.0 - [[entry]] path = "big-code-analysis-web/src/web/server/routing.rs" qualified = "register_endpoints" @@ -593,102 +473,30 @@ qualified = "RustCode::compute" metric = "cyclomatic" value = 16.0 -[[entry]] -path = "src/metrics/cognitive.rs" -qualified = "tcl_switch_decision_arms" -metric = "nargs" -value = 7.0 - [[entry]] path = "src/metrics/cognitive.rs" qualified = "tcl_switch_decision_arms" metric = "nexits" value = 6.0 -[[entry]] -path = "src/metrics/cognitive/csharp.rs" -qualified = "CsharpCode::compute" -metric = "nargs" -value = 7.0 - -[[entry]] -path = "src/metrics/cognitive/kotlin.rs" -qualified = "KotlinCode::compute" -metric = "nargs" -value = 7.0 - -[[entry]] -path = "src/metrics/cognitive/php.rs" -qualified = "PhpCode::compute" -metric = "nargs" -value = 7.0 - -[[entry]] -path = "src/metrics/loc/c.rs" -qualified = "CCode::compute" -metric = "nargs" -value = 7.0 - -[[entry]] -path = "src/metrics/loc/cpp.rs" -qualified = "CppCode::compute" -metric = "nargs" -value = 7.0 - -[[entry]] -path = "src/metrics/loc/groovy.rs" -qualified = "GroovyCode::compute" -metric = "nargs" -value = 7.0 - -[[entry]] -path = "src/metrics/loc/mozcpp.rs" -qualified = "MozcppCode::compute" -metric = "nargs" -value = 7.0 - -[[entry]] -path = "src/metrics/loc/objc.rs" -qualified = "ObjcCode::compute" -metric = "nargs" -value = 7.0 - [[entry]] path = "src/metrics/loc/perl.rs" qualified = "PerlCode::compute" metric = "halstead.effort" value = 55025.91689557041 -[[entry]] -path = "src/metrics/loc/shared.rs" -qualified = "add_multiline_string_ploc" -metric = "nargs" -value = 7.0 - [[entry]] path = "src/metrics/nargs.rs" qualified = "" metric = "loc.ploc" value = 547.0 -[[entry]] -path = "src/metrics/nargs.rs" -qualified = "ObjcCode::compute" -metric = "nargs" -value = 7.0 - [[entry]] path = "src/metrics/npa/csharp.rs" qualified = "CsharpCode::compute" metric = "cognitive" value = 15.0 -[[entry]] -path = "src/metrics/npa/csharp.rs" -qualified = "CsharpCode::compute" -metric = "nargs" -value = 7.0 - [[entry]] path = "src/metrics/npa/kotlin.rs" qualified = "KotlinCode::compute" @@ -707,12 +515,6 @@ qualified = "PhpCode::compute" metric = "halstead.effort" value = 52227.844233305164 -[[entry]] -path = "src/metrics/npa/php.rs" -qualified = "PhpCode::compute" -metric = "nargs" -value = 10.0 - [[entry]] path = "src/metrics/npa/python.rs" qualified = "python_self_attr_name_bytes" @@ -725,12 +527,6 @@ qualified = "GoCode::compute" metric = "halstead.effort" value = 65898.2477704053 -[[entry]] -path = "src/metrics/npm/go.rs" -qualified = "GoCode::compute" -metric = "nargs" -value = 10.0 - [[entry]] path = "src/metrics/npm/objc.rs" qualified = "ObjcCode::compute" @@ -803,12 +599,6 @@ qualified = "metric_values" metric = "halstead.effort" value = 80351.11907066956 -[[entry]] -path = "src/output/sarif.rs" -qualified = "write_sarif_with_suppressed" -metric = "nargs" -value = 7.0 - [[entry]] path = "src/parser.rs" qualified = "Parser::filters" @@ -857,36 +647,18 @@ qualified = "read_file_with_eol" metric = "nexits" value = 7.0 -[[entry]] -path = "src/vcs/bus_factor.rs" -qualified = "authors_of_file" -metric = "nargs" -value = 9.0 - [[entry]] path = "src/vcs/bus_factor.rs" qualified = "compute" metric = "halstead.effort" value = 47694.803479864124 -[[entry]] -path = "src/vcs/bus_factor.rs" -qualified = "compute" -metric = "nargs" -value = 10.0 - [[entry]] path = "src/vcs/git/blame.rs" qualified = "PerFunctionBlame" metric = "nom" value = 30.0 -[[entry]] -path = "src/vcs/git/blame.rs" -qualified = "PerFunctionBlame::blame_spans" -metric = "nargs" -value = 7.0 - [[entry]] path = "src/vcs/git/blame.rs" qualified = "PerFunctionBlame::open" @@ -915,13 +687,7 @@ value = 11.0 path = "src/vcs/git/cached.rs" qualified = "incremental_events" metric = "nargs" -value = 12.0 - -[[entry]] -path = "src/vcs/git/cached.rs" -qualified = "load_candidates" -metric = "nargs" -value = 7.0 +value = 9.0 [[entry]] path = "src/vcs/git/diff_parse.rs" @@ -989,12 +755,6 @@ qualified = "compute_features" metric = "nexits" value = 5.0 -[[entry]] -path = "src/vcs/git/jit.rs" -qualified = "experience_features" -metric = "nargs" -value = 7.0 - [[entry]] path = "src/vcs/git/jit.rs" qualified = "experience_features" @@ -1049,36 +809,18 @@ qualified = "parse_window" metric = "nexits" value = 5.0 -[[entry]] -path = "src/vcs/replay.rs" -qualified = "fold_commit" -metric = "nargs" -value = 7.0 - [[entry]] path = "src/vcs/score.rs" qualified = "apply_percentile" metric = "halstead.effort" value = 68450.87280037583 -[[entry]] -path = "src/vcs/score.rs" -qualified = "apply_percentile" -metric = "nargs" -value = 13.0 - [[entry]] path = "src/vcs/stats.rs" qualified = "Accumulator::finalize" metric = "halstead.effort" value = 79569.58843331039 -[[entry]] -path = "src/vcs/stats.rs" -qualified = "Accumulator::finalize" -metric = "nargs" -value = 7.0 - [[entry]] path = "src/vcs/stats.rs" qualified = "Accumulator::record" @@ -1090,9 +832,3 @@ path = "src/wire.rs" qualified = "CodeMetrics::from" metric = "halstead.effort" value = 49790.15156224738 - -[[entry]] -path = "src/wire/vcs.rs" -qualified = "VcsTrend::from_trend" -metric = "nargs" -value = 8.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index edf59870b..6c9555c53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -183,6 +183,42 @@ for historical reference. ### Changed +- **`bca check --threshold nargs=N` now gates each callable on its own + parameter list** rather than on `nargs.total()`, which summed a + function's parameters with every nested closure's (#1196). This changes + gate outcomes on existing configurations — read it before upgrading a + pinned CI. + + A three-parameter function containing a two-parameter sort comparator + was reported at 5, and the remediation the number implied — fewer + parameters — was not the one that would clear it. Measured on this + repository, of the 76 functions a limit of 5 would have newly gated, + only 17 had six or more parameters of their own; one had a single + parameter plus five contributed by closures in its body. Refreshing + this project's own baseline under the new rule retired 45 of its 61 + recorded `nargs` entries. + + Every comparable tool measures the same quantity the gate now does — + RuboCop `Metrics/ParameterLists`, ESLint `max-params`, Clippy + `too_many_arguments`, lizard, SonarQube S107, Pylint `R0913` — and two + of those are the anchors the shipped default of 5 is derived from, so + the default and the gate were previously calibrated against different + quantities. + + Nothing escapes the narrower rule. Where a closure opens its own space + (Rust, the JavaScript family, C#, Go, PHP, Perl, Ruby, Lua, Elixir) it + is gated on its own offender row. Where a lambda opens none (Python, + Java, Kotlin, C++) its arguments still fold into the enclosing + function, and the offender row now shows the split — + `nargs = 8 (1 own + 7 lambda)` — so the reader can tell whether the + lever is the signature or the lambda. + + Unchanged: the serialized `function_args` / `closure_args` / `total` + keys, which remain subtree sums. Only the gate's reading of them moved. + If you have a `nargs` limit tuned against the old behaviour, expect + fewer offenders and consider whether the limit is now looser than you + intended. + - This repository's own `bca.toml` gates `cognitive` at 15, the shipped default, instead of the pre-#1140 folklore value of 25 (#1143). This is self-scan configuration only — no public API, no metric diff --git a/big-code-analysis-book/src/metrics.md b/big-code-analysis-book/src/metrics.md index 2740c8058..7311a86a6 100644 --- a/big-code-analysis-book/src/metrics.md +++ b/big-code-analysis-book/src/metrics.md @@ -744,6 +744,37 @@ The implementation handles default arguments, variadic arguments, keyword-only arguments, and destructured parameters consistently per language. +### What the threshold gate measures {#nargs-gate} + +`bca check --threshold nargs=N` gates each callable on **its own** +parameter list, which is what every comparable tool measures — RuboCop +`Metrics/ParameterLists`, ESLint `max-params`, Clippy +`too_many_arguments`, lizard, SonarQube S107 and Pylint `R0913` all +count one callable at a time. + +Note that this is *not* the serialized `total`. The `function_args` and +`closure_args` keys above are subtree sums: a function that declares two +parameters and contains a three-parameter nested function reports +`function_args: 5`. Before +[#1196](https://github.com/dekobon/big-code-analysis/issues/1196) the +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. + +Nothing escapes the narrower rule. In the ten 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 +row, which is also where its fix belongs. In Python, Java, Kotlin and +C++ a lambda opens no space, so its arguments can only be attributed to +the enclosing function; there the offender row shows the split: + +```text +small: nargs = 8 (1 own + 7 lambda) (limit 5) +``` + +so you can tell at a glance whether the lever is the signature or the +lambda. + ### Languages where it reads 0 {#nargs-language-gaps} A metric that silently reports 0 reads as "no offenders" rather than diff --git a/big-code-analysis-book/src/recipes/thresholds.md b/big-code-analysis-book/src/recipes/thresholds.md index 6761f89bf..ae52b3038 100644 --- a/big-code-analysis-book/src/recipes/thresholds.md +++ b/big-code-analysis-book/src/recipes/thresholds.md @@ -71,7 +71,10 @@ who derive risk bands at the 70th, 80th, and 90th percentiles. Their 90th percen Two of the previous defaults failed that rule and changed as a result. `nargs` at 7 fired on under 1% of functions in the median language and on nothing at all in this project's own source, which -makes it a limit that cannot catch anything. `cognitive` at 25 was inherited from +makes it a limit that cannot catch anything. (That corpus measurement predates +[#1196](https://github.com/dekobon/big-code-analysis/issues/1196), which narrowed `nargs` to a +function's own parameters. Re-measuring would move the figure *down*, and most for closure-heavy +languages, so the "cannot catch anything" conclusion holds a fortiori.) `cognitive` at 25 was inherited from [clippy](https://rust-lang.github.io/rust-clippy/master/#cognitive_complexity), which is deliberately conservative because it lints rather than gates; 15 is what the metric's designers chose. @@ -366,6 +369,23 @@ short of rewriting the signatures. The limit stayed at `7`; the honest alternati `6 → 5` work. ([#1143](https://github.com/dekobon/big-code-analysis/issues/1143), [#1169](https://github.com/dekobon/big-code-analysis/issues/1169).) +Those figures were measured before +[#1196](https://github.com/dekobon/big-code-analysis/issues/1196), when the `nargs` gate summed a +function's own parameters with every nested closure's. Most of that population was not +parameter-heavy at all: of the 76 functions a limit of `5` would have newly gated, only 17 had six +or more parameters of their own, and one had a single parameter plus five contributed by closures +in its body. Re-measured on the same tree under own-parameter gating: + +| `nargs` limit | hard offenders | new (unbaselined) | soft offenders | new | +|---|---|---|---|---| +| 7 (kept) | 6 | 0 | 16 | 0 | +| 6 | 16 | 0 | 38 | 17 | +| 5 | 38 | 17 | 129 | 99 | + +The cluster trap itself is unchanged and is the reason this section exists — 91 functions now sit +at exactly `5`, so a limit of `5` still puts every one of them permanently inside the soft band. +What changed is the size of the real work behind it: 17 signatures rather than 76 mixed cases. + `bca check --explain-threshold =` measures both tiers in one walk and names the cluster when it finds one, without touching `bca.toml`: diff --git a/big-code-analysis-cli/src/baseline_tests.rs b/big-code-analysis-cli/src/baseline_tests.rs index e3a6ab1ac..12bbc18bb 100644 --- a/big-code-analysis-cli/src/baseline_tests.rs +++ b/big-code-analysis-cli/src/baseline_tests.rs @@ -21,6 +21,7 @@ fn v(path: &str, function: &str, start_line: usize, metric: &'static str, value: lower_is_worse: false, body_hash: None, suppressed: false, + nargs_split: None, } } @@ -52,6 +53,7 @@ fn v_hashed( Violation { body_hash: Some(body_hash), suppressed: false, + nargs_split: None, ..v(path, function, start_line, metric, value) } } @@ -944,6 +946,7 @@ fn from_str_defensive_anchor_normalization() { lower_is_worse: false, body_hash: None, suppressed: false, + nargs_split: None, }), Coverage::Covered { recorded } if recorded == 5.0 )); @@ -973,6 +976,7 @@ fn baseline_covers_distinguishes_non_utf8_paths() { lower_is_worse: false, body_hash: None, suppressed: false, + nargs_split: None, }; let violation_b = Violation { path: path_b, @@ -986,6 +990,7 @@ fn baseline_covers_distinguishes_non_utf8_paths() { lower_is_worse: false, body_hash: None, suppressed: false, + nargs_split: None, }; // Baseline contains only `path_a`. classify(violation_b) would diff --git a/big-code-analysis-cli/src/check_format.rs b/big-code-analysis-cli/src/check_format.rs index e4c838976..9d5c46dc0 100644 --- a/big-code-analysis-cli/src/check_format.rs +++ b/big-code-analysis-cli/src/check_format.rs @@ -576,6 +576,11 @@ pub(crate) fn violation_to_offender(v: Violation) -> OffenderRecord { // offender formatters (`OffenderRecord::default_message`, // Code Climate severity), so it is not carried on the record. lower_is_worse: _, + // A presentation detail of the human row (`summary_tail`), not + // part of the machine record — the serialized offender already + // carries the value, and consumers that want the split can read + // the `nargs` block on the space itself. + nargs_split: _, // The body hash is an internal baseline-matching aid, not part // of the serialized offender record. body_hash: _, @@ -614,6 +619,7 @@ mod tests { lower_is_worse: false, body_hash: None, suppressed: false, + nargs_split: None, } } @@ -661,6 +667,7 @@ mod tests { lower_is_worse: false, body_hash: None, suppressed: false, + nargs_split: None, }; let offender = violation_to_offender(v); assert_eq!(offender.path, path); @@ -682,6 +689,7 @@ mod tests { lower_is_worse: false, body_hash: None, suppressed: false, + nargs_split: None, } } @@ -825,6 +833,7 @@ mod tests { lower_is_worse: false, body_hash: None, suppressed: false, + nargs_split: None, }; let mut buf = Vec::new(); write_github_annotations(&mut buf, std::iter::once(&v), DEFAULT_GITHUB_ANNOTATION_CAP) @@ -895,6 +904,7 @@ mod tests { lower_is_worse: false, body_hash: None, suppressed: false, + nargs_split: None, }; let mut buf = Vec::new(); write_github_annotations(&mut buf, std::iter::once(&v), DEFAULT_GITHUB_ANNOTATION_CAP) diff --git a/big-code-analysis-cli/src/commands_tests.rs b/big-code-analysis-cli/src/commands_tests.rs index b00b1b6c6..f106a90bc 100644 --- a/big-code-analysis-cli/src/commands_tests.rs +++ b/big-code-analysis-cli/src/commands_tests.rs @@ -22,6 +22,7 @@ fn violation(path: &str, function: &str, value: f64, limit: f64) -> Violation { lower_is_worse: false, body_hash: None, suppressed: false, + nargs_split: None, } } diff --git a/big-code-analysis-cli/src/thresholds.rs b/big-code-analysis-cli/src/thresholds.rs index 64d91c0a4..9a6759836 100644 --- a/big-code-analysis-cli/src/thresholds.rs +++ b/big-code-analysis-cli/src/thresholds.rs @@ -175,7 +175,30 @@ const EXTRACTORS: &[MetricExtractor] = &[ }, MetricExtractor { name: "nargs", - extract: |m| m.nargs.total() as f64, + // The space's OWN parameters, not `total()` (#1196). `total()` is + // `function_args_sum() + closure_args_sum()` — subtree sums — so a + // function was gated on its own parameters *plus every nested + // closure's*. `write_top_offenders` has three parameters and was + // reported at 6 because a sort comparator and a format closure + // contributed three more, and the remediation the number implied + // (fewer parameters) was not the one that would clear it. + // + // Nothing escapes. A closure that opens its own space — Rust, + // JS/TS/TSX, C#, Go, PHP, Perl, Ruby, Lua, Elixir — is gated on + // its own row, which is also where its fix goes. In the four + // grammars whose lambdas open no space (Python, Java, Kotlin, + // C++/Mozcpp) their arguments land in the enclosing space's own + // `closure_args`, which is the only attribution available and is + // why that term is added here rather than dropped. + // + // This is what every comparable tool measures — RuboCop + // `Metrics/ParameterLists`, ESLint `max-params`, Clippy + // `too_many_arguments`, lizard, SonarQube S107, Pylint R0913 all + // count a callable's own formal parameters. Two of those are the + // 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, metric: Metric::Nargs, }, MetricExtractor { @@ -428,6 +451,28 @@ pub(crate) fn threshold_scalar( } } +/// The `own + lambda` split behind an `nargs` value. +/// +/// Carried only where it is not already obvious. In the grammars whose +/// closures open their own space — Rust, JS/TS/TSX, C#, Go, PHP, Perl, +/// Ruby, Lua, Elixir — a closure is gated on its own row and the row's +/// number *is* its signature, so there is nothing to split. In Python, +/// Java, Kotlin and C++/Mozcpp a lambda opens no space, so its arguments +/// can only be attributed to the enclosing function: `small` there can +/// declare one parameter and be reported at 8. +/// +/// A struct rather than a `(u64, u64)`, because the two are same-typed, +/// not interchangeable, and transposing them would print a fluent lie +/// (`AGENTS.md`, "do not pass two same-typed primitives where they could +/// be confused"). +#[derive(Debug, Clone, Copy)] +pub(crate) struct NargsSplit { + /// Parameters the offending function declares itself. + pub(crate) own: u64, + /// Parameters contributed by lambdas that open no space of their own. + pub(crate) lambda: u64, +} + /// One offending `(function, metric)` pair. #[derive(Debug, Clone)] pub(crate) struct Violation { @@ -493,6 +538,12 @@ pub(crate) struct Violation { /// `false` under the default policy (suppressed offenders are dropped) /// and under `--no-suppress` (markers ignored, so nothing is suppressed). pub(crate) suppressed: bool, + /// For an `nargs` violation whose value includes spaceless-lambda + /// arguments, the split to show the reader (#1196). `None` for every + /// other metric, and for an `nargs` value that is purely the + /// function's own parameter list — which is the usual case, and the + /// one where a parenthetical would be noise. + pub(crate) nargs_split: Option, } impl Violation { @@ -505,11 +556,21 @@ impl Violation { /// this method so their message body stays in lockstep with the /// human stderr line. pub(crate) fn summary_tail(&self) -> String { + // The split is appended, never substituted for the value: the + // number stays the thing compared against the limit, and tooling + // that parses the row keeps finding it in the same position. + let split = match self.nargs_split { + Some(NargsSplit { own, lambda }) => { + format!(" ({own} own + {lambda} lambda)") + } + None => String::new(), + }; format!( - "{}: {} = {} (limit {})", + "{}: {} = {}{} (limit {})", self.function, self.metric, MetricScalar(self.value), + split, MetricScalar(self.limit), ) } @@ -729,6 +790,20 @@ impl ResolvedThreshold { if suppressed && !ctx.report_suppressed { return None; } + // Only `nargs`, and only when a spaceless lambda actually + // contributed — the parenthetical would otherwise repeat the + // value and read as noise. `closure_args()` is the space's own + // term, so a closure that opened its *own* space contributes + // nothing here and is reported on its own row instead (#1196). + let nargs_split = (self.extractor.name == "nargs") + .then(|| { + let (own, lambda) = ( + space.metrics.nargs.function_args(), + space.metrics.nargs.closure_args(), + ); + (lambda > 0 && own > 0).then_some(NargsSplit { own, lambda }) + }) + .flatten(); Some(Violation { path: path.to_path_buf(), start_line: space.start_line, @@ -737,6 +812,7 @@ impl ResolvedThreshold { metric: self.extractor.name, value, limit: self.limit, + nargs_split, hard_limit: self.hard_limit, lower_is_worse: self.lower_is_worse, body_hash: None, diff --git a/big-code-analysis-cli/src/thresholds_tests.rs b/big-code-analysis-cli/src/thresholds_tests.rs index 2466a507b..560cf3335 100644 --- a/big-code-analysis-cli/src/thresholds_tests.rs +++ b/big-code-analysis-cli/src/thresholds_tests.rs @@ -353,6 +353,7 @@ fn violation_display_is_stable() { lower_is_worse: false, body_hash: None, suppressed: false, + nargs_split: None, }; assert_eq!( v.to_string(), @@ -374,6 +375,7 @@ fn violation_display_keeps_fractional_precision() { lower_is_worse: false, body_hash: None, suppressed: false, + nargs_split: None, }; assert!(v.to_string().contains("= 12.5"), "{v}"); assert!(v.to_string().contains("limit 10)"), "{v}"); @@ -410,6 +412,7 @@ fn violation_path_preserves_non_utf8_bytes() { lower_is_worse: false, body_hash: None, suppressed: false, + nargs_split: None, }; // Raw bytes round-trip identically — no lossy substitution. @@ -692,6 +695,7 @@ fn sample_violation() -> Violation { lower_is_worse: false, body_hash: None, suppressed: false, + nargs_split: None, } } @@ -1009,6 +1013,7 @@ fn mi_ratio_inverts_so_lower_value_ranks_worse() { lower_is_worse: true, body_hash: None, suppressed: false, + nargs_split: None, }; let mild = Violation { value: 45.0, diff --git a/big-code-analysis-cli/tests/check/check_thresholds.rs b/big-code-analysis-cli/tests/check/check_thresholds.rs index b93733eca..d5a60da1c 100644 --- a/big-code-analysis-cli/tests/check/check_thresholds.rs +++ b/big-code-analysis-cli/tests/check/check_thresholds.rs @@ -2263,3 +2263,121 @@ fn check_multi_metric_config_gates_on_every_named_metric() { ); } } + +/// The `nargs` gate measures a function's **own** parameters, not the +/// subtree sum (#1196). +/// +/// It gated on `nargs.total()` — `function_args_sum() + closure_args_sum()` +/// — so a function was charged for every nested closure's parameters +/// too. A three-parameter function with a two-parameter sort comparator +/// was reported at 5, and the remediation its number implied (fewer +/// parameters) was not the one that would clear it. Every comparable +/// tool — `RuboCop` `Metrics/ParameterLists`, `ESLint` `max-params`, +/// Clippy `too_many_arguments`, lizard, `SonarQube` S107, Pylint `R0913` +/// — counts a callable's own formal parameters, and two of those are the +/// anchors the shipped default is derived from. +/// +/// `outer` declares two parameters and contains a three-parameter nested +/// function plus a two-parameter closure. Under the old rule it scored +/// 7; the fixture is built so the old value and the new one fall on +/// opposite sides of the limit, which is what makes this discriminate. +#[test] +fn nargs_gates_a_function_on_its_own_parameters() { + let dir = TempDir::new().unwrap(); + let path = write_fixture( + &dir, + "own.rs", + "fn outer(a: u32, b: u32) -> u32 {\n\ + \x20 fn inner(p: u32, q: u32, r: u32) -> u32 { p + q + r }\n\ + \x20 let c = |x: u32, y: u32| x + y;\n\ + \x20 inner(a, b, 1) + c(a, b)\n\ + }\n", + ); + + cli(dir.path()) + .args(["check", "--paths", &path, "--threshold", "nargs=2"]) + .assert() + .code(2) + // `inner` really does declare three parameters. + .stdout(predicate::str::contains("outer::inner: nargs = 3")) + // `outer` declares two and must not be charged for its children. + // Asserting on the qualified name alone would match the `inner` + // row above, which is prefixed with it. + .stdout(predicate::str::contains("outer: nargs =").not()); +} + +/// Nothing escapes the narrower gate: a closure that opens its own space +/// is flagged on its own row, which is also where its fix goes (#1196). +/// +/// This is the guarantee that makes own-parameter gating safe rather +/// than a loophole, and it holds in the ten grammars whose closures are +/// `is_func_space` — Rust here. +#[test] +fn nargs_flags_a_fat_closure_on_its_own_row() { + let dir = TempDir::new().unwrap(); + let path = write_fixture( + &dir, + "fat.rs", + "fn small(a: u32) -> u32 {\n\ + \x20 let fat = |p: u32, q: u32, r: u32, s: u32, t: u32, u: u32| p+q+r+s+t+u;\n\ + \x20 fat(a, a, a, a, a, a)\n\ + }\n", + ); + + cli(dir.path()) + .args(["check", "--paths", &path, "--threshold", "nargs=5"]) + .assert() + .code(2) + .stdout(predicate::str::contains(": nargs = 6")) + // The enclosing function declares one parameter and is clean. + .stdout(predicate::str::contains("small: nargs =").not()); +} + +/// Where a lambda opens no space, its arguments still fold into the +/// enclosing function — and the row shows the split so the reader knows +/// which lever to pull (#1196). +/// +/// Python, Java, Kotlin and C++/Mozcpp are the four grammars in this +/// position: their lambdas are `is_closure` without being +/// `is_func_space`, so the enclosing function is the only attribution +/// available. Without the parenthetical the row reads as though `small` +/// declared eight parameters, which is the confusion this whole change +/// is about. +#[test] +fn nargs_shows_the_split_where_a_lambda_opens_no_space() { + let dir = TempDir::new().unwrap(); + let path = write_fixture( + &dir, + "lam.py", + "def small(a):\n\ + \x20 fat = lambda p, q, r, s, t, u, v: p+q+r+s+t+u+v\n\ + \x20 return fat(a, a, a, a, a, a, a)\n", + ); + + cli(dir.path()) + .args(["check", "--paths", &path, "--threshold", "nargs=5"]) + .assert() + .code(2) + .stdout(predicate::str::contains( + "small: nargs = 8 (1 own + 7 lambda)", + )); +} + +/// The parenthetical stays off an ordinary offender, where it would just +/// restate the value (#1196). +#[test] +fn nargs_omits_the_split_for_a_plain_parameter_list() { + let dir = TempDir::new().unwrap(); + let path = write_fixture( + &dir, + "plain.rs", + "fn wide(a: u32, b: u32, c: u32, d: u32, e: u32, f: u32) -> u32 { a+b+c+d+e+f }\n", + ); + + cli(dir.path()) + .args(["check", "--paths", &path, "--threshold", "nargs=5"]) + .assert() + .code(2) + .stdout(predicate::str::contains("wide: nargs = 6 (limit 5)")) + .stdout(predicate::str::contains("own +").not()); +} diff --git a/src/metric_catalog.rs b/src/metric_catalog.rs index 9c75cea72..454bbe4eb 100644 --- a/src/metric_catalog.rs +++ b/src/metric_catalog.rs @@ -47,8 +47,17 @@ pub enum MetricScope { /// Gate only individual function spaces ([`SpaceKind::Function`] — /// free functions, methods, closures). The per-function complexity /// metrics (cognitive, cyclomatic, abc, mi.*) and the subtree sums - /// that describe one function and its nested closures (halstead.*, - /// nargs, nexits, tokens) live here. + /// that describe one function (halstead.*, nargs, nexits, tokens) + /// live here. + /// + /// Whether "one function" includes its nested closures is per + /// metric, not per scope. `halstead.*`, `nexits` and `tokens` read + /// subtree sums, because a closure's tokens and exits really are + /// part of the enclosing body a reader must follow. `nargs` reads + /// the space's own parameters instead (#1196): a closure that opens + /// its own space is gated on its own row, and summing its arguments + /// into the enclosing signature made the offender's number describe + /// something its remediation could not change. Function, /// Gate only container spaces that own methods (class / struct / /// trait / impl / namespace / interface) — the object-oriented size From d64935b59361b38038ef046574b1dd3717dc9e73 Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Mon, 3 Aug 2026 11:15:24 -0700 Subject: [PATCH 21/27] fix: resolve the code-review findings on #1180-#1196 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two behaviour bugs, both introduced by this branch, both reproduced against its own binary before fixing. **`bca report` was not moved with the gate.** #1196 changed the threshold extractor to own-parameters and left `FunctionSummary.nargs` on `m.nargs.total()`. Both surfaces read the same `[thresholds] nargs` key — the report through `AdvisoryThresholds::from_manifest_hard` — so on one fixture `bca check --threshold nargs=4` exited 0 while `bca report` flagged the same function under "Many parameters", for a number the gate no longer enforced. Aligned. **`check_if_arrow_func!` never received the field-definition kind.** #1188's third divergence was fixed for `check_if_func!` only, so the class-field split survived wherever the field name is not a bare identifier: `class C { ["k"] = function(){} }` was a function and its arrow spelling a closure, likewise for `"s" =` and `#p =`, in all four JS-family languages. The comment above the macros claimed `$up` and `$stop` were already token-identical; they were not. The parity test exercised `p = …`, the one spelling that worked. Both macros now take the kind, and the test covers all four spellings. **The `own > 0` guard hid the split where it mattered most.** A function declaring no parameters at all rendered a bare `nargs = 7` when a spaceless lambda put it over — the misleading row #1196 exists to remove — while its one-parameter sibling got the annotation, making the omission look deliberate. The counts cannot separate that from a closure's own space, which is shaped identically (`0` own, `N` lambda) and correctly wants no split; the subject can, since a closure space carries no name. **Documentation that asserted things the code does not do.** The `--exclude` help I rewrote for #1189 claimed CLI globs resolve against the working directory and that manifest globs "anchor the same way" — measured, `bca check nested -X './nested/skipme/**'` excludes nothing while `'./skipme/**'` does, because the CLI half still matches the walk-root form. Rewritten to describe both anchors accurately, with `man/` regenerated. `bca.toml` still described #1196 as unsettled after the commit that settled it. The spaceless-closure language list omitted Groovy (verified: `K::small: nargs = 7 (1 own + 6 lambda)`). The accessor-convention block still filed `nargs` under subtree sums, three lines above the extractor that no longer reads one. Kotlin's `is_func_space` rationale implied a `nom.functions` fix that #1184 deliberately did not make. A C# comment still described the positional ternary reads #1181 replaced. **A real pre-existing lexer defect.** `find_macro_call_end` skipped line comments, strings and char literals but not block comments, in both directions: a `"` inside one opened a runaway span so the body ran into the next call and stole its `@"…"` anchor (under-count, 0 for 1), and a `)` inside one truncated the body before its own anchor (over-count, a spurious CI failure). `scan_ignore_spans` always handled them. Also `char_literal_end` missed `\xNN`, and the comment asserting `\u{}` was "the only multi-char escape" was wrong — benign for the count, but that comment is what would let the next reader skip it, this lexer having already been copied once. **Dead code that read as coverage.** The Tcl/iRules boolean-context seed carried a ternary-condition disjunct that can never fire: the flag is read only after a peel, `Expr` cannot sit under `ternary_expr`, and a `!`-unary sets the flag itself. Verified by deletion. It also cost two full child scans per ternary to produce `false`. Removed, with the reason it is live in the C family recorded. **Tests.** `seen_table` could not fail as intended — a fully-suppressed section emits the same `### {title}` heading, so a fixture exercising only that arm satisfied the guard. Two ABC baseline tests lacked the `checked > 0` guard their siblings carry and asserted nothing on a minimal-features build. `irules_abc_parenthesised_…` compared two values with no absolute anchor. `test_byte_raw_string_…`'s docstring claimed an end-to-end discrimination it does not have — measured, the `b` branch changes no count anywhere in the workspace — now stated honestly. Added: the four field-name spellings, the zero-own split, a closure's own row, both block-comment directions, `regular_string_end`'s escape branch and `scan_ignore_spans`' comment nesting (the last two extracted by #1192 and untested until now), and `\xNN`. Every new test was verified by perturbation. Note one of those perturbations produced a syntactically invalid file, so the module failed to import and unittest printed no failure line — which reads exactly like a non-discriminating test. Confirming the perturbation applied *and* still parses has to come before reading the result. Baseline refreshed; 137 entries, unchanged in count. --- .bca-baseline.toml | 4 +- bca.toml | 10 ++- big-code-analysis-cli/src/cli_args/mod.rs | 15 ++-- big-code-analysis-cli/src/markdown_report.rs | 17 ++++- big-code-analysis-cli/src/thresholds.rs | 64 +++++++++++------ .../tests/check/check_thresholds.rs | 54 +++++++++++++++ man/bca-check.1 | 2 +- man/bca-count.1 | 2 +- man/bca-diff.1 | 2 +- man/bca-dump.1 | 2 +- man/bca-exemptions.1 | 2 +- man/bca-find.1 | 2 +- man/bca-functions.1 | 2 +- man/bca-init.1 | 2 +- man/bca-metrics.1 | 2 +- man/bca-ops.1 | 2 +- man/bca-preproc.1 | 2 +- man/bca-report.1 | 2 +- man/bca-strip-comments.1 | 2 +- man/bca-vcs.1 | 2 +- src/checker.rs | 23 +++++-- src/checker/kotlin.rs | 9 ++- src/metrics/abc.rs | 16 ++++- src/metrics/abc/csharp.rs | 13 ++-- src/metrics/abc/irules.rs | 20 ++++-- src/metrics/abc/tcl.rs | 20 ++++-- src/metrics/nom.rs | 26 +++++++ utils/check-snapshot-anchors-test.py | 69 +++++++++++++++++-- utils/check-snapshot-anchors.py | 41 ++++++++++- 29 files changed, 351 insertions(+), 78 deletions(-) diff --git a/.bca-baseline.toml b/.bca-baseline.toml index 6e4e6493d..e0c6c9bee 100644 --- a/.bca-baseline.toml +++ b/.bca-baseline.toml @@ -141,7 +141,7 @@ value = 588.0 path = "big-code-analysis-cli/src/markdown_report.rs" qualified = "extract_summaries_inner" metric = "abc" -value = 39.153543900903784 +value = 40.124805295477756 [[entry]] path = "big-code-analysis-cli/src/markdown_report/hotspot.rs" @@ -171,7 +171,7 @@ value = 66737.04848699087 path = "big-code-analysis-cli/src/thresholds.rs" qualified = "" metric = "loc.ploc" -value = 543.0 +value = 544.0 [[entry]] path = "big-code-analysis-cli/src/vcs_command.rs" diff --git a/bca.toml b/bca.toml index e0afb94dd..7f3c5c8cd 100644 --- a/bca.toml +++ b/bca.toml @@ -161,9 +161,13 @@ exclude = [ # `Parser::filters` — one parameter, plus five from the closures in # its body. Converging would therefore pressure contributors to delete # idiomatic inline closures rather than to simplify signatures, which is -# not what the metric is for. Whether the gate should key on -# `function_args` instead is tracked in #1196; until that is settled a -# lower limit here would be measuring the wrong thing. +# not what the metric is for. #1196 settled that: the gate now keys on a +# callable's own parameters, which is what every comparable tool +# measures. Re-measured on this tree afterwards, `nargs = 6` costs 0 new +# hard-tier offenders and `nargs = 5` costs 17 — the genuine signatures, +# without the 59 closure-inflated ones attached. 91 functions sit at +# exactly 5, so a limit of 5 still parks all of them permanently in the +# soft band; that cluster, not the count, is why this stays at 7 for now. cognitive = 15 cyclomatic = 15 "halstead.effort" = 50000 diff --git a/big-code-analysis-cli/src/cli_args/mod.rs b/big-code-analysis-cli/src/cli_args/mod.rs index b8a27995b..39d7fa408 100644 --- a/big-code-analysis-cli/src/cli_args/mod.rs +++ b/big-code-analysis-cli/src/cli_args/mod.rs @@ -112,11 +112,16 @@ pub(crate) struct WalkSelectionArgs { /// something from `bca check`'s threshold gate whichever way it is /// named, use `--check-exclude` / `[check] exclude` instead. /// - /// A relative glob given here is resolved against the directory you - /// ran `bca` from. One written in `bca.toml` is resolved against the - /// manifest's own directory, whatever path you name and wherever you - /// run from — so `bca metrics -p sub` honours the same exclusions the - /// bare `bca metrics` does. `[check] exclude` anchors the same way. + /// The two sources anchor differently, and it is worth knowing which + /// you are writing. A glob given *here* is matched against each + /// file's path as the walk emits it — relative to the seed you + /// named — so `bca metrics -p sub -X './skipme/**'` excludes + /// `sub/skipme`, while `'./sub/skipme/**'` matches nothing. A glob + /// written in `bca.toml` is resolved against the manifest's own + /// directory instead, whatever path you name and wherever you run + /// from, so the bare `bca metrics` and `bca metrics -p sub` honour + /// the same manifest exclusions. `[check] exclude` anchors at the + /// manifest the same way. // The per-file agent hooks in the book's agent-feedback recipe are // the caller this bit exists for; see #1146. #1164 anchored the // explicit-path warning and the `[check] exclude` gate at the diff --git a/big-code-analysis-cli/src/markdown_report.rs b/big-code-analysis-cli/src/markdown_report.rs index 2829a5c5c..86c60a861 100644 --- a/big-code-analysis-cli/src/markdown_report.rs +++ b/big-code-analysis-cli/src/markdown_report.rs @@ -196,7 +196,14 @@ fn extract_summaries_inner( mi_original: m.mi.original(), mi_sei: m.mi.sei(), mi_visual_studio: m.mi.visual_studio(), - nargs: m.nargs.total() as usize, + // The space's OWN parameters, matching what + // `bca check --threshold nargs=N` gates on (#1196). Both + // surfaces read the same `[thresholds] nargs` key — the + // report through `AdvisoryThresholds::from_manifest_hard` — + // 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, nexits: m.nexits.nexits_sum() as usize, nom: m.nom.total() as usize, abc: m.abc.magnitude(), @@ -2516,7 +2523,13 @@ mod tests { "formats disagree on whether {title:?} is fully suppressed" ); - seen_table += usize::from(md_has_table); + // A fully-suppressed section emits the same `### {title}` + // heading (`emit_fully_suppressed_note_md`), so the heading + // alone cannot tell the two arms apart — count a rendered + // *table* only when the section is not the suppressed kind, + // or this guard passes on a fixture that exercised only the + // `FullySuppressed` arm. + seen_table += usize::from(md_has_table && !md_suppressed); seen_suppressed += usize::from(md_suppressed); } // Without both outcomes present the comparisons above are diff --git a/big-code-analysis-cli/src/thresholds.rs b/big-code-analysis-cli/src/thresholds.rs index 9a6759836..0d499bc89 100644 --- a/big-code-analysis-cli/src/thresholds.rs +++ b/big-code-analysis-cli/src/thresholds.rs @@ -75,13 +75,22 @@ struct MetricExtractor { /// /// - *Per-space own* value — `cognitive()`, `cyclomatic()`, /// `cyclomatic_modified()`, the `halstead.*`, `mi.*`, `loc.*`, and -/// `abc` accessors. These read the value for the single function space -/// under test, without rolling up nested children. +/// `abc` accessors, plus `nargs`'s `function_args() + closure_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()`, -/// `nom.total()`, `nargs.total()`, `wmc.total_wmc()`, `npm.total_npm()`, +/// `nom.total()`, `wmc.total_wmc()`, `npm.total_npm()`, /// `npa.total_npa()`. These aggregate the space and its descendants, so /// a threshold on, e.g., `nom` bounds the whole subtree's method count. /// +/// `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. +/// /// The split follows each metric's library accessor and its natural unit /// of measurement; it is intentional, not an oversight. When adding a new /// extractor, pick the accessor that matches the metric's reported figure @@ -184,12 +193,13 @@ const EXTRACTORS: &[MetricExtractor] = &[ // (fewer parameters) was not the one that would clear it. // // Nothing escapes. A closure that opens its own space — Rust, - // JS/TS/TSX, C#, Go, PHP, Perl, Ruby, Lua, Elixir — is gated on - // its own row, which is also where its fix goes. In the four - // grammars whose lambdas open no space (Python, Java, Kotlin, - // C++/Mozcpp) their arguments land in the enclosing space's own - // `closure_args`, which is the only attribution available and is - // why that term is added here rather than dropped. + // the JS family, C#, Go, PHP, Perl, Ruby, Lua, Elixir — is gated + // on its own row, which is also where its fix goes. Where the + // closure's arguments fold into the enclosing function instead + // (Python, Java, Kotlin, Groovy, C++/Mozcpp) they land in that + // space's own `closure_args`, which is the only attribution + // available and is why the term is added here rather than + // dropped. // // This is what every comparable tool measures — RuboCop // `Metrics/ParameterLists`, ESLint `max-params`, Clippy @@ -454,12 +464,13 @@ pub(crate) fn threshold_scalar( /// The `own + lambda` split behind an `nargs` value. /// /// Carried only where it is not already obvious. In the grammars whose -/// closures open their own space — Rust, JS/TS/TSX, C#, Go, PHP, Perl, -/// Ruby, Lua, Elixir — a closure is gated on its own row and the row's -/// number *is* its signature, so there is nothing to split. In Python, -/// Java, Kotlin and C++/Mozcpp a lambda opens no space, so its arguments -/// can only be attributed to the enclosing function: `small` there can -/// declare one parameter and be reported at 8. +/// 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 row and the row's number *is* its signature, so there is +/// nothing to split. In Python, Java, Kotlin, Groovy and C++/Mozcpp the +/// closure's arguments fold into the enclosing function instead, which +/// is the only attribution available: `small` there can declare one +/// parameter and be reported at 8. /// /// A struct rather than a `(u64, u64)`, because the two are same-typed, /// not interchangeable, and transposing them would print a fluent lie @@ -790,18 +801,29 @@ impl ResolvedThreshold { if suppressed && !ctx.report_suppressed { return None; } - // Only `nargs`, and only when a spaceless lambda actually - // contributed — the parenthetical would otherwise repeat the - // value and read as noise. `closure_args()` is the space's own - // term, so a closure that opened its *own* space contributes - // nothing here and is reported on its own row instead (#1196). + // Only `nargs`, and only where the number mixes two sources the + // reader would otherwise conflate (#1196). + // + // The discriminator cannot come from the counts. A closure's own + // space has `function_args() == 0, closure_args() == N` — and so + // does a *zero-parameter function* containing spaceless lambdas. + // The first is not a mix at all: `N` is that closure's own + // parameter list, and `(0 own + N lambda)` on it would be noise. + // The second is exactly the misleading row this exists to fix, + // and gating on `own > 0` hid it precisely there. + // + // What separates them is the subject, not the arithmetic: a + // closure space carries no name of its own. `` is that + // signal — the synthesised names #1184 added (``, + // ``, …) are function-like and do want the split. let nargs_split = (self.extractor.name == "nargs") .then(|| { let (own, lambda) = ( space.metrics.nargs.function_args(), space.metrics.nargs.closure_args(), ); - (lambda > 0 && own > 0).then_some(NargsSplit { own, lambda }) + let is_own_closure_space = space.name.as_deref() == Some(""); + (lambda > 0 && !is_own_closure_space).then_some(NargsSplit { own, lambda }) }) .flatten(); Some(Violation { diff --git a/big-code-analysis-cli/tests/check/check_thresholds.rs b/big-code-analysis-cli/tests/check/check_thresholds.rs index d5a60da1c..957d65254 100644 --- a/big-code-analysis-cli/tests/check/check_thresholds.rs +++ b/big-code-analysis-cli/tests/check/check_thresholds.rs @@ -2363,6 +2363,60 @@ fn nargs_shows_the_split_where_a_lambda_opens_no_space() { )); } +/// A function that declares *no* parameters still gets the split when a +/// spaceless lambda put it over (#1196). +/// +/// This is the case the first guard hid. It required `own > 0`, so +/// `zero` rendered a bare `nargs = 7` — reading as a seven-parameter +/// signature for a function with none, which is the misleading row the +/// split exists to remove. Its one-parameter sibling got the annotation, +/// which made the omission look deliberate. +#[test] +fn nargs_shows_the_split_even_when_the_function_declares_nothing() { + let dir = TempDir::new().unwrap(); + let path = write_fixture( + &dir, + "zero.py", + "def zero():\n\ + \x20 fat = lambda p, q, r, s, t, u, v: p\n\ + \x20 return fat\n", + ); + + cli(dir.path()) + .args(["check", "--paths", &path, "--threshold", "nargs=5"]) + .assert() + .code(2) + .stdout(predicate::str::contains( + "zero: nargs = 7 (0 own + 7 lambda)", + )); +} + +/// ...but a closure's *own* space never does, even though its counts are +/// shaped identically (`0` own, `N` lambda). +/// +/// `N` there is that closure's own parameter list, not a mix, so +/// `(0 own + N lambda)` would be noise. The counts cannot tell the two +/// apart — the subject can: a closure space carries no name. +#[test] +fn nargs_omits_the_split_on_a_closures_own_row() { + let dir = TempDir::new().unwrap(); + let path = write_fixture( + &dir, + "clo.rs", + "fn small(a: u32) -> u32 {\n\ + \x20 let f = |p: u32, q: u32, r: u32, s: u32, t: u32, u: u32| p;\n\ + \x20 f(a, a, a, a, a, a)\n\ + }\n", + ); + + cli(dir.path()) + .args(["check", "--paths", &path, "--threshold", "nargs=5"]) + .assert() + .code(2) + .stdout(predicate::str::contains(": nargs = 6 (limit 5)")) + .stdout(predicate::str::contains("own +").not()); +} + /// The parenthetical stays off an ordinary offender, where it would just /// restate the value (#1196). #[test] diff --git a/man/bca-check.1 b/man/bca-check.1 index 03f6f26f1..6c16e4c8c 100644 --- a/man/bca-check.1 +++ b/man/bca-check.1 @@ -175,7 +175,7 @@ Glob to exclude files. Repeat the flag to add multiple globs (`\-X \*(Aq*.tmp\*( Shapes directory\-walk scope only: a file named directly on the command line overrides every exclude glob and is analyzed anyway (the ripgrep/fd convention), with a warning on stderr naming the glob it overrode. `\-I` / `\-\-include` is not overridden. To exempt something from `bca check`\*(Aqs threshold gate whichever way it is named, use `\-\-check\-exclude` / `[check] exclude` instead. -A relative glob given here is resolved against the directory you ran `bca` from. One written in `bca.toml` is resolved against the manifest\*(Aqs own directory, whatever path you name and wherever you run from — so `bca metrics \-p sub` honours the same exclusions the bare `bca metrics` does. `[check] exclude` anchors the same way. +The two sources anchor differently, and it is worth knowing which you are writing. A glob given *here* is matched against each file\*(Aqs path as the walk emits it — relative to the seed you named — so `bca metrics \-p sub \-X \*(Aq./skipme/**\*(Aq` excludes `sub/skipme`, while `\*(Aq./sub/skipme/**\*(Aq` matches nothing. A glob written in `bca.toml` is resolved against the manifest\*(Aqs own directory instead, whatever path you name and wherever you run from, so the bare `bca metrics` and `bca metrics \-p sub` honour the same manifest exclusions. `[check] exclude` anchors at the manifest the same way. .TP \fB\-l\fR, \fB\-\-language\fR \fI\fR Force a language instead of inferring from extension. Accepts a canonical language name (`rust`, `python`, `cpp`, …) or a file extension (`rs`, `py`, …). An unrecognized value is a hard error diff --git a/man/bca-count.1 b/man/bca-count.1 index 79546d57b..1a3e8df1d 100644 --- a/man/bca-count.1 +++ b/man/bca-count.1 @@ -27,7 +27,7 @@ Glob to exclude files. Repeat the flag to add multiple globs (`\-X \*(Aq*.tmp\*( Shapes directory\-walk scope only: a file named directly on the command line overrides every exclude glob and is analyzed anyway (the ripgrep/fd convention), with a warning on stderr naming the glob it overrode. `\-I` / `\-\-include` is not overridden. To exempt something from `bca check`\*(Aqs threshold gate whichever way it is named, use `\-\-check\-exclude` / `[check] exclude` instead. -A relative glob given here is resolved against the directory you ran `bca` from. One written in `bca.toml` is resolved against the manifest\*(Aqs own directory, whatever path you name and wherever you run from — so `bca metrics \-p sub` honours the same exclusions the bare `bca metrics` does. `[check] exclude` anchors the same way. +The two sources anchor differently, and it is worth knowing which you are writing. A glob given *here* is matched against each file\*(Aqs path as the walk emits it — relative to the seed you named — so `bca metrics \-p sub \-X \*(Aq./skipme/**\*(Aq` excludes `sub/skipme`, while `\*(Aq./sub/skipme/**\*(Aq` matches nothing. A glob written in `bca.toml` is resolved against the manifest\*(Aqs own directory instead, whatever path you name and wherever you run from, so the bare `bca metrics` and `bca metrics \-p sub` honour the same manifest exclusions. `[check] exclude` anchors at the manifest the same way. .TP \fB\-l\fR, \fB\-\-language\fR \fI\fR Force a language instead of inferring from extension. Accepts a canonical language name (`rust`, `python`, `cpp`, …) or a file extension (`rs`, `py`, …). An unrecognized value is a hard error diff --git a/man/bca-diff.1 b/man/bca-diff.1 index e73a97741..a85f88c6b 100644 --- a/man/bca-diff.1 +++ b/man/bca-diff.1 @@ -65,7 +65,7 @@ Glob to exclude files. Repeat the flag to add multiple globs (`\-X \*(Aq*.tmp\*( Shapes directory\-walk scope only: a file named directly on the command line overrides every exclude glob and is analyzed anyway (the ripgrep/fd convention), with a warning on stderr naming the glob it overrode. `\-I` / `\-\-include` is not overridden. To exempt something from `bca check`\*(Aqs threshold gate whichever way it is named, use `\-\-check\-exclude` / `[check] exclude` instead. -A relative glob given here is resolved against the directory you ran `bca` from. One written in `bca.toml` is resolved against the manifest\*(Aqs own directory, whatever path you name and wherever you run from — so `bca metrics \-p sub` honours the same exclusions the bare `bca metrics` does. `[check] exclude` anchors the same way. +The two sources anchor differently, and it is worth knowing which you are writing. A glob given *here* is matched against each file\*(Aqs path as the walk emits it — relative to the seed you named — so `bca metrics \-p sub \-X \*(Aq./skipme/**\*(Aq` excludes `sub/skipme`, while `\*(Aq./sub/skipme/**\*(Aq` matches nothing. A glob written in `bca.toml` is resolved against the manifest\*(Aqs own directory instead, whatever path you name and wherever you run from, so the bare `bca metrics` and `bca metrics \-p sub` honour the same manifest exclusions. `[check] exclude` anchors at the manifest the same way. .TP \fB\-l\fR, \fB\-\-language\fR \fI\fR Force a language instead of inferring from extension. Accepts a canonical language name (`rust`, `python`, `cpp`, …) or a file extension (`rs`, `py`, …). An unrecognized value is a hard error diff --git a/man/bca-dump.1 b/man/bca-dump.1 index cc2ac5765..cbe620f81 100644 --- a/man/bca-dump.1 +++ b/man/bca-dump.1 @@ -30,7 +30,7 @@ Glob to exclude files. Repeat the flag to add multiple globs (`\-X \*(Aq*.tmp\*( Shapes directory\-walk scope only: a file named directly on the command line overrides every exclude glob and is analyzed anyway (the ripgrep/fd convention), with a warning on stderr naming the glob it overrode. `\-I` / `\-\-include` is not overridden. To exempt something from `bca check`\*(Aqs threshold gate whichever way it is named, use `\-\-check\-exclude` / `[check] exclude` instead. -A relative glob given here is resolved against the directory you ran `bca` from. One written in `bca.toml` is resolved against the manifest\*(Aqs own directory, whatever path you name and wherever you run from — so `bca metrics \-p sub` honours the same exclusions the bare `bca metrics` does. `[check] exclude` anchors the same way. +The two sources anchor differently, and it is worth knowing which you are writing. A glob given *here* is matched against each file\*(Aqs path as the walk emits it — relative to the seed you named — so `bca metrics \-p sub \-X \*(Aq./skipme/**\*(Aq` excludes `sub/skipme`, while `\*(Aq./sub/skipme/**\*(Aq` matches nothing. A glob written in `bca.toml` is resolved against the manifest\*(Aqs own directory instead, whatever path you name and wherever you run from, so the bare `bca metrics` and `bca metrics \-p sub` honour the same manifest exclusions. `[check] exclude` anchors at the manifest the same way. .TP \fB\-l\fR, \fB\-\-language\fR \fI\fR Force a language instead of inferring from extension. Accepts a canonical language name (`rust`, `python`, `cpp`, …) or a file extension (`rs`, `py`, …). An unrecognized value is a hard error diff --git a/man/bca-exemptions.1 b/man/bca-exemptions.1 index 74ccd7d55..bea26a924 100644 --- a/man/bca-exemptions.1 +++ b/man/bca-exemptions.1 @@ -63,7 +63,7 @@ Glob to exclude files. Repeat the flag to add multiple globs (`\-X \*(Aq*.tmp\*( Shapes directory\-walk scope only: a file named directly on the command line overrides every exclude glob and is analyzed anyway (the ripgrep/fd convention), with a warning on stderr naming the glob it overrode. `\-I` / `\-\-include` is not overridden. To exempt something from `bca check`\*(Aqs threshold gate whichever way it is named, use `\-\-check\-exclude` / `[check] exclude` instead. -A relative glob given here is resolved against the directory you ran `bca` from. One written in `bca.toml` is resolved against the manifest\*(Aqs own directory, whatever path you name and wherever you run from — so `bca metrics \-p sub` honours the same exclusions the bare `bca metrics` does. `[check] exclude` anchors the same way. +The two sources anchor differently, and it is worth knowing which you are writing. A glob given *here* is matched against each file\*(Aqs path as the walk emits it — relative to the seed you named — so `bca metrics \-p sub \-X \*(Aq./skipme/**\*(Aq` excludes `sub/skipme`, while `\*(Aq./sub/skipme/**\*(Aq` matches nothing. A glob written in `bca.toml` is resolved against the manifest\*(Aqs own directory instead, whatever path you name and wherever you run from, so the bare `bca metrics` and `bca metrics \-p sub` honour the same manifest exclusions. `[check] exclude` anchors at the manifest the same way. .TP \fB\-l\fR, \fB\-\-language\fR \fI\fR Force a language instead of inferring from extension. Accepts a canonical language name (`rust`, `python`, `cpp`, …) or a file extension (`rs`, `py`, …). An unrecognized value is a hard error diff --git a/man/bca-find.1 b/man/bca-find.1 index 5d3384396..9f3b3675e 100644 --- a/man/bca-find.1 +++ b/man/bca-find.1 @@ -33,7 +33,7 @@ Glob to exclude files. Repeat the flag to add multiple globs (`\-X \*(Aq*.tmp\*( Shapes directory\-walk scope only: a file named directly on the command line overrides every exclude glob and is analyzed anyway (the ripgrep/fd convention), with a warning on stderr naming the glob it overrode. `\-I` / `\-\-include` is not overridden. To exempt something from `bca check`\*(Aqs threshold gate whichever way it is named, use `\-\-check\-exclude` / `[check] exclude` instead. -A relative glob given here is resolved against the directory you ran `bca` from. One written in `bca.toml` is resolved against the manifest\*(Aqs own directory, whatever path you name and wherever you run from — so `bca metrics \-p sub` honours the same exclusions the bare `bca metrics` does. `[check] exclude` anchors the same way. +The two sources anchor differently, and it is worth knowing which you are writing. A glob given *here* is matched against each file\*(Aqs path as the walk emits it — relative to the seed you named — so `bca metrics \-p sub \-X \*(Aq./skipme/**\*(Aq` excludes `sub/skipme`, while `\*(Aq./sub/skipme/**\*(Aq` matches nothing. A glob written in `bca.toml` is resolved against the manifest\*(Aqs own directory instead, whatever path you name and wherever you run from, so the bare `bca metrics` and `bca metrics \-p sub` honour the same manifest exclusions. `[check] exclude` anchors at the manifest the same way. .TP \fB\-l\fR, \fB\-\-language\fR \fI\fR Force a language instead of inferring from extension. Accepts a canonical language name (`rust`, `python`, `cpp`, …) or a file extension (`rs`, `py`, …). An unrecognized value is a hard error diff --git a/man/bca-functions.1 b/man/bca-functions.1 index 7c7e5bb93..f4897240a 100644 --- a/man/bca-functions.1 +++ b/man/bca-functions.1 @@ -24,7 +24,7 @@ Glob to exclude files. Repeat the flag to add multiple globs (`\-X \*(Aq*.tmp\*( Shapes directory\-walk scope only: a file named directly on the command line overrides every exclude glob and is analyzed anyway (the ripgrep/fd convention), with a warning on stderr naming the glob it overrode. `\-I` / `\-\-include` is not overridden. To exempt something from `bca check`\*(Aqs threshold gate whichever way it is named, use `\-\-check\-exclude` / `[check] exclude` instead. -A relative glob given here is resolved against the directory you ran `bca` from. One written in `bca.toml` is resolved against the manifest\*(Aqs own directory, whatever path you name and wherever you run from — so `bca metrics \-p sub` honours the same exclusions the bare `bca metrics` does. `[check] exclude` anchors the same way. +The two sources anchor differently, and it is worth knowing which you are writing. A glob given *here* is matched against each file\*(Aqs path as the walk emits it — relative to the seed you named — so `bca metrics \-p sub \-X \*(Aq./skipme/**\*(Aq` excludes `sub/skipme`, while `\*(Aq./sub/skipme/**\*(Aq` matches nothing. A glob written in `bca.toml` is resolved against the manifest\*(Aqs own directory instead, whatever path you name and wherever you run from, so the bare `bca metrics` and `bca metrics \-p sub` honour the same manifest exclusions. `[check] exclude` anchors at the manifest the same way. .TP \fB\-l\fR, \fB\-\-language\fR \fI\fR Force a language instead of inferring from extension. Accepts a canonical language name (`rust`, `python`, `cpp`, …) or a file extension (`rs`, `py`, …). An unrecognized value is a hard error diff --git a/man/bca-init.1 b/man/bca-init.1 index 91b897218..b34d102da 100644 --- a/man/bca-init.1 +++ b/man/bca-init.1 @@ -33,7 +33,7 @@ Glob to exclude files. Repeat the flag to add multiple globs (`\-X \*(Aq*.tmp\*( Shapes directory\-walk scope only: a file named directly on the command line overrides every exclude glob and is analyzed anyway (the ripgrep/fd convention), with a warning on stderr naming the glob it overrode. `\-I` / `\-\-include` is not overridden. To exempt something from `bca check`\*(Aqs threshold gate whichever way it is named, use `\-\-check\-exclude` / `[check] exclude` instead. -A relative glob given here is resolved against the directory you ran `bca` from. One written in `bca.toml` is resolved against the manifest\*(Aqs own directory, whatever path you name and wherever you run from — so `bca metrics \-p sub` honours the same exclusions the bare `bca metrics` does. `[check] exclude` anchors the same way. +The two sources anchor differently, and it is worth knowing which you are writing. A glob given *here* is matched against each file\*(Aqs path as the walk emits it — relative to the seed you named — so `bca metrics \-p sub \-X \*(Aq./skipme/**\*(Aq` excludes `sub/skipme`, while `\*(Aq./sub/skipme/**\*(Aq` matches nothing. A glob written in `bca.toml` is resolved against the manifest\*(Aqs own directory instead, whatever path you name and wherever you run from, so the bare `bca metrics` and `bca metrics \-p sub` honour the same manifest exclusions. `[check] exclude` anchors at the manifest the same way. .TP \fB\-l\fR, \fB\-\-language\fR \fI\fR Force a language instead of inferring from extension. Accepts a canonical language name (`rust`, `python`, `cpp`, …) or a file extension (`rs`, `py`, …). An unrecognized value is a hard error diff --git a/man/bca-metrics.1 b/man/bca-metrics.1 index e831be74f..5392047b8 100644 --- a/man/bca-metrics.1 +++ b/man/bca-metrics.1 @@ -63,7 +63,7 @@ Glob to exclude files. Repeat the flag to add multiple globs (`\-X \*(Aq*.tmp\*( Shapes directory\-walk scope only: a file named directly on the command line overrides every exclude glob and is analyzed anyway (the ripgrep/fd convention), with a warning on stderr naming the glob it overrode. `\-I` / `\-\-include` is not overridden. To exempt something from `bca check`\*(Aqs threshold gate whichever way it is named, use `\-\-check\-exclude` / `[check] exclude` instead. -A relative glob given here is resolved against the directory you ran `bca` from. One written in `bca.toml` is resolved against the manifest\*(Aqs own directory, whatever path you name and wherever you run from — so `bca metrics \-p sub` honours the same exclusions the bare `bca metrics` does. `[check] exclude` anchors the same way. +The two sources anchor differently, and it is worth knowing which you are writing. A glob given *here* is matched against each file\*(Aqs path as the walk emits it — relative to the seed you named — so `bca metrics \-p sub \-X \*(Aq./skipme/**\*(Aq` excludes `sub/skipme`, while `\*(Aq./sub/skipme/**\*(Aq` matches nothing. A glob written in `bca.toml` is resolved against the manifest\*(Aqs own directory instead, whatever path you name and wherever you run from, so the bare `bca metrics` and `bca metrics \-p sub` honour the same manifest exclusions. `[check] exclude` anchors at the manifest the same way. .TP \fB\-l\fR, \fB\-\-language\fR \fI\fR Force a language instead of inferring from extension. Accepts a canonical language name (`rust`, `python`, `cpp`, …) or a file extension (`rs`, `py`, …). An unrecognized value is a hard error diff --git a/man/bca-ops.1 b/man/bca-ops.1 index 06fd4a3a3..79bbf3af3 100644 --- a/man/bca-ops.1 +++ b/man/bca-ops.1 @@ -54,7 +54,7 @@ Glob to exclude files. Repeat the flag to add multiple globs (`\-X \*(Aq*.tmp\*( Shapes directory\-walk scope only: a file named directly on the command line overrides every exclude glob and is analyzed anyway (the ripgrep/fd convention), with a warning on stderr naming the glob it overrode. `\-I` / `\-\-include` is not overridden. To exempt something from `bca check`\*(Aqs threshold gate whichever way it is named, use `\-\-check\-exclude` / `[check] exclude` instead. -A relative glob given here is resolved against the directory you ran `bca` from. One written in `bca.toml` is resolved against the manifest\*(Aqs own directory, whatever path you name and wherever you run from — so `bca metrics \-p sub` honours the same exclusions the bare `bca metrics` does. `[check] exclude` anchors the same way. +The two sources anchor differently, and it is worth knowing which you are writing. A glob given *here* is matched against each file\*(Aqs path as the walk emits it — relative to the seed you named — so `bca metrics \-p sub \-X \*(Aq./skipme/**\*(Aq` excludes `sub/skipme`, while `\*(Aq./sub/skipme/**\*(Aq` matches nothing. A glob written in `bca.toml` is resolved against the manifest\*(Aqs own directory instead, whatever path you name and wherever you run from, so the bare `bca metrics` and `bca metrics \-p sub` honour the same manifest exclusions. `[check] exclude` anchors at the manifest the same way. .TP \fB\-l\fR, \fB\-\-language\fR \fI\fR Force a language instead of inferring from extension. Accepts a canonical language name (`rust`, `python`, `cpp`, …) or a file extension (`rs`, `py`, …). An unrecognized value is a hard error diff --git a/man/bca-preproc.1 b/man/bca-preproc.1 index 9396b0bc9..0cad3c134 100644 --- a/man/bca-preproc.1 +++ b/man/bca-preproc.1 @@ -27,7 +27,7 @@ Glob to exclude files. Repeat the flag to add multiple globs (`\-X \*(Aq*.tmp\*( Shapes directory\-walk scope only: a file named directly on the command line overrides every exclude glob and is analyzed anyway (the ripgrep/fd convention), with a warning on stderr naming the glob it overrode. `\-I` / `\-\-include` is not overridden. To exempt something from `bca check`\*(Aqs threshold gate whichever way it is named, use `\-\-check\-exclude` / `[check] exclude` instead. -A relative glob given here is resolved against the directory you ran `bca` from. One written in `bca.toml` is resolved against the manifest\*(Aqs own directory, whatever path you name and wherever you run from — so `bca metrics \-p sub` honours the same exclusions the bare `bca metrics` does. `[check] exclude` anchors the same way. +The two sources anchor differently, and it is worth knowing which you are writing. A glob given *here* is matched against each file\*(Aqs path as the walk emits it — relative to the seed you named — so `bca metrics \-p sub \-X \*(Aq./skipme/**\*(Aq` excludes `sub/skipme`, while `\*(Aq./sub/skipme/**\*(Aq` matches nothing. A glob written in `bca.toml` is resolved against the manifest\*(Aqs own directory instead, whatever path you name and wherever you run from, so the bare `bca metrics` and `bca metrics \-p sub` honour the same manifest exclusions. `[check] exclude` anchors at the manifest the same way. .TP \fB\-l\fR, \fB\-\-language\fR \fI\fR Force a language instead of inferring from extension. Accepts a canonical language name (`rust`, `python`, `cpp`, …) or a file extension (`rs`, `py`, …). An unrecognized value is a hard error diff --git a/man/bca-report.1 b/man/bca-report.1 index 6d60796c7..9c949b540 100644 --- a/man/bca-report.1 +++ b/man/bca-report.1 @@ -62,7 +62,7 @@ Glob to exclude files. Repeat the flag to add multiple globs (`\-X \*(Aq*.tmp\*( Shapes directory\-walk scope only: a file named directly on the command line overrides every exclude glob and is analyzed anyway (the ripgrep/fd convention), with a warning on stderr naming the glob it overrode. `\-I` / `\-\-include` is not overridden. To exempt something from `bca check`\*(Aqs threshold gate whichever way it is named, use `\-\-check\-exclude` / `[check] exclude` instead. -A relative glob given here is resolved against the directory you ran `bca` from. One written in `bca.toml` is resolved against the manifest\*(Aqs own directory, whatever path you name and wherever you run from — so `bca metrics \-p sub` honours the same exclusions the bare `bca metrics` does. `[check] exclude` anchors the same way. +The two sources anchor differently, and it is worth knowing which you are writing. A glob given *here* is matched against each file\*(Aqs path as the walk emits it — relative to the seed you named — so `bca metrics \-p sub \-X \*(Aq./skipme/**\*(Aq` excludes `sub/skipme`, while `\*(Aq./sub/skipme/**\*(Aq` matches nothing. A glob written in `bca.toml` is resolved against the manifest\*(Aqs own directory instead, whatever path you name and wherever you run from, so the bare `bca metrics` and `bca metrics \-p sub` honour the same manifest exclusions. `[check] exclude` anchors at the manifest the same way. .TP \fB\-l\fR, \fB\-\-language\fR \fI\fR Force a language instead of inferring from extension. Accepts a canonical language name (`rust`, `python`, `cpp`, …) or a file extension (`rs`, `py`, …). An unrecognized value is a hard error diff --git a/man/bca-strip-comments.1 b/man/bca-strip-comments.1 index 69fab0835..2ac5cfcdb 100644 --- a/man/bca-strip-comments.1 +++ b/man/bca-strip-comments.1 @@ -30,7 +30,7 @@ Glob to exclude files. Repeat the flag to add multiple globs (`\-X \*(Aq*.tmp\*( Shapes directory\-walk scope only: a file named directly on the command line overrides every exclude glob and is analyzed anyway (the ripgrep/fd convention), with a warning on stderr naming the glob it overrode. `\-I` / `\-\-include` is not overridden. To exempt something from `bca check`\*(Aqs threshold gate whichever way it is named, use `\-\-check\-exclude` / `[check] exclude` instead. -A relative glob given here is resolved against the directory you ran `bca` from. One written in `bca.toml` is resolved against the manifest\*(Aqs own directory, whatever path you name and wherever you run from — so `bca metrics \-p sub` honours the same exclusions the bare `bca metrics` does. `[check] exclude` anchors the same way. +The two sources anchor differently, and it is worth knowing which you are writing. A glob given *here* is matched against each file\*(Aqs path as the walk emits it — relative to the seed you named — so `bca metrics \-p sub \-X \*(Aq./skipme/**\*(Aq` excludes `sub/skipme`, while `\*(Aq./sub/skipme/**\*(Aq` matches nothing. A glob written in `bca.toml` is resolved against the manifest\*(Aqs own directory instead, whatever path you name and wherever you run from, so the bare `bca metrics` and `bca metrics \-p sub` honour the same manifest exclusions. `[check] exclude` anchors at the manifest the same way. .TP \fB\-l\fR, \fB\-\-language\fR \fI\fR Force a language instead of inferring from extension. Accepts a canonical language name (`rust`, `python`, `cpp`, …) or a file extension (`rs`, `py`, …). An unrecognized value is a hard error diff --git a/man/bca-vcs.1 b/man/bca-vcs.1 index af72a0904..d222d8046 100644 --- a/man/bca-vcs.1 +++ b/man/bca-vcs.1 @@ -124,7 +124,7 @@ Glob to exclude files. Repeat the flag to add multiple globs (`\-X \*(Aq*.tmp\*( Shapes directory\-walk scope only: a file named directly on the command line overrides every exclude glob and is analyzed anyway (the ripgrep/fd convention), with a warning on stderr naming the glob it overrode. `\-I` / `\-\-include` is not overridden. To exempt something from `bca check`\*(Aqs threshold gate whichever way it is named, use `\-\-check\-exclude` / `[check] exclude` instead. -A relative glob given here is resolved against the directory you ran `bca` from. One written in `bca.toml` is resolved against the manifest\*(Aqs own directory, whatever path you name and wherever you run from — so `bca metrics \-p sub` honours the same exclusions the bare `bca metrics` does. `[check] exclude` anchors the same way. +The two sources anchor differently, and it is worth knowing which you are writing. A glob given *here* is matched against each file\*(Aqs path as the walk emits it — relative to the seed you named — so `bca metrics \-p sub \-X \*(Aq./skipme/**\*(Aq` excludes `sub/skipme`, while `\*(Aq./sub/skipme/**\*(Aq` matches nothing. A glob written in `bca.toml` is resolved against the manifest\*(Aqs own directory instead, whatever path you name and wherever you run from, so the bare `bca metrics` and `bca metrics \-p sub` honour the same manifest exclusions. `[check] exclude` anchors at the manifest the same way. .TP \fB\-l\fR, \fB\-\-language\fR \fI\fR Force a language instead of inferring from extension. Accepts a canonical language name (`rust`, `python`, `cpp`, …) or a file extension (`rs`, `py`, …). An unrecognized value is a hard error diff --git a/src/checker.rs b/src/checker.rs index 3bbef4e0c..fdeddb5f1 100644 --- a/src/checker.rs +++ b/src/checker.rs @@ -103,8 +103,17 @@ macro_rules! js_ancestor_walk { // // The first three are unified below, which makes an IIFE a closure in // both spellings: that is what a reader sees, and it matches the -// behaviour the arrow form already had. `$up` and `$stop` are now +// behaviour the arrow form already had. `$up` and `$stop` are // token-identical and only `$extra` differs, for the reason in (4). +// +// The field-definition kind is threaded in per language rather than +// named here because JS and TS spell it differently (`field_definition` +// vs `public_field_definition`). Both macros take it: `$extra`'s +// `property_identifier` sibling covers a class field only when its name +// *is* an identifier, so without the `$up` entry +// `class C { ["k"] = () => 1 }`, `{ "s" = … }` and `{ #p = … }` stayed +// closures while their `function` spellings were already functions — +// divergence (3) surviving in the shapes the first fix did not reach. macro_rules! check_if_func { ($node: ident, $ancestors: ident, $field_definition: ident) => { js_ancestor_walk!( @@ -122,11 +131,15 @@ macro_rules! check_if_func { } macro_rules! check_if_arrow_func { - ($node: ident, $ancestors: ident) => { + ($node: ident, $ancestors: ident, $field_definition: ident) => { js_ancestor_walk!( $node, $ancestors, - [VariableDeclarator | AssignmentExpression | LabeledStatement | Pair], + [VariableDeclarator + | AssignmentExpression + | LabeledStatement + | Pair + | $field_definition], [StatementBlock | ReturnStatement | NewExpression | CallExpression | Arguments], $node.has_sibling($ancestors, PropertyIdentifier as u16), ) @@ -156,7 +169,7 @@ macro_rules! is_js_func { FunctionExpression | GeneratorFunction => { check_if_func!($node, $ancestors, $field_definition) } - ArrowFunction => check_if_arrow_func!($node, $ancestors), + ArrowFunction => check_if_arrow_func!($node, $ancestors, $field_definition), _ => false, } }; @@ -168,7 +181,7 @@ macro_rules! is_js_closure { FunctionExpression | GeneratorFunction => { !check_if_func!($node, $ancestors, $field_definition) } - ArrowFunction => !check_if_arrow_func!($node, $ancestors), + ArrowFunction => !check_if_arrow_func!($node, $ancestors, $field_definition), _ => false, } }; diff --git a/src/checker/kotlin.rs b/src/checker/kotlin.rs index e75271287..93f186369 100644 --- a/src/checker/kotlin.rs +++ b/src/checker/kotlin.rs @@ -23,9 +23,12 @@ impl Checker for KotlinCode { // code but were referenced nowhere outside the generated // enum, so they opened no space at all: their control // flow was charged to the enclosing class and `bca check` - // could never flag one however complex it got. A file of - // nothing but accessors reported `nom.functions == 0` - // (#1184). + // could never flag one however complex it got (#1184). + // + // Note this does *not* change `nom.functions`, which + // keys on `is_func` — an accessor-only file still + // reports 0 there, deliberately, for the reason below. + // The defect fixed here is the missing metric scope. // // `is_func_space` and not `is_func`: none of the three is // a callable a user names at a call site — in Kotlin you diff --git a/src/metrics/abc.rs b/src/metrics/abc.rs index 8f15d0d86..a708c44a8 100644 --- a/src/metrics/abc.rs +++ b/src/metrics/abc.rs @@ -9147,9 +9147,11 @@ function f(int $a, int $b): int { .abc .conditions_sum() }; + let bare = conditions("when X {\n set y [expr { $a ? !$b : !$c }]\n}\n"); + assert_eq!(bare, 4, "the bare form is the documented reference value"); assert_eq!( conditions("when X {\n set y [expr { ($a) ? !$b : !$c }]\n}\n"), - conditions("when X {\n set y [expr { $a ? !$b : !$c }]\n}\n"), + bare, "parenthesising the condition must not change the count" ); } @@ -9268,6 +9270,7 @@ mod ternary_comment_invariance { /// more, since `!b` establishes boolean content for that slot = 3. #[test] fn the_baseline_values_are_two_and_three() { + let mut checked = 0; for lang in LANG::into_enum_iter() { if !lang.is_enabled() { continue; @@ -9281,7 +9284,12 @@ mod ternary_comment_invariance { "{lang:?}: parenthesised branch" ); assert_eq!(conditions(lang, &negated), 3, "{lang:?}: negated branch"); + checked += 1; } + assert!( + checked > 0, + "no ternary language enabled; this test asserted nothing" + ); } } @@ -9363,6 +9371,7 @@ mod keyword_negation_parity { /// boolean context, and one per negated branch operand. #[test] fn the_baseline_values_are_one_and_four() { + let mut checked = 0; for (lang, guard, ternary) in [ ( LANG::Ruby, @@ -9380,7 +9389,12 @@ mod keyword_negation_parity { } assert_eq!(conditions(lang, guard), 1, "{lang:?}: `if not b`"); assert_eq!(conditions(lang, ternary), 4, "{lang:?}: `not` ternary"); + checked += 1; } + assert!( + checked > 0, + "no language enabled; this test asserted nothing" + ); } /// Lua's only negation keyword is `not`, so it has no `!` twin to diff --git a/src/metrics/abc/csharp.rs b/src/metrics/abc/csharp.rs index f1ea14e12..e3e1ccf33 100644 --- a/src/metrics/abc/csharp.rs +++ b/src/metrics/abc/csharp.rs @@ -260,11 +260,14 @@ fn csharp_walk_for_conditions<'a>( } } -// `cond ? a : b` — children are [cond, ?, a, :, b]. The cond-classifier -// match is shared with `csharp_walk_for_conditions`'s `if`/`while`/`do` -// arms via `csharp_count_condition`; the two branch slots delegate to -// `csharp_inspect_container` via `csharp_inspect_child` so a parenthesised -// or `!`-prefixed branch contributes one condition just like a bare +// `cond ? a : b`, addressed by the grammar's `condition` / +// `consequence` / `alternative` fields rather than by index (#1181 — a +// comment between a token and its operand shifted every positional +// read). The cond-classifier match is shared with +// `csharp_walk_for_conditions`'s `if`/`while`/`do` arms via +// `csharp_count_condition`; the two branch slots go straight to +// `csharp_inspect_container`, so a parenthesised or `!`-prefixed branch +// contributes one condition just like a bare // invocation/identifier/boolean would. fn csharp_walk_conditional(node: &Node, stats: &mut Stats) { let conds = &mut stats.conditions; diff --git a/src/metrics/abc/irules.rs b/src/metrics/abc/irules.rs index 90e073eba..bd1ea3ca7 100644 --- a/src/metrics/abc/irules.rs +++ b/src/metrics/abc/irules.rs @@ -203,10 +203,22 @@ fn irules_inspect_container(container_node: &Node, parent: &Node, conditions: &m let mut has_boolean_content = matches!( parent_kind, Irules::BinopExpr | Irules::If | Irules::Elseif | Irules::While - ) || (matches!(parent_kind, Irules::TernaryExpr) - && irules_ternary_slots(parent) - .0 - .is_some_and(|condition| condition.id() == node.id())); + ); + // No ternary-condition disjunct here, unlike `cpp_inspect_container`. + // It would be dead: the flag is read only *after* a peel, and the + // loop peels exactly two kinds. `Expr` cannot sit under + // `ternary_expr` — the grammar's `_expr` alternatives are + // `unary_expr | binop_expr | ternary_expr | escaped_character | + // '(' _expr ')' | _expr_atom_no_brace | braced_word_simple` — and a + // `!`-unary sets the flag itself two lines below. Every other + // condition-slot kind breaks before the flag is read, and every + // terminal is counted by the ternary walker directly. Verified by + // deletion: the whole suite passes without it. + // + // The C-family version *is* live because C keeps `(a)` as a + // `parenthesized_expression` for the loop to peel, where this + // grammar inlines the parens. Porting it verbatim also cost two full + // child scans per ternary to produce `false`. loop { // The `expr` wrapper is this grammar's `{ … }` predicate node — diff --git a/src/metrics/abc/tcl.rs b/src/metrics/abc/tcl.rs index 4b083cd4a..57d1d254d 100644 --- a/src/metrics/abc/tcl.rs +++ b/src/metrics/abc/tcl.rs @@ -74,10 +74,22 @@ fn tcl_inspect_container(container_node: &Node, parent: &Node, conditions: &mut let mut has_boolean_content = matches!( parent_kind, Tcl::BinopExpr | Tcl::If | Tcl::Elseif | Tcl::While - ) || (matches!(parent_kind, Tcl::TernaryExpr) - && tcl_ternary_slots(parent) - .0 - .is_some_and(|condition| condition.id() == node.id())); + ); + // No ternary-condition disjunct here, unlike `cpp_inspect_container`. + // It would be dead: the flag is read only *after* a peel, and the + // loop peels exactly two kinds. `Expr` cannot sit under + // `ternary_expr` — the grammar's `_expr` alternatives are + // `unary_expr | binop_expr | ternary_expr | escaped_character | + // '(' _expr ')' | _expr_atom_no_brace | braced_word_simple` — and a + // `!`-unary sets the flag itself two lines below. Every other + // condition-slot kind breaks before the flag is read, and every + // terminal is counted by the ternary walker directly. Verified by + // deletion: the whole suite passes without it. + // + // The C-family version *is* live because C keeps `(a)` as a + // `parenthesized_expression` for the loop to peel, where this + // grammar inlines the parens. Porting it verbatim also cost two full + // child scans per ternary to produce `false`. loop { // The `expr` wrapper is this grammar's `{ … }` predicate node — diff --git a/src/metrics/nom.rs b/src/metrics/nom.rs index 43df81a5a..6f13037a1 100644 --- a/src/metrics/nom.rs +++ b/src/metrics/nom.rs @@ -703,6 +703,32 @@ mod tests { "class C { p = () => 1; }", true, ), + // The three field-name spellings `$extra`'s + // `property_identifier` sibling does *not* cover. The first + // fix reached the identifier case only, so these stayed + // closures as arrows while their `function` spellings were + // already functions — the same divergence, surviving in the + // shapes the test did not exercise. `p = …` above is the one + // spelling that worked before the field-definition kind + // joined both `$up` lists. + ( + "computed-key class field", + "class C { [\"k\"] = function () { return 1; }; }", + "class C { [\"k\"] = () => 1; }", + true, + ), + ( + "string-named class field", + "class C { \"s\" = function () { return 1; }; }", + "class C { \"s\" = () => 1; }", + true, + ), + ( + "private class field", + "class C { #p = function () { return 1; }; }", + "class C { #p = () => 1; }", + true, + ), // Plain positional callbacks, unchanged by all of the above. ( "positional callback", diff --git a/utils/check-snapshot-anchors-test.py b/utils/check-snapshot-anchors-test.py index f23a231c3..7e3955a2c 100644 --- a/utils/check-snapshot-anchors-test.py +++ b/utils/check-snapshot-anchors-test.py @@ -65,6 +65,10 @@ def test_forms_and_their_ends(self) -> None: (r"'\\'", 4), ("'\"'", 3), (r"'\u{1F600}'", 11), + # Rust's other multi-char escape, always two hex digits. The + # comment here used to claim the braced form was the only one. + (r"'\x41'", 6), + (r"'\x22'", 6), ]: with self.subTest(source=source): self.assertEqual(gate.char_literal_end(source, 0), expected) @@ -133,16 +137,69 @@ def test_lifetime_does_not_swallow_the_call_that_follows_it(self) -> None: self.assertEqual(gate.count_bare_in_source(source), 1) def test_byte_raw_string_does_not_hide_a_later_call(self) -> None: - """The raw string holds an *odd* number of double quotes. - - `br#"a"#` alone proves nothing: with the byte-raw branch removed - the inner `"a"` still lexes as a balanced regular string and the - call stays visible. An unpaired quote is what makes the missing - branch bite. + """A `br#"…"#` holding an unpaired quote leaves later calls visible. + + Honest caveat: this does **not** discriminate the `b`-prefix + branch of `raw_string_end`. Measured — with that branch disabled + the count is still 1, because the walk consumes the `b` as an + ordinary character and then recognises `r#"a"b"#` one index + later, producing the same span from a different start. Across + every `.rs` file in the workspace the `b` branch changes no + count at all; only `test_raw_and_byte_raw_forms` pins it. + + The case is kept because it pins the *scanner's* behaviour on a + real Rust spelling, which is worth having even though the branch + it looks like it guards is guarded elsewhere. """ source = 'let s = br#"a"b"#;\ninsta::assert_json_snapshot!(metric.x);\n' self.assertEqual(gate.count_bare_in_source(source), 1) + def test_block_comment_in_the_arguments_does_not_steal_a_later_anchor(self) -> None: + """`find_macro_call_end` skipped every literal kind but block comments. + + A `"` inside one opened a runaway string span, so the body scan + ran past the real closing paren into the next call and the bare + call claimed that call's `@"1"` as its own: 0 instead of 1. + """ + source = ( + 'insta::assert_json_snapshot!(m.x /* " */);\n' + 'insta::assert_json_snapshot!(m.y, @"1");\n' + ) + self.assertEqual(gate.count_bare_in_source(source), 1) + + def test_block_comment_in_the_arguments_does_not_truncate_the_body(self) -> None: + """The same gap in the other direction — a spurious gate failure. + + A `)` inside a block comment dropped the paren depth to zero + early, truncating the body before its real inline anchor, so an + anchored call counted as bare. + """ + source = 'insta::assert_json_snapshot!(m.x /* ) */, @"1");\n' + self.assertEqual(gate.count_bare_in_source(source), 0) + # Rust block comments nest; the body scan tracks depth. + nested = 'insta::assert_json_snapshot!(m.x /* a /* b */ c */, @"1");\n' + self.assertEqual(gate.count_bare_in_source(nested), 0) + + def test_escaped_quote_in_a_string_does_not_hide_a_later_call(self) -> None: + """Pins `regular_string_end`'s escape branch. + + Extracted as a named helper by #1192 and untested until now: + replacing it with a naive "find the next quote" makes this count + 0 instead of 1, and the rest of the suite stays green. + """ + source = 'let s = "a\\"b"; insta::assert_json_snapshot!(m.x);\n' + self.assertEqual(gate.count_bare_in_source(source), 1) + + def test_nested_block_comments_stay_commented_out(self) -> None: + """Pins the depth counter in `scan_ignore_spans`. + + Removing the depth-increment survives the rest of the suite; this + input is what discriminates it — the inner `*/` would otherwise + close the comment early and expose the call as live code. + """ + source = "/* outer /* inner */ insta::assert_json_snapshot!(m.x); */" + self.assertEqual(gate.count_bare_in_source(source), 0) + def test_char_literal_in_the_arguments_does_not_borrow_a_later_anchor(self) -> None: """`find_macro_call_end` carries the same lexer and the same gap. diff --git a/utils/check-snapshot-anchors.py b/utils/check-snapshot-anchors.py index 530b5ab10..379ef5a71 100755 --- a/utils/check-snapshot-anchors.py +++ b/utils/check-snapshot-anchors.py @@ -68,11 +68,22 @@ def char_literal_end(source: str, i: int) -> int | None: if j >= n: return None if source[j] == "u": - # '\u{1F600}' — the braced form is the only multi-char escape. + # '\u{1F600}' — braced, variable length. close = source.find("}", j) if close == -1: return None j = close + 1 + elif source[j] == "x": + # '\x41' — always exactly two hex digits. Rust's other + # multi-char escape; missing it made `char_literal_end` + # return `None` for a valid literal. Benign for the count + # (an unrecognised literal opens no span, and both its quotes + # are then consumed as ordinary characters), but the comment + # that used to sit here claimed the braced form was the only + # one — which is what would let the next reader skip it when + # this lexer is copied again, as it already was from + # `check-rustfmt-bail.py`. + j += 3 else: j += 1 else: @@ -125,13 +136,22 @@ def find_macro_call_end(source: str, open_paren_idx: int) -> int: ``open_paren_idx`` must point at the ``(`` that opens the call. Tracks parens, skipping string literals (regular, raw and byte), - char literals and ``//`` line comments so a ``)`` inside the inline - ``@r###"…"###`` anchor does not confuse the depth counter. + char literals, ``//`` line comments and ``/* … */`` block comments so + a ``)`` inside the inline ``@r###"…"###`` anchor does not confuse the + depth counter. Char literals are skipped for the same reason ``scan_ignore_spans`` skips them: a ``b'"'`` in the macro body would otherwise open a bogus string span, run past the real closing paren, and hand back a body the anchor test then reads the wrong answer out of. + + Block comments are skipped for the same reason again, in both + directions. A ``"`` inside one opened a runaway string span and the + body ran into the *next* call, whose ``@"…"`` the bare call then + claimed as its own anchor (under-count); a ``)`` inside one dropped + the depth to zero early and truncated the body before its real + anchor (over-count, i.e. a spurious CI failure). ``scan_ignore_spans`` + has always handled them; this sibling scanner did not. """ depth = 0 i = open_paren_idx @@ -143,6 +163,21 @@ def find_macro_call_end(source: str, open_paren_idx: int) -> int: nl = source.find("\n", i) i = n if nl == -1 else nl + 1 continue + # Block comment, which Rust allows to nest. + if ch == "/" and i + 1 < n and source[i + 1] == "*": + depth_comment = 1 + i += 2 + while i < n and depth_comment > 0: + if source[i] == "/" and i + 1 < n and source[i + 1] == "*": + depth_comment += 1 + i += 2 + continue + if source[i] == "*" and i + 1 < n and source[i + 1] == "/": + depth_comment -= 1 + i += 2 + continue + i += 1 + continue # Raw / byte-raw string: r"…", r#"…"#, br##"…"##, … if ch in "rb": stop = raw_string_end(source, i) From 4890c81c0fc5901e93a97fbd28bde6ffeee65ebe Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Mon, 3 Aug 2026 11:32:44 -0700 Subject: [PATCH 22/27] chore(self-scan): converge nargs from 7 to the shipped default of 5 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the step #1183 opened. It was declined twice before and both refusals were right at the time: #1143 measured 7 -> 6 against a hard-tier count and missed that the proportional soft tier would park 74 functions permanently in the band, and #1183 measured 6 -> 5 and found the offenders were mostly not parameter-heavy at all — the gate summed a function's own parameters with every nested closure's, so 59 of 76 would-be offenders were artifacts. #1196 removed that, and with it the reason to stay. Staying would also have left a limit that caught nothing: at 7 there were six offenders and all six were already baselined, which is the "cannot catch anything" argument the book uses against the old shipped default. Absorption ratio, in AGENTS.md's order of preference: **4 fixed, 0 suppressed, 129 baselined.** That is overwhelmingly baseline, and per #1183's own procedure the ratio is the finding rather than a footnote — see below. Fixed: the four HTML section writers took `(out, headings, id_prefix)` as their first three arguments. Those jointly answer "which language's section am I appending to", no caller holds one without the others, and an `HtmlSection` bundle is the refactor the parameter count was pointing at rather than a split invented to move a number. Behaviour-preserving; the report snapshots are untouched. Not fixed, deliberately: ten `dispatch_*` functions share `(language, source, path, pr)` — `parse_ast` takes exactly those four, so a bundle is clearly right there too — but only three of the ten breach. Bundling three would leave the router inconsistent, and bundling ten is a subcommand-router refactor that does not belong inside a threshold change. It is the obvious follow-up. The baseline roughly doubles, 137 -> 247 entries, 126 of them `nargs`. One correction to the reasoning recorded in #1143 and repeated in the book, found by measuring rather than reasoning: the cluster effect is **structural for every integer metric**, not a property of landing on a population's mode. A function sitting exactly at limit L is always above 0.95 x L, so the ten functions at exactly 7 were in the soft band before this change just as the ninety-one at exactly 5 are after it. The limit choice controls only how many sit there. #1143's objection was sound because the hard-tier gain was zero, not because 6 was near a mode. The corollary is that the proportional soft tier carries little information for a small-integer metric: at `halstead.effort` it is a real band (50000 vs 47500), at `nargs` it is 5 vs 4.75 and flags every compliant function at the ceiling by construction. Most of the 110 new soft-tier entries are that artifact rather than debt. Recorded in the book; worth taking back to #1140. Refs #1183, #1196 --- .bca-baseline.toml | 716 +++++++++++++++++- CHANGELOG.md | 9 + bca.toml | 59 +- .../src/recipes/thresholds.md | 15 +- .../src/html_report/sections.rs | 98 ++- 5 files changed, 796 insertions(+), 101 deletions(-) diff --git a/.bca-baseline.toml b/.bca-baseline.toml index e0c6c9bee..af5683037 100644 --- a/.bca-baseline.toml +++ b/.bca-baseline.toml @@ -47,18 +47,66 @@ qualified = "write_aggregate" metric = "halstead.effort" value = 48544.21441032752 +[[entry]] +path = "big-code-analysis-cli/src/commands/check.rs" +qualified = "classify_check_violations" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "big-code-analysis-cli/src/commands/check.rs" +qualified = "emit_check_results" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "big-code-analysis-cli/src/commands/check.rs" +qualified = "filter_by_baseline" +metric = "nargs" +value = 6.0 + [[entry]] path = "big-code-analysis-cli/src/commands/check.rs" qualified = "run_check" metric = "halstead.effort" value = 60852.18615151166 +[[entry]] +path = "big-code-analysis-cli/src/commands/check/effective_config.rs" +qualified = "EffectiveCheck::from_resolved" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "big-code-analysis-cli/src/commands/check/effective_config.rs" +qualified = "EffectiveConfig::from_resolved" +metric = "nargs" +value = 6.0 + [[entry]] path = "big-code-analysis-cli/src/commands/check/effective_config.rs" qualified = "print_effective_config" metric = "nargs" value = 7.0 +[[entry]] +path = "big-code-analysis-cli/src/commands/check/explain.rs" +qualified = "run_explain_thresholds" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "big-code-analysis-cli/src/commands/check/thresholds.rs" +qualified = "SharedLayers<'a>::new" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "big-code-analysis-cli/src/commands/init.rs" +qualified = "scaffold_baseline" +metric = "nargs" +value = 6.0 + [[entry]] path = "big-code-analysis-cli/src/commands/report.rs" qualified = "run_command_report" @@ -89,6 +137,48 @@ qualified = "parse_ls_tree_record" metric = "nexits" value = 6.0 +[[entry]] +path = "big-code-analysis-cli/src/dispatch.rs" +qualified = "analyze_file" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "big-code-analysis-cli/src/dispatch.rs" +qualified = "dispatch_check_file" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "big-code-analysis-cli/src/dispatch.rs" +qualified = "dispatch_count" +metric = "nargs" +value = 6.0 + +[[entry]] +path = "big-code-analysis-cli/src/dispatch.rs" +qualified = "dispatch_dump" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "big-code-analysis-cli/src/dispatch.rs" +qualified = "dispatch_exemptions" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "big-code-analysis-cli/src/dispatch.rs" +qualified = "dispatch_find" +metric = "nargs" +value = 6.0 + +[[entry]] +path = "big-code-analysis-cli/src/dispatch.rs" +qualified = "dispatch_functions" +metric = "nargs" +value = 5.0 + [[entry]] path = "big-code-analysis-cli/src/dispatch.rs" qualified = "dispatch_metrics" @@ -101,6 +191,30 @@ qualified = "dispatch_ops" metric = "nargs" value = 7.0 +[[entry]] +path = "big-code-analysis-cli/src/dispatch.rs" +qualified = "dispatch_report" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "big-code-analysis-cli/src/dispatch.rs" +qualified = "dispatch_strip_comments" +metric = "nargs" +value = 6.0 + +[[entry]] +path = "big-code-analysis-cli/src/html_report.rs" +qualified = "generate_html_report_with_vcs" +metric = "nargs" +value = 6.0 + +[[entry]] +path = "big-code-analysis-cli/src/html_report.rs" +qualified = "write_table_classed" +metric = "nargs" +value = 5.0 + [[entry]] path = "big-code-analysis-cli/src/html_report.rs" qualified = "write_table_classed_with_tooltips" @@ -113,11 +227,23 @@ qualified = "write_table_core" metric = "nargs" value = 7.0 +[[entry]] +path = "big-code-analysis-cli/src/html_report.rs" +qualified = "write_table_head" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "big-code-analysis-cli/src/html_report.rs" +qualified = "write_table_with_tooltips" +metric = "nargs" +value = 6.0 + [[entry]] path = "big-code-analysis-cli/src/html_report/sections.rs" qualified = "" metric = "loc.ploc" -value = 524.0 +value = 529.0 [[entry]] path = "big-code-analysis-cli/src/html_report/sections.rs" @@ -137,18 +263,48 @@ qualified = "" metric = "loc.ploc" value = 588.0 +[[entry]] +path = "big-code-analysis-cli/src/markdown_report.rs" +qualified = "extract_summaries" +metric = "nargs" +value = 5.0 + [[entry]] path = "big-code-analysis-cli/src/markdown_report.rs" qualified = "extract_summaries_inner" metric = "abc" value = 40.124805295477756 +[[entry]] +path = "big-code-analysis-cli/src/markdown_report.rs" +qualified = "generate_report_with_vcs" +metric = "nargs" +value = 6.0 + +[[entry]] +path = "big-code-analysis-cli/src/markdown_report.rs" +qualified = "write_language_section" +metric = "nargs" +value = 6.0 + [[entry]] path = "big-code-analysis-cli/src/markdown_report/hotspot.rs" qualified = "" metric = "loc.ploc" value = 696.0 +[[entry]] +path = "big-code-analysis-cli/src/markdown_report/hotspot.rs" +qualified = "select" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "big-code-analysis-cli/src/markdown_report/hotspot.rs" +qualified = "select_cc" +metric = "nargs" +value = 5.0 + [[entry]] path = "big-code-analysis-cli/src/markdown_report/hotspot.rs" qualified = "select_for" @@ -173,6 +329,12 @@ qualified = "" metric = "loc.ploc" value = 544.0 +[[entry]] +path = "big-code-analysis-cli/src/thresholds.rs" +qualified = "ThresholdSet::evaluate_with_policy" +metric = "nargs" +value = 5.0 + [[entry]] path = "big-code-analysis-cli/src/vcs_command.rs" qualified = "" @@ -185,6 +347,30 @@ qualified = "write_table" metric = "nexits" value = 5.0 +[[entry]] +path = "big-code-analysis-cli/src/walk.rs" +qualified = "assemble_globals" +metric = "nargs" +value = 6.0 + +[[entry]] +path = "big-code-analysis-cli/src/walk.rs" +qualified = "expand_seed_paths" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "big-code-analysis-cli/src/walk.rs" +qualified = "visit_walk_entry" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "big-code-analysis-cli/src/walk_seed.rs" +qualified = "AnchoredExcludes<'a>::new" +metric = "nargs" +value = 5.0 + [[entry]] path = "big-code-analysis-cli/src/walk_seed.rs" qualified = "relative_tail_with" @@ -227,6 +413,12 @@ qualified = "analyze_paths" metric = "nexits" value = 5.0 +[[entry]] +path = "big-code-analysis-py/src/batch.rs" +qualified = "push_one_result" +metric = "nargs" +value = 5.0 + [[entry]] path = "big-code-analysis-py/src/lib.rs" qualified = "PyVcsOptions::py_new" @@ -245,6 +437,12 @@ qualified = "analyze" metric = "nargs" value = 8.0 +[[entry]] +path = "big-code-analysis-py/src/lib.rs" +qualified = "analyze_source" +metric = "nargs" +value = 5.0 + [[entry]] path = "big-code-analysis-py/src/lib.rs" qualified = "extract_as_of" @@ -257,6 +455,12 @@ qualified = "register_vcs_submodule" metric = "nexits" value = 14.0 +[[entry]] +path = "big-code-analysis-py/src/lib.rs" +qualified = "vcs_rank" +metric = "nargs" +value = 6.0 + [[entry]] path = "big-code-analysis-py/src/lib.rs" qualified = "vcs_trend" @@ -293,6 +497,12 @@ qualified = "extract_line_number" metric = "nexits" value = 5.0 +[[entry]] +path = "big-code-analysis-py/src/sarif.rs" +qualified = "record_threshold_breaches" +metric = "nargs" +value = 6.0 + [[entry]] path = "big-code-analysis-py/src/sarif.rs" qualified = "resolve_thresholds" @@ -312,46 +522,232 @@ metric = "halstead.effort" value = 47661.70050580648 [[entry]] -path = "big-code-analysis-py/src/vcs.rs" -qualified = "options_from" -metric = "nexits" -value = 8.0 +path = "big-code-analysis-py/src/vcs.rs" +qualified = "options_from" +metric = "nexits" +value = 8.0 + +[[entry]] +path = "big-code-analysis-py/src/vcs.rs" +qualified = "vcs_trend_json" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "big-code-analysis-py/src/walk.rs" +qualified = "walk_seed" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "big-code-analysis-web/src/web/server.rs" +qualified = "run_parse" +metric = "halstead.effort" +value = 82478.1543040951 + +[[entry]] +path = "big-code-analysis-web/src/web/server.rs" +qualified = "run_with_timeout" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "big-code-analysis-web/src/web/server/routing.rs" +qualified = "register_endpoints" +metric = "abc" +value = 91.0 + +[[entry]] +path = "big-code-analysis-web/src/web/vcs.rs" +qualified = "compute_vcs_jit" +metric = "nexits" +value = 5.0 + +[[entry]] +path = "big-code-analysis-web/src/web/vcs.rs" +qualified = "options_from" +metric = "halstead.effort" +value = 51846.28098938873 + +[[entry]] +path = "big-code-analysis-web/src/web/vcs.rs" +qualified = "options_from" +metric = "nexits" +value = 8.0 + +[[entry]] +path = "src/alterator.rs" +qualified = "" +metric = "loc.ploc" +value = 545.0 + +[[entry]] +path = "src/alterator.rs" +qualified = "Alterator::alterate" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "src/alterator.rs" +qualified = "Alterator::get_ast_node" +metric = "nargs" +value = 6.0 + +[[entry]] +path = "src/alterator.rs" +qualified = "Alterator::get_default" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "src/alterator.rs" +qualified = "BashCode::alterate" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "src/alterator.rs" +qualified = "CCode::alterate" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "src/alterator.rs" +qualified = "CppCode::alterate" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "src/alterator.rs" +qualified = "CsharpCode::alterate" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "src/alterator.rs" +qualified = "ElixirCode::alterate" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "src/alterator.rs" +qualified = "GoCode::alterate" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "src/alterator.rs" +qualified = "GroovyCode::alterate" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "src/alterator.rs" +qualified = "IrulesCode::alterate" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "src/alterator.rs" +qualified = "JavaCode::alterate" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "src/alterator.rs" +qualified = "JavascriptCode::alterate" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "src/alterator.rs" +qualified = "KotlinCode::alterate" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "src/alterator.rs" +qualified = "LuaCode::alterate" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "src/alterator.rs" +qualified = "MozcppCode::alterate" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "src/alterator.rs" +qualified = "MozjsCode::alterate" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "src/alterator.rs" +qualified = "ObjcCode::alterate" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "src/alterator.rs" +qualified = "PerlCode::alterate" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "src/alterator.rs" +qualified = "PhpCode::alterate" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "src/alterator.rs" +qualified = "PythonCode::alterate" +metric = "nargs" +value = 5.0 [[entry]] -path = "big-code-analysis-web/src/web/server.rs" -qualified = "run_parse" -metric = "halstead.effort" -value = 82478.1543040951 +path = "src/alterator.rs" +qualified = "RubyCode::alterate" +metric = "nargs" +value = 5.0 [[entry]] -path = "big-code-analysis-web/src/web/server/routing.rs" -qualified = "register_endpoints" -metric = "abc" -value = 91.0 +path = "src/alterator.rs" +qualified = "RustCode::alterate" +metric = "nargs" +value = 5.0 [[entry]] -path = "big-code-analysis-web/src/web/vcs.rs" -qualified = "compute_vcs_jit" -metric = "nexits" +path = "src/alterator.rs" +qualified = "TclCode::alterate" +metric = "nargs" value = 5.0 [[entry]] -path = "big-code-analysis-web/src/web/vcs.rs" -qualified = "options_from" -metric = "halstead.effort" -value = 51846.28098938873 +path = "src/alterator.rs" +qualified = "TsxCode::alterate" +metric = "nargs" +value = 5.0 [[entry]] -path = "big-code-analysis-web/src/web/vcs.rs" -qualified = "options_from" -metric = "nexits" -value = 8.0 +path = "src/alterator.rs" +qualified = "TypescriptCode::alterate" +metric = "nargs" +value = 5.0 [[entry]] -path = "src/alterator.rs" -qualified = "" -metric = "loc.ploc" -value = 545.0 +path = "src/ast.rs" +qualified = "AstNode::with_field_name" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "src/ast.rs" +qualified = "Span::new" +metric = "nargs" +value = 6.0 [[entry]] path = "src/ast.rs" @@ -371,12 +767,24 @@ qualified = "step_normal" metric = "halstead.effort" value = 66515.67234293568 +[[entry]] +path = "src/c_macro.rs" +qualified = "step_normal" +metric = "nargs" +value = 5.0 + [[entry]] path = "src/c_macro.rs" qualified = "step_normal" metric = "nexits" value = 6.0 +[[entry]] +path = "src/c_macro.rs" +qualified = "step_raw_string" +metric = "nargs" +value = 5.0 + [[entry]] path = "src/getter/bash.rs" qualified = "BashCode::get_op_type" @@ -479,12 +887,144 @@ qualified = "tcl_switch_decision_arms" metric = "nexits" value = 6.0 +[[entry]] +path = "src/metrics/cognitive/bash.rs" +qualified = "BashCode::compute" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "src/metrics/cognitive/c.rs" +qualified = "CCode::compute" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "src/metrics/cognitive/cpp.rs" +qualified = "CppCode::compute" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "src/metrics/cognitive/csharp.rs" +qualified = "CsharpCode::compute" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "src/metrics/cognitive/elixir.rs" +qualified = "ElixirCode::compute" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "src/metrics/cognitive/go.rs" +qualified = "GoCode::compute" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "src/metrics/cognitive/groovy.rs" +qualified = "GroovyCode::compute" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "src/metrics/cognitive/irules.rs" +qualified = "IrulesCode::compute" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "src/metrics/cognitive/java.rs" +qualified = "JavaCode::compute" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "src/metrics/cognitive/kotlin.rs" +qualified = "KotlinCode::compute" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "src/metrics/cognitive/lua.rs" +qualified = "LuaCode::compute" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "src/metrics/cognitive/mozcpp.rs" +qualified = "MozcppCode::compute" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "src/metrics/cognitive/objc.rs" +qualified = "ObjcCode::compute" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "src/metrics/cognitive/perl.rs" +qualified = "PerlCode::compute" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "src/metrics/cognitive/php.rs" +qualified = "PhpCode::compute" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "src/metrics/cognitive/python.rs" +qualified = "PythonCode::compute" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "src/metrics/cognitive/ruby.rs" +qualified = "RubyCode::compute" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "src/metrics/cognitive/rust.rs" +qualified = "RustCode::compute" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "src/metrics/cognitive/tcl.rs" +qualified = "TclCode::compute" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "src/metrics/cyclomatic.rs" +qualified = "Cyclomatic::compute_with_options" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "src/metrics/cyclomatic/rust.rs" +qualified = "RustCode::compute_with_options" +metric = "nargs" +value = 5.0 + [[entry]] path = "src/metrics/loc/perl.rs" qualified = "PerlCode::compute" metric = "halstead.effort" value = 55025.91689557041 +[[entry]] +path = "src/metrics/loc/shared.rs" +qualified = "add_multiline_string_ploc" +metric = "nargs" +value = 5.0 + [[entry]] path = "src/metrics/nargs.rs" qualified = "" @@ -557,6 +1097,24 @@ qualified = "write_code_climate" metric = "halstead.effort" value = 60471.77938561803 +[[entry]] +path = "src/output/dump.rs" +qualified = "dump_node" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "src/output/dump.rs" +qualified = "write_node_line" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "src/output/dump_metrics.rs" +qualified = "dump_group" +metric = "nargs" +value = 5.0 + [[entry]] path = "src/output/dump_metrics.rs" qualified = "dump_metrics" @@ -569,12 +1127,24 @@ qualified = "dump_space" metric = "nexits" value = 9.0 +[[entry]] +path = "src/output/dump_metrics.rs" +qualified = "dump_value" +metric = "nargs" +value = 5.0 + [[entry]] path = "src/output/dump_metrics.rs" qualified = "dump_value" metric = "nexits" value = 5.0 +[[entry]] +path = "src/output/dump_ops.rs" +qualified = "dump_ops_values" +metric = "nargs" +value = 5.0 + [[entry]] path = "src/output/dump_ops.rs" qualified = "dump_ops_values" @@ -605,6 +1175,30 @@ qualified = "Parser::filters" metric = "halstead.effort" value = 58860.61554860244 +[[entry]] +path = "src/preproc.rs" +qualified = "accumulate_reachable_includes" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "src/preproc.rs" +qualified = "classify_preproc_node" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "src/preproc.rs" +qualified = "record_indirect_includes" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "src/spaces.rs" +qualified = "FuncSpace::new" +metric = "nargs" +value = 5.0 + [[entry]] path = "src/spaces/ast.rs" qualified = "Ast::from_path" @@ -617,6 +1211,12 @@ qualified = "CodeMetrics::fmt" metric = "nexits" value = 8.0 +[[entry]] +path = "src/spaces/compute.rs" +qualified = "apply_comment_suppression" +metric = "nargs" +value = 5.0 + [[entry]] path = "src/spaces/compute.rs" qualified = "compute_per_node" @@ -635,6 +1235,12 @@ qualified = "metrics_inner" metric = "halstead.effort" value = 120010.12175375846 +[[entry]] +path = "src/spaces/compute.rs" +qualified = "open_func_space" +metric = "nargs" +value = 6.0 + [[entry]] path = "src/suppression.rs" qualified = "parse_native" @@ -653,6 +1259,12 @@ qualified = "compute" metric = "halstead.effort" value = 47694.803479864124 +[[entry]] +path = "src/vcs/bus_factor.rs" +qualified = "greedy_truck_factor" +metric = "nargs" +value = 5.0 + [[entry]] path = "src/vcs/git/blame.rs" qualified = "PerFunctionBlame" @@ -671,6 +1283,12 @@ qualified = "PerFunctionBlame::resolve_commit" metric = "nexits" value = 6.0 +[[entry]] +path = "src/vcs/git/cached.rs" +qualified = "assemble" +metric = "nargs" +value = 6.0 + [[entry]] path = "src/vcs/git/cached.rs" qualified = "build_cached" @@ -689,6 +1307,18 @@ qualified = "incremental_events" metric = "nargs" value = 9.0 +[[entry]] +path = "src/vcs/git/cached.rs" +qualified = "load_candidates" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "src/vcs/git/cached.rs" +qualified = "persist" +metric = "nargs" +value = 6.0 + [[entry]] path = "src/vcs/git/diff_parse.rs" qualified = "unquote_git_path" @@ -701,6 +1331,12 @@ qualified = "collect_events" metric = "halstead.effort" value = 48928.29416311421 +[[entry]] +path = "src/vcs/git/history.rs" +qualified = "collect_events" +metric = "nargs" +value = 5.0 + [[entry]] path = "src/vcs/git/history.rs" qualified = "collect_events" @@ -755,6 +1391,12 @@ qualified = "compute_features" metric = "nexits" value = 5.0 +[[entry]] +path = "src/vcs/git/jit.rs" +qualified = "experience_features" +metric = "nargs" +value = 6.0 + [[entry]] path = "src/vcs/git/jit.rs" qualified = "experience_features" @@ -767,6 +1409,12 @@ qualified = "history_features" metric = "halstead.effort" value = 52560.02538757721 +[[entry]] +path = "src/vcs/git/jit.rs" +qualified = "history_features" +metric = "nargs" +value = 5.0 + [[entry]] path = "src/vcs/git/jit.rs" qualified = "score_commit" @@ -809,6 +1457,12 @@ qualified = "parse_window" metric = "nexits" value = 5.0 +[[entry]] +path = "src/vcs/replay.rs" +qualified = "fold_commit" +metric = "nargs" +value = 5.0 + [[entry]] path = "src/vcs/score.rs" qualified = "apply_percentile" @@ -827,6 +1481,12 @@ qualified = "Accumulator::record" metric = "halstead.effort" value = 56240.27018612609 +[[entry]] +path = "src/vcs/trend.rs" +qualified = "Trend::from_snapshots" +metric = "nargs" +value = 5.0 + [[entry]] path = "src/wire.rs" qualified = "CodeMetrics::from" diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c9555c53..d91fcc69c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -183,6 +183,15 @@ for historical reference. ### Changed +- This project's own `nargs` limit converges from 7 to the shipped + default of 5 (#1183). Repository configuration only — no library or CLI + behaviour changes. The convergence was declined twice before, both + times correctly: #1143 measured it against a hard-tier count that + missed the soft tier, and #1183 found the offenders were mostly + artifacts of the gate summing closure parameters into the enclosing + function. #1196 removed that, and with it the reason to stay at 7 — + which had become a limit catching nothing in the current tree. + - **`bca check --threshold nargs=N` now gates each callable on its own parameter list** rather than on `nargs.total()`, which summed a function's parameters with every nested closure's (#1196). This changes diff --git a/bca.toml b/bca.toml index 7f3c5c8cd..027e73965 100644 --- a/bca.toml +++ b/bca.toml @@ -135,39 +135,34 @@ exclude = [ # limit is not over it, and a baseline entry keeps the growth alarm a # suppression marker would discard. # -# `nargs` deliberately stays at 7. #1143 proposed 7 -> 6 as a free -# ratchet on the strength of a hard-tier measurement: every offender at -# limit 6 is already baselined, so `bca check --threshold nargs=6` -# reports zero. But the soft tier scales every limit by BCA_HEADROOM -# (0.95), and 74 functions sit at exactly 6, so a limit of 6 puts all of -# them 0.3 below the band at once — they cannot ever clear it, because -# they are the limit. That is 74 baseline entries bought for no -# hard-tier gain, which is the "reads as debt rather than as a decision" -# outcome #1143 exists to avoid. +# `nargs` is at the shipped default of 5, converged from 7 (#1183). # -# Re-measure with `bca check --explain-threshold nargs=6` (#1169) rather -# than a bare `--threshold`, which has no soft tier by design. What makes -# `nargs` different from the `cognitive` convergence above is where the -# limit falls in the distribution: nothing *unbaselined* exceeds 6, so a -# limit of 6 sits on the population's ceiling and buys no hard-tier gate, -# whereas `cognitive` runs to 20 with 15 inside the distribution. +# The convergence was declined twice before, and both refusals were +# right at the time. #1143 measured 7 -> 6 as a free ratchet on a +# hard-tier count and missed that the proportional soft tier would park +# 74 functions permanently in the band. #1183 then measured 6 -> 5 and +# found the offenders were mostly not parameter-heavy at all: the gate +# summed a function's own parameters with every nested closure's, so 59 +# of 76 would-be offenders were artifacts and one had a single parameter +# plus five from closures in its body. # -# The 6 -> 5 step (#1183) was measured and rejected, and the reason is -# about the metric rather than about this codebase. The gate extracts -# `nargs.total()`, which sums a function's own parameters with every -# nested closure's. Of the 76 functions a limit of 5 would newly gate, -# only 17 have 6 or more parameters of their own; 59 carry closure args -# and 44 have four or fewer own parameters. The extreme is -# `Parser::filters` — one parameter, plus five from the closures in -# its body. Converging would therefore pressure contributors to delete -# idiomatic inline closures rather than to simplify signatures, which is -# not what the metric is for. #1196 settled that: the gate now keys on a -# callable's own parameters, which is what every comparable tool -# measures. Re-measured on this tree afterwards, `nargs = 6` costs 0 new -# hard-tier offenders and `nargs = 5` costs 17 — the genuine signatures, -# without the 59 closure-inflated ones attached. 91 functions sit at -# exactly 5, so a limit of 5 still parks all of them permanently in the -# soft band; that cluster, not the count, is why this stays at 7 for now. +# #1196 fixed that — the gate now counts a callable's own parameters, +# which is what RuboCop, ESLint, Clippy, lizard, SonarQube and Pylint all +# measure, and what this limit's own rationale in +# `default_thresholds.rs` was derived from. That removed the reason to +# stay at 7, and staying would have left a limit that caught nothing in +# the current tree: at 7 there were 6 offenders and all 6 were already +# baselined, which is the same "cannot catch anything" the book uses to +# argue the shipped default off 7. +# +# The remaining cost is the soft tier, and it is worth naming precisely. +# For an integer metric, a function sitting *exactly* at the limit is +# always above `0.95 x limit`, so it is always in the soft band — that is +# structural, not specific to 5, and it is true of the functions at +# exactly 7 today. What changes with the limit is only how many sit +# there: 91 at 5 against 10 at 7. That is the real content of #1143's +# "cluster" objection, and it is an argument about the soft tier's fit +# for small-integer metrics rather than about this number. cognitive = 15 cyclomatic = 15 "halstead.effort" = 50000 @@ -198,7 +193,7 @@ cyclomatic = 15 # limit, which is trimming-to-fit rather than a natural distribution. "loc.sloc" = 1300 nom = 30 -nargs = 7 +nargs = 5 nexits = 5 abc = 40 wmc = 60 diff --git a/big-code-analysis-book/src/recipes/thresholds.md b/big-code-analysis-book/src/recipes/thresholds.md index ae52b3038..d5191c2c4 100644 --- a/big-code-analysis-book/src/recipes/thresholds.md +++ b/big-code-analysis-book/src/recipes/thresholds.md @@ -384,7 +384,20 @@ in its body. Re-measured on the same tree under own-parameter gating: The cluster trap itself is unchanged and is the reason this section exists — 91 functions now sit at exactly `5`, so a limit of `5` still puts every one of them permanently inside the soft band. -What changed is the size of the real work behind it: 17 signatures rather than 76 mixed cases. +What changed is the size of the real work behind it: signatures rather than mixed cases. + +The convergence landed on that evidence. One thing measuring it clarified, which this section +previously got wrong: the cluster effect is **structural for every integer metric**, not a property +of landing on a population's mode. A function sitting exactly at limit `L` is always above +`0.95 x L`, so it is always in the soft band — the ten functions at exactly `7` were in it before +the change, just as the ninety-one at exactly `5` are after. What the limit choice controls is only +*how many* sit there. Read `#1143`'s objection that way: it was sound because the hard-tier gain +was zero, not because `6` was near a mode. + +That also means the soft tier carries little information for a small-integer metric, where +`0.95 x L` and `L` differ by less than one. It is doing real work for `halstead.effort` (50000 vs +47500) and almost none for `nargs` (5 vs 4.75, which flags every compliant function at the +ceiling). `bca check --explain-threshold =` measures both tiers in one walk and names the cluster when it finds one, without touching `bca.toml`: diff --git a/big-code-analysis-cli/src/html_report/sections.rs b/big-code-analysis-cli/src/html_report/sections.rs index 22f364507..052c61644 100644 --- a/big-code-analysis-cli/src/html_report/sections.rs +++ b/big-code-analysis-cli/src/html_report/sections.rs @@ -444,27 +444,44 @@ fn write_language_header(out: &mut String, headings: &mut Headings, lang_name: & let _ = out.write_str("\n"); } -fn write_language_summary( - out: &mut String, - headings: &mut Headings, - id_prefix: &str, - units: &[&FunctionSummary], -) { +/// Where an HTML section is being written: the buffer, the heading +/// emitter that assigns and records ids, and the language slug those ids +/// are namespaced under. +/// +/// The three always travel together — every section writer below took +/// all three as its first arguments, and no caller has one without the +/// others, because they jointly answer "which language's section am I +/// appending to". Bundling them is what the parameter count was pointing +/// at: `nargs` counted six on three of these, and four of the six were +/// this one idea spelled out longhand. +struct HtmlSection<'a> { + out: &'a mut String, + headings: &'a mut Headings, + /// Slug-based id prefix for this language's `h3` headings, so a + /// hotspot table deep-links to e.g. `#rust-cyclomatic-complexity-hotspots`. + id_prefix: &'a str, +} + +fn write_language_summary(section: &mut HtmlSection<'_>, units: &[&FunctionSummary]) { let totals = LanguageTotals::from_units(units); let cr = totals.comment_ratio(); let avg_mi = totals.avg_mi(); let rating = mi_rating(avg_mi); - headings.emit_h3(out, &format!("{id_prefix}-summary"), "Summary"); + section.headings.emit_h3( + section.out, + &format!("{}-summary", section.id_prefix), + "Summary", + ); let _ = writeln!( - out, + section.out, "

Files: {} | SLOC: {} | PLOC: {} | Comment ratio: {cr:.1}%

", escape_html(&thousands(totals.files)), escape_html(&thousands(totals.sloc)), escape_html(&thousands(totals.ploc)), ); let _ = writeln!( - out, + section.out, "

{}: {avg_mi:.1} ({rating})

", crate::markdown_report::AVG_MI_LABEL ); @@ -477,9 +494,7 @@ fn write_language_summary( /// [`hotspot::HotspotTitle::render`]), it is a no-op for today's /// metachar-free titles. fn emit_html_section( - out: &mut String, - headings: &mut Headings, - id_prefix: &str, + section: &mut HtmlSection<'_>, spec: &HotspotSpec, top_n: usize, rows: &[&FunctionSummary], @@ -489,12 +504,12 @@ fn emit_html_section( // (" hotspots", without the `(top N by …)` clause), so the // fragment (`#rust-cyclomatic-complexity-hotspots`) does not shift with // `--top` (issue #677). The displayed text is the full rendered title. - headings.emit_h3( - out, - &format!("{id_prefix}-{}", spec.title.id_basis()), + section.headings.emit_h3( + section.out, + &format!("{}-{}", section.id_prefix, spec.title.id_basis()), &escape_html(&title), ); - write_hotspot_table(out, spec, rows); + write_hotspot_table(section.out, spec, rows); } /// The cyclomatic summary note under the CC hotspot table. A caption over the @@ -575,36 +590,36 @@ fn write_actionable_bullets( } fn write_actionable_summary( - out: &mut String, - headings: &mut Headings, - id_prefix: &str, + section: &mut HtmlSection<'_>, funcs: &[&FunctionSummary], policy: SuppressionPolicy, advisory: AdvisoryThresholds, ) { let counts = advisory.count_over(funcs); - headings.emit_h3( - out, - &format!("{id_prefix}-actionable-summary"), + section.headings.emit_h3( + section.out, + &format!("{}-actionable-summary", section.id_prefix), "Actionable Summary", ); // Provenance so the cutoffs are always attributable (issue #630). let _ = writeln!( - out, + section.out, "

{}

", escape_html(advisory.provenance_line()) ); let breakdown = hotspot::suppressed_metric_breakdown(funcs, policy, advisory); let _ = writeln!( - out, + section.out, "

{}

", escape_html(&hotspot::actionable_summary_caption(&breakdown)) ); if counts.all_clear() { - let _ = out.write_str("

No major quality concerns detected.

\n"); + let _ = section + .out + .write_str("

No major quality concerns detected.

\n"); return; } - write_actionable_bullets(out, counts, advisory); + write_actionable_bullets(section.out, counts, advisory); } /// Emit the section heading (`

` + id) followed by the "table omitted: all @@ -616,9 +631,7 @@ fn write_actionable_summary( /// summary bullet never points at a table absent from the document (issue /// #616). A no-op when `count == 0`. fn emit_fully_suppressed_note_html( - out: &mut String, - headings: &mut Headings, - id_prefix: &str, + section: &mut HtmlSection<'_>, spec: &HotspotSpec, top_n: usize, count: usize, @@ -631,13 +644,13 @@ fn emit_fully_suppressed_note_html( // `emit_html_section` uses, so a fully-suppressed section keeps its place // in the heading/id sequence and deep links resolve regardless of // suppression state (issue #681). The omission note is the section body. - headings.emit_h3( - out, - &format!("{id_prefix}-{}", spec.title.id_basis()), + section.headings.emit_h3( + section.out, + &format!("{}-{}", section.id_prefix, spec.title.id_basis()), &escape_html(&title), ); let _ = writeln!( - out, + section.out, "

{}

", escape_html(&hotspot::fully_suppressed_caption(&title, count)) ); @@ -657,13 +670,18 @@ pub(crate) fn write_language_section( // table deep-links to e.g. `#rust-cyclomatic-complexity-hotspots`. Built // from the raw language slug, matching the `h2` section id. let id_prefix = slugify(lang_name); + let section = &mut HtmlSection { + out, + headings, + id_prefix: &id_prefix, + }; let (units, funcs) = hotspot::partition_by_kind(entries); - write_language_summary(out, headings, &id_prefix, &units); + write_language_summary(section, &units); // The Actionable Summary leads the section (directly after Summary, // before any hotspot table) so a reader sees the highest-altitude counts // first (issue #678). - write_actionable_summary(out, headings, &id_prefix, &funcs, policy, advisory); + write_actionable_summary(section, &funcs, policy, advisory); // Drive every hotspot section from the shared `SPECS` table so the HTML // and Markdown reports cannot diverge in membership/order/suppression. @@ -672,17 +690,17 @@ pub(crate) fn write_language_section( for spec in SPECS { match hotspot::select_for(spec, &units, &funcs, entries, top_n, policy, advisory) { hotspot::SpecOutcome::FullySuppressed(suppressed) => { - emit_fully_suppressed_note_html(out, headings, &id_prefix, spec, top_n, suppressed); + emit_fully_suppressed_note_html(section, spec, top_n, suppressed); } hotspot::SpecOutcome::Rows { rows, cc_stats } => { - emit_html_section(out, headings, &id_prefix, spec, top_n, &rows); + emit_html_section(section, spec, top_n, &rows); if let Some(stats) = cc_stats { - emit_cc_note_html(out, &stats, policy); + emit_cc_note_html(section.out, &stats, policy); } else if spec.mi_note { // HTML wraps the note in a styled `

`; the Markdown // writer has a dedicated emitter for the same text. let _ = writeln!( - out, + section.out, "

{}

", escape_html(hotspot::MI_NOTE) ); @@ -691,5 +709,5 @@ pub(crate) fn write_language_section( } } - let _ = out.write_str("\n"); + let _ = section.out.write_str("\n"); } From bfbcd4b8c8673af42d917e95368f4f04299676ee Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Mon, 3 Aug 2026 12:47:21 -0700 Subject: [PATCH 23/27] refactor(elixir): drop duplicated func-space rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `promotes_to_func_space_with_code` spelled out the whole of `is_func_space_with_code` a second time rather than calling it. The two bodies were identical, and only the override was reachable from the walk, so no test could observe the copy it was not using — the pair could drift apart silently. Forwarding to `is_func_space_with_code` keeps the single-lookup saving the override exists for: the default is `is_func_with_code || is_func_space_with_code`, and the second predicate already subsumes the first (its final clause *is* `is_func_with_code`, since a method macro outside a `quote` block satisfies both), so one `elixir_call_keyword` call still answers the combined question. Verified live by perturbation: making `is_func_space_with_code` return `false` unconditionally now fails the Elixir ABC and space tests. Before this change the same perturbation failed nothing. --- src/checker/elixir.rs | 31 ++++++++++++------------------- 1 file changed, 12 insertions(+), 19 deletions(-) diff --git a/src/checker/elixir.rs b/src/checker/elixir.rs index fa7a4b56b..daeb5cee1 100644 --- a/src/checker/elixir.rs +++ b/src/checker/elixir.rs @@ -71,25 +71,18 @@ impl Checker for ElixirCode { code: &[u8], ancestors: Ancestors<'a, '_>, ) -> bool { - use crate::metrics::cognitive::{ - elixir_call_keyword, elixir_is_class_macro, elixir_is_inside_quote_block, - elixir_is_method_macro, - }; - // Cheap path: byte-less `is_func_space` matches `Source` and - // `AnonymousFunction` without needing any text inspection. - if Self::is_func_space(node) { - return true; - } - // Otherwise one `elixir_call_keyword` lookup answers the - // combined question instead of the two the default impl would - // have made via `is_func_with_code || is_func_space_with_code`. - let Some(kw) = elixir_call_keyword(node, code) else { - return false; - }; - if elixir_is_class_macro(kw) { - return true; - } - elixir_is_method_macro(kw) && !elixir_is_inside_quote_block(node, code, ancestors) + // The default is `is_func_with_code || is_func_space_with_code`, + // which costs two `elixir_call_keyword` lookups. Here the second + // predicate already subsumes the first — its final clause *is* + // `is_func_with_code`, since a method macro outside a `quote` + // block satisfies both — so forwarding to it alone answers the + // combined question with one lookup (#310 follow-on perf) and + // keeps a single copy of the rule. Spelling the body out a + // second time is what let this override and + // `is_func_space_with_code` drift apart unnoticed: only the + // override was reachable from the walk, so no test could see + // the copy it was not using. + Self::is_func_space_with_code(node, code, ancestors) } fn is_closure<'a>(node: &Node<'a>, _ancestors: Ancestors<'a, '_>) -> bool { From 9647f8ade8cfaec255b3b853cd178a2ad3145487 Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Mon, 3 Aug 2026 12:47:21 -0700 Subject: [PATCH 24/27] test(preproc): pin PreprocDiagnostic Display output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `Display` impl was entirely unexercised, yet it is the only rendering of these diagnostics a user sees — the CLI writes it to stderr and `bca-web` may surface it verbatim. Each variant's text is asserted whole rather than probed with `contains`, which is what catches a dropped path interpolation; a substring check passes against both. The cycle case uses three unsorted members, one containing a space, so it can distinguish "prints every member" from "prints the first" and pins the quoting that exists to keep whitespace visible. Records without changing the shipped `Warning:` / `warning:` split across the five variants, so normalising it later is a reviewed diff rather than an accident. Verified by perturbation: dropping the `file.display()` interpolation from `SelfInclusion` fails the test. --- src/preproc_tests.rs | 74 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/src/preproc_tests.rs b/src/preproc_tests.rs index 162278bea..8277c3500 100644 --- a/src/preproc_tests.rs +++ b/src/preproc_tests.rs @@ -846,3 +846,77 @@ fn preprocess_truncated_include_does_not_panic() { .expect("file entry must be inserted"); assert!(pf.direct_includes.is_empty()); } + +/// The `Display` impl is the only rendering of these diagnostics a user +/// ever sees — the CLI writes it to stderr and `bca-web` may surface it +/// verbatim — so each variant's text is pinned exactly rather than +/// probed with `contains`. Asserting the whole string is what catches a +/// dropped path interpolation or a lost prefix; a substring check passes +/// against both. +/// +/// Note the deliberate `Warning:` / `warning:` split recorded here: three +/// variants capitalise and two do not. That is the shipped text as of +/// this commit, pinned so a normalisation is a visible, reviewed diff +/// rather than an accident. +#[test] +fn preproc_diagnostic_display_renders_each_variant() { + assert_eq!( + PreprocDiagnostic::SelfInclusion { + file: PathBuf::from("inc/self ref.h"), + } + .to_string(), + "Warning: possible self inclusion inc/self ref.h", + ); + + assert_eq!( + PreprocDiagnostic::NonUtf8CyclePath { + path: "bad/\u{fffd}.h".to_owned(), + } + .to_string(), + "warning: skipping non-UTF-8 path in include cycle: bad/\u{fffd}.h", + ); + + assert_eq!( + PreprocDiagnostic::NonUtf8IndirectInclude { + path: "bad/\u{fffd}.h".to_owned(), + } + .to_string(), + "warning: skipping non-UTF-8 indirect include path: bad/\u{fffd}.h", + ); + + assert_eq!( + PreprocDiagnostic::NotPreprocessed { + file: PathBuf::from("vendor/unseen.h"), + } + .to_string(), + "Warning: included file which has not been preprocessed: vendor/unseen.h", + ); +} + +/// `IncludeCycle` is the only multi-line variant: a header line plus one +/// quoted line per member, each newline-terminated. The member list here +/// is deliberately unsorted and contains a path with a space — the +/// quoting exists precisely so that whitespace stays visible, and a +/// member set of one could not distinguish "prints every member" from +/// "prints the first". +#[test] +fn preproc_diagnostic_display_lists_every_cycle_member() { + let rendered = PreprocDiagnostic::IncludeCycle { + members: vec!["z.h".to_owned(), "a b.h".to_owned(), "m.h".to_owned()], + } + .to_string(); + + assert_eq!( + rendered, + "Warning: possible include cycle:\n - \"z.h\"\n - \"a b.h\"\n - \"m.h\"\n", + ); + + let empty = PreprocDiagnostic::IncludeCycle { + members: Vec::new(), + } + .to_string(); + assert_eq!( + empty, "Warning: possible include cycle:\n", + "an empty member list still renders the header and nothing else" + ); +} From 2571b4d2777ee72bf0774332f481cff0a5eb73dd Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Mon, 3 Aug 2026 12:47:21 -0700 Subject: [PATCH 25/27] test(vcs): pin the blame commit-lookup retry gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `is_transient_object_miss` decides whether the post-blame commit lookup is retried, and was not executed by any test. Getting it wrong is silent in both directions: too narrow and the #579 ODB race resurfaces as a hard failure, too wide and a genuinely-absent or wrong-kind object burns the whole retry budget before reporting what was actually wrong. The predicate is a two-level `matches!`, so supplying only the retryable value would pass against `|_| true`. Each rejectable shape is constructed too, one per arm the pattern can reject: the sibling `Find` variant for the inner arm and `Convert` for the outer. Verified by perturbation in both directions — the test fails against a predicate hardwired to `true` and against one hardwired to `false`. --- src/vcs/git/blame_tests.rs | 44 +++++++++++++++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/src/vcs/git/blame_tests.rs b/src/vcs/git/blame_tests.rs index 025aad60c..bdc0551c9 100644 --- a/src/vcs/git/blame_tests.rs +++ b/src/vcs/git/blame_tests.rs @@ -4,7 +4,9 @@ use std::cell::Cell; -use super::{LineRun, LineSpan, MAX_BLAME_ATTEMPTS, ObjectId, retry_transient}; +use super::{ + LineRun, LineSpan, MAX_BLAME_ATTEMPTS, ObjectId, is_transient_object_miss, retry_transient, +}; /// Drive `retry_transient` with a synthetic closure whose i-th call /// returns `outcomes[i]` (and counts calls), treating every `Err` as @@ -259,3 +261,43 @@ fn a_session_handle_carries_the_object_cache() { "a blame session must set an object cache on its thread-local handle" ); } + +/// `is_transient_object_miss` decides whether the post-blame commit +/// lookup is retried. Getting it wrong is silent in both directions: too +/// narrow and the #579 race resurfaces as a hard failure, too wide and a +/// genuinely-missing or wrong-kind object burns the whole retry budget +/// before reporting what was actually wrong. +/// +/// The predicate is a two-level `matches!`, so a test that only supplied +/// the retryable value would pass against `|_| true`. Each non-retryable +/// shape is therefore constructed too, one per arm the pattern can +/// reject: the sibling `Find` variant (inner arm) and `Convert` (outer +/// arm). +#[test] +fn only_a_not_found_commit_lookup_is_retried() { + use gix::object::find::existing::Error as ExistingError; + use gix::object::find::existing::with_conversion::Error as LookupError; + + let oid = ObjectId::null(gix::hash::Kind::Sha1); + + assert!( + is_transient_object_miss(&LookupError::Find(ExistingError::NotFound { oid })), + "a NotFound miss is the transient ODB race (#579) and must be retried" + ); + + assert!( + !is_transient_object_miss(&LookupError::Find(ExistingError::Find( + "odb handle is closed".into() + ))), + "a lower-level Find failure is deterministic — retrying it only delays the error" + ); + + assert!( + !is_transient_object_miss(&LookupError::Convert(gix::object::try_into::Error { + actual: gix::objs::Kind::Tree, + expected: gix::objs::Kind::Commit, + id: oid, + })), + "an object that is not a commit will never become one; retrying cannot help" + ); +} From c04fff8c1c90697eb38c3f01370b0379d18f2fe6 Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Mon, 3 Aug 2026 13:23:46 -0700 Subject: [PATCH 26/27] test(preproc): name the Display test for what it covers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `preproc_diagnostic_display_renders_each_variant` asserted four of the five variants — the multi-line `IncludeCycle` needs a different assertion shape and lives in the sibling test — while its doc claimed each variant was pinned. A name that overstates coverage is how a gap gets read as a guard. Renamed to `…_renders_each_single_line_variant`, with the doc pointing at the sibling test and scoping the `Warning:` / `warning:` note to all five variants taken together, which is where that three-versus-two count actually holds. Name and comment only; both tests are unchanged and still pass. --- src/preproc_tests.rs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/preproc_tests.rs b/src/preproc_tests.rs index 8277c3500..36996bf3b 100644 --- a/src/preproc_tests.rs +++ b/src/preproc_tests.rs @@ -854,12 +854,17 @@ fn preprocess_truncated_include_does_not_panic() { /// dropped path interpolation or a lost prefix; a substring check passes /// against both. /// -/// Note the deliberate `Warning:` / `warning:` split recorded here: three -/// variants capitalise and two do not. That is the shipped text as of -/// this commit, pinned so a normalisation is a visible, reviewed diff -/// rather than an accident. +/// This covers the four single-line variants. The multi-line +/// `IncludeCycle` is pinned by +/// `preproc_diagnostic_display_lists_every_cycle_member` below, which +/// needs a different assertion shape. +/// +/// Note the deliberate `Warning:` / `warning:` split across the five +/// variants taken together: three capitalise and two do not. That is +/// the shipped text as of this commit, pinned so a normalisation is a +/// visible, reviewed diff rather than an accident. #[test] -fn preproc_diagnostic_display_renders_each_variant() { +fn preproc_diagnostic_display_renders_each_single_line_variant() { assert_eq!( PreprocDiagnostic::SelfInclusion { file: PathBuf::from("inc/self ref.h"), From 490e048a599019edc8b1e2709e9989b0a1bf13f8 Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Mon, 3 Aug 2026 13:37:00 -0700 Subject: [PATCH 27/27] fix(preproc): lowercase every diagnostic warning prefix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `PreprocDiagnostic` shipped two spellings: `SelfInclusion`, `IncludeCycle` and `NotPreprocessed` capitalised `Warning:` while the two non-UTF-8 variants did not. `bca preproc` prints all five verbatim via `eprintln!("{diagnostic}")`, so both reached users from one command. The capitalised three also matched no other CLI diagnostic: `warn()` has rendered lowercase `warning:` since #609, alongside `note:`. Lowercase is therefore the convention, not a coin toss. Chosen downward rather than upward on purpose. `warn(msg)` expands to `eprintln!("warning: {msg}")`, so with the prefix lowercased the current bare `eprintln!` and a future `warn()` call emit identical bytes — moving the prefix out of `Display` and onto the CLI's severity ladder becomes a pure refactor with no output diff. Flipping upward would have made these five the only capitalised warnings in the CLI and forced the same decision again later. Not a breaking change: STABILITY.md holds `Display` impls stable but explicitly excludes their exact wording. `output/csv.rs` and `output/offenders.rs` still self-prefix capitalised; that and the `warn()` routing are left to the follow-up issue. --- CHANGELOG.md | 13 +++++++++++++ src/preproc.rs | 6 +++--- src/preproc_tests.rs | 20 ++++++++++++-------- 3 files changed, 28 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d91fcc69c..74bba396b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -183,6 +183,19 @@ for historical reference. ### Changed +- `PreprocDiagnostic`'s `Display` output now uses a lowercase + `warning:` prefix for all five variants. `SelfInclusion`, + `IncludeCycle` and `NotPreprocessed` previously capitalised it while + the two non-UTF-8 variants did not, so `bca preproc` emitted both + spellings — and the capitalised three matched no other CLI + diagnostic, since `warn()` has printed lowercase `warning:` since + #609. Per `STABILITY.md`, `Display` impls are stable but their exact + wording is not, so this is not a breaking change. It is also + deliberately byte-compatible with routing these through `warn()` + later: `warn(msg)` renders `warning: {msg}`, so moving the prefix out + of `Display` will produce identical output rather than a second + user-visible change. + - This project's own `nargs` limit converges from 7 to the shipped default of 5 (#1183). Repository configuration only — no library or CLI behaviour changes. The convergence was declined twice before, both diff --git a/src/preproc.rs b/src/preproc.rs index 4a0acd0f0..47725f733 100644 --- a/src/preproc.rs +++ b/src/preproc.rs @@ -77,10 +77,10 @@ impl std::fmt::Display for PreprocDiagnostic { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::SelfInclusion { file } => { - write!(f, "Warning: possible self inclusion {}", file.display()) + write!(f, "warning: possible self inclusion {}", file.display()) } Self::IncludeCycle { members } => { - writeln!(f, "Warning: possible include cycle:")?; + writeln!(f, "warning: possible include cycle:")?; for member in members { // Explicit quotes preserve whitespace visibility for // paths that contain spaces — important when the cycle @@ -101,7 +101,7 @@ impl std::fmt::Display for PreprocDiagnostic { ), Self::NotPreprocessed { file } => write!( f, - "Warning: included file which has not been preprocessed: {}", + "warning: included file which has not been preprocessed: {}", file.display() ), } diff --git a/src/preproc_tests.rs b/src/preproc_tests.rs index 36996bf3b..c01b55d50 100644 --- a/src/preproc_tests.rs +++ b/src/preproc_tests.rs @@ -859,10 +859,14 @@ fn preprocess_truncated_include_does_not_panic() { /// `preproc_diagnostic_display_lists_every_cycle_member` below, which /// needs a different assertion shape. /// -/// Note the deliberate `Warning:` / `warning:` split across the five -/// variants taken together: three capitalise and two do not. That is -/// the shipped text as of this commit, pinned so a normalisation is a -/// visible, reviewed diff rather than an accident. +/// All five variants use the lowercase `warning:` prefix, matching the +/// CLI's own severity ladder in `big-code-analysis-cli/src/diag.rs` +/// (#609). That is load-bearing rather than cosmetic: `warn(msg)` is +/// `eprintln!("warning: {msg}")`, and `bca preproc` currently emits +/// these through a bare `eprintln!("{diagnostic}")`, so the two +/// spellings now produce identical bytes. Moving the prefix out of +/// `Display` and onto `warn()` is therefore a pure refactor — these +/// assertions are what will prove it emitted nothing new. #[test] fn preproc_diagnostic_display_renders_each_single_line_variant() { assert_eq!( @@ -870,7 +874,7 @@ fn preproc_diagnostic_display_renders_each_single_line_variant() { file: PathBuf::from("inc/self ref.h"), } .to_string(), - "Warning: possible self inclusion inc/self ref.h", + "warning: possible self inclusion inc/self ref.h", ); assert_eq!( @@ -894,7 +898,7 @@ fn preproc_diagnostic_display_renders_each_single_line_variant() { file: PathBuf::from("vendor/unseen.h"), } .to_string(), - "Warning: included file which has not been preprocessed: vendor/unseen.h", + "warning: included file which has not been preprocessed: vendor/unseen.h", ); } @@ -913,7 +917,7 @@ fn preproc_diagnostic_display_lists_every_cycle_member() { assert_eq!( rendered, - "Warning: possible include cycle:\n - \"z.h\"\n - \"a b.h\"\n - \"m.h\"\n", + "warning: possible include cycle:\n - \"z.h\"\n - \"a b.h\"\n - \"m.h\"\n", ); let empty = PreprocDiagnostic::IncludeCycle { @@ -921,7 +925,7 @@ fn preproc_diagnostic_display_lists_every_cycle_member() { } .to_string(); assert_eq!( - empty, "Warning: possible include cycle:\n", + empty, "warning: possible include cycle:\n", "an empty member list still renders the header and nothing else" ); }