Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ but add common mistakes of AI agents here instead.
- do not do any git commands unless explicitly asked for
- Rust coverage is in `target/coverage/`.
- When working with GitHub, e.g. looking at PRs and issues, check if the GitHub CLI is installed (`gh --version`).
- When writing tests, add a brief description explaining the purpose and expected behaviour. Avoid complex setups like using heavily parameterized tests (eg `@pytest.mark.parametrize` for Python)

## Fuzzing (`fuzz/` + cargo-fuzz)
- Install: `cargo install cargo-fuzz`, use a **nightly** toolchain (`rustup run nightly cargo fuzz …`).
Expand Down
8 changes: 5 additions & 3 deletions PythonScripts/audit_translations/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
* **Definition Coverage:** Compares literal `definitions.yaml` entries by name and collection kind (`vector`, `set`, or `map`).

Add `# audit-ignore` to a rule block to suppress auditing that rule.

Expand Down Expand Up @@ -49,10 +50,11 @@ The tool automatically adjusts its matching logic based on the file type:
2. **Unicode Files:**
* Matches rules based on character/range keys.
* *Examples:* `unicode.yaml`, `unicode-full.yaml` (keys like `a-z`, `!`, `0-9`).
3. **Definition Files:**
* `definitions.yaml` is audited by default and can be selected with `--file definitions.yaml`.
* Definitions are matched by name. Missing definitions and collection-kind mismatches are issues; target-only definitions are informational.
* Definition contents are not compared, includes are not resolved, and translation verification is not available for definitions.

`definitions.yaml` is intentionally excluded from audits *for now*. It does not have the same semantics
as normal rules, so the tool ignores it during automatic file discovery and when it is passed to
`--file`.

---

Expand Down
153 changes: 127 additions & 26 deletions PythonScripts/audit_translations/auditor.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,19 @@
from pathlib import Path

from .differ import diff_rules
from .models import AuditError, AuditSummary, ComparisonResult, RuleInfo
from .parsers import parse_yaml_file
from .renderer import console, print_audit_header, print_audit_summary, print_language_list, print_warnings
from .errors import AuditError
from .models.audit import AuditSummary
from .models.definitions import DefinitionComparisonResult, DefinitionInfo, DefinitionTypeMismatch
from .models.rules import ComparisonResult, RuleInfo
from .parsers import parse_definitions_file, parse_yaml_file
from .renderer import (
console,
print_audit_header,
print_audit_summary,
print_definition_findings,
print_language_list,
print_warnings,
)


def split_language_into_base_and_region(language: str) -> tuple[str, str | None]:
Expand All @@ -32,7 +42,7 @@ def get_rules_dir(rules_dir: str | None = None) -> Path:


def is_definitions_file(file_path: str | Path) -> bool:
"""Return if the file name is definitions.yaml, which is not yet supported."""
"""Return whether a file needs the dedicated definitions audit path."""
return Path(file_path).name == "definitions.yaml"


Expand All @@ -44,13 +54,12 @@ def collect_from(directory: Path, root: Path) -> None:
if not directory.exists():
return
for f in directory.glob("*.yaml"):
if f.name != "prefs.yaml" and not is_definitions_file(f):
if f.name != "prefs.yaml":
files.add(f.relative_to(root))
shared_dir = directory / "SharedRules"
if shared_dir.exists():
for f in shared_dir.glob("*.yaml"):
if not is_definitions_file(f):
files.add(f.relative_to(root))
files.add(f.relative_to(root))

collect_from(lang_dir, lang_dir)
if region_dir:
Expand Down Expand Up @@ -142,6 +151,67 @@ def merge_rules(base_rules: list[RuleInfo], region_rules: list[RuleInfo]) -> lis
)


def compare_definition_files(
source_path: Path,
target_path: Path,
issue_filter: set[str] | None = None,
target_region_path: Path | None = None,
source_region_path: Path | None = None,
) -> DefinitionComparisonResult:
"""Compare literal definitions by name and collection kind."""

