From 4fe8b203de1d192b9a62d2c00393e9ce31418acb Mon Sep 17 00:00:00 2001 From: Chuyue Wang Date: Tue, 15 Sep 2026 18:13:56 -0400 Subject: [PATCH 1/3] chore: enforce Rust production file size limits Signed-off-by: Chuyue Wang --- .pre-commit-config.yaml | 16 ++ .rust-file-sizes.json | 25 +++ CONTRIBUTING.md | 59 ++++++ scripts/check_rust_file_sizes.py | 220 ++++++++++++++++++++ scripts/tests/test_rust_file_sizes.py | 285 ++++++++++++++++++++++++++ 5 files changed, 605 insertions(+) create mode 100644 .rust-file-sizes.json create mode 100644 scripts/check_rust_file_sizes.py create mode 100644 scripts/tests/test_rust_file_sizes.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7ad91719..cd198668 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -28,6 +28,22 @@ repos: - repo: local hooks: + - id: rust-file-sizes + name: Rust production file sizes + language: python + entry: python scripts/check_rust_file_sizes.py + additional_dependencies: &rust_size_dependencies + - tree-sitter==0.25.2 + - tree-sitter-rust==0.24.2 + pass_filenames: false + always_run: true + - id: rust-file-size-tests + name: Rust file-size checker tests + language: python + entry: python -m unittest discover -s scripts/tests -p test_rust_file_sizes.py + additional_dependencies: *rust_size_dependencies + files: '^(scripts/(check_rust_file_sizes.py|tests/test_rust_file_sizes.py)|\.rust-file-sizes.json|\.pre-commit-config.yaml)$' + pass_filenames: false - id: rustfmt name: rustfmt language: system diff --git a/.rust-file-sizes.json b/.rust-file-sizes.json new file mode 100644 index 00000000..93e6012a --- /dev/null +++ b/.rust-file-sizes.json @@ -0,0 +1,25 @@ +{ + "version": 1, + "baseline": { + "crates/agentic-server-core/src/executor/accumulator/mod.rs": 706, + "crates/agentic-server-core/src/executor/accumulator/slot.rs": 679, + "crates/agentic-server-core/src/executor/compaction.rs": 530, + "crates/agentic-server-core/src/executor/engine.rs": 823, + "crates/agentic-server-core/src/executor/gateway.rs": 653, + "crates/agentic-server-core/src/executor/messages_stream.rs": 520, + "crates/agentic-server-core/src/executor/session.rs": 508, + "crates/agentic-server-core/src/storage/schema.rs": 552, + "crates/agentic-server-core/src/tool/registry.rs": 515, + "crates/agentic-server-core/src/tool/tool_search.rs": 1764, + "crates/agentic-server-core/src/tool/web_search/mod.rs": 535, + "crates/agentic-server-core/src/types/io/input.rs": 678, + "crates/agentic-server-core/src/types/io/output.rs": 1110, + "crates/agentic-server-core/src/types/request_response.rs": 582, + "crates/agentic-server-core/src/types/tools/params.rs": 537, + "crates/agentic-server/src/agentic_process.rs": 558, + "crates/agentic-server/src/auth.rs": 799, + "crates/agentic-server/src/handler/websocket/responses.rs": 864 + }, + "exceptions": {}, + "generated": {} +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 357e7841..a03e7846 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -79,6 +79,65 @@ Code style is enforced by `rustfmt` and `clippy` via pre-commit. Key settings: Do not worry about manually formatting code -- the pre-commit hooks will handle it. +### Rust production file sizes + +Prefer production modules below 300 lines; 300–500 lines is reasonable for one +clear responsibility. The `rust-file-sizes` pre-commit hook enforces a maximum of +500 physical production lines for new files. Existing oversized files have +explicit caps in `.rust-file-sizes.json`, tracked for cleanup in +[#312](https://github.com/vllm-project/agentic-api/issues/312). Split files by +responsibility while preserving architecture boundaries, rather than moving +arbitrary lines to satisfy the check. + +Run the same check used by CI: + +```bash +pre-commit run rust-file-sizes --all-files +pre-commit run rust-file-size-tests --all-files +# Show the hook's success summary: +pre-commit run rust-file-sizes --all-files --verbose +``` + +The checker scans all Git-tracked `.rs` files on every invocation, including +staged additions. Stage new files before checking them. Full scans also catch +deleted/renamed files and policy changes. The hook's pinned Python/Rust-parser +dependencies are installed once by pre-commit and reused; a check neither builds +the Rust workspace nor accesses the network. The existing Pre-commit workflow +runs these hooks with `--all-files`. + +Counting rules: + +- Count physical lines, including comments and blanks, with or without a final + newline. CRLF and LF have the same count. +- Parse Rust syntax with Tree-sitter; exclude `#[cfg(test)]` items and their + attributes, nested test modules/items/statements, inner `#![cfg(test)]`, and + built-in `#[test]`/`#[bench]` items. A test-only item can appear anywhere in a file. + `all`/`any`/`not` predicates are excluded only when they require `test`; unknown + feature/platform configurations remain production code. +- Only remove an entire physical line from the production count when no + non-whitespace source remains outside test-only ranges. A mixed line counts as + both production and test. Comments/blanks outside those ranges count as + production. Test lines have no size limit. +- Exclude files named `tests.rs` and files under a `tests`, `benches`, or `examples` + directory. These names are reserved for dedicated non-production source. +- Macros, including `cfg_attr` and attribute macros, are not expanded. Their + source is conservatively counted unless enclosed in a recognized test-only item. + Rust parser errors fail the check instead of undercounting. +- Generated Rust requires an exact path in `generated`, with a nonempty string + identifying its generator and why it is excluded. Directory globs and generated + markers in source do not grant exclusions. Stale or conflicting entries fail. + +When a baselined file shrinks, lower its cap to the reported production count in +the same commit; remove the entry at 500 lines or fewer. Delete or rename its +policy entry when deleting or renaming the file. The checker reports the required +update and never rewrites the baseline. Do not add or increase baseline caps to +accommodate growth. A justified cohesion-based exception belongs in `exceptions` +as an exact path mapped to `{"limit": 550, "reason": "Specific rationale and review/issue reference"}`. +Keep any existing baseline cap so the exception remains explicit. Exceptions +must exceed the normal allowance and be removed when no longer needed. +Policy changes, including the initial baseline and every exception, require code +review; the checker validates policy contents, not GitHub approval state. + ## Reporting Issues Use the issue templates provided on the diff --git a/scripts/check_rust_file_sizes.py b/scripts/check_rust_file_sizes.py new file mode 100644 index 00000000..c8541e3f --- /dev/null +++ b/scripts/check_rust_file_sizes.py @@ -0,0 +1,220 @@ +"""Enforce production Rust file sizes without building or expanding macros.""" + +import argparse +import json +import os +import subprocess +import sys +from pathlib import Path +from typing import NamedTuple + +from tree_sitter import Language, Parser, Query, QueryCursor +import tree_sitter_rust + + +LIMIT = 500 +POLICY = ".rust-file-sizes.json" +COMMENTS = {"line_comment", "block_comment"} +RUST = Language(tree_sitter_rust.language()) +PARSER = Parser(RUST) +ATTRIBUTES = Query(RUST, "[(attribute_item) (inner_attribute_item)] @attribute") + + +class Counts(NamedTuple): + production: int + tests: int + total: int + + +def cfg_value(tokens, test): + """Evaluate only test; other configurations remain unknown, never disabled.""" + tokens = [node for node in tokens if node.type not in COMMENTS] + if len(tokens) == 1 and tokens[0].type == "identifier": + return test if tokens[0].text == b"test" else None + if len(tokens) != 2 or tokens[1].type != "token_tree": + return None + name, arguments = tokens + groups = [[]] + for node in arguments.children[1:-1]: + if node.type == ",": + groups.append([]) + elif node.type not in COMMENTS: + groups[-1].append(node) + if not groups[-1]: + groups.pop() # Empty argument list or a trailing comma. + values = [cfg_value(group, test) for group in groups] + if name.text == b"all": + return False if False in values else (True if all(v is True for v in values) else None) + if name.text == b"any": + return True if True in values else (False if all(v is False for v in values) else None) + if name.text == b"not" and len(values) == 1 and values[0] is not None: + return not values[0] + return None + + +def test_attribute(node): + attribute = next((child for child in node.named_children if child.type == "attribute"), None) + if attribute is None: + return False + parts = [child for child in attribute.named_children if child.type not in COMMENTS] + if len(parts) == 1 and parts[0].text in (b"test", b"bench"): + return True + if len(parts) != 2 or parts[0].text != b"cfg" or parts[1].type != "token_tree": + return False + tokens = parts[1].children[1:-1] + return cfg_value(tokens, False) is False and cfg_value(tokens, True) is not False + + +def attributed_start(node): + start = node.start_byte + previous = node.prev_named_sibling + while previous is not None and previous.type in COMMENTS | {"attribute_item"}: + if previous.type == "attribute_item": + start = previous.start_byte + previous = previous.prev_named_sibling + return start + + +def test_ranges(node): + """Yield complete test-only syntax ranges, including their outer attributes.""" + for attribute in QueryCursor(ATTRIBUTES).captures(node).get("attribute", []): + if not test_attribute(attribute): + continue + if attribute.type == "inner_attribute_item": + owner = attribute.parent + if owner.parent is not None and owner.parent.child_by_field_name("body") == owner: + owner = owner.parent + yield attributed_start(owner), owner.end_byte + continue + owner = attribute.next_named_sibling + while owner is not None and owner.type in COMMENTS | {"attribute_item"}: + owner = owner.next_named_sibling + if owner is None: + raise ValueError("test attribute has no following Rust item") + end = owner.end_byte + if owner.next_sibling is not None and owner.next_sibling.type in {",", ";"}: + end = owner.next_sibling.end_byte + yield attributed_start(attribute), end + + +def count_source(source): + source.decode("utf-8") # Fail explicitly on malformed source encoding. + tree = PARSER.parse(source) + if tree.root_node.has_error: + raise ValueError("cannot parse Rust; fix syntax or update the pinned Rust grammar") + remaining = bytearray(source) + test_rows = set() + for start, end in test_ranges(tree.root_node): + first = source.count(b"\n", 0, start) + last = source.count(b"\n", 0, end - 1) + test_rows.update(range(first, last + 1)) + remaining[start:end] = bytes(10 if byte == 10 else 32 for byte in source[start:end]) + lines = bytes(remaining).split(b"\n") if source else [] + if source.endswith(b"\n"): + lines.pop() + production = sum(row not in test_rows or bool(line.strip()) for row, line in enumerate(lines)) + return Counts(production, len(test_rows), len(lines)) + + +def dedicated_test(path): + parts = Path(path).parts + return bool({"tests", "benches", "examples"}.intersection(parts)) or parts[-1] == "tests.rs" + + +def unique_keys(pairs): + result = {} + for key, value in pairs: + if key in result: + raise ValueError(f"duplicate policy key: {key}") + result[key] = value + return result + + +def load_policy(root, paths): + policy = json.loads((root / POLICY).read_text(encoding="utf-8"), object_pairs_hook=unique_keys) + if not isinstance(policy, dict) or set(policy) != {"version", "baseline", "exceptions", "generated"}: + raise ValueError("policy must contain version, baseline, exceptions, and generated") + if type(policy["version"]) is not int or policy["version"] != 1: + raise ValueError("unsupported policy version") + for section in ("baseline", "exceptions", "generated"): + entries = policy[section] + if not isinstance(entries, dict): + raise ValueError(f"{section} must be an object") + for path, entry in entries.items(): + if path not in paths or dedicated_test(path): + raise ValueError(f"{path}: stale {section} entry; remove or update the path") + if section == "baseline": + if type(entry) is not int or entry <= LIMIT: + raise ValueError(f"{path}: baseline must be an integer above {LIMIT}") + elif section == "exceptions": + if not isinstance(entry, dict) or set(entry) != {"limit", "reason"}: + raise ValueError(f"{path}: exception requires limit and reason") + if type(entry["limit"]) is not int or entry["limit"] <= policy["baseline"].get(path, LIMIT): + raise ValueError(f"{path}: exception limit must exceed its normal allowance") + if not isinstance(entry["reason"], str) or not entry["reason"].strip(): + raise ValueError(f"{path}: exception requires a documented reason") + elif not isinstance(entry, str) or not entry.strip(): + raise ValueError(f"{path}: generated exclusion requires a generator/reason") + overlap = set(policy["generated"]) & (set(policy["baseline"]) | set(policy["exceptions"])) + if overlap: + raise ValueError(f"generated exclusions also have limits: {', '.join(sorted(overlap))}") + return policy + + +def check(root, report=False): + listed = subprocess.check_output(["git", "ls-files", "-z", "--", "*.rs"], cwd=root) + paths = sorted({os.fsdecode(path) for path in listed.split(b"\0") if path}) + policy = load_policy(root, paths) + errors = [] + checked = 0 + for path in paths: + if dedicated_test(path): + continue + file = root / path + if file.is_symlink() or not file.is_file(): + errors.append(f"{path}: expected a regular file; remove stale policy entries when deleting files") + continue + if path in policy["generated"]: + continue + try: + counts = count_source(file.read_bytes()) + except (OSError, ValueError) as error: + errors.append(f"{path}: {error}") + continue + checked += 1 + baseline = policy["baseline"].get(path) + exception = policy["exceptions"].get(path) + allowed = exception["limit"] if exception else (baseline or LIMIT) + if report: + print(f"{path}: {counts.production} production, {counts.tests} test, {counts.total} total; limit {allowed}") + if counts.production > allowed: + errors.append( + f"{path}: {counts.production} production lines, limit {allowed}; " + "split by responsibility or request a reviewed exception with a reason" + ) + if baseline is not None and counts.production < baseline: + action = "remove its baseline entry" if counts.production <= LIMIT else f"lower its baseline to {counts.production}" + errors.append(f"{path}: {counts.production} production lines, baseline {baseline}; {action}") + if exception and counts.production <= (baseline or LIMIT): + errors.append(f"{path}: {counts.production} production lines; remove the unused exception") + for error in errors: + print(error, file=sys.stderr) + if not errors: + print(f"Rust file sizes: {checked} production files checked (limit {LIMIT}; explicit baseline/exceptions).") + return bool(errors) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[1]) + parser.add_argument("--report", action="store_true", help="Print counts without modifying the policy") + args = parser.parse_args() + try: + return int(check(args.root, args.report)) + except (OSError, ValueError, subprocess.CalledProcessError) as error: + print(f"Rust file sizes: {error}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/tests/test_rust_file_sizes.py b/scripts/tests/test_rust_file_sizes.py new file mode 100644 index 00000000..cfec5834 --- /dev/null +++ b/scripts/tests/test_rust_file_sizes.py @@ -0,0 +1,285 @@ +"""Exercise Rust syntax counting and the actual Git-backed checker command.""" + +import json +import runpy +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + + +CHECKER = Path(__file__).resolve().parents[1] / "check_rust_file_sizes.py" +COUNT = runpy.run_path(str(CHECKER))["count_source"] + + +class CountingTests(unittest.TestCase): + def test_physical_lines_include_comments_blanks_and_final_line(self): + for source, expected in [(b"", 0), (b"\n", 1), (b"// comment\n\nfn f() {}", 3), (b"// c\r\n\r\n", 2)]: + with self.subTest(source=source): + self.assertEqual(COUNT(source), (expected, 0, expected)) + + def test_test_module_does_not_hide_later_production(self): + source = b'''// production comment +#[cfg(test)] +mod tests { + /* nested { /* } */ comment */ + fn check() { let s = r###" } { #[cfg(test)] "###; } +} + +fn production() {} +''' + self.assertEqual(COUNT(source), (3, 5, 8)) + + def test_nested_test_items_and_attributes(self): + source = b'''mod production { + #[allow(dead_code)] + #[cfg(test)] + // belongs to the following item + fn check() {} + fn live() {} +} +''' + self.assertEqual(COUNT(source), (3, 4, 7)) + + def test_same_line_production_is_counted(self): + for source in [b"#[cfg(test)] fn test() {} fn live() {}", b"fn live() {} #[cfg(test)] fn test() {}"]: + self.assertEqual(COUNT(source), (1, 1, 1)) + + def test_two_test_items_can_share_a_line(self): + self.assertEqual(COUNT(b"#[cfg(test)] fn one() {} #[cfg(test)] fn two() {}"), (0, 1, 1)) + + def test_inner_test_configuration(self): + self.assertEqual(COUNT(b"#![cfg(test)]\n\nfn test() {}\n"), (0, 3, 3)) + self.assertEqual(COUNT(b"mod tests {\n#![cfg(test)]\nfn test() {}\n}\nfn live() {}\n"), (1, 4, 5)) + + def test_inner_configuration_excludes_the_items_outer_attributes(self): + source = b"#[allow(dead_code)]\nmod tests {\n#![cfg(test)]\nfn only_test() {}\n}\nfn live() {}\n" + self.assertEqual(COUNT(source), (1, 5, 6)) + + def test_test_predicates_are_evaluated_conservatively(self): + cases = { + "test": 0, + "all(test, feature = \"fixture\")": 0, + "all(/* x */ test, any(unix, windows),)": 0, + "not(not(test))": 0, + "any(test, unix)": 2, + "not(test)": 2, + "feature = \"test\"": 2, + "all(test, not(test))": 2, + "any()": 2, + "all()": 2, + } + for predicate, expected in cases.items(): + with self.subTest(predicate=predicate): + self.assertEqual(COUNT(f"#[cfg({predicate})]\nfn f() {{}}\n".encode()).production, expected) + + def test_cfg_attr_and_macro_bodies_are_not_expanded(self): + self.assertEqual(COUNT(b"#[cfg_attr(not(test), cfg(any()))]\nfn f() {}\n").production, 2) + self.assertEqual(COUNT(b"macro_rules! m { () => { #[cfg(test)] fn f() {} }; }\n").production, 1) + + def test_test_and_bench_attributes(self): + for attribute in ["test", "bench"]: + self.assertEqual(COUNT(f"#[{attribute}]\nfn f() {{}}\n".encode()), (0, 2, 2)) + + def test_test_statements_and_unicode(self): + source = '// 雪\nfn f() {\n #[cfg(test)]\n let test_only = "雪";\n}\n' + self.assertEqual(COUNT(source.encode()), (3, 2, 5)) + + def test_strings_and_comments_do_not_create_test_attributes(self): + source = b'''/* #[cfg(test)] mod tests { } */ +fn live() { + let s = "#[cfg(test)] mod tests { }"; + let ch = '}'; +} +''' + self.assertEqual(COUNT(source), (5, 0, 5)) + + def test_cfg_fields_and_variants_include_their_separator(self): + for source in [b"struct S {\n#[cfg(test)]\nx: u8,\ny: u8,\n}\n", b"enum E {\n#[cfg(test)]\nTest,\nLive,\n}\n"]: + self.assertEqual(COUNT(source), (3, 2, 5)) + + def test_raw_identifier_borrow_and_multiline_strings_parse(self): + source = b'''fn live(raw: String) { + let borrowed = &raw; + let s = r###" } +#[cfg(test)] +mod tests { fake } +"###; +} +''' + self.assertEqual(COUNT(source), (7, 0, 7)) + + def test_malformed_source_fails_instead_of_undercounting(self): + for source in [b"fn broken( {", b"\xff"]: + with self.subTest(source=source), self.assertRaises(ValueError): + COUNT(source) + + +class CommandTests(unittest.TestCase): + def setUp(self): + self.directory = tempfile.TemporaryDirectory() + self.addCleanup(self.directory.cleanup) + self.root = Path(self.directory.name) + self.policy = {"version": 1, "baseline": {}, "exceptions": {}, "generated": {}} + self.git("init", "-q") + + def git(self, *args): + return subprocess.run(["git", *args], cwd=self.root, check=True, capture_output=True) + + def source(self, name="src/main.rs", lines=500, tracked=True): + path = self.root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("fn f() {}\n" + "// comment\n" * (lines - 1), encoding="utf-8") + if tracked: + self.git("add", "--", name) + return path + + def check(self, expected, text="", raw=None): + policy = self.root / ".rust-file-sizes.json" + policy.write_text(json.dumps(self.policy) if raw is None else raw, encoding="utf-8") + before = policy.read_bytes() + result = subprocess.run( + [sys.executable, str(CHECKER), "--root", str(self.root), "--report"], + capture_output=True, text=True, + ) + self.assertEqual(result.returncode, expected, result.stdout + result.stderr) + self.assertIn(text, result.stdout + result.stderr) + self.assertEqual(policy.read_bytes(), before, "checker must not change the baseline") + return result + + def test_new_500_passes_and_501_fails_through_command(self): + self.source() + self.check(0, "500 production") + self.source(lines=501) + self.check(1, "501 production lines, limit 500") + + def test_baseline_accepts_exact_size_and_rejects_growth(self): + self.policy["baseline"]["src/main.rs"] = 520 + self.source(lines=520) + self.check(0, "limit 520") + self.source(lines=521) + self.check(1, "521 production lines, limit 520") + + def test_reduction_requires_ratchet_and_eventual_removal(self): + self.policy["baseline"]["src/main.rs"] = 520 + self.source(lines=510) + self.check(1, "lower its baseline to 510") + self.policy["baseline"]["src/main.rs"] = 510 + self.check(0) + self.source(lines=500) + self.check(1, "remove its baseline entry") + self.policy["baseline"].clear() + self.check(0) + + def test_inline_tests_do_not_inflate_command_count(self): + path = self.source(lines=500) + with path.open("a") as output: + output.write("#[cfg(test)]\nmod tests {\n" + "// test\n" * 600 + "}\n") + self.check(0, "500 production, 603 test") + with path.open("a") as output: + output.write("fn later_production() {}\n") + self.check(1, "501 production lines, limit 500") + + def test_dedicated_tests_benches_examples_are_excluded(self): + for name in ["tests/large.rs", "src/tests.rs", "src/tests/helpers.rs", "benches/large.rs", "examples/large.rs"]: + self.source(name, 700) + self.check(0, "0 production files checked") + + def test_generated_exclusion_is_exact_and_requires_reason(self): + self.source("src/generated.rs", 900) + self.policy["generated"]["src/generated.rs"] = "Generated by schema compiler; do not edit." + self.check(0) + self.source("src/other.rs", 501) + self.check(1, "src/other.rs: 501") + self.policy["generated"]["src/generated.rs"] = " " + self.check(1, "requires a generator/reason") + + def test_exception_is_bounded_and_does_not_replace_baseline(self): + self.source(lines=530) + self.policy["baseline"]["src/main.rs"] = 520 + self.policy["exceptions"]["src/main.rs"] = {"limit": 530, "reason": "One cohesive protocol table; reviewed in #123."} + self.check(0) + self.source(lines=531) + self.check(1, "531 production lines, limit 530") + self.source(lines=520) + self.check(1, "remove the unused exception") + + def test_missing_reason_and_redundant_exception_fail(self): + self.source(lines=501) + self.policy["exceptions"]["src/main.rs"] = {"limit": 520, "reason": ""} + self.check(1, "documented reason") + self.policy["exceptions"]["src/main.rs"] = {"limit": 500, "reason": "reason"} + self.check(1, "must exceed its normal allowance") + + def test_new_file_exception_and_generated_limit_conflict(self): + self.source(lines=510) + self.policy["exceptions"]["src/main.rs"] = {"limit": 510, "reason": "Reviewed cohesive table."} + self.check(0) + self.policy["generated"]["src/main.rs"] = "Generated by schema tool." + self.check(1, "generated exclusions also have limits") + + def test_invalid_policy_sections_and_exception_shapes(self): + self.source(lines=510) + for value in [None, [], True]: + with self.subTest(value=value): + self.policy["generated"] = value + self.check(1, "generated must be an object") + self.policy["generated"] = {} + for entry in [None, {"limit": 510}, {"limit": 510, "reason": "ok", "extra": 1}]: + with self.subTest(entry=entry): + self.policy["exceptions"]["src/main.rs"] = entry + self.check(1, "exception requires limit and reason") + + def test_deleted_and_renamed_files_require_policy_cleanup(self): + self.source(lines=520) + self.policy["baseline"]["src/main.rs"] = 520 + self.git("mv", "src/main.rs", "src/renamed.rs") + self.check(1, "stale baseline entry") + self.policy["baseline"] = {"src/renamed.rs": 520} + self.check(0) + self.git("rm", "-f", "src/renamed.rs") + self.check(1, "stale baseline entry") + self.policy["baseline"].clear() + self.check(0) + + def test_excluded_and_missing_paths_cannot_keep_policy_entries(self): + for section, entry in [("baseline", 600), ("exceptions", {"limit": 600, "reason": "reason"}), ("generated", "generator")]: + with self.subTest(section=section): + self.source("tests/large.rs", 600) + self.policy[section] = {"tests/large.rs": entry} + self.check(1, f"stale {section} entry") + self.policy[section] = {"missing.rs": entry} + self.check(1, f"stale {section} entry") + self.policy[section].clear() + + def test_untracked_files_are_not_commits_but_staged_additions_are(self): + self.source("src/new file.rs", 501, tracked=False) + self.check(0) + self.git("add", "--", "src/new file.rs") + self.check(1, "src/new file.rs: 501") + + def test_invalid_rust_reaches_command_failure(self): + self.source().write_text("fn broken( {", encoding="utf-8") + self.check(1, "cannot parse Rust") + + def test_policy_errors_fail_with_diagnostics(self): + self.source() + for raw in ["{", "[]", '{"version":1,"version":1}', '{"unexpected":1}']: + with self.subTest(raw=raw): + self.check(1, "Rust file sizes:", raw=raw) + for value in [500, True, "600", -1]: + with self.subTest(value=value): + self.policy["baseline"]["src/main.rs"] = value + self.check(1, "baseline must be an integer above 500") + + def test_missing_or_symlink_source_is_not_silently_ignored(self): + path = self.source() + path.unlink() + self.check(1, "expected a regular file") + path.symlink_to("/missing-rust-source") + self.check(1, "expected a regular file") + + +if __name__ == "__main__": + unittest.main() From 790db2f402f4408ad69274dbcaf5ba63c2e9d41e Mon Sep 17 00:00:00 2001 From: Chuyue Wang Date: Tue, 15 Sep 2026 19:03:37 -0400 Subject: [PATCH 2/3] fix: count complete conditional Rust fragments Signed-off-by: Chuyue Wang --- CONTRIBUTING.md | 5 ++- scripts/check_rust_file_sizes.py | 9 ++-- scripts/tests/test_rust_file_sizes.py | 63 +++++++++++++++++++++++++++ 3 files changed, 72 insertions(+), 5 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a03e7846..56bd8a34 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -110,8 +110,9 @@ Counting rules: - Count physical lines, including comments and blanks, with or without a final newline. CRLF and LF have the same count. - Parse Rust syntax with Tree-sitter; exclude `#[cfg(test)]` items and their - attributes, nested test modules/items/statements, inner `#![cfg(test)]`, and - built-in `#[test]`/`#[bench]` items. A test-only item can appear anywhere in a file. + attributes, nested test modules/items/statements, fields, initializers, match + arms, inner `#![cfg(test)]`, and built-in `#[test]`/`#[bench]` functions. + A test-only item can appear anywhere in a file. `all`/`any`/`not` predicates are excluded only when they require `test`; unknown feature/platform configurations remain production code. - Only remove an entire physical line from the production count when no diff --git a/scripts/check_rust_file_sizes.py b/scripts/check_rust_file_sizes.py index c8541e3f..82906210 100644 --- a/scripts/check_rust_file_sizes.py +++ b/scripts/check_rust_file_sizes.py @@ -86,9 +86,12 @@ def test_ranges(node): owner = owner.parent yield attributed_start(owner), owner.end_byte continue - owner = attribute.next_named_sibling - while owner is not None and owner.type in COMMENTS | {"attribute_item"}: - owner = owner.next_named_sibling + owner = attribute.parent + if owner.type not in {"match_arm", "field_initializer", "shorthand_field_initializer"}: + owner = attribute.next_named_sibling + # Tuple fields store visibility and type as separate siblings. + while owner is not None and owner.type in COMMENTS | {"attribute_item", "visibility_modifier"}: + owner = owner.next_named_sibling if owner is None: raise ValueError("test attribute has no following Rust item") end = owner.end_byte diff --git a/scripts/tests/test_rust_file_sizes.py b/scripts/tests/test_rust_file_sizes.py index cfec5834..761a7304 100644 --- a/scripts/tests/test_rust_file_sizes.py +++ b/scripts/tests/test_rust_file_sizes.py @@ -99,6 +99,53 @@ def test_cfg_fields_and_variants_include_their_separator(self): for source in [b"struct S {\n#[cfg(test)]\nx: u8,\ny: u8,\n}\n", b"enum E {\n#[cfg(test)]\nTest,\nLive,\n}\n"]: self.assertEqual(COUNT(source), (3, 2, 5)) + def test_cfg_match_arm_excludes_its_complete_value(self): + source = b'''pub fn live(a: u32) -> u32 { + match a { + #[allow(unused)] + #[cfg(test)] + 1 => { + // test only + 3 + }, + _ => 4, + } +} +''' + self.assertEqual(COUNT(source), (5, 6, 11)) + + def test_cfg_field_initializers_include_values_and_separators(self): + for field in [b"b: {\n // test only\n 3\n },", b"b,"]: + source = b'''pub struct S { pub a: u32, #[cfg(test)] pub b: u32 } +pub fn live() -> S { + #[cfg(test)] + let b = 3; + S { + #[allow(unused)] + #[cfg(test)] + ''' + field + b''' + a: 4, + } +} +''' + with self.subTest(field=field): + total = source.count(b"\n") + self.assertEqual(COUNT(source), (6, total - 5, total)) + + def test_cfg_tuple_fields_include_visibility_and_complete_type(self): + for visibility in [b"", b"pub", b"pub(crate)"]: + source = b'''pub struct Live( + #[allow(unused)] + #[cfg(test)] + ''' + visibility + b''' + (u32, + u32), + pub u64, +); +''' + with self.subTest(visibility=visibility): + self.assertEqual(COUNT(source), (3, 5, 8)) + def test_raw_identifier_borrow_and_multiline_strings_parse(self): source = b'''fn live(raw: String) { let borrowed = &raw; @@ -181,6 +228,22 @@ def test_inline_tests_do_not_inflate_command_count(self): output.write("fn later_production() {}\n") self.check(1, "501 production lines, limit 500") + def test_conditional_fragments_at_the_command_boundary(self): + cases = [ + (b"pub struct S(\n#[cfg(test)]\npub u32,\npub u64,\n);\n", 3), + (b"pub fn f(a: u32) -> u32 {\nmatch a {\n#[cfg(test)]\n1 => 3,\n_ => 4,\n}\n}\n", 5), + (b"pub struct S { pub a: u32, #[cfg(test)] pub b: u32 }\n" + b"pub fn f() -> S {\nS {\n#[cfg(test)]\nb: 3,\na: 4,\n}\n}\n", 6), + ] + for source, production in cases: + with self.subTest(source=source): + path = self.source() + path.write_bytes(b"// production\n" * (500 - production) + source) + self.check(0, "500 production") + with path.open("ab") as output: + output.write(b"// one more production line\n") + self.check(1, "501 production lines, limit 500") + def test_dedicated_tests_benches_examples_are_excluded(self): for name in ["tests/large.rs", "src/tests.rs", "src/tests/helpers.rs", "benches/large.rs", "examples/large.rs"]: self.source(name, 700) From efee1fed9089742feb56d7a9ff2c7fe369f53eb2 Mon Sep 17 00:00:00 2001 From: Chuyue Wang Date: Tue, 15 Sep 2026 19:53:40 -0400 Subject: [PATCH 3/3] fix: validate Rust baselines against prior revisions Signed-off-by: Chuyue Wang --- .github/workflows/pre-commit.yml | 3 + CONTRIBUTING.md | 25 +++- scripts/check_rust_file_sizes.py | 72 +++++++++- scripts/tests/test_rust_file_sizes.py | 191 +++++++++++++++++++++++++- 4 files changed, 277 insertions(+), 14 deletions(-) diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index 7359b99a..9120d12c 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -19,6 +19,8 @@ jobs: steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 - name: Set up Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 @@ -52,6 +54,7 @@ jobs: exit 0 env: SKIP: no-commit-to-branch + RUST_FILE_SIZE_BASE: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha || github.event.before }} - name: Check pre-commit results if: steps.precommit.outputs.status != '0' diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 56bd8a34..720745cb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -105,6 +105,22 @@ dependencies are installed once by pre-commit and reused; a check neither builds the Rust workspace nor accesses the network. The existing Pre-commit workflow runs these hooks with `--all-files`. +The local hook compares baseline entries with the committed policy at `HEAD`. +CI compares against the PR base, merge-queue base, or the previous main commit, +using `RUST_FILE_SIZE_BASE`; CI fetches full history for that comparison. +This prevents an earlier commit in a PR from hiding a baseline addition or increase. +To check a whole branch locally, select an already-fetched base: + +```bash +RUST_FILE_SIZE_BASE=upstream/main pre-commit run rust-file-sizes --all-files +``` + +The script also accepts `--base-ref`, which overrides the environment variable. +An unavailable base fails the check; fetch that revision before retrying. When +introducing the policy, initial caps may only come from production counts in +regular Rust files that already existed at the base revision. A repository's +first commit or first push has no prior allowances. + Counting rules: - Count physical lines, including comments and blanks, with or without a final @@ -130,9 +146,12 @@ Counting rules: When a baselined file shrinks, lower its cap to the reported production count in the same commit; remove the entry at 500 lines or fewer. Delete or rename its -policy entry when deleting or renaming the file. The checker reports the required -update and never rewrites the baseline. Do not add or increase baseline caps to -accommodate growth. A justified cohesion-based exception belongs in `exceptions` +policy entry when deleting or renaming the file. A rename detected by Git may +retain or lower the original file's cap; copies and other new paths cannot inherit +a baseline. The checker reports the required update and never rewrites the baseline. +Outside initial setup and detected renames, new baseline entries are rejected. +Caps cannot increase relative to the selected prior revision. +A justified cohesion-based exception belongs in `exceptions` as an exact path mapped to `{"limit": 550, "reason": "Specific rationale and review/issue reference"}`. Keep any existing baseline cap so the exception remains explicit. Exceptions must exceed the normal allowance and be removed when no longer needed. diff --git a/scripts/check_rust_file_sizes.py b/scripts/check_rust_file_sizes.py index 82906210..9f934ed3 100644 --- a/scripts/check_rust_file_sizes.py +++ b/scripts/check_rust_file_sizes.py @@ -133,8 +133,15 @@ def unique_keys(pairs): return result -def load_policy(root, paths): - policy = json.loads((root / POLICY).read_text(encoding="utf-8"), object_pairs_hook=unique_keys) +def git_bytes(root, *args): + return subprocess.check_output(["git", *args], cwd=root, stderr=subprocess.PIPE) + + +def load_policy(root, paths, revision=None): + source = (root / POLICY).read_text(encoding="utf-8") if revision is None else git_bytes( + root, "show", f"{revision}:{POLICY}" + ).decode("utf-8") + policy = json.loads(source, object_pairs_hook=unique_keys) if not isinstance(policy, dict) or set(policy) != {"version", "baseline", "exceptions", "generated"}: raise ValueError("policy must contain version, baseline, exceptions, and generated") if type(policy["version"]) is not int or policy["version"] != 1: @@ -164,11 +171,62 @@ def load_policy(root, paths): return policy -def check(root, report=False): +def prior_baselines(root, baseline, base_ref): + """Read allowances from a prior commit, never from the edited policy.""" + if base_ref in {"0" * 40, "0" * 64}: # First push: every file is new. + return {} + resolved = subprocess.run( + ["git", "rev-parse", "--verify", "--quiet", "--end-of-options", f"{base_ref}^{{commit}}"], + cwd=root, capture_output=True, + ) + if resolved.returncode: + if base_ref == "HEAD": # An unborn repository has no existing allowances. + return {} + raise ValueError(f"cannot resolve baseline base {base_ref!r}; fetch it or supply --base-ref") + revision = resolved.stdout.decode().strip() + old_files = {} + for entry in git_bytes(root, "ls-tree", "-rz", revision).split(b"\0"): + if entry: + metadata, path = entry.split(b"\t", 1) + old_files[os.fsdecode(path)] = metadata.split(b" ", 1)[0] + regular = {b"100644", b"100755"} + rust_paths = {path for path, mode in old_files.items() if path.endswith(".rs") and mode in regular} + if POLICY in old_files and old_files[POLICY] not in regular: + raise ValueError(f"{POLICY}: prior policy at {base_ref} must be a regular file") + old_policy = load_policy(root, rust_paths, revision) if POLICY in old_files else None + known = rust_paths if old_policy is None else set(old_policy["baseline"]) + renames = {} + if set(baseline) - known: + renamed = git_bytes( + root, "diff", "--name-status", "-z", "--find-renames=50%", "-l0", + "--no-ext-diff", "--no-textconv", "--diff-filter=R", revision, "--", + ).split(b"\0") + renames = { + os.fsdecode(renamed[i + 2]): os.fsdecode(renamed[i + 1]) + for i in range(0, len(renamed) - 1, 3) + } + allowances = {} + for path in baseline: + previous = renames.get(path, path) + if old_policy is not None: + allowances[path] = old_policy["baseline"].get(previous, LIMIT) + elif previous in rust_paths and not dedicated_test(previous): + # Bootstrap only from production source that existed before the policy. + source = git_bytes(root, "show", f"{revision}:{previous}") + allowances[path] = max(LIMIT, count_source(source).production) + return allowances + + +def check(root, report=False, base_ref="HEAD"): listed = subprocess.check_output(["git", "ls-files", "-z", "--", "*.rs"], cwd=root) paths = sorted({os.fsdecode(path) for path in listed.split(b"\0") if path}) policy = load_policy(root, paths) - errors = [] + prior = prior_baselines(root, policy["baseline"], base_ref) + errors = [ + f"{path}: baseline {limit} exceeds prior allowance {prior.get(path, LIMIT)} at {base_ref}; " + "keep the prior cap (or omit a new entry) and use a bounded exception with a reason for growth" + for path, limit in policy["baseline"].items() if limit > prior.get(path, LIMIT) + ] checked = 0 for path in paths: if dedicated_test(path): @@ -211,9 +269,13 @@ def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[1]) parser.add_argument("--report", action="store_true", help="Print counts without modifying the policy") + parser.add_argument( + "--base-ref", default=os.environ.get("RUST_FILE_SIZE_BASE", "HEAD"), + help="Prior commit for baseline validation (default: RUST_FILE_SIZE_BASE or HEAD)", + ) args = parser.parse_args() try: - return int(check(args.root, args.report)) + return int(check(args.root, args.report, args.base_ref)) except (OSError, ValueError, subprocess.CalledProcessError) as error: print(f"Rust file sizes: {error}", file=sys.stderr) return 1 diff --git a/scripts/tests/test_rust_file_sizes.py b/scripts/tests/test_rust_file_sizes.py index 761a7304..5d4d26f3 100644 --- a/scripts/tests/test_rust_file_sizes.py +++ b/scripts/tests/test_rust_file_sizes.py @@ -1,6 +1,7 @@ """Exercise Rust syntax counting and the actual Git-backed checker command.""" import json +import os import runpy import subprocess import sys @@ -169,6 +170,8 @@ def setUp(self): self.addCleanup(self.directory.cleanup) self.root = Path(self.directory.name) self.policy = {"version": 1, "baseline": {}, "exceptions": {}, "generated": {}} + self.environment = dict(os.environ) + self.environment.pop("RUST_FILE_SIZE_BASE", None) self.git("init", "-q") def git(self, *args): @@ -182,14 +185,25 @@ def source(self, name="src/main.rs", lines=500, tracked=True): self.git("add", "--", name) return path - def check(self, expected, text="", raw=None): + def commit(self, with_policy=True): + if with_policy: + (self.root / ".rust-file-sizes.json").write_text(json.dumps(self.policy), encoding="utf-8") + self.git("add", "--all") + self.git( + "-c", "user.name=Test", "-c", "user.email=test@example.com", + "-c", "commit.gpgsign=false", "-c", f"core.hooksPath={self.root / 'disabled-hooks'}", + "commit", "--quiet", "--allow-empty", "-s", "-m", "test: record prior policy", + ) + return self.git("rev-parse", "HEAD").stdout.decode().strip() + + def check(self, expected, text="", raw=None, base_ref=None): policy = self.root / ".rust-file-sizes.json" policy.write_text(json.dumps(self.policy) if raw is None else raw, encoding="utf-8") before = policy.read_bytes() - result = subprocess.run( - [sys.executable, str(CHECKER), "--root", str(self.root), "--report"], - capture_output=True, text=True, - ) + command = [sys.executable, str(CHECKER), "--root", str(self.root), "--report"] + if base_ref is not None: + command.extend(["--base-ref", base_ref]) + result = subprocess.run(command, capture_output=True, text=True, env=self.environment) self.assertEqual(result.returncode, expected, result.stdout + result.stderr) self.assertIn(text, result.stdout + result.stderr) self.assertEqual(policy.read_bytes(), before, "checker must not change the baseline") @@ -204,12 +218,15 @@ def test_new_500_passes_and_501_fails_through_command(self): def test_baseline_accepts_exact_size_and_rejects_growth(self): self.policy["baseline"]["src/main.rs"] = 520 self.source(lines=520) + self.commit() self.check(0, "limit 520") self.source(lines=521) self.check(1, "521 production lines, limit 520") def test_reduction_requires_ratchet_and_eventual_removal(self): self.policy["baseline"]["src/main.rs"] = 520 + self.source(lines=520) + self.commit() self.source(lines=510) self.check(1, "lower its baseline to 510") self.policy["baseline"]["src/main.rs"] = 510 @@ -219,6 +236,165 @@ def test_reduction_requires_ratchet_and_eventual_removal(self): self.policy["baseline"].clear() self.check(0) + def test_raising_the_baseline_cannot_hide_growth(self): + self.source(lines=520) + self.policy["baseline"]["src/main.rs"] = 520 + self.commit() + self.source(lines=530) + self.policy["baseline"]["src/main.rs"] = 530 + self.check(1, "baseline 530 exceeds prior allowance 520") + self.policy["baseline"]["src/main.rs"] = 520 + self.policy["exceptions"]["src/main.rs"] = {"limit": 530, "reason": "Reviewed cohesive table."} + self.check(0) + + def test_new_baselines_require_exceptions_instead(self): + self.source() + self.commit() + for path in ["src/new.rs", "src/main.rs"]: + with self.subTest(path=path): + self.source(path, 601) + self.policy["baseline"][path] = 601 + self.check(1, "baseline 601 exceeds prior allowance 500") + self.policy["baseline"].clear() + self.policy["exceptions"][path] = {"limit": 601, "reason": "Reviewed cohesive table."} + self.check(0) + + def test_ci_base_catches_growth_committed_before_the_latest_commit(self): + self.source(lines=520) + self.policy["baseline"]["src/main.rs"] = 520 + base = self.commit() + self.source(lines=530) + self.policy["baseline"]["src/main.rs"] = 530 + self.commit() # Simulate a commit made without running the local hook. + self.environment["RUST_FILE_SIZE_BASE"] = base + self.check(1, "baseline 530 exceeds prior allowance 520") + self.check(1, "baseline 530 exceeds prior allowance 520", base_ref=base) + + def test_initial_baseline_uses_only_existing_production_counts(self): + self.source(lines=520) + self.commit(with_policy=False) + self.policy["baseline"]["src/main.rs"] = 520 + self.check(0) + self.source(lines=521) + self.policy["baseline"]["src/main.rs"] = 521 + self.check(1, "baseline 521 exceeds prior allowance 520") + self.source(lines=520) + self.policy["baseline"]["src/main.rs"] = 520 + self.source("src/new.rs", 601) + self.policy["baseline"]["src/new.rs"] = 601 + self.check(1, "baseline 601 exceeds prior allowance 500") + + def test_an_unborn_repository_cannot_grant_a_baseline(self): + self.source(lines=520) + self.policy["baseline"]["src/main.rs"] = 520 + self.check(1, "baseline 520 exceeds prior allowance 500") + + def test_first_push_has_no_prior_allowances(self): + self.source() + self.commit() + self.environment["RUST_FILE_SIZE_BASE"] = "0" * 40 + self.check(0) + self.source(lines=520) + self.policy["baseline"]["src/main.rs"] = 520 + self.check(1, "baseline 520 exceeds prior allowance 500") + + def test_bootstrap_does_not_use_test_lines_as_production_allowances(self): + path = self.source() + with path.open("a") as output: + output.write("#[cfg(test)]\nmod tests {\n" + "// test\n" * 600 + "}\n") + self.commit(with_policy=False) + self.source(lines=601) + self.policy["baseline"]["src/main.rs"] = 601 + self.check(1, "baseline 601 exceeds prior allowance 500") + + def test_a_renamed_dedicated_test_cannot_bootstrap_a_production_baseline(self): + self.source("tests/large.rs", 601) + self.commit(with_policy=False) + (self.root / "src").mkdir() + self.git("mv", "tests/large.rs", "src/large.rs") + self.policy["baseline"]["src/large.rs"] = 601 + self.check(1, "baseline 601 exceeds prior allowance 500") + + def test_a_renamed_non_rust_file_cannot_bootstrap_a_baseline(self): + self.source("template.txt", 601) + self.commit(with_policy=False) + self.git("mv", "template.txt", "production.rs") + self.policy["baseline"]["production.rs"] = 601 + self.check(1, "baseline 601 exceeds prior allowance 500") + + @unittest.skipUnless(os.name == "posix", "requires a POSIX symlink target") + def test_a_prior_symlink_cannot_bootstrap_a_production_baseline(self): + path = self.source(lines=600) + path.unlink() + path.symlink_to("\n" * 600) + self.commit(with_policy=False) + path.unlink() + self.source(lines=600) + self.policy["baseline"]["src/main.rs"] = 600 + self.check(1, "baseline 600 exceeds prior allowance 500") + + def test_a_copy_cannot_reuse_an_existing_baseline(self): + self.source(lines=520) + self.policy["baseline"]["src/main.rs"] = 520 + self.commit() + self.source("src/copy.rs", 520) + self.policy["baseline"]["src/copy.rs"] = 520 + self.check(1, "baseline 520 exceeds prior allowance 500") + + def test_malformed_prior_policy_fails_instead_of_bootstrapping(self): + self.source(lines=520) + self.policy["baseline"]["src/main.rs"] = 520 + (self.root / ".rust-file-sizes.json").write_text('{"version":1,"version":1}', encoding="utf-8") + self.commit(with_policy=False) + self.check(1, "duplicate policy key") + + @unittest.skipUnless(os.name == "posix", "requires a POSIX filename") + def test_a_prior_policy_symlink_cannot_supply_its_target_name_as_policy(self): + self.source("source.rs", 520) + self.policy["baseline"] = {"source.rs": 520} + target = json.dumps({**self.policy, "baseline": {"source.rs": 900}}) + (self.root / target).write_text(json.dumps(self.policy), encoding="utf-8") + (self.root / ".rust-file-sizes.json").symlink_to(target) + self.commit(with_policy=False) + (self.root / ".rust-file-sizes.json").unlink() + self.source("source.rs", 600) + self.policy["baseline"]["source.rs"] = 600 + self.check(1, "must be a regular file") + + def test_renames_preserve_the_prior_cap_but_cannot_raise_it(self): + self.source(lines=520) + self.policy["baseline"]["src/main.rs"] = 520 + self.commit() + self.git("mv", "src/main.rs", "src/renamed file.rs") + self.policy["baseline"] = {"src/renamed file.rs": 520} + self.check(0) + self.source("src/renamed file.rs", 521) + self.policy["baseline"]["src/renamed file.rs"] = 521 + self.check(1, "baseline 521 exceeds prior allowance 520") + self.source("src/renamed file.rs", 510) + self.policy["baseline"]["src/renamed file.rs"] = 510 + self.check(0) + + def test_unavailable_base_fails_and_explicit_base_overrides_environment(self): + self.source() + base = self.commit() + self.environment["RUST_FILE_SIZE_BASE"] = "missing-base" + self.check(1, "cannot resolve baseline base") + self.check(0, base_ref=base) + + def test_a_missing_base_in_a_shallow_clone_requires_a_fetch(self): + self.source(lines=520) + self.policy["baseline"]["src/main.rs"] = 520 + base = self.commit() + self.commit() + clone = self.root / "shallow" + self.git("clone", "--quiet", "--depth", "1", self.root.as_uri(), str(clone)) + self.root = clone + self.environment["RUST_FILE_SIZE_BASE"] = base + self.check(1, "cannot resolve baseline base") + self.git("fetch", "--quiet", "--unshallow") + self.check(0) + def test_inline_tests_do_not_inflate_command_count(self): path = self.source(lines=500) with path.open("a") as output: @@ -259,8 +435,10 @@ def test_generated_exclusion_is_exact_and_requires_reason(self): self.check(1, "requires a generator/reason") def test_exception_is_bounded_and_does_not_replace_baseline(self): - self.source(lines=530) + self.source(lines=520) self.policy["baseline"]["src/main.rs"] = 520 + self.commit() + self.source(lines=530) self.policy["exceptions"]["src/main.rs"] = {"limit": 530, "reason": "One cohesive protocol table; reviewed in #123."} self.check(0) self.source(lines=531) @@ -297,6 +475,7 @@ def test_invalid_policy_sections_and_exception_shapes(self): def test_deleted_and_renamed_files_require_policy_cleanup(self): self.source(lines=520) self.policy["baseline"]["src/main.rs"] = 520 + self.commit() self.git("mv", "src/main.rs", "src/renamed.rs") self.check(1, "stale baseline entry") self.policy["baseline"] = {"src/renamed.rs": 520}