diff --git a/jenkins/scripts/cbts/rules/README.md b/jenkins/scripts/cbts/rules/README.md index 1e45663ef9ec..49dcc961af92 100644 --- a/jenkins/scripts/cbts/rules/README.md +++ b/jenkins/scripts/cbts/rules/README.md @@ -50,6 +50,11 @@ For each file under `tests/` in the diff: - file-level anchor `path` (any line lands at module scope, AST parse fails, or the file is unreadable — the latter covers .yaml/.txt/ .json/etc. data files). + Decorator lines count as part of what they decorate: `_scope_start_line` + extends each node's range up over its `decorator_list`, so editing + `@pytest.mark.parametrize` stays at method level and a class-level + `@skip_pre_blackwell` stays at class level instead of dropping to + module scope. 3. `lookup_paths_into_block_filters` calls `find_match_for_path` (bidirectional pytest-tree lineage) for each anchor; matches feed `block_filters`. For non-`test_*.py` paths, the lookup walks up @@ -58,13 +63,51 @@ For each file under `tests/` in the diff: `unittest/api_stability/references/llmapi.yaml` lifts to `unittest/api_stability/`. -`accuracy/references/*.yaml` gets a finer-grained refinement: each -top-level YAML key is a HF model name, mapped (via AST scan of -`accuracy/test_*.py` for `MODEL_NAME = ""` literals) to test -classes. A diff under `meta-llama/Llama-3.1-8B-Instruct:` narrows to -`accuracy/test_*.py::TestLlama3_1_8BInstruct` rather than the whole -`accuracy/` subtree. Models with no matching test class fall back to -the dir-level anchor. +### Deletion-only diffs + +Deleted lines have no post-image position. `iter_diff_post_line_numbers` +anchors them to the next surviving line, which can land outside the scope +they were deleted from — between two classes for a `.py` file, or on the +next model section for a reference YAML. Rather than widening to +file/dir level, both paths re-read the deleted side from the diff's own +pre-image view (`iter_diff_pre_image`: context + `-` lines, in-hunk +order), where a deleted class's `class` statement or a deleted section's +key line is directly visible: + +- `.py`: `_recover_deleted_scope_anchors` runs only after post-image + mapping already failed, so modification diffs keep their finer + method-level anchors. `_py_class_scopes_from_deletions` attributes at + class level — a method-level walk would have to guess which `def` a + stray decorator line belongs to. +- reference YAML: `_yaml_top_keys_from_deletions` reads `-` lines, + `iter_diff_added_post_line_numbers` reads `+` lines off the post-image. + +Both return `None` — forcing the file-level fallback — when a `-` line's +owning scope is not visible inside its hunk, since only 3 context lines +are guaranteed. Pre-image *line numbers* are never used: +`strip_noop_diff_lines` drops blank / comment-only `-` lines, which +shifts every later pre-image number. + +### accuracy/references/*.yaml + +Top-level keys name what a section is a reference for, and resolve to +anchors under `accuracy/test_*.py` so the lineage walk matches every +parametrization: + +- HF model name (15 of the 16 files) → test classes carrying that + `MODEL_NAME = ""` literal. A diff under + `meta-llama/Llama-3.1-8B-Instruct:` narrows to + `accuracy/test_*.py::TestLlama3_1_8BInstruct` rather than the whole + `accuracy/` subtree. +- `TestC::test_m` id (`acceptance_length.yaml` only) → that method in + every module defining `TestC`. + +Resolving to *no* anchor is a zero-impact claim, not a failure, when the +changed keys are also absent from the post-PR YAML: that is the shape of +a test-pruning PR, which drops a reference section and its test in one +commit, so nothing left in the tree can read it. Keys that survive in +the YAML but resolve to no test keep the dir-level fallback — some test +this rule failed to resolve may still read them. Outcomes: @@ -75,10 +118,11 @@ Outcomes: - basename is conftest / `__init__` / a helper / data file: unhandled — Selector reports it and falls back. Could be implicitly imported (top-level conftest, sys-path helpers, test input fixtures). -- Path is in-namespace but no YAML-covered ancestor exists at any - walk-up level: claimed as no-narrow contribution (`scope=noop` if - all paths are like this; a miss-note in the reason on partial-narrow - runs). +- Path is in-namespace but its anchors match no YAML entry — no + YAML-covered ancestor at any walk-up level, or a zero-impact claim + from the accuracy-reference path: claimed as no-narrow contribution + (`scope=noop` if all paths are like this; a miss-note in the reason + on partial-narrow runs). - Block-filter coverage ≥ `BLAST_RADIUS_FRACTION` (0.8) of total YAML blocks: `scope=None` (rule cannot usefully narrow — fallback). - `sanity_relevant` / `perfsanity_relevant` follow from the matched @@ -278,7 +322,9 @@ in `out_of_scope_rule.py` list the patterns. | Helper | Used by | Purpose | |---|---|---| | `iter_diff_changes(diff)` | waives, testlist | Yields `(sign, body)` for each `+`/`-` content line. | -| `iter_diff_post_line_numbers(diff)` | testdef | Yields post-image (`+`) line numbers for AST scope mapping. | +| `iter_diff_post_line_numbers(diff)` | testdef | Yields post-image line numbers for AST scope mapping; `-` lines anchor to the next surviving line. | +| `iter_diff_added_post_line_numbers(diff)` | testdef | Same, but `+` lines only — for callers reading meaning off the post-image. | +| `iter_diff_pre_image(diff)` | testdef | Yields `(sign, body)` reconstructing each hunk's pre-image (`-` + context); `("@", "")` marks a hunk boundary. | | `lookup_ids_into_block_filters` | waives, testlist | Runs `find_match_for_waive` over a set of test ids; returns block_filters and miss set. | | `lookup_paths_into_block_filters` | testdef | Runs `find_match_for_path` over a set of anchors; returns block_filters and miss set. | | `resolve_affected_stages` | all narrowing rules | Maps `block_filters` keys to stage names via `stages_by_yaml_stem`. | diff --git a/jenkins/scripts/cbts/rules/_helpers.py b/jenkins/scripts/cbts/rules/_helpers.py index e0433a27eaf5..b697cce06c2b 100644 --- a/jenkins/scripts/cbts/rules/_helpers.py +++ b/jenkins/scripts/cbts/rules/_helpers.py @@ -116,6 +116,59 @@ def iter_diff_post_line_numbers(diff: str) -> set[int]: return out +def iter_diff_added_post_line_numbers(diff: str) -> set[int]: + """Post-PR line numbers (1-indexed) touched by `+` lines only. + + Unlike `iter_diff_post_line_numbers`, `-` lines are excluded rather + than anchored to the next surviving line. Callers that read meaning + from the post-image (YAML section, AST scope) need this: a deleted + line's anchor can land in a different section than the one it was + deleted from. + """ + out: set[int] = set() + new_line = 0 + for line in diff.splitlines(): + m = _HUNK_HEADER_RE.match(line) + if m is not None: + new_line = int(m.group(1)) + continue + if not line or line.startswith(("+++", "---")): + continue + sign = line[0] + if sign == "+": + out.add(new_line) + new_line += 1 + elif sign != "-": + new_line += 1 + return out + + +def iter_diff_pre_image(diff: str) -> Iterator[tuple[str, str]]: + """Yield `(sign, body)` for the pre-image content of each hunk. + + Context and `-` lines together reconstruct what the file looked like + before the change, in order, within each hunk. `sign` is `-` for + deleted lines and ` ` for context. A `@@` hunk header yields + `("@", "")` so callers can reset any positional state they track — + pre-image continuity does not hold across hunk boundaries. + + Blank/comment-only additions may appear as context despite being absent before the change. + Line numbers are deliberately not reported: `strip_noop_diff_lines` + drops blank / comment-only `-` lines, which shifts every later + pre-image line number. Callers must derive meaning from the + in-hunk ordering instead. + """ + for line in diff.splitlines(): + if line.startswith("@@"): + yield "@", "" + continue + if not line or line.startswith(("+++", "---")): + continue + sign = line[0] + if sign in ("-", " "): + yield sign, line[1:] + + def lookup_ids_into_block_filters( yaml_index: YAMLIndex, test_ids: Iterable[str], diff --git a/jenkins/scripts/cbts/rules/tests_def_rule.py b/jenkins/scripts/cbts/rules/tests_def_rule.py index 6adec4b83a57..f948d05feb4a 100644 --- a/jenkins/scripts/cbts/rules/tests_def_rule.py +++ b/jenkins/scripts/cbts/rules/tests_def_rule.py @@ -31,6 +31,7 @@ from __future__ import annotations import ast +import re from pathlib import Path from typing import Optional @@ -38,7 +39,9 @@ from ._helpers import ( is_perf_stem, + iter_diff_added_post_line_numbers, iter_diff_post_line_numbers, + iter_diff_pre_image, lookup_paths_into_block_filters, resolve_affected_stages, stages_by_yaml_stem, @@ -53,6 +56,23 @@ ACCURACY_REFS_PREFIX = "tests/integration/defs/accuracy/references/" ACCURACY_DIR = "tests/integration/defs/accuracy" +_CLASS_RE = re.compile(r"class\s+(\w+)") +# `acceptance_length.yaml` keys tests directly (`TestC::test_m`) instead of +# by HF model name, unlike every other reference YAML. +_TEST_ID_KEY_RE = re.compile(r"^(Test\w+)::(\w+)$") + + +def _scope_start_line(node: ast.ClassDef | ast.FunctionDef | ast.AsyncFunctionDef) -> int: + """First line owned by `node`, decorators included. + + `node.lineno` points at the `def` / `class` keyword, so decorators sit + above it. A decorator edit belongs to what it decorates — a + `@parametrize` change affects that test, a class-level + `@skip_pre_blackwell` affects that class — so folding the decorator + lines into the node's range keeps them off module scope. + """ + return min([node.lineno, *(d.lineno for d in node.decorator_list)]) + def _map_lines_to_pytest_scopes(content: str, line_numbers: set[int]) -> Optional[set[str]]: """Resolve each line to its enclosing pytest scope. @@ -73,13 +93,13 @@ def _map_lines_to_pytest_scopes(content: str, line_numbers: set[int]) -> Optiona if not node.name.startswith("test"): continue end = node.end_lineno or node.lineno - for ln in range(node.lineno, end + 1): + for ln in range(_scope_start_line(node), end + 1): line_to_scope[ln] = node.name elif isinstance(node, ast.ClassDef): if not node.name.startswith("Test"): continue class_end = node.end_lineno or node.lineno - for ln in range(node.lineno, class_end + 1): + for ln in range(_scope_start_line(node), class_end + 1): line_to_scope[ln] = node.name for child in node.body: if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)): @@ -87,7 +107,7 @@ def _map_lines_to_pytest_scopes(content: str, line_numbers: set[int]) -> Optiona continue method_end = child.end_lineno or child.lineno method_scope = f"{node.name}::{child.name}" - for ln in range(child.lineno, method_end + 1): + for ln in range(_scope_start_line(child), method_end + 1): line_to_scope[ln] = method_scope scopes: set[str] = set() @@ -109,6 +129,14 @@ def _diff_has_deletions(diff: str) -> bool: return False +def _yaml_top_key(raw: str) -> Optional[str]: + """The key of an unindented `:` line, else None.""" + if not raw or raw[0].isspace() or raw.lstrip().startswith("#"): + return None + stripped = raw.split("#", 1)[0].rstrip() + return stripped[:-1].strip().strip("'\"") if stripped.endswith(":") else None + + def _yaml_top_keys_for_lines(content: str, line_numbers: set[int]) -> set[str]: """Return the set of top-level YAML keys whose section contains any line in `line_numbers`. @@ -118,14 +146,84 @@ def _yaml_top_keys_for_lines(content: str, line_numbers: set[int]) -> set[str]: keys: list[Optional[str]] = [] current: Optional[str] = None for raw in content.splitlines(): - if raw and not raw[0].isspace() and not raw.lstrip().startswith("#"): - stripped = raw.split("#", 1)[0].rstrip() - if stripped.endswith(":"): - current = stripped[:-1].strip().strip("'\"") + key = _yaml_top_key(raw) + if key is not None: + current = key keys.append(current) return {keys[i - 1] for i in line_numbers if 1 <= i <= len(keys) and keys[i - 1]} +def _yaml_all_top_keys(content: str) -> set[str]: + """Every top-level key in the file.""" + return {k for k in (_yaml_top_key(raw) for raw in content.splitlines()) if k} + + +def _yaml_top_keys_from_deletions(diff: str) -> Optional[set[str]]: + """Top-level YAML keys owning each `-` line, read from the pre-image. + + A deleted section's own key line is itself a `-` line, so the + pre-image view resolves it directly — which the post-image cannot, + since the section is gone. Returns None when any `-` line's owning + key is not visible in its hunk (only 3 context lines are guaranteed), + so the caller can fall back. + """ + keys: set[str] = set() + current: Optional[str] = None + for sign, body in iter_diff_pre_image(diff): + if sign == "@": + current = None + continue + key = _yaml_top_key(body) + if key is not None: + current = key + if sign != "-" or not body.strip() or body.lstrip().startswith("#"): + continue + if current is None: + return None + keys.add(current) + return keys or None + + +def _py_class_scopes_from_deletions(diff: str) -> Optional[set[str]]: + """`Test*` classes owning each `-` line, read from the pre-image. + + A deleted class's own `class` statement is itself a `-` line, so the + pre-image view resolves it where the post-image cannot. Attribution + stops at class level: a method-level walk would have to guess which + `def` a stray decorator line belongs to, and class level is already + narrow enough to matter. Returns None when any `-` line has no + enclosing `Test*` class visible in its hunk (module scope, imports, + helpers), so the caller can fall back. + """ + scopes: set[str] = set() + cls: Optional[tuple[str, int]] = None + pending = False # `-` decorator lines awaiting the class they decorate + for sign, body in iter_diff_pre_image(diff): + if sign == "@": + cls, pending = None, False + continue + stripped = body.strip() + if not stripped or stripped.startswith("#"): + continue + indent = len(body) - len(body.lstrip()) + match = _CLASS_RE.match(stripped) + if match is not None: + cls = (match.group(1), indent) if match.group(1).startswith("Test") else None + elif cls is not None and indent <= cls[1]: + cls = None + if sign != "-": + continue + if cls is None: + # A decorator can precede the class statement it decorates. + if stripped.startswith("@"): + pending = True + continue + return None + scopes.add(cls[0]) + pending = False + return None if pending else (scopes or None) + + class TestsDefRule(Rule): name = "testdef" needs_diff_for: tuple[str, ...] = ("tests/**/*",) @@ -140,6 +238,8 @@ def __init__( self._stages_by_yaml = stages_by_yaml_stem(stages) self._repo_root = repo_root self._total_blocks = len(yaml_index.blocks) + self._acc_class_indexes: Optional[tuple[dict[str, list[str]], dict[str, list[str]]]] = None + self._acc_source_texts: tuple[str, ...] = () def _compute_anchors(self, git_path: str, yaml_path: str, diff: str) -> list[str]: """Return lookup anchors for one file. @@ -158,6 +258,8 @@ class anchors via the model-name mapping in content = (self._repo_root / git_path).read_text(encoding="utf-8") except (OSError, UnicodeDecodeError): return [yaml_path] + if _diff_has_deletions(diff): + return self._recover_deleted_scope_anchors(yaml_path, content, diff) line_numbers = iter_diff_post_line_numbers(diff) if not line_numbers: return [yaml_path] @@ -166,65 +268,147 @@ class anchors via the model-name mapping in return [yaml_path] return [f"{yaml_path}::{s}" for s in sorted(scopes)] + def _recover_deleted_scope_anchors(self, yaml_path: str, content: str, diff: str) -> list[str]: + """Combine post-image scopes for `+` lines with pre-image scopes for `-` lines. + + Removed lines can anchor to the next surviving post-image scope, + so every diff with deletions takes this path before accepting + post-image mappings. Reading deleted lines from the pre-image + avoids attributing them to an adjacent surviving scope. + """ + if not _diff_has_deletions(diff): + return [yaml_path] + deleted_scopes = _py_class_scopes_from_deletions(diff) + if deleted_scopes is None: + return [yaml_path] + scopes = set(deleted_scopes) + added = iter_diff_added_post_line_numbers(diff) + if added: + added_scopes = _map_lines_to_pytest_scopes(content, added) + if added_scopes is None: + return [yaml_path] + scopes |= added_scopes + return [f"{yaml_path}::{s}" for s in sorted(scopes)] + def _compute_accuracy_reference_anchors( self, git_path: str, yaml_path: str, diff: str ) -> list[str]: - """Map a `references/.yaml` diff to per-class anchors. - - Each top-level YAML key is a HF model name; map those to test - classes via the `MODEL_NAME = ""` literal in - `accuracy/test_*.py`. Class-level anchors let - `find_match_for_path`'s lineage walk match every parametrization - of those classes. Falls back to `[yaml_path]` (→ dir walk-up to - `accuracy/`) when refinement isn't possible. - - Refinement is only sound when every changed line has a post- - image position whose top-level key can be read directly. A `-` - line has no post-image position; `iter_diff_post_line_numbers` - anchors it to the next surviving line, which may belong to a - different model section (e.g. deleting `ModelA:` and its body - attributes those `-` lines to the start of `ModelB:`). Any - deletion therefore triggers fallback. + """Map a `references/.yaml` diff to per-test anchors. + + Top-level YAML keys name what the section is a reference for: + a HF model in most files, a `TestC::test_m` id in + `acceptance_length.yaml`. Either way they resolve to anchors + under `accuracy/test_*.py`, whose lineage walk then matches every + parametrization. Falls back to `[yaml_path]` (→ dir walk-up to + `accuracy/`) when the changed sections can't be resolved. + + Each side of the diff is read from the image that can actually + answer for it: `+` lines from the post-image, `-` lines from the + diff's own pre-image view. Anchoring a `-` line to the next + surviving post-image line would misattribute it — deleting + `ModelA:` and its body lands those lines on `ModelB:`. + + An empty anchor list is a zero-impact claim rather than a + failure. That is only sound when the sections are gone from the + post-PR YAML and their keys are absent from accuracy test sources + — the shape of a test-pruning PR, which drops a reference and its + test together. Otherwise the file-level fallback is retained. """ if not diff: return [yaml_path] - if _diff_has_deletions(diff): - return [yaml_path] - line_numbers = iter_diff_post_line_numbers(diff) - if not line_numbers: - return [yaml_path] try: content = (self._repo_root / git_path).read_text(encoding="utf-8") except (OSError, UnicodeDecodeError): return [yaml_path] - changed_models = _yaml_top_keys_for_lines(content, line_numbers) - if not changed_models: + + changed_keys: set[str] = set() + if _diff_has_deletions(diff): + deleted_keys = _yaml_top_keys_from_deletions(diff) + if deleted_keys is None: + return [yaml_path] + changed_keys |= deleted_keys + added = iter_diff_added_post_line_numbers(diff) + if added: + changed_keys |= _yaml_top_keys_for_lines(content, added) + if not changed_keys: return [yaml_path] - model_map = self._accuracy_model_to_classes() - anchors = sorted({a for m in changed_models for a in model_map.get(m, ())}) - return anchors or [yaml_path] + + anchors: set[str] = set() + unresolved_keys: set[str] = set() + for key in changed_keys: + key_anchors = self._reference_key_anchors(key) + if key_anchors: + anchors.update(key_anchors) + else: + unresolved_keys.add(key) + if unresolved_keys and ( + unresolved_keys & _yaml_all_top_keys(content) + or self._accuracy_sources_contain_any(unresolved_keys) + ): + return [yaml_path] + return sorted(anchors) + + def _reference_key_anchors(self, key: str) -> list[str]: + """Anchors for one reference-YAML top-level key. + + A `TestC::test_m` key (`acceptance_length.yaml`) resolves through + the class index; any other key is treated as a HF model name. + """ + match = _TEST_ID_KEY_RE.match(key) + if match is None: + return list(self._accuracy_model_to_classes().get(key, ())) + class_name, method = match.groups() + return [f"{c}::{method}" for c in self._accuracy_class_to_paths().get(class_name, ())] def _accuracy_model_to_classes(self) -> dict[str, list[str]]: - """Cached: HF model name → list of `accuracy/test_X.py::ClassName`. + """HF model name → list of `accuracy/test_X.py::ClassName`.""" + return self._scan_accuracy_classes()[0] + + def _accuracy_class_to_paths(self) -> dict[str, list[str]]: + """Test class name → list of `accuracy/test_X.py::ClassName`. - Built by AST-scanning `accuracy/test_*.py` for `class TestX:` with - a literal `MODEL_NAME = ""` assignment. + A class name can appear in several modules (e.g. `TestKimiK3` in + both the text and multimodal accuracy files), so every definition + is kept. """ - cached = getattr(self, "_acc_model_map", None) - if cached is not None: - return cached - out: dict[str, list[str]] = {} + return self._scan_accuracy_classes()[1] + + def _accuracy_sources_contain_any(self, keys: set[str]) -> bool: + """Return whether any changed reference key appears in an accuracy test source.""" + self._scan_accuracy_classes() + return any(key in source for source in self._acc_source_texts for key in keys) + + def _scan_accuracy_classes(self) -> tuple[dict[str, list[str]], dict[str, list[str]]]: + """Cached AST scan of `accuracy/test_*.py` for `Test*` classes. + + Returns (model name → qualified classes, class name → qualified + classes). The first index is keyed by the literal + `MODEL_NAME = ""` assignment; classes without one appear only + in the second. + """ + if self._acc_class_indexes is not None: + return self._acc_class_indexes + by_model: dict[str, list[str]] = {} + by_class: dict[str, list[str]] = {} + source_texts: list[str] = [] acc_dir = self._repo_root / ACCURACY_DIR if acc_dir.is_dir(): for py in sorted(acc_dir.glob("test_*.py")): try: - tree = ast.parse(py.read_text(encoding="utf-8")) - except (OSError, SyntaxError, UnicodeDecodeError): + source = py.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + continue + source_texts.append(source) + try: + tree = ast.parse(source) + except (SyntaxError, ValueError): continue rel = f"accuracy/{py.name}" for node in tree.body: if not isinstance(node, ast.ClassDef) or not node.name.startswith("Test"): continue + qualified = f"{rel}::{node.name}" + by_class.setdefault(node.name, []).append(qualified) for child in node.body: if not isinstance(child, ast.Assign): continue @@ -234,10 +418,11 @@ def _accuracy_model_to_classes(self) -> dict[str, list[str]]: continue v = child.value if isinstance(v, ast.Constant) and isinstance(v.value, str): - out.setdefault(v.value, []).append(f"{rel}::{node.name}") + by_model.setdefault(v.value, []).append(qualified) break - self._acc_model_map = out - return out + self._acc_source_texts = tuple(source_texts) + self._acc_class_indexes = (by_model, by_class) + return self._acc_class_indexes def apply(self, pr: PRInputs) -> Optional[RuleResult]: candidates = [ diff --git a/tests/unittest/scripts/test_cbts_tests_def_rule.py b/tests/unittest/scripts/test_cbts_tests_def_rule.py new file mode 100644 index 000000000000..20a60c25cf79 --- /dev/null +++ b/tests/unittest/scripts/test_cbts_tests_def_rule.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Tests for CBTS test-definition scope recovery.""" + +from __future__ import annotations + +import ast +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[3] +CBTS_ROOT = REPO_ROOT / "jenkins/scripts/cbts" +sys.path.insert(0, str(CBTS_ROOT)) + +from blocks import YAMLIndex # noqa: E402 +from rules.tests_def_rule import ( # noqa: E402 + ACCURACY_DIR, + ACCURACY_REFS_PREFIX, + _py_class_scopes_from_deletions, + _scope_start_line, + _yaml_top_keys_from_deletions, +) +from rules.tests_def_rule import TestsDefRule as CbtsTestsDefRule # noqa: E402 + +pytestmark = pytest.mark.cpu_only + + +def _make_rule(repo_root: Path) -> CbtsTestsDefRule: + return CbtsTestsDefRule(YAMLIndex(), {}, repo_root) + + +def test_scope_start_line_includes_decorators() -> None: + tree = ast.parse("@decorator\nclass TestExample:\n pass\n") + node = tree.body[0] + assert isinstance(node, ast.ClassDef) + assert _scope_start_line(node) == 1 + + +def test_compute_anchors_recovers_deleted_scope_before_post_image( + tmp_path: Path, +) -> None: + git_path = "tests/integration/defs/test_example.py" + yaml_path = "test_example.py" + test_file = tmp_path / git_path + test_file.parent.mkdir(parents=True) + test_file.write_text( + "class TestB:\n def test_b(self):\n pass\n", + encoding="utf-8", + ) + diff = ( + "@@ -1,6 +1,3 @@\n" + "-class TestA:\n" + "- def test_a(self):\n" + "- pass\n" + " class TestB:\n" + " def test_b(self):\n" + " pass\n" + ) + + assert _make_rule(tmp_path)._compute_anchors(git_path, yaml_path, diff) == [ + "test_example.py::TestA" + ] + + +def test_deleted_scope_recovery_resets_at_hunk_boundary() -> None: + diff = "@@ -1,2 +1 @@\n class TestA:\n- value = 1\n@@ -10 +9,0 @@\n-module_value = 2\n" + + assert _py_class_scopes_from_deletions(diff) is None + + +def test_deleted_decorator_without_visible_owner_falls_back() -> None: + diff = "@@ -5 +5,0 @@\n- @pytest.mark.parametrize('value', [1])\n" + + assert _py_class_scopes_from_deletions(diff) is None + + +def test_deleted_yaml_body_without_visible_key_falls_back() -> None: + diff = "@@ -4 +4,0 @@\n- - expected: 1\n" + + assert _yaml_top_keys_from_deletions(diff) is None + + +@pytest.mark.parametrize( + ("source", "expected"), + ( + ( + 'def test_model():\n task = GSM8K("GPT-OSS/20B-MXFP4")\n', + ["accuracy/references/gsm8k.yaml"], + ), + ("def test_other():\n pass\n", []), + ), +) +def test_deleted_accuracy_key_requires_absence_from_test_sources( + tmp_path: Path, + source: str, + expected: list[str], +) -> None: + git_path = f"{ACCURACY_REFS_PREFIX}gsm8k.yaml" + yaml_path = "accuracy/references/gsm8k.yaml" + reference = tmp_path / git_path + reference.parent.mkdir(parents=True) + reference.write_text("Other:\n - expected: 2\n", encoding="utf-8") + accuracy_test = tmp_path / ACCURACY_DIR / "test_models.py" + accuracy_test.write_text(source, encoding="utf-8") + diff = "@@ -1,4 +1,2 @@\n-GPT-OSS/20B-MXFP4:\n- - expected: 1\n Other:\n - expected: 2\n" + + assert _make_rule(tmp_path)._compute_anchors(git_path, yaml_path, diff) == expected