From 6b0306b43d046ed5f505718450db84684f440556 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Sat, 8 Aug 2026 12:21:13 +0200 Subject: [PATCH] feat: settle ambiguity by file line shift --- scripts/profile_sync.py | 65 +++++++++++++++++++++++++++++++++++--- tests/test_profile_sync.py | 52 +++++++++++++++++++++++++++++- 2 files changed, 112 insertions(+), 5 deletions(-) diff --git a/scripts/profile_sync.py b/scripts/profile_sync.py index d3cf064c..33a04a07 100644 --- a/scripts/profile_sync.py +++ b/scripts/profile_sync.py @@ -36,7 +36,7 @@ STATUS_ORDER = ("ANCHORED", "SHIFTED", "RENAMED", "AMBIGUOUS", "CHANGED", "GONE" REVIEW_STATUSES = ("CHANGED", "GONE", "AMBIGUOUS") REBASE_STATUSES = ("SHIFTED", "RENAMED") -WIDEN_STEPS = (0, 3, 6, 12) +WIDEN_STEPS = (0, 3, 6, 12, 25, 50) MAX_MATCH_LINES = 20000 PIN = "pin" @@ -381,6 +381,57 @@ def anchor_part(part: RefPart, fetch, rename_getter, describe=None) -> PartResul ) +SHIFT_SUPPORT = 3 + + +def dominant_shifts(parts, support: int = SHIFT_SUPPORT) -> dict[str, int]: + """Most common line shift per file, taken from the unambiguous anchors. + + A file whose refs mostly moved by the same amount gives strong evidence + for the ones that stayed ambiguous: repetitive tables, as in the NeoGeo + driver, match a cited line in several places, and only one of them sits + where the rest of the file moved to. + """ + by_file: dict[str, dict[int, int]] = {} + for part in parts: + if part.status not in ("ANCHORED", "SHIFTED"): + continue + if part.start is None or part.part.start is None: + continue + counts = by_file.setdefault(part.part.path, {}) + delta = part.start - part.part.start + counts[delta] = counts.get(delta, 0) + 1 + shifts = {} + for path, counts in by_file.items(): + delta, seen = max(counts.items(), key=lambda kv: kv[1]) + if seen >= support: + shifts[path] = delta + return shifts + + +def resolve_by_shift(part: PartResult, shifts: dict[str, int]) -> PartResult: + """Settle an ambiguous part when one candidate matches the file's shift.""" + if part.status != "AMBIGUOUS" or not part.candidates: + return part + if part.part.start is None or part.part.path not in shifts: + return part + wanted = part.part.start + shifts[part.part.path] + if part.candidates.count(wanted) != 1: + return part + span = (part.part.end or part.part.start) - part.part.start + return PartResult( + part.part, + "ANCHORED" if wanted == part.part.start else "SHIFTED", + part.new_path, + wanted, + wanted + span, + [], + f"settled by the {shifts[part.part.path]:+d} line shift of its file", + part.repo, + part.head_url, + ) + + @dataclass class EntryReport: name: str @@ -576,11 +627,17 @@ def build_report( slug = view.repo.slug if view is not primary else None return slug, upstream.raw_url(view.repo, view.head, actual), actual - for entry_name, ref in refs: - parts = [ + staged = [ + (entry_name, ref, [ anchor_part(part, fetch, rename_getter, describe) for part in split_source_ref(ref) - ] + ]) + for entry_name, ref in refs + ] + + shifts = dominant_shifts([p for _, _, parts in staged for p in parts]) + for entry_name, ref, parts in staged: + parts = [resolve_by_shift(p, shifts) for p in parts] status = worst_status([p.status for p in parts]) report.entries.append(EntryReport(entry_name, ref, status, parts)) report.counts[status] = report.counts.get(status, 0) + 1 diff --git a/tests/test_profile_sync.py b/tests/test_profile_sync.py index 98c0fad8..eb05af8d 100644 --- a/tests/test_profile_sync.py +++ b/tests/test_profile_sync.py @@ -244,7 +244,7 @@ class TestAnchorBlock(unittest.TestCase): self.assertEqual((result.status, result.start), ("SHIFTED", 6)) def test_ambiguous_when_widening_never_resolves(self): - result = anchor_block(["dup"] * 40, ["dup"] * 40, 5, 5) + result = anchor_block(["dup"] * 400, ["dup"] * 400, 5, 5) self.assertEqual(result.status, "AMBIGUOUS") self.assertGreater(len(result.candidates), 1) @@ -275,6 +275,56 @@ class TestAnchorBlock(unittest.TestCase): self.assertIsNone(result.start) +def _part(path, old, new, status): + return PartResult(RefPart(path, old, old), status, None, new, new, []) + + +def _ambiguous(path, old, candidates): + return PartResult(RefPart(path, old, old), "AMBIGUOUS", None, None, None, candidates) + + +class TestDominantShift(unittest.TestCase): + def test_shift_needs_enough_support(self): + parts = [_part("a.c", 10, 30, "SHIFTED"), _part("a.c", 20, 40, "SHIFTED")] + self.assertEqual(profile_sync.dominant_shifts(parts), {}) + + def test_shift_is_taken_from_resolved_anchors(self): + parts = [_part("a.c", i, i + 20, "SHIFTED") for i in (10, 20, 30)] + self.assertEqual(profile_sync.dominant_shifts(parts), {"a.c": 20}) + + def test_ambiguous_parts_do_not_vote(self): + parts = [_part("a.c", i, i + 20, "SHIFTED") for i in (10, 20, 30)] + parts.append(_ambiguous("a.c", 40, [99, 500])) + self.assertEqual(profile_sync.dominant_shifts(parts), {"a.c": 20}) + + def test_candidate_matching_the_shift_is_selected(self): + part = _ambiguous("a.c", 1624, [1644, 1790, 1834]) + settled = profile_sync.resolve_by_shift(part, {"a.c": 20}) + self.assertEqual(settled.status, "SHIFTED") + self.assertEqual(settled.start, 1644) + self.assertIn("+20", settled.reason) + + def test_no_candidate_matches_the_shift(self): + part = _ambiguous("a.c", 1624, [1790, 1834]) + self.assertEqual(profile_sync.resolve_by_shift(part, {"a.c": 20}).status, + "AMBIGUOUS") + + def test_two_candidates_on_the_shift_stay_ambiguous(self): + part = _ambiguous("a.c", 1624, [1644, 1644]) + self.assertEqual(profile_sync.resolve_by_shift(part, {"a.c": 20}).status, + "AMBIGUOUS") + + def test_range_length_is_preserved(self): + part = PartResult(RefPart("a.c", 100, 104), "AMBIGUOUS", None, None, None, [120]) + settled = profile_sync.resolve_by_shift(part, {"a.c": 20}) + self.assertEqual((settled.start, settled.end), (120, 124)) + + def test_file_without_a_known_shift_is_untouched(self): + part = _ambiguous("b.c", 10, [30]) + self.assertEqual(profile_sync.resolve_by_shift(part, {"a.c": 20}).status, + "AMBIGUOUS") + + PIN = profile_sync.PIN HEAD = profile_sync.HEAD