def load_definitions(path: Path | None) -> dict[str, DefinitionInfo]:
if path and path.exists():
definitions, _ = parse_definitions_file(path)
return definitions
return {}

def merge_definitions(
base_definitions: dict[str, DefinitionInfo],
region_definitions: dict[str, DefinitionInfo],
) -> dict[str, DefinitionInfo]:
merged = dict(base_definitions)
merged.update(region_definitions)
return merged

source_definitions = merge_definitions(
load_definitions(source_path),
load_definitions(source_region_path),
)
target_definitions = merge_definitions(
load_definitions(target_path),
load_definitions(target_region_path),
)

include_all = issue_filter is None
include_missing = include_all or "missing" in issue_filter
include_extra = include_all or "extra" in issue_filter
include_diffs = include_all or "diffs" in issue_filter

missing_definitions = (
[definition for name, definition in source_definitions.items() if name not in target_definitions]
if include_missing
else []
)
extra_definitions = (
[definition for name, definition in target_definitions.items() if name not in source_definitions] if include_extra else []
)
type_mismatches = []
if include_diffs:
for name, source_definition in source_definitions.items():
target_definition = target_definitions.get(name)
if target_definition and source_definition.kind is not target_definition.kind:
type_mismatches.append(DefinitionTypeMismatch(source_definition, target_definition))

return DefinitionComparisonResult(
missing_definitions=missing_definitions,
extra_definitions=extra_definitions,
type_mismatches=type_mismatches,
source_definition_count=len(source_definitions),
target_definition_count=len(target_definitions),
)


