diff --git a/PythonScripts/audit_translations/README.md b/PythonScripts/audit_translations/README.md index 241a500d2..5ebf83203 100644 --- a/PythonScripts/audit_translations/README.md +++ b/PythonScripts/audit_translations/README.md @@ -10,6 +10,7 @@ The tool analyzes rule files to detect the following issues: * **Extra Rules:** Rules present in the target translation but absent in the source (flagged as potentially intentional language-specific additions). * **Untranslated Text:** Detects text keys that still use **lowercase** formatting, indicating they haven't been verified or translated yet. * **Rule Differences:** Structural changes (match expressions, conditions, variables, or test/replace layout) between the source and target translation. +* **Rule Order:** After the summary, reports the first differing position of shared named rules in each affected file. Missing, extra, and `# audit-ignore` rules do not shift the comparison. Include entries are not checked. * **Definition Coverage:** Compares literal `definitions.yaml` entries by name and collection kind (`vector`, `set`, or `map`). After a blank line, add `# audit-ignore` immediately before a rule to suppress diff --git a/PythonScripts/audit_translations/auditor.py b/PythonScripts/audit_translations/auditor.py index b67cbf911..6cf718ea1 100644 --- a/PythonScripts/audit_translations/auditor.py +++ b/PythonScripts/audit_translations/auditor.py @@ -19,6 +19,7 @@ print_audit_summary, print_definition_findings, print_language_list, + print_order_findings, print_warnings, ) @@ -109,6 +110,19 @@ def merge_rules(base_rules: list[RuleInfo], region_rules: list[RuleInfo]) -> lis include_untranslated = include_all or "untranslated" in issue_filter include_extra = include_all or "extra" in issue_filter include_diffs = include_all or "diffs" in issue_filter + include_order = include_all + + first_order_mismatch_position = None + if include_order: + source_keys = {rule.key for rule in english_rules if rule.name is not None and not rule.audit_ignore} + target_keys = {rule.key for rule in translated_rules if rule.name is not None and not rule.audit_ignore} + shared_keys = source_keys & target_keys + source_order = [rule for rule in english_rules if rule.key in shared_keys and not rule.audit_ignore] + target_order = [rule for rule in translated_rules if rule.key in shared_keys and not rule.audit_ignore] + for position, (source_rule, target_rule) in enumerate(zip(source_order, target_order), start=1): + if source_rule.key != target_rule.key: + first_order_mismatch_position = position + break # Find missing rules (in source but not in translation) missing_rules = [] @@ -146,6 +160,7 @@ def merge_rules(base_rules: list[RuleInfo], region_rules: list[RuleInfo]) -> lis extra_rules=extra_rules, untranslated_text=untranslated_text, rule_differences=rule_differences, + first_order_mismatch_position=first_order_mismatch_position, english_rule_count=len(english_rules), translated_rule_count=len(translated_rules), ) @@ -262,6 +277,7 @@ def audit_language( total_definition_type_mismatches = 0 files_with_issues = 0 files_ok = 0 + order_findings: list[tuple[str, int]] = [] # One item per file, eg [("calculus.yaml", 2), ...] for file_name in files: english_path = source_dir / file_name @@ -308,6 +324,8 @@ def audit_language( existing_translated_region_path, existing_english_region_path, ) + if result.first_order_mismatch_position is not None: + order_findings.append((file_name, result.first_order_mismatch_position)) if result.has_issues: issues = print_warnings(result, file_name, verbose, language, source_language) @@ -338,6 +356,8 @@ def audit_language( ) ) + print_order_findings(order_findings) + return total_issues diff --git a/PythonScripts/audit_translations/models/rules.py b/PythonScripts/audit_translations/models/rules.py index 39069d9db..fd0044521 100644 --- a/PythonScripts/audit_translations/models/rules.py +++ b/PythonScripts/audit_translations/models/rules.py @@ -107,6 +107,7 @@ class ComparisonResult: english_rule_count: int translated_rule_count: int rule_differences: list[RuleDifference] = field(default_factory=list) + first_order_mismatch_position: int | None = None @property def has_issues(self) -> bool: diff --git a/PythonScripts/audit_translations/renderer.py b/PythonScripts/audit_translations/renderer.py index d79449e80..1ba2db693 100644 --- a/PythonScripts/audit_translations/renderer.py +++ b/PythonScripts/audit_translations/renderer.py @@ -260,6 +260,15 @@ def print_audit_summary(summary: AuditSummary) -> None: console.print(Panel(table, style="cyan")) +def print_order_findings(findings: list[tuple[str, int]]) -> None: + """Print the first shared-rule order difference for each affected file.""" + for file_name, position in findings: + console.print( + f"[yellow]Rule order differs[/] in {escape(str(file_name))}: " + f"first difference at shared rule position {position}" + ) + + def print_language_list(languages: list[tuple[str, int]]) -> None: """Print the available languages table. diff --git a/PythonScripts/audit_translations/tests/test_auditor.py b/PythonScripts/audit_translations/tests/test_auditor.py index 38b63694f..ff7f32a0f 100644 --- a/PythonScripts/audit_translations/tests/test_auditor.py +++ b/PythonScripts/audit_translations/tests/test_auditor.py @@ -46,6 +46,133 @@ def fixture_rules_dir() -> Path: return Path(__file__).resolve().parent / "fixtures" / "Rules" / "Languages" +def test_rule_order_reports_first_shared_difference(tmp_path) -> None: + """A swapped pair records the first shared rule position without adding an issue.""" + source = tmp_path / "source.yaml" + target = tmp_path / "target.yaml" + source.write_text("""- name: a + tag: mi +- name: b + tag: mi +- name: c + tag: mi +""", encoding="utf-8") + target.write_text("""- name: a + tag: mi +- name: c + tag: mi +- name: b + tag: mi +""", encoding="utf-8") + + result = compare_files(source, target) + assert result.first_order_mismatch_position == 2 + assert not result.has_issues + + +def test_missing_and_ignored_rules_do_not_shift_order(tmp_path) -> None: + """Only shared rules without audit-ignore markers participate in ordering.""" + source = tmp_path / "source.yaml" + target = tmp_path / "target.yaml" + source.write_text("""- name: a + tag: mi +- name: missing + tag: mi +- name: b + tag: mi +""", encoding="utf-8") + target.write_text("""- name: a + tag: mi +- name: extra + tag: mi +- name: b + tag: mi +""", encoding="utf-8") + assert compare_files(source, target).first_order_mismatch_position is None + + source.write_text("""- name: a + tag: mi +- name: b + tag: mi +# audit-ignore +- name: c + tag: mi +""", encoding="utf-8") + target.write_text("""# audit-ignore +- name: c + tag: mi +- name: a + tag: mi +- name: b + tag: mi +""", encoding="utf-8") + assert compare_files(source, target).first_order_mismatch_position is None + + +def test_include_placement_is_not_checked(tmp_path) -> None: + """An include moving around a named rule has no effect on named rule order.""" + source = tmp_path / "source.yaml" + target = tmp_path / "target.yaml" + source.write_text("""- include: shared.yaml +- name: a + tag: mi +""", encoding="utf-8") + target.write_text("""- name: a + tag: mi +- include: shared.yaml +""", encoding="utf-8") + result = compare_files(source, target) + assert result.first_order_mismatch_position is None + assert result.english_rule_count == result.translated_rule_count == 1 + assert not result.missing_rules and not result.extra_rules + assert compare_files(source, target, {"missing"}).first_order_mismatch_position is None + + +def test_unicode_entries_are_not_checked_for_order(tmp_path) -> None: + """The order check only covers named rules, not Unicode character entries.""" + source = tmp_path / "unicode.yaml" + target = tmp_path / "target" / "unicode.yaml" + target.parent.mkdir() + source.write_text("""- "←": [t: left] +- "∥": [t: parallel] +""", encoding="utf-8") + target.write_text("""- "∥": [T: parallel] +- "←": [T: left] +""", encoding="utf-8") + + assert compare_files(source, target).first_order_mismatch_position is None + + +def test_order_findings_appear_after_summary_without_affecting_totals(tmp_path, capsys) -> None: + """Each affected file gets one final line while the audit issue count stays zero.""" + rules_dir = tmp_path / "Rules" / "Languages" + source_dir = rules_dir / "en" + target_dir = rules_dir / "de" + source_dir.mkdir(parents=True) + target_dir.mkdir(parents=True) + source = """- name: a + tag: mi +- name: b + tag: mi +""" + target = """- name: b + tag: mi +- name: a + tag: mi +""" + for filename in ("first.yaml", "second.yaml"): + (source_dir / filename).write_text(source, encoding="utf-8") + (target_dir / filename).write_text(target, encoding="utf-8") + + assert audit_language("de", rules_dir=str(rules_dir)) == 0 + output = strip_ansi(capsys.readouterr().out) + assert "Files with issues 0" in output + assert "Rule order differences" not in output + assert output.count("first difference at shared rule position 1") == 2 + assert output.index("SUMMARY") < output.index("Rule order differs in first.yaml") + assert output.index("Rule order differs in first.yaml") < output.index("Rule order differs in second.yaml") + + def aggregate_issue_counts( language: str, issue_filter: set[str] | None = None, diff --git a/PythonScripts/audit_translations/tests/test_parsers.py b/PythonScripts/audit_translations/tests/test_parsers.py index 6714f3581..92ea56ab0 100644 --- a/PythonScripts/audit_translations/tests/test_parsers.py +++ b/PythonScripts/audit_translations/tests/test_parsers.py @@ -303,7 +303,7 @@ def test_skips_items_without_name(self): assert rules[0].name == "real-rule" def test_mixed_valid_and_skipped_items(self): - """Valid rules interspersed with non-rule items keep correct line numbers.""" + """Named rules retain their line numbers around skipped includes.""" content = """- name: first tag: mo match: "."