def audit_language(
language: str,
specific_file: str | None = None,
Expand Down Expand Up @@ -178,10 +248,7 @@ def audit_language(
raise AuditError(f"Target region directory not found: {translated_region_dir}")

# Get list of files to audit
if specific_file:
files = [] if is_definitions_file(Path(specific_file)) else [specific_file]
else:
files = get_yaml_files(source_dir, source_region_dir)
files = [specific_file] if specific_file else get_yaml_files(source_dir, source_region_dir)

print_audit_header(language, len(files), source_language)

Expand All @@ -190,6 +257,9 @@ def audit_language(
total_untranslated = 0
total_extra = 0
total_differences = 0
total_missing_definitions = 0
total_extra_definitions = 0
total_definition_type_mismatches = 0
files_with_issues = 0
files_ok = 0

Expand All @@ -203,26 +273,54 @@ def audit_language(
console.print(f"\n[yellow]⚠ Warning:[/] Source file not found: {english_path}")
continue

result = compare_files(
english_path,
translated_path,
issue_filter,
translated_region_path if translated_region_path and translated_region_path.exists() else None,
english_region_path if english_region_path and english_region_path.exists() else None,
existing_translated_region_path = (
translated_region_path if translated_region_path and translated_region_path.exists() else None
)

if result.has_issues:
issues = print_warnings(result, file_name, verbose, language, source_language)
existing_english_region_path = english_region_path if english_region_path and english_region_path.exists() else None

if is_definitions_file(file_name):
definition_result = compare_definition_files(
english_path,
translated_path,
issue_filter,
existing_translated_region_path,
existing_english_region_path,
)
issues = print_definition_findings(
definition_result,
file_name,
language,
source_language,
)
if issues > 0:
files_with_issues += 1
else:
files_ok += 1
total_issues += issues
total_missing_definitions += len(definition_result.missing_definitions)
total_extra_definitions += len(definition_result.extra_definitions)
total_definition_type_mismatches += len(definition_result.type_mismatches)
else:
files_ok += 1

total_missing += len(result.missing_rules)
total_untranslated += sum(len(entries) for _rule, entries in result.untranslated_text)
total_extra += len(result.extra_rules)
total_differences += len(result.rule_differences)
result = compare_files(
english_path,
translated_path,
issue_filter,
existing_translated_region_path,
existing_english_region_path,
)

if result.has_issues:
issues = print_warnings(result, file_name, verbose, language, source_language)
if issues > 0:
files_with_issues += 1
total_issues += issues
else:
files_ok += 1

total_missing += len(result.missing_rules)
total_untranslated += sum(len(entries) for _rule, entries in result.untranslated_text)
total_extra += len(result.extra_rules)
total_differences += len(result.rule_differences)

print_audit_summary(
AuditSummary(
Expand All @@ -233,6 +331,9 @@ def audit_language(
total_untranslated=total_untranslated,
total_extra=total_extra,
total_differences=total_differences,
total_missing_definitions=total_missing_definitions,
total_extra_definitions=total_extra_definitions,
total_definition_type_mismatches=total_definition_type_mismatches,
total_issues=total_issues,
)
)
Expand Down
2 changes: 1 addition & 1 deletion PythonScripts/audit_translations/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import sys

from .auditor import audit_language, list_languages
from .models import AuditError
from .errors import AuditError
from .renderer import console


Expand Down
2 changes: 1 addition & 1 deletion PythonScripts/audit_translations/differ.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
extract_variables,
normalize_xpath,
)
from .models import DiffType, RuleDifference, RuleInfo
from .models.rules import DiffType, RuleDifference, RuleInfo


def dedup_list(values: list[str]) -> list[str]:
Expand Down
5 changes: 5 additions & 0 deletions PythonScripts/audit_translations/errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Exceptions raised by the translation audit tool."""


class AuditError(Exception):
"""Raised when the audit encounters a configuration or validation error."""
2 changes: 1 addition & 1 deletion PythonScripts/audit_translations/line_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"""

from .extractors import extract_structure_elements
from .models import DiffType, RuleDifference, RuleInfo
from .models.rules import DiffType, RuleDifference, RuleInfo


def _get_line_map_lines(rule: RuleInfo, kind: DiffType, token: str | None = None) -> list[int]:
Expand Down
1 change: 1 addition & 0 deletions PythonScripts/audit_translations/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Domain models used by the translation audit tool."""
20 changes: 20 additions & 0 deletions PythonScripts/audit_translations/models/audit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
"""Models for aggregate audit output."""

from dataclasses import dataclass


@dataclass
class AuditSummary:
"""Accumulated totals from a full language audit."""

files_checked: int
files_with_issues: int
files_ok: int
total_missing: int
total_untranslated: int
total_extra: int
total_differences: int
total_missing_definitions: int
total_extra_definitions: int
total_definition_type_mismatches: int
total_issues: int
52 changes: 52 additions & 0 deletions PythonScripts/audit_translations/models/definitions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
"""Models for definitions.yaml parsing and comparison."""

from dataclasses import dataclass
from enum import StrEnum
from typing import Any


class DefinitionKind(StrEnum):
"""Collection shapes supported by MathCAT definitions files."""

VECTOR = "vector"
SET = "set"
MAP = "map"


@dataclass
class DefinitionInfo:
"""Information about one literal entry in a definitions file."""

name: str
kind: DefinitionKind
line_number: int
raw_content: str
data: Any


@dataclass
class DefinitionTypeMismatch:
"""A definition whose collection kind differs between source and target."""

source_definition: DefinitionInfo
target_definition: DefinitionInfo


@dataclass
class DefinitionComparisonResult:
"""Results from comparing two literal definitions files."""

missing_definitions: list[DefinitionInfo]
extra_definitions: list[DefinitionInfo]
type_mismatches: list[DefinitionTypeMismatch]
source_definition_count: int
target_definition_count: int

@property
def issue_count(self) -> int:
"""Count actionable findings; target-only definitions are informational."""
return len(self.missing_definitions) + len(self.type_mismatches)

@property
def has_findings(self) -> bool:
return bool(self.missing_definitions or self.extra_definitions or self.type_mismatches)
Loading
Loading