From 2a84c0b85a688c4c599715827e4ed2583247676d Mon Sep 17 00:00:00 2001 From: mgros Date: Wed, 23 Sep 2026 03:45:33 +0200 Subject: [PATCH 01/18] v1 of rule coverage report --- Cargo.toml | 1 + PythonScripts/README.md | 9 ++ PythonScripts/audit_translations/README.md | 3 + PythonScripts/audit_translations/cli.py | 14 +- .../audit_translations/rule_coverage.py | 125 ++++++++++++++++++ .../tests/test_rule_coverage_cli.py | 70 ++++++++++ src/lib.rs | 2 + src/rule_coverage.rs | 69 ++++++++++ src/shim_filesystem.rs | 18 ++- src/speech.rs | 12 ++ 10 files changed, 316 insertions(+), 7 deletions(-) create mode 100644 PythonScripts/audit_translations/rule_coverage.py create mode 100644 PythonScripts/audit_translations/tests/test_rule_coverage_cli.py create mode 100644 src/rule_coverage.rs diff --git a/Cargo.toml b/Cargo.toml index 2e73cbf80..60f712d32 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ exclude = ["src/main.rs", "docs", "BrailleDocs", "PythonScripts"] # should ha [features] +"rule-coverage" = [] "include-zip" = [] "enable-logs" = ["android_logger"] "tts" = [ "natural-tts" ] diff --git a/PythonScripts/README.md b/PythonScripts/README.md index 9eb6ed44e..1ed867c38 100644 --- a/PythonScripts/README.md +++ b/PythonScripts/README.md @@ -9,3 +9,12 @@ If you run from the repo root instead of inside `PythonScripts`, point uv at the uv sync --project PythonScripts uv run --project PythonScripts audit-translations de ``` + +To run rule YAML coverage from the repo root, use the audit tool: + +```bash +uv run --project PythonScripts audit-translations --rule-coverage +``` + +The tool opens `target/rule-coverage/index.html` in a browser when the run finishes. Expand a YAML file to see each active rule's name, tag, and match status. +The Markdown report and test output remain at `target/rule-coverage/report.md` and `target/rule-coverage/test.log`. diff --git a/PythonScripts/audit_translations/README.md b/PythonScripts/audit_translations/README.md index 241a500d2..1e4712a1c 100644 --- a/PythonScripts/audit_translations/README.md +++ b/PythonScripts/audit_translations/README.md @@ -66,11 +66,13 @@ The tool automatically adjusts its matching logic based on the file type: uv run audit-translations [--file ] uv run audit-translations --source uv run audit-translations --list +uv run audit-translations --rule-coverage # If running from the repo root, point uv at the project: uv run --project PythonScripts audit-translations uv run --project PythonScripts audit-translations --source uv run --project PythonScripts audit-translations --list +uv run --project PythonScripts audit-translations --rule-coverage ``` **Convenience Features:** @@ -81,6 +83,7 @@ uv run --project PythonScripts audit-translations --list * `--rules-dir`: Override the Rules/Languages directory path. * `--only`: Filter issue types (comma-separated): `missing`, `untranslated`, `extra`, `diffs`, `all`. * `--verbose`: Show detailed output including source/target snippets for rule differences. +* `--rule-coverage`: Run the full Rust test suite, generate file- and rule-level YAML coverage under `target/rule-coverage/`, and open the HTML report. Expand a file to inspect matched and unmatched rules by name and tag. This runs independently of a language audit. * **Summary Stats:** Provides a statistical summary after every run. **Examples:** diff --git a/PythonScripts/audit_translations/cli.py b/PythonScripts/audit_translations/cli.py index 6916a636e..70cf629a4 100644 --- a/PythonScripts/audit_translations/cli.py +++ b/PythonScripts/audit_translations/cli.py @@ -10,6 +10,7 @@ from .auditor import audit_language, list_languages from .errors import AuditError from .renderer import console +from .rule_coverage import run as run_rule_coverage def main() -> None: @@ -17,7 +18,7 @@ def main() -> None: sys.stdout.reconfigure(encoding="utf-8") parser = argparse.ArgumentParser( - description="Audit MathCAT translation files against a source language", + description="Audit MathCAT translations or report rule YAML test coverage", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: @@ -25,6 +26,7 @@ def main() -> None: uv run audit-translations nb --source sv uv run audit-translations de --file SharedRules/default.yaml uv run audit-translations --list + uv run audit-translations --rule-coverage """, ) @@ -32,6 +34,9 @@ def main() -> None: parser.add_argument("--source", default="en", help="Source/reference language code (default: 'en')") parser.add_argument("--file", dest="specific_file", help="Audit only a specific file (e.g., 'SharedRules/default.yaml')") parser.add_argument("--list", action="store_true", help="List available languages") + parser.add_argument( + "--rule-coverage", action="store_true", help="Run the full Rust tests and open the rule YAML coverage report" + ) parser.add_argument("--rules-dir", help="Override Rules/Languages directory path") parser.add_argument( "--only", @@ -45,7 +50,12 @@ def main() -> None: args = parser.parse_args() - if args.list: + if args.rule_coverage: + if (args.language or args.list or args.specific_file or args.rules_dir or args.only + or args.verbose or args.source != "en"): + parser.error("--rule-coverage cannot be combined with translation audit options") + sys.exit(run_rule_coverage()) + elif args.list: list_languages(args.rules_dir) elif not args.language: parser.print_help() diff --git a/PythonScripts/audit_translations/rule_coverage.py b/PythonScripts/audit_translations/rule_coverage.py new file mode 100644 index 000000000..331bcde78 --- /dev/null +++ b/PythonScripts/audit_translations/rule_coverage.py @@ -0,0 +1,125 @@ +"""Run the full Rust test suite and report which rule YAML files it exercises.""" + +import shutil +import subprocess +import sys +import webbrowser +from pathlib import Path, PurePosixPath + +from .rule_coverage_report import RuleKey, render_html, rule_section, section + +ROOT = Path(__file__).resolve().parents[2] +OUTPUT = ROOT / "target" / "rule-coverage" +EVENTS = OUTPUT / "events" + + +def read_events() -> tuple[set[str], set[str], set[RuleKey], set[RuleKey], list[str]]: + loaded: set[str] = set() + matched: set[str] = set() + defined_rules: set[RuleKey] = set() + matched_rules: set[RuleKey] = set() + errors: list[str] = [] + for event_file in sorted(EVENTS.glob("*.events")): + for number, line in enumerate(event_file.read_text(encoding="utf-8").splitlines(), 1): + fields = line.split("\t") + kind = fields[0] + path = fields[1] if len(fields) > 1 else "" + path = path.replace("\\", "/") + parts = PurePosixPath(path).parts + if (not parts + or PurePosixPath(path).is_absolute() or ".." in parts + or PurePosixPath(path).suffix not in (".yaml", ".yml")): + errors.append(f"Invalid event in {event_file.name}:{number}") + elif kind == "loaded" and len(fields) == 2: + loaded.add(path) + elif kind == "matched" and len(fields) == 2: + matched.add(path) + elif kind in ("defined-rule", "matched-rule") and len(fields) == 4: + try: + name, tag = (bytes.fromhex(value).decode("utf-8") for value in fields[2:]) + except (ValueError, UnicodeDecodeError): + errors.append(f"Invalid rule identity in {event_file.name}:{number}") + continue + if not name or not tag: + errors.append(f"Empty rule identity in {event_file.name}:{number}") + elif kind == "defined-rule": + defined_rules.add((path, name, tag)) + else: + matched_rules.add((path, name, tag)) + else: + errors.append(f"Invalid event in {event_file.name}:{number}") + return loaded, matched, defined_rules, matched_rules, errors + + +def run() -> int: + """Generate all reports and open the HTML page when the run finishes.""" + OUTPUT.mkdir(parents=True, exist_ok=True) + if EVENTS.exists(): + shutil.rmtree(EVENTS) + EVENTS.mkdir() + + command = ["cargo", "test", "--features", "rule-coverage"] + log_path = OUTPUT / "test.log" + print(f"Running {' '.join(command)}; saving output to {log_path.relative_to(ROOT)}", flush=True) + with log_path.open("w", encoding="utf-8") as log: + try: + result = subprocess.run(command, cwd=ROOT, stdout=log, stderr=subprocess.STDOUT, check=False) + test_status = result.returncode + except OSError as error: + log.write(f"Could not run cargo: {error}\n") + test_status = 1 + + loaded, matched, defined_rules, matched_rules, errors = read_events() + if test_status: + errors.insert(0, f"cargo test failed (exit status {test_status}); see test.log") + if not loaded: + errors.append("No loaded YAML events found") + if not matched: + errors.append("No matched pattern events found") + if not defined_rules: + errors.append("No active rule definitions found") + if not matched_rules: + errors.append("No matched rule events found") + if matched - loaded: + errors.append("Matched pattern files lack loaded events: " + ", ".join(sorted(matched - loaded))) + if matched_rules - defined_rules: + errors.append("Matched rules lack definition events") + if {path for path, _, _ in defined_rules} - loaded: + errors.append("Defined rules have no loaded YAML event") + if matched != {path for path, _, _ in matched_rules}: + errors.append("File and rule match events disagree") + + status = "Incomplete" if errors else "Complete" + report = [ + "# Rule YAML coverage\n", + f"Status: **{status}**\n", + "Paths are relative to `Rules/`. A pattern file is matched when a rule from it completes its replacement successfully.\n", + ] + if errors: + report.append("## Problems\n\n" + "\n".join(f"- {error}" for error in errors) + "\n") + report.extend(( + section("Loaded YAML files", loaded), + section("Matched pattern files", matched), + section("Loaded files with no pattern match", loaded - matched), + rule_section("Matched rules", matched_rules), + rule_section("Active rules with no match", defined_rules - matched_rules), + )) + report_path = OUTPUT / "report.md" + report_path.write_text("\n".join(report), encoding="utf-8") + html_path = OUTPUT / "index.html" + html_path.write_text(render_html(loaded, matched, defined_rules, matched_rules, errors), encoding="utf-8") + print( + f"{status}: {len(loaded)} files loaded, {len(matched)} files matched; " + f"{len(matched_rules)} of {len(defined_rules)} rules matched; report: {html_path}" + ) + try: + opened = webbrowser.open(html_path.resolve().as_uri()) + except (OSError, webbrowser.Error): + opened = False + if not opened: + print(f"Browser unavailable; open {html_path} manually") + return 1 if errors else 0 + + +if __name__ == "__main__": + sys.exit(run()) diff --git a/PythonScripts/audit_translations/tests/test_rule_coverage_cli.py b/PythonScripts/audit_translations/tests/test_rule_coverage_cli.py new file mode 100644 index 000000000..f07fe1872 --- /dev/null +++ b/PythonScripts/audit_translations/tests/test_rule_coverage_cli.py @@ -0,0 +1,70 @@ +"""Checks the rule coverage command without rerunning the Rust test suite.""" + +import subprocess +import sys +from pathlib import Path + +import pytest + +from .. import cli, rule_coverage + + +def test_coverage_command_generates_reports_and_opens_browser(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """The CLI uses test events to write both reports and opens the HTML page.""" + output = tmp_path / "target" / "rule-coverage" + monkeypatch.setattr(rule_coverage, "ROOT", tmp_path) + monkeypatch.setattr(rule_coverage, "OUTPUT", output) + monkeypatch.setattr(rule_coverage, "EVENTS", output / "events") + + def fake_cargo(command: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]: + assert command == ["cargo", "test", "--features", "rule-coverage"] + assert kwargs["cwd"] == tmp_path + (output / "events" / "123.events").write_text( + "loaded\tLanguages/en/SimpleSpeak_Rules.yaml\n" + "loaded\tLanguages/en/definitions.yaml\n" + "matched\tLanguages/en/SimpleSpeak_Rules.yaml\n" + "defined-rule\tLanguages/en/SimpleSpeak_Rules.yaml\t73696d706c65\t6d69\n" + "defined-rule\tLanguages/en/SimpleSpeak_Rules.yaml\t64656661756c74\t6d69\n" + "matched-rule\tLanguages/en/SimpleSpeak_Rules.yaml\t73696d706c65\t6d69\n", + encoding="utf-8", + ) + return subprocess.CompletedProcess(command, 0) + + opened: list[str] = [] + monkeypatch.setattr(rule_coverage.subprocess, "run", fake_cargo) + monkeypatch.setattr(rule_coverage.webbrowser, "open", lambda url: opened.append(url) or True) + monkeypatch.setattr(sys, "argv", ["audit-translations", "--rule-coverage"]) + + with pytest.raises(SystemExit) as result: + cli.main() + + assert result.value.code == 0 + assert opened == [(output / "index.html").as_uri()] + assert "Status: **Complete**" in (output / "report.md").read_text(encoding="utf-8") + assert "## Matched rules (1)" in (output / "report.md").read_text(encoding="utf-8") + assert "## Active rules with no match (1)" in (output / "report.md").read_text(encoding="utf-8") + html = (output / "index.html").read_text(encoding="utf-8") + assert 'data-rule-search="simple mi" data-status="matched"' in html + assert 'data-rule-search="default mi" data-status="unmatched"' in html + assert "No active pattern rules" in html + assert (output / "test.log").is_file() + + +def test_failed_coverage_run_opens_incomplete_report(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """A failed test run remains unsuccessful but still produces and opens a useful report.""" + output = tmp_path / "target" / "rule-coverage" + monkeypatch.setattr(rule_coverage, "ROOT", tmp_path) + monkeypatch.setattr(rule_coverage, "OUTPUT", output) + monkeypatch.setattr(rule_coverage, "EVENTS", output / "events") + monkeypatch.setattr( + rule_coverage.subprocess, + "run", + lambda command, **kwargs: subprocess.CompletedProcess(command, 1), + ) + opened: list[str] = [] + monkeypatch.setattr(rule_coverage.webbrowser, "open", lambda url: opened.append(url) or True) + + assert rule_coverage.run() == 1 + assert opened == [(output / "index.html").as_uri()] + assert "Status: **Incomplete**" in (output / "report.md").read_text(encoding="utf-8") + assert "No loaded YAML events found" in (output / "index.html").read_text(encoding="utf-8") diff --git a/src/lib.rs b/src/lib.rs index ac577a47f..97cf10d08 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -38,6 +38,8 @@ mod prefs; mod tts; mod xpath_functions; mod definitions; +#[cfg(feature = "rule-coverage")] +mod rule_coverage; pub mod pretty_print; mod chemistry; diff --git a/src/rule_coverage.rs b/src/rule_coverage.rs new file mode 100644 index 000000000..9fdea5504 --- /dev/null +++ b/src/rule_coverage.rs @@ -0,0 +1,69 @@ +//! Per-process YAML rule coverage events, enabled only by `rule-coverage`. + +use std::collections::HashSet; +use std::fs::{self, OpenOptions}; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; + +#[derive(Clone, Copy, Eq, Hash, PartialEq)] +enum EventKind { + Loaded, + Matched, + DefinedRule, + MatchedRule, +} + +static RECORDED: OnceLock>> = OnceLock::new(); + +fn rule_relative_path(path: &Path) -> Option { + let rules_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("Rules"); + let relative = path.strip_prefix(&rules_dir) + .or_else(|_| path.strip_prefix("Rules")) + .ok()?; + if relative.as_os_str().is_empty() || !matches!(relative.extension().and_then(|s| s.to_str()), Some("yaml" | "yml")) { + return None; + } + Some(relative.to_path_buf()) +} + +fn hex(value: &str) -> String { + value.as_bytes().iter().map(|byte| format!("{byte:02x}")).collect() +} + +fn record(kind: EventKind, path: &Path, name: &str, tag: &str) { + let Some(relative) = rule_relative_path(path) else { return }; + let mut recorded = RECORDED.get_or_init(|| Mutex::new(HashSet::new())) + .lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + let key = (kind, relative.clone(), name.to_string(), tag.to_string()); + if recorded.contains(&key) { + return; + } + + let event_dir = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("target/rule-coverage/events"); + fs::create_dir_all(&event_dir).expect("cannot create rule coverage event directory"); + let event_file = event_dir.join(format!("{}.events", std::process::id())); + let mut output = OpenOptions::new().create(true).append(true).open(event_file) + .expect("cannot open rule coverage event file"); + match kind { + EventKind::Loaded => writeln!(output, "loaded\t{}", relative.display()), + EventKind::Matched => writeln!(output, "matched\t{}", relative.display()), + EventKind::DefinedRule => writeln!(output, "defined-rule\t{}\t{}\t{}", relative.display(), hex(name), hex(tag)), + EventKind::MatchedRule => writeln!(output, "matched-rule\t{}\t{}\t{}", relative.display(), hex(name), hex(tag)), + }.expect("cannot write rule coverage event"); + recorded.insert(key); +} + +pub(crate) fn loaded(path: &Path) { + record(EventKind::Loaded, path, "", ""); +} + +pub(crate) fn defined_rule(path: &Path, name: &str, tag: &str) { + record(EventKind::DefinedRule, path, name, tag); +} + +pub(crate) fn matched_rule(path: &Path, name: &str, tag: &str) { + record(EventKind::Matched, path, "", ""); + record(EventKind::MatchedRule, path, name, tag); +} diff --git a/src/shim_filesystem.rs b/src/shim_filesystem.rs index d539a6300..b66316fc5 100644 --- a/src/shim_filesystem.rs +++ b/src/shim_filesystem.rs @@ -6,6 +6,14 @@ use std::path::{Path, PathBuf}; use crate::errors::*; use cfg_if::cfg_if; +fn record_successful_read(path: &Path, contents: String) -> String { + #[cfg(feature = "rule-coverage")] + crate::rule_coverage::loaded(path); + #[cfg(not(feature = "rule-coverage"))] + let _ = path; + contents +} + #[allow(unused_imports)] use log::{debug}; @@ -219,7 +227,7 @@ cfg_if! { return None; } }) { - return Ok(contents); + return Ok(record_successful_read(&path, contents)); }; let file_name = file_name.replace('\\', "/"); // zip files always use forward slash @@ -256,7 +264,7 @@ cfg_if! { if let Err(e) = file.read_to_string(&mut contents) { bail!("read_to_string: {}", e); } - return Ok(contents); + return Ok(record_successful_read(&path, contents)); }); } @@ -425,7 +433,7 @@ cfg_if! { pub fn read_to_string_shim(path: &Path) -> Result { if let Some(content) = IN_MEMORY_FILES.read().unwrap().as_ref().and_then(|f| f.get(&get_in_memory_key(path)).cloned()) { - return Ok(content); + return Ok(record_successful_read(path, content)); } let path = match path.canonicalize() { @@ -436,12 +444,12 @@ cfg_if! { if should_lock_rules_yaml_path(&path) { let _guard = RULES_ZIP_EXTRACT_LOCK.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); match std::fs::read_to_string(&path) { - Ok(str) => return Ok(str), + Ok(str) => return Ok(record_successful_read(&path, str)), Err(e) => bail!("Read error while trying to read {}: {}", path.display(), e), } } else { match std::fs::read_to_string(&path) { - Ok(str) => return Ok(str), + Ok(str) => return Ok(record_successful_read(&path, str)), Err(e) => bail!("Read error while trying to read {}: {}", path.display(), e), } } diff --git a/src/speech.rs b/src/speech.rs index 6d5b252a7..5bef345df 100644 --- a/src/speech.rs +++ b/src/speech.rs @@ -2301,6 +2301,14 @@ impl SpeechRules { if self.rules.is_empty() || !self.rule_files.is_file_up_to_date(&rule_file, should_ignore_file_time) { self.rules.clear(); let files_read = self.read_patterns(&rule_file)?; + #[cfg(feature = "rule-coverage")] + for patterns in self.rules.values() { + for pattern in patterns { + crate::rule_coverage::defined_rule( + Path::new(&pattern.file_name), &pattern.pattern_name, &pattern.tag_name + ); + } + } self.rule_files.set_files_and_times(files_read); } @@ -2524,6 +2532,10 @@ impl<'c, 's:'c, 'r, 'm:'c> SpeechRulesWithContext<'c, 's,'m> { } return match result { Ok(s) => { + #[cfg(feature = "rule-coverage")] + crate::rule_coverage::matched_rule( + Path::new(&pattern.file_name), &pattern.pattern_name, &pattern.tag_name + ); // for all except braille and navigation, nav_node_id will be an empty string and will not match if self.nav_node_id.is_empty() { Ok( Some(s) ) From d95a0210e075caad3550b61f6d16904675d5bf05 Mon Sep 17 00:00:00 2001 From: mgros Date: Sat, 26 Sep 2026 22:23:34 +0200 Subject: [PATCH 02/18] Add Python script to generate Markdown and HTML rule coverage reports --- .../rule_coverage_report.py | 136 ++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 PythonScripts/audit_translations/rule_coverage_report.py diff --git a/PythonScripts/audit_translations/rule_coverage_report.py b/PythonScripts/audit_translations/rule_coverage_report.py new file mode 100644 index 000000000..c24177898 --- /dev/null +++ b/PythonScripts/audit_translations/rule_coverage_report.py @@ -0,0 +1,136 @@ +"""Format file and rule coverage events as Markdown and an interactive HTML report.""" + +from collections import defaultdict +from html import escape + +type RuleKey = tuple[str, str, str] + + +def section(title: str, paths: set[str]) -> str: + """List covered YAML paths in a Markdown section.""" + lines = [f"## {title} ({len(paths)})"] + lines.extend(f"- `{path}`" for path in sorted(paths)) + return "\n".join(lines) + "\n" + + +def rule_section(title: str, rules: set[RuleKey]) -> str: + """List active rules with their source path, name, and MathML tag.""" + lines = [f"## {title} ({len(rules)})"] + lines.extend(f"- `{path}`: `{name}` (`{tag}`)" for path, name, tag in sorted(rules)) + return "\n".join(lines) + "\n" + + +def render_html( + loaded: set[str], + matched: set[str], + defined_rules: set[RuleKey], + matched_rules: set[RuleKey], + errors: list[str], +) -> str: + """Show file coverage and searchable rule details in a standalone page.""" + rules_by_path: dict[str, list[tuple[str, str, bool]]] = defaultdict(list) + for path, name, tag in defined_rules: + rules_by_path[path].append((name, tag, (path, name, tag) in matched_rules)) + + problems = "" + if errors: + problems = "

Problems

    " + "".join( + f"
  • {escape(error)}
  • " for error in errors + ) + "
" + + files = [] + for path in sorted(loaded | matched | rules_by_path.keys()): + rules = sorted(rules_by_path.get(path, []), key=lambda rule: (rule[0], rule[1])) + matched_count = sum(is_matched for _, _, is_matched in rules) + status = "matched" if path in matched else "unmatched" + if rules: + rule_items = "".join( + f'
  • ' + f'' + f'{"Matched" if is_matched else "No match"} ' + f'{escape(name)} ' + f'{escape(tag)}
  • ' + for name, tag, is_matched in rules + ) + contents = f"

    {matched_count} of {len(rules)} active rules matched

      {rule_items}
    " + else: + contents = '

    No active pattern rules

    ' + files.append( + f'
    ' + f'{"Matched" if path in matched else "No match"} ' + f'{escape(path)} {matched_count}/{len(rules)} rules' + f'{contents}
    ' + ) + + status = "Incomplete" if errors else "Complete" + return f""" + + + + +Rule YAML coverage + + + +

    Rule YAML coverage

    +

    Status: {status}

    +
    {len(loaded)} files loaded{len(matched)} pattern files matched +{len(matched_rules)} of {len(defined_rules)} active rules matched
    +

    Paths are relative to Rules/. A pattern file is matched when a rule from it +completes its replacement successfully.

    +{problems} +
    +
    +
    {"".join(files)}
    + + + + +""" From fe42cf8a2de905388e8f1e75ca6088f47929f7d8 Mon Sep 17 00:00:00 2001 From: mgros Date: Sat, 26 Sep 2026 22:35:30 +0200 Subject: [PATCH 03/18] clarify name of logfiles --- .../audit_translations/tests/test_rule_coverage_cli.py | 2 +- src/rule_coverage.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/PythonScripts/audit_translations/tests/test_rule_coverage_cli.py b/PythonScripts/audit_translations/tests/test_rule_coverage_cli.py index f07fe1872..01a5eb5ef 100644 --- a/PythonScripts/audit_translations/tests/test_rule_coverage_cli.py +++ b/PythonScripts/audit_translations/tests/test_rule_coverage_cli.py @@ -19,7 +19,7 @@ def test_coverage_command_generates_reports_and_opens_browser(tmp_path: Path, mo def fake_cargo(command: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]: assert command == ["cargo", "test", "--features", "rule-coverage"] assert kwargs["cwd"] == tmp_path - (output / "events" / "123.events").write_text( + (output / "events" / "pid-123.events").write_text( "loaded\tLanguages/en/SimpleSpeak_Rules.yaml\n" "loaded\tLanguages/en/definitions.yaml\n" "matched\tLanguages/en/SimpleSpeak_Rules.yaml\n" diff --git a/src/rule_coverage.rs b/src/rule_coverage.rs index 9fdea5504..595261909 100644 --- a/src/rule_coverage.rs +++ b/src/rule_coverage.rs @@ -43,7 +43,7 @@ fn record(kind: EventKind, path: &Path, name: &str, tag: &str) { let event_dir = Path::new(env!("CARGO_MANIFEST_DIR")) .join("target/rule-coverage/events"); fs::create_dir_all(&event_dir).expect("cannot create rule coverage event directory"); - let event_file = event_dir.join(format!("{}.events", std::process::id())); + let event_file = event_dir.join(format!("pid-{}.events", std::process::id())); let mut output = OpenOptions::new().create(true).append(true).open(event_file) .expect("cannot open rule coverage event file"); match kind { From d0c518484e699473b2ce6707c05c130e775d7944 Mon Sep 17 00:00:00 2001 From: mgros Date: Sat, 26 Sep 2026 22:46:53 +0200 Subject: [PATCH 04/18] Switch rule coverage events to JSONL format --- Cargo.lock | 26 +++++++++++++ Cargo.toml | 3 +- .../audit_translations/rule_coverage.py | 33 ++++++++++------- .../tests/test_rule_coverage_cli.py | 37 +++++++++++++++---- src/rule_coverage.rs | 20 +++++----- 5 files changed, 87 insertions(+), 32 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 529029471..66743aed7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -651,6 +651,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + [[package]] name = "jiff" version = "0.2.20" @@ -805,6 +811,7 @@ dependencies = [ "radix_fmt", "regex", "roman-numerals-rs", + "serde_json", "strum", "sxd-document-no-unsafe", "sxd-xpath-no-unsafe", @@ -1362,6 +1369,19 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "shlex" version = "2.0.1" @@ -2074,6 +2094,12 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7948af682ccbc3342b6e9420e8c51c1fe5d7bf7756002b4a3c6cabfe96a7e3c" +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + [[package]] name = "zopfli" version = "0.8.3" diff --git a/Cargo.toml b/Cargo.toml index d8dbefc0e..a51507536 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ exclude = ["src/main.rs", "docs", "BrailleDocs", "PythonScripts"] # should ha [features] -"rule-coverage" = [] +"rule-coverage" = ["dep:serde_json"] "include-zip" = [] "enable-logs" = ["android_logger"] "tts" = [ "natural-tts" ] @@ -39,6 +39,7 @@ cfg-if = "1.0.1" fastrand = { version = "2.3.0" } clap = { version = "4.5.60", features = ["derive"] } html-escape = "0.2.13" +serde_json = { version = "1.0", optional = true } elements_rs = { version = "0.2.8", default-features = false } # Runtime extracts inner language/braille zips (BZIP2). The package-rules binary also diff --git a/PythonScripts/audit_translations/rule_coverage.py b/PythonScripts/audit_translations/rule_coverage.py index 331bcde78..296430f51 100644 --- a/PythonScripts/audit_translations/rule_coverage.py +++ b/PythonScripts/audit_translations/rule_coverage.py @@ -1,5 +1,6 @@ """Run the full Rust test suite and report which rule YAML files it exercises.""" +import json import shutil import subprocess import sys @@ -19,28 +20,34 @@ def read_events() -> tuple[set[str], set[str], set[RuleKey], set[RuleKey], list[ defined_rules: set[RuleKey] = set() matched_rules: set[RuleKey] = set() errors: list[str] = [] - for event_file in sorted(EVENTS.glob("*.events")): + for event_file in sorted(EVENTS.glob("*.jsonl")): for number, line in enumerate(event_file.read_text(encoding="utf-8").splitlines(), 1): - fields = line.split("\t") - kind = fields[0] - path = fields[1] if len(fields) > 1 else "" + try: + event = json.loads(line) + except json.JSONDecodeError: + errors.append(f"Invalid JSON in {event_file.name}:{number}") + continue + if not isinstance(event, dict): + errors.append(f"Invalid event in {event_file.name}:{number}") + continue + kind = event.get("kind") + path = event.get("path") + if not isinstance(path, str): + errors.append(f"Invalid event in {event_file.name}:{number}") + continue path = path.replace("\\", "/") parts = PurePosixPath(path).parts if (not parts or PurePosixPath(path).is_absolute() or ".." in parts or PurePosixPath(path).suffix not in (".yaml", ".yml")): errors.append(f"Invalid event in {event_file.name}:{number}") - elif kind == "loaded" and len(fields) == 2: + elif kind == "loaded" and event.keys() == {"kind", "path"}: loaded.add(path) - elif kind == "matched" and len(fields) == 2: + elif kind == "matched" and event.keys() == {"kind", "path"}: matched.add(path) - elif kind in ("defined-rule", "matched-rule") and len(fields) == 4: - try: - name, tag = (bytes.fromhex(value).decode("utf-8") for value in fields[2:]) - except (ValueError, UnicodeDecodeError): - errors.append(f"Invalid rule identity in {event_file.name}:{number}") - continue - if not name or not tag: + elif kind in ("defined-rule", "matched-rule") and event.keys() == {"kind", "path", "name", "tag"}: + name, tag = event["name"], event["tag"] + if not isinstance(name, str) or not isinstance(tag, str) or not name or not tag: errors.append(f"Empty rule identity in {event_file.name}:{number}") elif kind == "defined-rule": defined_rules.add((path, name, tag)) diff --git a/PythonScripts/audit_translations/tests/test_rule_coverage_cli.py b/PythonScripts/audit_translations/tests/test_rule_coverage_cli.py index 01a5eb5ef..c0cb6a48b 100644 --- a/PythonScripts/audit_translations/tests/test_rule_coverage_cli.py +++ b/PythonScripts/audit_translations/tests/test_rule_coverage_cli.py @@ -1,5 +1,6 @@ """Checks the rule coverage command without rerunning the Rust test suite.""" +import json import subprocess import sys from pathlib import Path @@ -19,13 +20,16 @@ def test_coverage_command_generates_reports_and_opens_browser(tmp_path: Path, mo def fake_cargo(command: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]: assert command == ["cargo", "test", "--features", "rule-coverage"] assert kwargs["cwd"] == tmp_path - (output / "events" / "pid-123.events").write_text( - "loaded\tLanguages/en/SimpleSpeak_Rules.yaml\n" - "loaded\tLanguages/en/definitions.yaml\n" - "matched\tLanguages/en/SimpleSpeak_Rules.yaml\n" - "defined-rule\tLanguages/en/SimpleSpeak_Rules.yaml\t73696d706c65\t6d69\n" - "defined-rule\tLanguages/en/SimpleSpeak_Rules.yaml\t64656661756c74\t6d69\n" - "matched-rule\tLanguages/en/SimpleSpeak_Rules.yaml\t73696d706c65\t6d69\n", + events = [ + {"kind": "loaded", "path": "Languages/en/SimpleSpeak_Rules.yaml"}, + {"kind": "loaded", "path": "Languages/en/definitions.yaml"}, + {"kind": "matched", "path": "Languages/en/SimpleSpeak_Rules.yaml"}, + {"kind": "defined-rule", "path": "Languages/en/SimpleSpeak_Rules.yaml", "name": "simple", "tag": "mi"}, + {"kind": "defined-rule", "path": "Languages/en/SimpleSpeak_Rules.yaml", "name": "default", "tag": "mi"}, + {"kind": "matched-rule", "path": "Languages/en/SimpleSpeak_Rules.yaml", "name": "simple", "tag": "mi"}, + ] + (output / "events" / "pid-123.jsonl").write_text( + "\n".join(json.dumps(event) for event in events) + "\n", encoding="utf-8", ) return subprocess.CompletedProcess(command, 0) @@ -68,3 +72,22 @@ def test_failed_coverage_run_opens_incomplete_report(tmp_path: Path, monkeypatch assert opened == [(output / "index.html").as_uri()] assert "Status: **Incomplete**" in (output / "report.md").read_text(encoding="utf-8") assert "No loaded YAML events found" in (output / "index.html").read_text(encoding="utf-8") + + +def test_jsonl_rule_identity_preserves_separators_and_unicode(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """JSONL rule names survive tabs, newlines, and Unicode without splitting events.""" + events = tmp_path / "events" + events.mkdir() + monkeypatch.setattr(rule_coverage, "EVENTS", events) + name = "fraction\tname\nüber" + path = "Languages/en/SimpleSpeak_Rules.yaml" + (events / "pid-123.jsonl").write_text( + json.dumps({"kind": "defined-rule", "path": path, "name": name, "tag": "mfrac"}) + "\n", + encoding="utf-8", + ) + + loaded, matched, defined, matched_rules, errors = rule_coverage.read_events() + + assert loaded == matched == matched_rules == set() + assert defined == {(path, name, "mfrac")} + assert errors == [] diff --git a/src/rule_coverage.rs b/src/rule_coverage.rs index 595261909..56ca8f60d 100644 --- a/src/rule_coverage.rs +++ b/src/rule_coverage.rs @@ -27,10 +27,6 @@ fn rule_relative_path(path: &Path) -> Option { Some(relative.to_path_buf()) } -fn hex(value: &str) -> String { - value.as_bytes().iter().map(|byte| format!("{byte:02x}")).collect() -} - fn record(kind: EventKind, path: &Path, name: &str, tag: &str) { let Some(relative) = rule_relative_path(path) else { return }; let mut recorded = RECORDED.get_or_init(|| Mutex::new(HashSet::new())) @@ -43,15 +39,17 @@ fn record(kind: EventKind, path: &Path, name: &str, tag: &str) { let event_dir = Path::new(env!("CARGO_MANIFEST_DIR")) .join("target/rule-coverage/events"); fs::create_dir_all(&event_dir).expect("cannot create rule coverage event directory"); - let event_file = event_dir.join(format!("pid-{}.events", std::process::id())); + let event_file = event_dir.join(format!("pid-{}.jsonl", std::process::id())); let mut output = OpenOptions::new().create(true).append(true).open(event_file) .expect("cannot open rule coverage event file"); - match kind { - EventKind::Loaded => writeln!(output, "loaded\t{}", relative.display()), - EventKind::Matched => writeln!(output, "matched\t{}", relative.display()), - EventKind::DefinedRule => writeln!(output, "defined-rule\t{}\t{}\t{}", relative.display(), hex(name), hex(tag)), - EventKind::MatchedRule => writeln!(output, "matched-rule\t{}\t{}\t{}", relative.display(), hex(name), hex(tag)), - }.expect("cannot write rule coverage event"); + let path = relative.to_string_lossy(); + let event = match kind { + EventKind::Loaded => serde_json::json!({"kind": "loaded", "path": path}), + EventKind::Matched => serde_json::json!({"kind": "matched", "path": path}), + EventKind::DefinedRule => serde_json::json!({"kind": "defined-rule", "path": path, "name": name, "tag": tag}), + EventKind::MatchedRule => serde_json::json!({"kind": "matched-rule", "path": path, "name": name, "tag": tag}), + }; + writeln!(output, "{event}").expect("cannot write rule coverage event"); recorded.insert(key); } From 7b025f118813cec795024122f89bca28bad8aae5 Mon Sep 17 00:00:00 2001 From: mgros Date: Sat, 26 Sep 2026 22:58:16 +0200 Subject: [PATCH 05/18] switch to jinja2 templates --- .../rule_coverage_report.py | 121 ++++-------------- PythonScripts/pyproject.toml | 1 + PythonScripts/uv.lock | 44 +++++++ 3 files changed, 69 insertions(+), 97 deletions(-) diff --git a/PythonScripts/audit_translations/rule_coverage_report.py b/PythonScripts/audit_translations/rule_coverage_report.py index c24177898..fc0875109 100644 --- a/PythonScripts/audit_translations/rule_coverage_report.py +++ b/PythonScripts/audit_translations/rule_coverage_report.py @@ -1,10 +1,17 @@ """Format file and rule coverage events as Markdown and an interactive HTML report.""" from collections import defaultdict -from html import escape +from pathlib import Path + +from jinja2 import Environment, FileSystemLoader, select_autoescape type RuleKey = tuple[str, str, str] +TEMPLATE = Environment( + loader=FileSystemLoader(Path(__file__).parent), + autoescape=select_autoescape(("html",)), +).get_template("rule_coverage.html") + def section(title: str, paths: set[str]) -> str: """List covered YAML paths in a Markdown section.""" @@ -32,105 +39,25 @@ def render_html( for path, name, tag in defined_rules: rules_by_path[path].append((name, tag, (path, name, tag) in matched_rules)) - problems = "" - if errors: - problems = "

    Problems

      " + "".join( - f"
    • {escape(error)}
    • " for error in errors - ) + "
    " - files = [] for path in sorted(loaded | matched | rules_by_path.keys()): rules = sorted(rules_by_path.get(path, []), key=lambda rule: (rule[0], rule[1])) - matched_count = sum(is_matched for _, _, is_matched in rules) - status = "matched" if path in matched else "unmatched" - if rules: - rule_items = "".join( - f'
  • ' - f'' - f'{"Matched" if is_matched else "No match"} ' - f'{escape(name)} ' - f'{escape(tag)}
  • ' - for name, tag, is_matched in rules - ) - contents = f"

    {matched_count} of {len(rules)} active rules matched

      {rule_items}
    " - else: - contents = '

    No active pattern rules

    ' files.append( - f'
    ' - f'{"Matched" if path in matched else "No match"} ' - f'{escape(path)} {matched_count}/{len(rules)} rules' - f'{contents}
    ' + { + "path": path, + "matched": path in matched, + "matched_count": sum(is_matched for _, _, is_matched in rules), + "rule_count": len(rules), + "rules": [{"name": name, "tag": tag, "matched": is_matched} for name, tag, is_matched in rules], + } ) - status = "Incomplete" if errors else "Complete" - return f""" - - - - -Rule YAML coverage - - - -

    Rule YAML coverage

    -

    Status: {status}

    -
    {len(loaded)} files loaded{len(matched)} pattern files matched -{len(matched_rules)} of {len(defined_rules)} active rules matched
    -

    Paths are relative to Rules/. A pattern file is matched when a rule from it -completes its replacement successfully.

    -{problems} -
    -
    -
    {"".join(files)}
    - - - - -""" + return TEMPLATE.render( + status="Incomplete" if errors else "Complete", + loaded_count=len(loaded), + matched_count=len(matched), + defined_rule_count=len(defined_rules), + matched_rule_count=len(matched_rules), + errors=errors, + files=files, + ) diff --git a/PythonScripts/pyproject.toml b/PythonScripts/pyproject.toml index 7b9adf68d..e1f579a31 100644 --- a/PythonScripts/pyproject.toml +++ b/PythonScripts/pyproject.toml @@ -14,6 +14,7 @@ dependencies = [ "deepl", "googletrans", "html-table-extractor", + "jinja2>=3.1.6", "jsonpath-ng>=1.8.0", "pyyaml", "rich", diff --git a/PythonScripts/uv.lock b/PythonScripts/uv.lock index 81e0404c4..d2323646c 100644 --- a/PythonScripts/uv.lock +++ b/PythonScripts/uv.lock @@ -200,6 +200,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + [[package]] name = "jsonpath-ng" version = "1.8.0" @@ -221,6 +233,36 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, ] +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -282,6 +324,7 @@ dependencies = [ { name = "deepl" }, { name = "googletrans" }, { name = "html-table-extractor" }, + { name = "jinja2" }, { name = "jsonpath-ng" }, { name = "pyyaml" }, { name = "rich" }, @@ -300,6 +343,7 @@ requires-dist = [ { name = "deepl" }, { name = "googletrans" }, { name = "html-table-extractor" }, + { name = "jinja2", specifier = ">=3.1.6" }, { name = "jsonpath-ng", specifier = ">=1.8.0" }, { name = "pyyaml" }, { name = "rich" }, From 9667b81d9936d20951aa89a5ce9c3b8c21a955d4 Mon Sep 17 00:00:00 2001 From: mgros Date: Sat, 26 Sep 2026 23:06:59 +0200 Subject: [PATCH 06/18] Exclude Unicode mapping files from rule coverage report and improve report generation --- PythonScripts/audit_translations/README.md | 2 +- .../audit_translations/rule_coverage.html | 92 +++++++++++++++++++ .../audit_translations/rule_coverage.py | 4 + 3 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 PythonScripts/audit_translations/rule_coverage.html diff --git a/PythonScripts/audit_translations/README.md b/PythonScripts/audit_translations/README.md index 6f16189b7..ba3f341c2 100644 --- a/PythonScripts/audit_translations/README.md +++ b/PythonScripts/audit_translations/README.md @@ -84,7 +84,7 @@ uv run --project PythonScripts audit-translations --rule-coverage * `--rules-dir`: Override the Rules/Languages directory path. * `--only`: Filter issue types (comma-separated): `missing`, `untranslated`, `extra`, `diffs`, `all`. * `--verbose`: Show detailed output including source/target snippets for rule differences. -* `--rule-coverage`: Run the full Rust test suite, generate file- and rule-level YAML coverage under `target/rule-coverage/`, and open the HTML report. Expand a file to inspect matched and unmatched rules by name and tag. This runs independently of a language audit. +* `--rule-coverage`: Run the full Rust test suite, generate file- and rule-level pattern coverage under `target/rule-coverage/`, and open the HTML report. Unicode mapping files are omitted. Expand a file to inspect matched and unmatched rules by name and tag. This runs independently of a language audit. * **Summary Stats:** Provides a statistical summary after every run. **Examples:** diff --git a/PythonScripts/audit_translations/rule_coverage.html b/PythonScripts/audit_translations/rule_coverage.html new file mode 100644 index 000000000..8571b71d2 --- /dev/null +++ b/PythonScripts/audit_translations/rule_coverage.html @@ -0,0 +1,92 @@ + + + + + +Rule YAML coverage + + + +

    Rule YAML coverage

    +

    Status: {{ status }}

    +
    {{ loaded_count }} files loaded{{ matched_count }} pattern files matched +{{ matched_rule_count }} of {{ defined_rule_count }} active rules matched
    +

    Paths are relative to Rules/. A pattern file is matched when a rule from it +completes its replacement successfully.

    +

    Unicode mapping files are omitted because this report measures pattern-rule coverage.

    +{% if errors %} +

    Problems

      +{% for error in errors %}
    • {{ error }}
    • {% endfor %} +
    +{% endif %} +
    +
    +
    +{% for file in files %} +
    +{{ 'Matched' if file.matched else 'No match' }} +{{ file.path }} {{ file.matched_count }}/{{ file.rule_count }} rules +{% if file.rules %} +

    {{ file.matched_count }} of {{ file.rule_count }} active rules matched

      +{% for rule in file.rules %} +
    • +{{ 'Matched' if rule.matched else 'No match' }} +{{ rule.name }} {{ rule.tag }}
    • +{% endfor %} +
    +{% else %} +

    No active pattern rules

    +{% endif %} +
    +{% endfor %} +
    + + + + diff --git a/PythonScripts/audit_translations/rule_coverage.py b/PythonScripts/audit_translations/rule_coverage.py index 296430f51..0bf5f0c36 100644 --- a/PythonScripts/audit_translations/rule_coverage.py +++ b/PythonScripts/audit_translations/rule_coverage.py @@ -12,6 +12,7 @@ ROOT = Path(__file__).resolve().parents[2] OUTPUT = ROOT / "target" / "rule-coverage" EVENTS = OUTPUT / "events" +UNICODE_FILE_NAMES = {"unicode.yaml", "unicode-full.yaml"} def read_events() -> tuple[set[str], set[str], set[RuleKey], set[RuleKey], list[str]]: @@ -41,6 +42,8 @@ def read_events() -> tuple[set[str], set[str], set[RuleKey], set[RuleKey], list[ or PurePosixPath(path).is_absolute() or ".." in parts or PurePosixPath(path).suffix not in (".yaml", ".yml")): errors.append(f"Invalid event in {event_file.name}:{number}") + elif PurePosixPath(path).name.lower() in UNICODE_FILE_NAMES: + continue elif kind == "loaded" and event.keys() == {"kind", "path"}: loaded.add(path) elif kind == "matched" and event.keys() == {"kind", "path"}: @@ -101,6 +104,7 @@ def run() -> int: "# Rule YAML coverage\n", f"Status: **{status}**\n", "Paths are relative to `Rules/`. A pattern file is matched when a rule from it completes its replacement successfully.\n", + "Unicode mapping files are omitted because this report measures pattern-rule coverage.\n", ] if errors: report.append("## Problems\n\n" + "\n".join(f"- {error}" for error in errors) + "\n") From 8c7aebfab56cfa2d6b61a0ab1b58ce83bdce20b7 Mon Sep 17 00:00:00 2001 From: mgros Date: Sat, 26 Sep 2026 23:10:19 +0200 Subject: [PATCH 07/18] Exclude definition files from rule coverage report and update related documentation --- PythonScripts/audit_translations/README.md | 2 +- PythonScripts/audit_translations/rule_coverage.html | 2 +- PythonScripts/audit_translations/rule_coverage.py | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/PythonScripts/audit_translations/README.md b/PythonScripts/audit_translations/README.md index ba3f341c2..2f8acd6d7 100644 --- a/PythonScripts/audit_translations/README.md +++ b/PythonScripts/audit_translations/README.md @@ -84,7 +84,7 @@ uv run --project PythonScripts audit-translations --rule-coverage * `--rules-dir`: Override the Rules/Languages directory path. * `--only`: Filter issue types (comma-separated): `missing`, `untranslated`, `extra`, `diffs`, `all`. * `--verbose`: Show detailed output including source/target snippets for rule differences. -* `--rule-coverage`: Run the full Rust test suite, generate file- and rule-level pattern coverage under `target/rule-coverage/`, and open the HTML report. Unicode mapping files are omitted. Expand a file to inspect matched and unmatched rules by name and tag. This runs independently of a language audit. +* `--rule-coverage`: Run the full Rust test suite, generate file- and rule-level pattern coverage under `target/rule-coverage/`, and open the HTML report. Unicode mapping and definition files are omitted. Expand a file to inspect matched and unmatched rules by name and tag. This runs independently of a language audit. * **Summary Stats:** Provides a statistical summary after every run. **Examples:** diff --git a/PythonScripts/audit_translations/rule_coverage.html b/PythonScripts/audit_translations/rule_coverage.html index 8571b71d2..6970450df 100644 --- a/PythonScripts/audit_translations/rule_coverage.html +++ b/PythonScripts/audit_translations/rule_coverage.html @@ -32,7 +32,7 @@

    Rule YAML coverage

    {{ matched_rule_count }} of {{ defined_rule_count }} active rules matched

    Paths are relative to Rules/. A pattern file is matched when a rule from it completes its replacement successfully.

    -

    Unicode mapping files are omitted because this report measures pattern-rule coverage.

    +

    Unicode mapping and definition files are omitted because this report measures pattern-rule coverage.

    {% if errors %}

    Problems

      {% for error in errors %}
    • {{ error }}
    • {% endfor %} diff --git a/PythonScripts/audit_translations/rule_coverage.py b/PythonScripts/audit_translations/rule_coverage.py index 0bf5f0c36..968a27c3e 100644 --- a/PythonScripts/audit_translations/rule_coverage.py +++ b/PythonScripts/audit_translations/rule_coverage.py @@ -12,7 +12,7 @@ ROOT = Path(__file__).resolve().parents[2] OUTPUT = ROOT / "target" / "rule-coverage" EVENTS = OUTPUT / "events" -UNICODE_FILE_NAMES = {"unicode.yaml", "unicode-full.yaml"} +EXCLUDED_FILE_NAMES = {"definitions.yaml", "unicode.yaml", "unicode-full.yaml"} def read_events() -> tuple[set[str], set[str], set[RuleKey], set[RuleKey], list[str]]: @@ -42,7 +42,7 @@ def read_events() -> tuple[set[str], set[str], set[RuleKey], set[RuleKey], list[ or PurePosixPath(path).is_absolute() or ".." in parts or PurePosixPath(path).suffix not in (".yaml", ".yml")): errors.append(f"Invalid event in {event_file.name}:{number}") - elif PurePosixPath(path).name.lower() in UNICODE_FILE_NAMES: + elif PurePosixPath(path).name.lower() in EXCLUDED_FILE_NAMES: continue elif kind == "loaded" and event.keys() == {"kind", "path"}: loaded.add(path) @@ -104,7 +104,7 @@ def run() -> int: "# Rule YAML coverage\n", f"Status: **{status}**\n", "Paths are relative to `Rules/`. A pattern file is matched when a rule from it completes its replacement successfully.\n", - "Unicode mapping files are omitted because this report measures pattern-rule coverage.\n", + "Unicode mapping and definition files are omitted because this report measures pattern-rule coverage.\n", ] if errors: report.append("## Problems\n\n" + "\n".join(f"- {error}" for error in errors) + "\n") From a030a2331001667bb57a503ed174a08aead3f297 Mon Sep 17 00:00:00 2001 From: mgros Date: Sat, 26 Sep 2026 23:35:20 +0200 Subject: [PATCH 08/18] Add hit counts and test details to rule coverage report Enhance the rule coverage report to include hit counts, test details, and hoverable tooltips for matched rules. Improve report formatting and coverage fraction calculation. Update related tests and documentation to reflect these changes. --- PythonScripts/README.md | 2 +- PythonScripts/audit_translations/README.md | 2 +- .../audit_translations/rule_coverage.html | 21 +++++--- .../audit_translations/rule_coverage.py | 33 ++++++++----- .../rule_coverage_report.py | 49 +++++++++++++++---- .../tests/test_rule_coverage_cli.py | 18 +++++-- src/rule_coverage.rs | 11 +++-- 7 files changed, 99 insertions(+), 37 deletions(-) diff --git a/PythonScripts/README.md b/PythonScripts/README.md index 1ed867c38..b8393e22b 100644 --- a/PythonScripts/README.md +++ b/PythonScripts/README.md @@ -16,5 +16,5 @@ To run rule YAML coverage from the repo root, use the audit tool: uv run --project PythonScripts audit-translations --rule-coverage ``` -The tool opens `target/rule-coverage/index.html` in a browser when the run finishes. Expand a YAML file to see each active rule's name, tag, and match status. +The tool opens `target/rule-coverage/index.html` in a browser when the run finishes. Expand a YAML file to see each active rule's name, tag, hit count, and match status. Hover over a rule to see which tests hit it. Coverage fractions include percentages. The Markdown report and test output remain at `target/rule-coverage/report.md` and `target/rule-coverage/test.log`. diff --git a/PythonScripts/audit_translations/README.md b/PythonScripts/audit_translations/README.md index 2f8acd6d7..798d3d245 100644 --- a/PythonScripts/audit_translations/README.md +++ b/PythonScripts/audit_translations/README.md @@ -84,7 +84,7 @@ uv run --project PythonScripts audit-translations --rule-coverage * `--rules-dir`: Override the Rules/Languages directory path. * `--only`: Filter issue types (comma-separated): `missing`, `untranslated`, `extra`, `diffs`, `all`. * `--verbose`: Show detailed output including source/target snippets for rule differences. -* `--rule-coverage`: Run the full Rust test suite, generate file- and rule-level pattern coverage under `target/rule-coverage/`, and open the HTML report. Unicode mapping and definition files are omitted. Expand a file to inspect matched and unmatched rules by name and tag. This runs independently of a language audit. +* `--rule-coverage`: Run the full Rust test suite, generate file- and rule-level pattern coverage under `target/rule-coverage/`, and open the HTML report. Unicode mapping and definition files are omitted. Expand a file to inspect matched and unmatched rules by name and tag, their hit counts, and hover over a rule to see the tests that matched it. Coverage fractions include percentages. This runs independently of a language audit. * **Summary Stats:** Provides a statistical summary after every run. **Examples:** diff --git a/PythonScripts/audit_translations/rule_coverage.html b/PythonScripts/audit_translations/rule_coverage.html index 6970450df..c1d41b086 100644 --- a/PythonScripts/audit_translations/rule_coverage.html +++ b/PythonScripts/audit_translations/rule_coverage.html @@ -19,7 +19,12 @@ .badge.matched { color: #155c36; background: #dcf5e5; } .badge.unmatched { color: #8b3d00; background: #fff0cf; } .rules { list-style: none; padding-left: 1rem; } -.rules li { padding: .2rem 0; } +.rules li { position: relative; padding: .2rem 0; } +.rule-tooltip { display: none; position: absolute; z-index: 1; top: 100%; left: 0; + max-width: min(35rem, 90vw); max-height: 60vh; overflow: auto; padding: .5rem .7rem; + border: 1px solid #8396a5; border-radius: .3rem; background: #fff; color: #17212b; + box-shadow: 0 .2rem .6rem #0003; white-space: pre-line; overflow-wrap: anywhere; } +.rules li:hover .rule-tooltip, .rules li:focus .rule-tooltip { display: block; } .rule-name { font-weight: 600; } .empty { color: #52616d; } [hidden] { display: none !important; } @@ -28,8 +33,8 @@

      Rule YAML coverage

      Status: {{ status }}

      -
      {{ loaded_count }} files loaded{{ matched_count }} pattern files matched -{{ matched_rule_count }} of {{ defined_rule_count }} active rules matched
      +
      {{ loaded_count }} files loaded{{ file_coverage }} pattern files matched +{{ rule_coverage }} active rules matched

      Paths are relative to Rules/. A pattern file is matched when a rule from it completes its replacement successfully.

      Unicode mapping and definition files are omitted because this report measures pattern-rule coverage.

      @@ -46,13 +51,15 @@

      Rule YAML coverage

      {% for file in files %}
      {{ 'Matched' if file.matched else 'No match' }} -{{ file.path }} {{ file.matched_count }}/{{ file.rule_count }} rules +{{ file.path }} {{ file.coverage }} rules {% if file.rules %} -

      {{ file.matched_count }} of {{ file.rule_count }} active rules matched

        +

        {{ file.coverage }} active rules matched

          +{% set file_index = loop.index0 %} {% for rule in file.rules %} -
        • +
        • {{ 'Matched' if rule.matched else 'No match' }} -{{ rule.name }} {{ rule.tag }}
        • +{{ rule.name }} {{ rule.tag }} {{ rule.hits }} {{ 'hit' if rule.hits == 1 else 'hits' }} +{{ rule.tooltip }} {% endfor %}
        {% else %} diff --git a/PythonScripts/audit_translations/rule_coverage.py b/PythonScripts/audit_translations/rule_coverage.py index 968a27c3e..84812699f 100644 --- a/PythonScripts/audit_translations/rule_coverage.py +++ b/PythonScripts/audit_translations/rule_coverage.py @@ -5,9 +5,10 @@ import subprocess import sys import webbrowser +from collections import Counter, defaultdict from pathlib import Path, PurePosixPath -from .rule_coverage_report import RuleKey, render_html, rule_section, section +from .rule_coverage_report import RuleKey, coverage, render_html, rule_section, section ROOT = Path(__file__).resolve().parents[2] OUTPUT = ROOT / "target" / "rule-coverage" @@ -15,11 +16,12 @@ EXCLUDED_FILE_NAMES = {"definitions.yaml", "unicode.yaml", "unicode-full.yaml"} -def read_events() -> tuple[set[str], set[str], set[RuleKey], set[RuleKey], list[str]]: +def read_events() -> tuple[set[str], set[str], set[RuleKey], Counter[RuleKey], dict[RuleKey, Counter[str]], list[str]]: loaded: set[str] = set() matched: set[str] = set() defined_rules: set[RuleKey] = set() - matched_rules: set[RuleKey] = set() + rule_hits: Counter[RuleKey] = Counter() + rule_tests: dict[RuleKey, Counter[str]] = defaultdict(Counter) errors: list[str] = [] for event_file in sorted(EVENTS.glob("*.jsonl")): for number, line in enumerate(event_file.read_text(encoding="utf-8").splitlines(), 1): @@ -48,17 +50,21 @@ def read_events() -> tuple[set[str], set[str], set[RuleKey], set[RuleKey], list[ loaded.add(path) elif kind == "matched" and event.keys() == {"kind", "path"}: matched.add(path) - elif kind in ("defined-rule", "matched-rule") and event.keys() == {"kind", "path", "name", "tag"}: + elif kind in ("defined-rule", "matched-rule") and {"name", "tag"} <= event.keys(): name, tag = event["name"], event["tag"] if not isinstance(name, str) or not isinstance(tag, str) or not name or not tag: errors.append(f"Empty rule identity in {event_file.name}:{number}") - elif kind == "defined-rule": + elif kind == "defined-rule" and event.keys() == {"kind", "path", "name", "tag"}: defined_rules.add((path, name, tag)) + elif kind == "matched-rule" and event.keys() == {"kind", "path", "name", "tag", "test"} and isinstance(event["test"], str) and event["test"]: + key = (path, name, tag) + rule_hits[key] += 1 + rule_tests[key][event["test"]] += 1 else: - matched_rules.add((path, name, tag)) + errors.append(f"Invalid event in {event_file.name}:{number}") else: errors.append(f"Invalid event in {event_file.name}:{number}") - return loaded, matched, defined_rules, matched_rules, errors + return loaded, matched, defined_rules, rule_hits, dict(rule_tests), errors def run() -> int: @@ -79,7 +85,8 @@ def run() -> int: log.write(f"Could not run cargo: {error}\n") test_status = 1 - loaded, matched, defined_rules, matched_rules, errors = read_events() + loaded, matched, defined_rules, rule_hits, rule_tests, errors = read_events() + matched_rules = set(rule_hits) if test_status: errors.insert(0, f"cargo test failed (exit status {test_status}); see test.log") if not loaded: @@ -103,6 +110,8 @@ def run() -> int: report = [ "# Rule YAML coverage\n", f"Status: **{status}**\n", + f"Pattern files matched: **{coverage(len(matched), len(loaded))}**\n", + f"Active rules matched: **{coverage(len(matched_rules), len(defined_rules))}**\n", "Paths are relative to `Rules/`. A pattern file is matched when a rule from it completes its replacement successfully.\n", "Unicode mapping and definition files are omitted because this report measures pattern-rule coverage.\n", ] @@ -112,16 +121,16 @@ def run() -> int: section("Loaded YAML files", loaded), section("Matched pattern files", matched), section("Loaded files with no pattern match", loaded - matched), - rule_section("Matched rules", matched_rules), + rule_section("Matched rules", matched_rules, rule_hits, rule_tests), rule_section("Active rules with no match", defined_rules - matched_rules), )) report_path = OUTPUT / "report.md" report_path.write_text("\n".join(report), encoding="utf-8") html_path = OUTPUT / "index.html" - html_path.write_text(render_html(loaded, matched, defined_rules, matched_rules, errors), encoding="utf-8") + html_path.write_text(render_html(loaded, matched, defined_rules, rule_hits, rule_tests, errors), encoding="utf-8") print( - f"{status}: {len(loaded)} files loaded, {len(matched)} files matched; " - f"{len(matched_rules)} of {len(defined_rules)} rules matched; report: {html_path}" + f"{status}: {len(loaded)} files loaded, {coverage(len(matched), len(loaded))} files matched; " + f"{coverage(len(matched_rules), len(defined_rules))} rules matched; report: {html_path}" ) try: opened = webbrowser.open(html_path.resolve().as_uri()) diff --git a/PythonScripts/audit_translations/rule_coverage_report.py b/PythonScripts/audit_translations/rule_coverage_report.py index fc0875109..870a500f4 100644 --- a/PythonScripts/audit_translations/rule_coverage_report.py +++ b/PythonScripts/audit_translations/rule_coverage_report.py @@ -1,6 +1,6 @@ """Format file and rule coverage events as Markdown and an interactive HTML report.""" -from collections import defaultdict +from collections import Counter, defaultdict from pathlib import Path from jinja2 import Environment, FileSystemLoader, select_autoescape @@ -20,10 +20,27 @@ def section(title: str, paths: set[str]) -> str: return "\n".join(lines) + "\n" -def rule_section(title: str, rules: set[RuleKey]) -> str: +def coverage(count: int, total: int) -> str: + """Show the matched fraction and its percentage, including empty groups.""" + return f"{count}/{total} ({100 * count / total:.0f}%)" if total else "0/0 (0%)" + + +def rule_section( + title: str, + rules: set[RuleKey], + hits: Counter[RuleKey] | None = None, + tests: dict[RuleKey, Counter[str]] | None = None, +) -> str: """List active rules with their source path, name, and MathML tag.""" lines = [f"## {title} ({len(rules)})"] - lines.extend(f"- `{path}`: `{name}` (`{tag}`)" for path, name, tag in sorted(rules)) + for path, name, tag in sorted(rules): + key = (path, name, tag) + line = f"- `{path}`: `{name}` (`{tag}`)" + if hits is not None and tests is not None: + line += f" — {hits[key]} hits; tests: " + ", ".join( + f"`{test}` ({count})" for test, count in sorted(tests[key].items()) + ) + lines.append(line) return "\n".join(lines) + "\n" @@ -31,24 +48,34 @@ def render_html( loaded: set[str], matched: set[str], defined_rules: set[RuleKey], - matched_rules: set[RuleKey], + rule_hits: Counter[RuleKey], + rule_tests: dict[RuleKey, Counter[str]], errors: list[str], ) -> str: """Show file coverage and searchable rule details in a standalone page.""" - rules_by_path: dict[str, list[tuple[str, str, bool]]] = defaultdict(list) + rules_by_path: dict[str, list[dict]] = defaultdict(list) for path, name, tag in defined_rules: - rules_by_path[path].append((name, tag, (path, name, tag) in matched_rules)) + key = (path, name, tag) + rules_by_path[path].append({ + "name": name, "tag": tag, "matched": rule_hits[key] > 0, + "hits": rule_hits[key], "tests": sorted(rule_tests.get(key, {}).items()), + "tooltip": "Tests:\n" + "\n".join( + f"{test} ({count} {'hit' if count == 1 else 'hits'})" + for test, count in sorted(rule_tests.get(key, {}).items()) + ) if rule_hits[key] else "No test hits", + }) files = [] for path in sorted(loaded | matched | rules_by_path.keys()): - rules = sorted(rules_by_path.get(path, []), key=lambda rule: (rule[0], rule[1])) + rules = sorted(rules_by_path.get(path, []), key=lambda rule: (rule["name"], rule["tag"])) files.append( { "path": path, "matched": path in matched, - "matched_count": sum(is_matched for _, _, is_matched in rules), + "matched_count": sum(rule["matched"] for rule in rules), "rule_count": len(rules), - "rules": [{"name": name, "tag": tag, "matched": is_matched} for name, tag, is_matched in rules], + "coverage": coverage(sum(rule["matched"] for rule in rules), len(rules)), + "rules": rules, } ) @@ -57,7 +84,9 @@ def render_html( loaded_count=len(loaded), matched_count=len(matched), defined_rule_count=len(defined_rules), - matched_rule_count=len(matched_rules), + matched_rule_count=len(rule_hits), + file_coverage=coverage(len(matched), len(loaded)), + rule_coverage=coverage(len(rule_hits), len(defined_rules)), errors=errors, files=files, ) diff --git a/PythonScripts/audit_translations/tests/test_rule_coverage_cli.py b/PythonScripts/audit_translations/tests/test_rule_coverage_cli.py index c0cb6a48b..a22c33bc4 100644 --- a/PythonScripts/audit_translations/tests/test_rule_coverage_cli.py +++ b/PythonScripts/audit_translations/tests/test_rule_coverage_cli.py @@ -22,11 +22,14 @@ def fake_cargo(command: list[str], **kwargs: object) -> subprocess.CompletedProc assert kwargs["cwd"] == tmp_path events = [ {"kind": "loaded", "path": "Languages/en/SimpleSpeak_Rules.yaml"}, + {"kind": "loaded", "path": "Languages/en/SharedRules/empty.yaml"}, {"kind": "loaded", "path": "Languages/en/definitions.yaml"}, {"kind": "matched", "path": "Languages/en/SimpleSpeak_Rules.yaml"}, {"kind": "defined-rule", "path": "Languages/en/SimpleSpeak_Rules.yaml", "name": "simple", "tag": "mi"}, {"kind": "defined-rule", "path": "Languages/en/SimpleSpeak_Rules.yaml", "name": "default", "tag": "mi"}, - {"kind": "matched-rule", "path": "Languages/en/SimpleSpeak_Rules.yaml", "name": "simple", "tag": "mi"}, + {"kind": "matched-rule", "path": "Languages/en/SimpleSpeak_Rules.yaml", "name": "simple", "tag": "mi", "test": "test_alpha"}, + {"kind": "matched-rule", "path": "Languages/en/SimpleSpeak_Rules.yaml", "name": "simple", "tag": "mi", "test": "test_alpha"}, + {"kind": "matched-rule", "path": "Languages/en/SimpleSpeak_Rules.yaml", "name": "simple", "tag": "mi", "test": "test_beta"}, ] (output / "events" / "pid-123.jsonl").write_text( "\n".join(json.dumps(event) for event in events) + "\n", @@ -47,9 +50,17 @@ def fake_cargo(command: list[str], **kwargs: object) -> subprocess.CompletedProc assert "Status: **Complete**" in (output / "report.md").read_text(encoding="utf-8") assert "## Matched rules (1)" in (output / "report.md").read_text(encoding="utf-8") assert "## Active rules with no match (1)" in (output / "report.md").read_text(encoding="utf-8") + assert "3 hits; tests: `test_alpha` (2), `test_beta` (1)" in (output / "report.md").read_text(encoding="utf-8") html = (output / "index.html").read_text(encoding="utf-8") assert 'data-rule-search="simple mi" data-status="matched"' in html assert 'data-rule-search="default mi" data-status="unmatched"' in html + assert "1/2 (50%) active rules matched" in html + assert "1/2 (50%) rules" in html + assert 'role="tooltip">Tests:\ntest_alpha (2 hits)\ntest_beta (1 hit)' in html + assert ".rules li:hover .rule-tooltip, .rules li:focus .rule-tooltip { display: block; }" in html + assert 'aria-describedby="rule-tooltip-1-1"' in html + assert "3 hits" in html + assert "0 hits" in html assert "No active pattern rules" in html assert (output / "test.log").is_file() @@ -86,8 +97,9 @@ def test_jsonl_rule_identity_preserves_separators_and_unicode(tmp_path: Path, mo encoding="utf-8", ) - loaded, matched, defined, matched_rules, errors = rule_coverage.read_events() + loaded, matched, defined, rule_hits, rule_tests, errors = rule_coverage.read_events() - assert loaded == matched == matched_rules == set() + assert loaded == matched == set() + assert rule_hits == rule_tests == {} assert defined == {(path, name, "mfrac")} assert errors == [] diff --git a/src/rule_coverage.rs b/src/rule_coverage.rs index 56ca8f60d..5cde10f45 100644 --- a/src/rule_coverage.rs +++ b/src/rule_coverage.rs @@ -32,7 +32,7 @@ fn record(kind: EventKind, path: &Path, name: &str, tag: &str) { let mut recorded = RECORDED.get_or_init(|| Mutex::new(HashSet::new())) .lock().unwrap_or_else(|poisoned| poisoned.into_inner()); let key = (kind, relative.clone(), name.to_string(), tag.to_string()); - if recorded.contains(&key) { + if kind != EventKind::MatchedRule && recorded.contains(&key) { return; } @@ -47,10 +47,15 @@ fn record(kind: EventKind, path: &Path, name: &str, tag: &str) { EventKind::Loaded => serde_json::json!({"kind": "loaded", "path": path}), EventKind::Matched => serde_json::json!({"kind": "matched", "path": path}), EventKind::DefinedRule => serde_json::json!({"kind": "defined-rule", "path": path, "name": name, "tag": tag}), - EventKind::MatchedRule => serde_json::json!({"kind": "matched-rule", "path": path, "name": name, "tag": tag}), + EventKind::MatchedRule => serde_json::json!({ + "kind": "matched-rule", "path": path, "name": name, "tag": tag, + "test": std::thread::current().name().unwrap_or("(unnamed thread)"), + }), }; writeln!(output, "{event}").expect("cannot write rule coverage event"); - recorded.insert(key); + if kind != EventKind::MatchedRule { + recorded.insert(key); + } } pub(crate) fn loaded(path: &Path) { From bcecc325ad4e7a5a3ba6fd4cf45f5f559cdf5da0 Mon Sep 17 00:00:00 2001 From: mgros Date: Sat, 26 Sep 2026 23:36:45 +0200 Subject: [PATCH 09/18] fix title --- PythonScripts/audit_translations/rule_coverage.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PythonScripts/audit_translations/rule_coverage.py b/PythonScripts/audit_translations/rule_coverage.py index 84812699f..844da018e 100644 --- a/PythonScripts/audit_translations/rule_coverage.py +++ b/PythonScripts/audit_translations/rule_coverage.py @@ -108,7 +108,7 @@ def run() -> int: status = "Incomplete" if errors else "Complete" report = [ - "# Rule YAML coverage\n", + "# Rule Coverage\n", f"Status: **{status}**\n", f"Pattern files matched: **{coverage(len(matched), len(loaded))}**\n", f"Active rules matched: **{coverage(len(matched_rules), len(defined_rules))}**\n", From 80622d07c6e54b25562655e27a7f8cd56af0fb9f Mon Sep 17 00:00:00 2001 From: mgros Date: Sat, 26 Sep 2026 23:53:18 +0200 Subject: [PATCH 10/18] Simplify rule coverage event recording --- .../audit_translations/rule_coverage.py | 6 +- .../tests/test_rule_coverage_cli.py | 1 - src/rule_coverage.rs | 67 ++++++++++--------- 3 files changed, 36 insertions(+), 38 deletions(-) diff --git a/PythonScripts/audit_translations/rule_coverage.py b/PythonScripts/audit_translations/rule_coverage.py index 844da018e..428b70c2f 100644 --- a/PythonScripts/audit_translations/rule_coverage.py +++ b/PythonScripts/audit_translations/rule_coverage.py @@ -18,7 +18,6 @@ def read_events() -> tuple[set[str], set[str], set[RuleKey], Counter[RuleKey], dict[RuleKey, Counter[str]], list[str]]: loaded: set[str] = set() - matched: set[str] = set() defined_rules: set[RuleKey] = set() rule_hits: Counter[RuleKey] = Counter() rule_tests: dict[RuleKey, Counter[str]] = defaultdict(Counter) @@ -48,8 +47,6 @@ def read_events() -> tuple[set[str], set[str], set[RuleKey], Counter[RuleKey], d continue elif kind == "loaded" and event.keys() == {"kind", "path"}: loaded.add(path) - elif kind == "matched" and event.keys() == {"kind", "path"}: - matched.add(path) elif kind in ("defined-rule", "matched-rule") and {"name", "tag"} <= event.keys(): name, tag = event["name"], event["tag"] if not isinstance(name, str) or not isinstance(tag, str) or not name or not tag: @@ -64,6 +61,7 @@ def read_events() -> tuple[set[str], set[str], set[RuleKey], Counter[RuleKey], d errors.append(f"Invalid event in {event_file.name}:{number}") else: errors.append(f"Invalid event in {event_file.name}:{number}") + matched = {path for path, _, _ in rule_hits} return loaded, matched, defined_rules, rule_hits, dict(rule_tests), errors @@ -103,8 +101,6 @@ def run() -> int: errors.append("Matched rules lack definition events") if {path for path, _, _ in defined_rules} - loaded: errors.append("Defined rules have no loaded YAML event") - if matched != {path for path, _, _ in matched_rules}: - errors.append("File and rule match events disagree") status = "Incomplete" if errors else "Complete" report = [ diff --git a/PythonScripts/audit_translations/tests/test_rule_coverage_cli.py b/PythonScripts/audit_translations/tests/test_rule_coverage_cli.py index a22c33bc4..b96794666 100644 --- a/PythonScripts/audit_translations/tests/test_rule_coverage_cli.py +++ b/PythonScripts/audit_translations/tests/test_rule_coverage_cli.py @@ -24,7 +24,6 @@ def fake_cargo(command: list[str], **kwargs: object) -> subprocess.CompletedProc {"kind": "loaded", "path": "Languages/en/SimpleSpeak_Rules.yaml"}, {"kind": "loaded", "path": "Languages/en/SharedRules/empty.yaml"}, {"kind": "loaded", "path": "Languages/en/definitions.yaml"}, - {"kind": "matched", "path": "Languages/en/SimpleSpeak_Rules.yaml"}, {"kind": "defined-rule", "path": "Languages/en/SimpleSpeak_Rules.yaml", "name": "simple", "tag": "mi"}, {"kind": "defined-rule", "path": "Languages/en/SimpleSpeak_Rules.yaml", "name": "default", "tag": "mi"}, {"kind": "matched-rule", "path": "Languages/en/SimpleSpeak_Rules.yaml", "name": "simple", "tag": "mi", "test": "test_alpha"}, diff --git a/src/rule_coverage.rs b/src/rule_coverage.rs index 5cde10f45..52fe8c2d9 100644 --- a/src/rule_coverage.rs +++ b/src/rule_coverage.rs @@ -1,20 +1,35 @@ //! Per-process YAML rule coverage events, enabled only by `rule-coverage`. use std::collections::HashSet; -use std::fs::{self, OpenOptions}; +use std::fs::{self, File, OpenOptions}; use std::io::Write; use std::path::{Path, PathBuf}; use std::sync::{Mutex, OnceLock}; #[derive(Clone, Copy, Eq, Hash, PartialEq)] -enum EventKind { +enum OnceKind { Loaded, - Matched, DefinedRule, - MatchedRule, } -static RECORDED: OnceLock>> = OnceLock::new(); +struct Recorder { + output: File, + recorded: HashSet<(OnceKind, PathBuf, String, String)>, +} + +static RECORDER: OnceLock> = OnceLock::new(); + +fn recorder() -> &'static Mutex { + RECORDER.get_or_init(|| { + let event_dir = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("target/rule-coverage/events"); + fs::create_dir_all(&event_dir).expect("cannot create rule coverage event directory"); + let event_file = event_dir.join(format!("pid-{}.jsonl", std::process::id())); + let output = OpenOptions::new().create(true).append(true).open(event_file) + .expect("cannot open rule coverage event file"); + Mutex::new(Recorder { output, recorded: HashSet::new() }) + }) +} fn rule_relative_path(path: &Path) -> Option { let rules_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("Rules"); @@ -27,46 +42,34 @@ fn rule_relative_path(path: &Path) -> Option { Some(relative.to_path_buf()) } -fn record(kind: EventKind, path: &Path, name: &str, tag: &str) { +fn record_once(kind: OnceKind, path: &Path, name: &str, tag: &str) { let Some(relative) = rule_relative_path(path) else { return }; - let mut recorded = RECORDED.get_or_init(|| Mutex::new(HashSet::new())) - .lock().unwrap_or_else(|poisoned| poisoned.into_inner()); - let key = (kind, relative.clone(), name.to_string(), tag.to_string()); - if kind != EventKind::MatchedRule && recorded.contains(&key) { + let mut recorder = recorder().lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + if !recorder.recorded.insert((kind, relative.clone(), name.to_string(), tag.to_string())) { return; } - - let event_dir = Path::new(env!("CARGO_MANIFEST_DIR")) - .join("target/rule-coverage/events"); - fs::create_dir_all(&event_dir).expect("cannot create rule coverage event directory"); - let event_file = event_dir.join(format!("pid-{}.jsonl", std::process::id())); - let mut output = OpenOptions::new().create(true).append(true).open(event_file) - .expect("cannot open rule coverage event file"); let path = relative.to_string_lossy(); let event = match kind { - EventKind::Loaded => serde_json::json!({"kind": "loaded", "path": path}), - EventKind::Matched => serde_json::json!({"kind": "matched", "path": path}), - EventKind::DefinedRule => serde_json::json!({"kind": "defined-rule", "path": path, "name": name, "tag": tag}), - EventKind::MatchedRule => serde_json::json!({ - "kind": "matched-rule", "path": path, "name": name, "tag": tag, - "test": std::thread::current().name().unwrap_or("(unnamed thread)"), - }), + OnceKind::Loaded => serde_json::json!({"kind": "loaded", "path": path}), + OnceKind::DefinedRule => serde_json::json!({"kind": "defined-rule", "path": path, "name": name, "tag": tag}), }; - writeln!(output, "{event}").expect("cannot write rule coverage event"); - if kind != EventKind::MatchedRule { - recorded.insert(key); - } + writeln!(recorder.output, "{event}").expect("cannot write rule coverage event"); } pub(crate) fn loaded(path: &Path) { - record(EventKind::Loaded, path, "", ""); + record_once(OnceKind::Loaded, path, "", ""); } pub(crate) fn defined_rule(path: &Path, name: &str, tag: &str) { - record(EventKind::DefinedRule, path, name, tag); + record_once(OnceKind::DefinedRule, path, name, tag); } pub(crate) fn matched_rule(path: &Path, name: &str, tag: &str) { - record(EventKind::Matched, path, "", ""); - record(EventKind::MatchedRule, path, name, tag); + let Some(relative) = rule_relative_path(path) else { return }; + let mut recorder = recorder().lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + let event = serde_json::json!({ + "kind": "matched-rule", "path": relative.to_string_lossy(), "name": name, "tag": tag, + "test": std::thread::current().name().unwrap_or("(unnamed thread)"), + }); + writeln!(recorder.output, "{event}").expect("cannot write rule coverage event"); } From a1ce78a2d986826df1f439404dd03556307ae852 Mon Sep 17 00:00:00 2001 From: mgros Date: Sat, 26 Sep 2026 23:57:52 +0200 Subject: [PATCH 11/18] Stream rule coverage events and trim unused report fields --- PythonScripts/audit_translations/rule_coverage_report.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/PythonScripts/audit_translations/rule_coverage_report.py b/PythonScripts/audit_translations/rule_coverage_report.py index 870a500f4..5c13cafd4 100644 --- a/PythonScripts/audit_translations/rule_coverage_report.py +++ b/PythonScripts/audit_translations/rule_coverage_report.py @@ -58,7 +58,7 @@ def render_html( key = (path, name, tag) rules_by_path[path].append({ "name": name, "tag": tag, "matched": rule_hits[key] > 0, - "hits": rule_hits[key], "tests": sorted(rule_tests.get(key, {}).items()), + "hits": rule_hits[key], "tooltip": "Tests:\n" + "\n".join( f"{test} ({count} {'hit' if count == 1 else 'hits'})" for test, count in sorted(rule_tests.get(key, {}).items()) @@ -72,8 +72,6 @@ def render_html( { "path": path, "matched": path in matched, - "matched_count": sum(rule["matched"] for rule in rules), - "rule_count": len(rules), "coverage": coverage(sum(rule["matched"] for rule in rules), len(rules)), "rules": rules, } @@ -82,9 +80,6 @@ def render_html( return TEMPLATE.render( status="Incomplete" if errors else "Complete", loaded_count=len(loaded), - matched_count=len(matched), - defined_rule_count=len(defined_rules), - matched_rule_count=len(rule_hits), file_coverage=coverage(len(matched), len(loaded)), rule_coverage=coverage(len(rule_hits), len(defined_rules)), errors=errors, From 4133764808840f9a8bbf2e61717367559b67a371 Mon Sep 17 00:00:00 2001 From: mgros Date: Sun, 27 Sep 2026 00:02:37 +0200 Subject: [PATCH 12/18] Suppress unused variable warning when `rule-coverage` feature is disabled --- src/shim_filesystem.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/shim_filesystem.rs b/src/shim_filesystem.rs index b66316fc5..e828495bc 100644 --- a/src/shim_filesystem.rs +++ b/src/shim_filesystem.rs @@ -10,7 +10,7 @@ fn record_successful_read(path: &Path, contents: String) -> String { #[cfg(feature = "rule-coverage")] crate::rule_coverage::loaded(path); #[cfg(not(feature = "rule-coverage"))] - let _ = path; + let _ = path; // suppress unused variable warning if feature is not enabled contents } From 34add29036edda4ce5e6aeddeb5b935249c0ff8e Mon Sep 17 00:00:00 2001 From: mgros Date: Sun, 27 Sep 2026 00:06:25 +0200 Subject: [PATCH 13/18] Migrate `rule_coverage` implementation to a dedicated `rulecoverage` package, separating logic, templates, and CLI. --- PythonScripts/audit_translations/cli.py | 2 +- .../audit_translations/rulecoverage/__init__.py | 1 + .../{ => rulecoverage}/rule_coverage.html | 0 .../{ => rulecoverage}/rule_coverage.py | 5 +++-- .../{ => rulecoverage}/rule_coverage_report.py | 0 .../tests/test_rule_coverage_cli.py | 12 ++++++++---- 6 files changed, 13 insertions(+), 7 deletions(-) create mode 100644 PythonScripts/audit_translations/rulecoverage/__init__.py rename PythonScripts/audit_translations/{ => rulecoverage}/rule_coverage.html (100%) rename PythonScripts/audit_translations/{ => rulecoverage}/rule_coverage.py (96%) rename PythonScripts/audit_translations/{ => rulecoverage}/rule_coverage_report.py (100%) diff --git a/PythonScripts/audit_translations/cli.py b/PythonScripts/audit_translations/cli.py index 70cf629a4..8fd53485c 100644 --- a/PythonScripts/audit_translations/cli.py +++ b/PythonScripts/audit_translations/cli.py @@ -10,7 +10,7 @@ from .auditor import audit_language, list_languages from .errors import AuditError from .renderer import console -from .rule_coverage import run as run_rule_coverage +from .rulecoverage.rule_coverage import run as run_rule_coverage def main() -> None: diff --git a/PythonScripts/audit_translations/rulecoverage/__init__.py b/PythonScripts/audit_translations/rulecoverage/__init__.py new file mode 100644 index 000000000..f9de05fe7 --- /dev/null +++ b/PythonScripts/audit_translations/rulecoverage/__init__.py @@ -0,0 +1 @@ +"""Rule coverage collection and report generation.""" diff --git a/PythonScripts/audit_translations/rule_coverage.html b/PythonScripts/audit_translations/rulecoverage/rule_coverage.html similarity index 100% rename from PythonScripts/audit_translations/rule_coverage.html rename to PythonScripts/audit_translations/rulecoverage/rule_coverage.html diff --git a/PythonScripts/audit_translations/rule_coverage.py b/PythonScripts/audit_translations/rulecoverage/rule_coverage.py similarity index 96% rename from PythonScripts/audit_translations/rule_coverage.py rename to PythonScripts/audit_translations/rulecoverage/rule_coverage.py index 428b70c2f..01cc6a429 100644 --- a/PythonScripts/audit_translations/rule_coverage.py +++ b/PythonScripts/audit_translations/rulecoverage/rule_coverage.py @@ -10,7 +10,7 @@ from .rule_coverage_report import RuleKey, coverage, render_html, rule_section, section -ROOT = Path(__file__).resolve().parents[2] +ROOT = Path(__file__).resolve().parents[3] OUTPUT = ROOT / "target" / "rule-coverage" EVENTS = OUTPUT / "events" EXCLUDED_FILE_NAMES = {"definitions.yaml", "unicode.yaml", "unicode-full.yaml"} @@ -53,7 +53,8 @@ def read_events() -> tuple[set[str], set[str], set[RuleKey], Counter[RuleKey], d errors.append(f"Empty rule identity in {event_file.name}:{number}") elif kind == "defined-rule" and event.keys() == {"kind", "path", "name", "tag"}: defined_rules.add((path, name, tag)) - elif kind == "matched-rule" and event.keys() == {"kind", "path", "name", "tag", "test"} and isinstance(event["test"], str) and event["test"]: + elif (kind == "matched-rule" and event.keys() == {"kind", "path", "name", "tag", "test"} + and isinstance(event["test"], str) and event["test"]): key = (path, name, tag) rule_hits[key] += 1 rule_tests[key][event["test"]] += 1 diff --git a/PythonScripts/audit_translations/rule_coverage_report.py b/PythonScripts/audit_translations/rulecoverage/rule_coverage_report.py similarity index 100% rename from PythonScripts/audit_translations/rule_coverage_report.py rename to PythonScripts/audit_translations/rulecoverage/rule_coverage_report.py diff --git a/PythonScripts/audit_translations/tests/test_rule_coverage_cli.py b/PythonScripts/audit_translations/tests/test_rule_coverage_cli.py index b96794666..e99276b01 100644 --- a/PythonScripts/audit_translations/tests/test_rule_coverage_cli.py +++ b/PythonScripts/audit_translations/tests/test_rule_coverage_cli.py @@ -7,7 +7,8 @@ import pytest -from .. import cli, rule_coverage +from .. import cli +from ..rulecoverage import rule_coverage def test_coverage_command_generates_reports_and_opens_browser(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: @@ -26,9 +27,12 @@ def fake_cargo(command: list[str], **kwargs: object) -> subprocess.CompletedProc {"kind": "loaded", "path": "Languages/en/definitions.yaml"}, {"kind": "defined-rule", "path": "Languages/en/SimpleSpeak_Rules.yaml", "name": "simple", "tag": "mi"}, {"kind": "defined-rule", "path": "Languages/en/SimpleSpeak_Rules.yaml", "name": "default", "tag": "mi"}, - {"kind": "matched-rule", "path": "Languages/en/SimpleSpeak_Rules.yaml", "name": "simple", "tag": "mi", "test": "test_alpha"}, - {"kind": "matched-rule", "path": "Languages/en/SimpleSpeak_Rules.yaml", "name": "simple", "tag": "mi", "test": "test_alpha"}, - {"kind": "matched-rule", "path": "Languages/en/SimpleSpeak_Rules.yaml", "name": "simple", "tag": "mi", "test": "test_beta"}, + {"kind": "matched-rule", "path": "Languages/en/SimpleSpeak_Rules.yaml", + "name": "simple", "tag": "mi", "test": "test_alpha"}, + {"kind": "matched-rule", "path": "Languages/en/SimpleSpeak_Rules.yaml", + "name": "simple", "tag": "mi", "test": "test_alpha"}, + {"kind": "matched-rule", "path": "Languages/en/SimpleSpeak_Rules.yaml", + "name": "simple", "tag": "mi", "test": "test_beta"}, ] (output / "events" / "pid-123.jsonl").write_text( "\n".join(json.dumps(event) for event in events) + "\n", From ca3000f0a10b95628d8b529a015b413b4510d671 Mon Sep 17 00:00:00 2001 From: mgros Date: Sun, 27 Sep 2026 00:08:14 +0200 Subject: [PATCH 14/18] uv run ruff format --- PythonScripts/audit_translations/cli.py | 3 +- .../rulecoverage/rule_coverage.py | 35 ++++++++++++------- .../rulecoverage/rule_coverage_report.py | 23 +++++++----- .../tests/test_rule_coverage_cli.py | 27 ++++++++++---- 4 files changed, 59 insertions(+), 29 deletions(-) diff --git a/PythonScripts/audit_translations/cli.py b/PythonScripts/audit_translations/cli.py index 8fd53485c..81cac0dd0 100644 --- a/PythonScripts/audit_translations/cli.py +++ b/PythonScripts/audit_translations/cli.py @@ -51,8 +51,7 @@ def main() -> None: args = parser.parse_args() if args.rule_coverage: - if (args.language or args.list or args.specific_file or args.rules_dir or args.only - or args.verbose or args.source != "en"): + if args.language or args.list or args.specific_file or args.rules_dir or args.only or args.verbose or args.source != "en": parser.error("--rule-coverage cannot be combined with translation audit options") sys.exit(run_rule_coverage()) elif args.list: diff --git a/PythonScripts/audit_translations/rulecoverage/rule_coverage.py b/PythonScripts/audit_translations/rulecoverage/rule_coverage.py index 01cc6a429..9b59c6632 100644 --- a/PythonScripts/audit_translations/rulecoverage/rule_coverage.py +++ b/PythonScripts/audit_translations/rulecoverage/rule_coverage.py @@ -39,9 +39,12 @@ def read_events() -> tuple[set[str], set[str], set[RuleKey], Counter[RuleKey], d continue path = path.replace("\\", "/") parts = PurePosixPath(path).parts - if (not parts - or PurePosixPath(path).is_absolute() or ".." in parts - or PurePosixPath(path).suffix not in (".yaml", ".yml")): + if ( + not parts + or PurePosixPath(path).is_absolute() + or ".." in parts + or PurePosixPath(path).suffix not in (".yaml", ".yml") + ): errors.append(f"Invalid event in {event_file.name}:{number}") elif PurePosixPath(path).name.lower() in EXCLUDED_FILE_NAMES: continue @@ -53,8 +56,12 @@ def read_events() -> tuple[set[str], set[str], set[RuleKey], Counter[RuleKey], d errors.append(f"Empty rule identity in {event_file.name}:{number}") elif kind == "defined-rule" and event.keys() == {"kind", "path", "name", "tag"}: defined_rules.add((path, name, tag)) - elif (kind == "matched-rule" and event.keys() == {"kind", "path", "name", "tag", "test"} - and isinstance(event["test"], str) and event["test"]): + elif ( + kind == "matched-rule" + and event.keys() == {"kind", "path", "name", "tag", "test"} + and isinstance(event["test"], str) + and event["test"] + ): key = (path, name, tag) rule_hits[key] += 1 rule_tests[key][event["test"]] += 1 @@ -114,13 +121,15 @@ def run() -> int: ] if errors: report.append("## Problems\n\n" + "\n".join(f"- {error}" for error in errors) + "\n") - report.extend(( - section("Loaded YAML files", loaded), - section("Matched pattern files", matched), - section("Loaded files with no pattern match", loaded - matched), - rule_section("Matched rules", matched_rules, rule_hits, rule_tests), - rule_section("Active rules with no match", defined_rules - matched_rules), - )) + report.extend( + ( + section("Loaded YAML files", loaded), + section("Matched pattern files", matched), + section("Loaded files with no pattern match", loaded - matched), + rule_section("Matched rules", matched_rules, rule_hits, rule_tests), + rule_section("Active rules with no match", defined_rules - matched_rules), + ) + ) report_path = OUTPUT / "report.md" report_path.write_text("\n".join(report), encoding="utf-8") html_path = OUTPUT / "index.html" @@ -131,7 +140,7 @@ def run() -> int: ) try: opened = webbrowser.open(html_path.resolve().as_uri()) - except (OSError, webbrowser.Error): + except OSError, webbrowser.Error: opened = False if not opened: print(f"Browser unavailable; open {html_path} manually") diff --git a/PythonScripts/audit_translations/rulecoverage/rule_coverage_report.py b/PythonScripts/audit_translations/rulecoverage/rule_coverage_report.py index 5c13cafd4..4b49ac22c 100644 --- a/PythonScripts/audit_translations/rulecoverage/rule_coverage_report.py +++ b/PythonScripts/audit_translations/rulecoverage/rule_coverage_report.py @@ -56,14 +56,21 @@ def render_html( rules_by_path: dict[str, list[dict]] = defaultdict(list) for path, name, tag in defined_rules: key = (path, name, tag) - rules_by_path[path].append({ - "name": name, "tag": tag, "matched": rule_hits[key] > 0, - "hits": rule_hits[key], - "tooltip": "Tests:\n" + "\n".join( - f"{test} ({count} {'hit' if count == 1 else 'hits'})" - for test, count in sorted(rule_tests.get(key, {}).items()) - ) if rule_hits[key] else "No test hits", - }) + rules_by_path[path].append( + { + "name": name, + "tag": tag, + "matched": rule_hits[key] > 0, + "hits": rule_hits[key], + "tooltip": "Tests:\n" + + "\n".join( + f"{test} ({count} {'hit' if count == 1 else 'hits'})" + for test, count in sorted(rule_tests.get(key, {}).items()) + ) + if rule_hits[key] + else "No test hits", + } + ) files = [] for path in sorted(loaded | matched | rules_by_path.keys()): diff --git a/PythonScripts/audit_translations/tests/test_rule_coverage_cli.py b/PythonScripts/audit_translations/tests/test_rule_coverage_cli.py index e99276b01..711ddaeb6 100644 --- a/PythonScripts/audit_translations/tests/test_rule_coverage_cli.py +++ b/PythonScripts/audit_translations/tests/test_rule_coverage_cli.py @@ -27,12 +27,27 @@ def fake_cargo(command: list[str], **kwargs: object) -> subprocess.CompletedProc {"kind": "loaded", "path": "Languages/en/definitions.yaml"}, {"kind": "defined-rule", "path": "Languages/en/SimpleSpeak_Rules.yaml", "name": "simple", "tag": "mi"}, {"kind": "defined-rule", "path": "Languages/en/SimpleSpeak_Rules.yaml", "name": "default", "tag": "mi"}, - {"kind": "matched-rule", "path": "Languages/en/SimpleSpeak_Rules.yaml", - "name": "simple", "tag": "mi", "test": "test_alpha"}, - {"kind": "matched-rule", "path": "Languages/en/SimpleSpeak_Rules.yaml", - "name": "simple", "tag": "mi", "test": "test_alpha"}, - {"kind": "matched-rule", "path": "Languages/en/SimpleSpeak_Rules.yaml", - "name": "simple", "tag": "mi", "test": "test_beta"}, + { + "kind": "matched-rule", + "path": "Languages/en/SimpleSpeak_Rules.yaml", + "name": "simple", + "tag": "mi", + "test": "test_alpha", + }, + { + "kind": "matched-rule", + "path": "Languages/en/SimpleSpeak_Rules.yaml", + "name": "simple", + "tag": "mi", + "test": "test_alpha", + }, + { + "kind": "matched-rule", + "path": "Languages/en/SimpleSpeak_Rules.yaml", + "name": "simple", + "tag": "mi", + "test": "test_beta", + }, ] (output / "events" / "pid-123.jsonl").write_text( "\n".join(json.dumps(event) for event in events) + "\n", From 8786236e304354f114e3e38ac55854a1388df446 Mon Sep 17 00:00:00 2001 From: mgros Date: Sun, 27 Sep 2026 00:30:27 +0200 Subject: [PATCH 15/18] simplify dataclasses in Python --- .../rulecoverage/rule_coverage.py | 20 +++++------ .../rulecoverage/rule_coverage_report.py | 27 +++++++------- .../tests/test_rule_coverage_cli.py | 36 +++++++++++++++++-- 3 files changed, 56 insertions(+), 27 deletions(-) diff --git a/PythonScripts/audit_translations/rulecoverage/rule_coverage.py b/PythonScripts/audit_translations/rulecoverage/rule_coverage.py index 9b59c6632..0fa09a167 100644 --- a/PythonScripts/audit_translations/rulecoverage/rule_coverage.py +++ b/PythonScripts/audit_translations/rulecoverage/rule_coverage.py @@ -16,11 +16,10 @@ EXCLUDED_FILE_NAMES = {"definitions.yaml", "unicode.yaml", "unicode-full.yaml"} -def read_events() -> tuple[set[str], set[str], set[RuleKey], Counter[RuleKey], dict[RuleKey, Counter[str]], list[str]]: +def read_events() -> tuple[set[str], set[RuleKey], dict[RuleKey, Counter[str]], list[str]]: loaded: set[str] = set() defined_rules: set[RuleKey] = set() - rule_hits: Counter[RuleKey] = Counter() - rule_tests: dict[RuleKey, Counter[str]] = defaultdict(Counter) + hits_by_test: dict[RuleKey, Counter[str]] = defaultdict(Counter) errors: list[str] = [] for event_file in sorted(EVENTS.glob("*.jsonl")): for number, line in enumerate(event_file.read_text(encoding="utf-8").splitlines(), 1): @@ -63,14 +62,12 @@ def read_events() -> tuple[set[str], set[str], set[RuleKey], Counter[RuleKey], d and event["test"] ): key = (path, name, tag) - rule_hits[key] += 1 - rule_tests[key][event["test"]] += 1 + hits_by_test[key][event["test"]] += 1 else: errors.append(f"Invalid event in {event_file.name}:{number}") else: errors.append(f"Invalid event in {event_file.name}:{number}") - matched = {path for path, _, _ in rule_hits} - return loaded, matched, defined_rules, rule_hits, dict(rule_tests), errors + return loaded, defined_rules, dict(hits_by_test), errors def run() -> int: @@ -91,8 +88,9 @@ def run() -> int: log.write(f"Could not run cargo: {error}\n") test_status = 1 - loaded, matched, defined_rules, rule_hits, rule_tests, errors = read_events() - matched_rules = set(rule_hits) + loaded, defined_rules, hits_by_test, errors = read_events() + matched_rules = set(hits_by_test) + matched = {path for path, _, _ in matched_rules} if test_status: errors.insert(0, f"cargo test failed (exit status {test_status}); see test.log") if not loaded: @@ -126,14 +124,14 @@ def run() -> int: section("Loaded YAML files", loaded), section("Matched pattern files", matched), section("Loaded files with no pattern match", loaded - matched), - rule_section("Matched rules", matched_rules, rule_hits, rule_tests), + rule_section("Matched rules", matched_rules, hits_by_test), rule_section("Active rules with no match", defined_rules - matched_rules), ) ) report_path = OUTPUT / "report.md" report_path.write_text("\n".join(report), encoding="utf-8") html_path = OUTPUT / "index.html" - html_path.write_text(render_html(loaded, matched, defined_rules, rule_hits, rule_tests, errors), encoding="utf-8") + html_path.write_text(render_html(loaded, defined_rules, hits_by_test, errors), encoding="utf-8") print( f"{status}: {len(loaded)} files loaded, {coverage(len(matched), len(loaded))} files matched; " f"{coverage(len(matched_rules), len(defined_rules))} rules matched; report: {html_path}" diff --git a/PythonScripts/audit_translations/rulecoverage/rule_coverage_report.py b/PythonScripts/audit_translations/rulecoverage/rule_coverage_report.py index 4b49ac22c..483badf22 100644 --- a/PythonScripts/audit_translations/rulecoverage/rule_coverage_report.py +++ b/PythonScripts/audit_translations/rulecoverage/rule_coverage_report.py @@ -28,17 +28,17 @@ def coverage(count: int, total: int) -> str: def rule_section( title: str, rules: set[RuleKey], - hits: Counter[RuleKey] | None = None, - tests: dict[RuleKey, Counter[str]] | None = None, + hits_by_test: dict[RuleKey, Counter[str]] | None = None, ) -> str: """List active rules with their source path, name, and MathML tag.""" lines = [f"## {title} ({len(rules)})"] for path, name, tag in sorted(rules): key = (path, name, tag) line = f"- `{path}`: `{name}` (`{tag}`)" - if hits is not None and tests is not None: - line += f" — {hits[key]} hits; tests: " + ", ".join( - f"`{test}` ({count})" for test, count in sorted(tests[key].items()) + if hits_by_test is not None: + tests = hits_by_test[key] + line += f" — {sum(tests.values())} hits; tests: " + ", ".join( + f"`{test}` ({count})" for test, count in sorted(tests.items()) ) lines.append(line) return "\n".join(lines) + "\n" @@ -46,28 +46,29 @@ def rule_section( def render_html( loaded: set[str], - matched: set[str], defined_rules: set[RuleKey], - rule_hits: Counter[RuleKey], - rule_tests: dict[RuleKey, Counter[str]], + hits_by_test: dict[RuleKey, Counter[str]], errors: list[str], ) -> str: """Show file coverage and searchable rule details in a standalone page.""" + matched = {path for path, _, _ in hits_by_test} rules_by_path: dict[str, list[dict]] = defaultdict(list) for path, name, tag in defined_rules: key = (path, name, tag) + tests = hits_by_test.get(key, {}) + total_hits = sum(tests.values()) rules_by_path[path].append( { "name": name, "tag": tag, - "matched": rule_hits[key] > 0, - "hits": rule_hits[key], + "matched": key in hits_by_test, + "hits": total_hits, "tooltip": "Tests:\n" + "\n".join( f"{test} ({count} {'hit' if count == 1 else 'hits'})" - for test, count in sorted(rule_tests.get(key, {}).items()) + for test, count in sorted(tests.items()) ) - if rule_hits[key] + if total_hits else "No test hits", } ) @@ -88,7 +89,7 @@ def render_html( status="Incomplete" if errors else "Complete", loaded_count=len(loaded), file_coverage=coverage(len(matched), len(loaded)), - rule_coverage=coverage(len(rule_hits), len(defined_rules)), + rule_coverage=coverage(len(hits_by_test), len(defined_rules)), errors=errors, files=files, ) diff --git a/PythonScripts/audit_translations/tests/test_rule_coverage_cli.py b/PythonScripts/audit_translations/tests/test_rule_coverage_cli.py index 711ddaeb6..2894e95ab 100644 --- a/PythonScripts/audit_translations/tests/test_rule_coverage_cli.py +++ b/PythonScripts/audit_translations/tests/test_rule_coverage_cli.py @@ -115,9 +115,39 @@ def test_jsonl_rule_identity_preserves_separators_and_unicode(tmp_path: Path, mo encoding="utf-8", ) - loaded, matched, defined, rule_hits, rule_tests, errors = rule_coverage.read_events() + loaded, defined, hits_by_test, errors = rule_coverage.read_events() - assert loaded == matched == set() - assert rule_hits == rule_tests == {} + assert loaded == set() + assert hits_by_test == {} assert defined == {(path, name, "mfrac")} assert errors == [] + + +def test_invalid_and_unknown_rule_events_make_report_incomplete(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Malformed events and hits without definitions must keep the report incomplete.""" + output = tmp_path / "target" / "rule-coverage" + monkeypatch.setattr(rule_coverage, "ROOT", tmp_path) + monkeypatch.setattr(rule_coverage, "OUTPUT", output) + monkeypatch.setattr(rule_coverage, "EVENTS", output / "events") + path = "Languages/en/SimpleSpeak_Rules.yaml" + + def fake_cargo(command: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]: + events = [ + {"kind": "loaded", "path": path}, + {"kind": "defined-rule", "path": path, "name": "known", "tag": "mi"}, + {"kind": "matched-rule", "path": path, "name": "unknown", "tag": "mi", "test": "test_alpha"}, + ] + (output / "events" / "pid-123.jsonl").write_text( + "\n".join(json.dumps(event) for event in events) + "\n{bad json\n", + encoding="utf-8", + ) + return subprocess.CompletedProcess(command, 0) + + monkeypatch.setattr(rule_coverage.subprocess, "run", fake_cargo) + monkeypatch.setattr(rule_coverage.webbrowser, "open", lambda url: True) + + assert rule_coverage.run() == 1 + report = (output / "report.md").read_text(encoding="utf-8") + assert "Status: **Incomplete**" in report + assert "Invalid JSON in pid-123.jsonl:4" in report + assert "Matched rules lack definition events" in report From 005e63afbab1bc1b5a26d791da0f5f3b6912580a Mon Sep 17 00:00:00 2001 From: mgros Date: Sun, 27 Sep 2026 00:43:13 +0200 Subject: [PATCH 16/18] remove "loaded" as statistic for rule coverage --- PythonScripts/audit_translations/README.md | 2 +- .../rulecoverage/rule_coverage.html | 12 ++---- .../rulecoverage/rule_coverage.py | 39 +++++++------------ .../rulecoverage/rule_coverage_report.py | 12 +++--- .../tests/test_rule_coverage_cli.py | 22 ++++++----- src/rule_coverage.rs | 25 ++---------- src/shim_filesystem.rs | 18 +++------ 7 files changed, 44 insertions(+), 86 deletions(-) diff --git a/PythonScripts/audit_translations/README.md b/PythonScripts/audit_translations/README.md index 798d3d245..3e6d67add 100644 --- a/PythonScripts/audit_translations/README.md +++ b/PythonScripts/audit_translations/README.md @@ -84,7 +84,7 @@ uv run --project PythonScripts audit-translations --rule-coverage * `--rules-dir`: Override the Rules/Languages directory path. * `--only`: Filter issue types (comma-separated): `missing`, `untranslated`, `extra`, `diffs`, `all`. * `--verbose`: Show detailed output including source/target snippets for rule differences. -* `--rule-coverage`: Run the full Rust test suite, generate file- and rule-level pattern coverage under `target/rule-coverage/`, and open the HTML report. Unicode mapping and definition files are omitted. Expand a file to inspect matched and unmatched rules by name and tag, their hit counts, and hover over a rule to see the tests that matched it. Coverage fractions include percentages. This runs independently of a language audit. +* `--rule-coverage`: Run the full Rust test suite, generate file- and rule-level pattern coverage under `target/rule-coverage/`, and open the HTML report. File coverage includes files with active rules; empty and include-only files are not separate coverage targets. Expand a file to inspect matched and unmatched rules by name and tag, their hit counts, and hover over a rule to see the tests that matched it. Coverage fractions include percentages. This runs independently of a language audit. * **Summary Stats:** Provides a statistical summary after every run. **Examples:** diff --git a/PythonScripts/audit_translations/rulecoverage/rule_coverage.html b/PythonScripts/audit_translations/rulecoverage/rule_coverage.html index c1d41b086..c5235df79 100644 --- a/PythonScripts/audit_translations/rulecoverage/rule_coverage.html +++ b/PythonScripts/audit_translations/rulecoverage/rule_coverage.html @@ -26,18 +26,16 @@ box-shadow: 0 .2rem .6rem #0003; white-space: pre-line; overflow-wrap: anywhere; } .rules li:hover .rule-tooltip, .rules li:focus .rule-tooltip { display: block; } .rule-name { font-weight: 600; } -.empty { color: #52616d; } [hidden] { display: none !important; }

        Rule YAML coverage

        Status: {{ status }}

        -
        {{ loaded_count }} files loaded{{ file_coverage }} pattern files matched +
        {{ rule_file_count }} files with active rules{{ file_coverage }} rule files matched {{ rule_coverage }} active rules matched
        -

        Paths are relative to Rules/. A pattern file is matched when a rule from it -completes its replacement successfully.

        -

        Unicode mapping and definition files are omitted because this report measures pattern-rule coverage.

        +

        Paths are relative to Rules/. Files with active rules are coverage targets; +a file is matched when a rule from it completes its replacement successfully.

        {% if errors %}

        Problems

          {% for error in errors %}
        • {{ error }}
        • {% endfor %} @@ -52,7 +50,6 @@

          Rule YAML coverage

          {{ 'Matched' if file.matched else 'No match' }} {{ file.path }} {{ file.coverage }} rules -{% if file.rules %}

          {{ file.coverage }} active rules matched

            {% set file_index = loop.index0 %} {% for rule in file.rules %} @@ -62,9 +59,6 @@

            Rule YAML coverage

            {{ rule.tooltip }} {% endfor %}
          -{% else %} -

          No active pattern rules

          -{% endif %}
          {% endfor %}
        diff --git a/PythonScripts/audit_translations/rulecoverage/rule_coverage.py b/PythonScripts/audit_translations/rulecoverage/rule_coverage.py index 0fa09a167..5b174bdfb 100644 --- a/PythonScripts/audit_translations/rulecoverage/rule_coverage.py +++ b/PythonScripts/audit_translations/rulecoverage/rule_coverage.py @@ -13,11 +13,9 @@ ROOT = Path(__file__).resolve().parents[3] OUTPUT = ROOT / "target" / "rule-coverage" EVENTS = OUTPUT / "events" -EXCLUDED_FILE_NAMES = {"definitions.yaml", "unicode.yaml", "unicode-full.yaml"} -def read_events() -> tuple[set[str], set[RuleKey], dict[RuleKey, Counter[str]], list[str]]: - loaded: set[str] = set() +def read_events() -> tuple[set[RuleKey], dict[RuleKey, Counter[str]], list[str]]: defined_rules: set[RuleKey] = set() hits_by_test: dict[RuleKey, Counter[str]] = defaultdict(Counter) errors: list[str] = [] @@ -45,10 +43,6 @@ def read_events() -> tuple[set[str], set[RuleKey], dict[RuleKey, Counter[str]], or PurePosixPath(path).suffix not in (".yaml", ".yml") ): errors.append(f"Invalid event in {event_file.name}:{number}") - elif PurePosixPath(path).name.lower() in EXCLUDED_FILE_NAMES: - continue - elif kind == "loaded" and event.keys() == {"kind", "path"}: - loaded.add(path) elif kind in ("defined-rule", "matched-rule") and {"name", "tag"} <= event.keys(): name, tag = event["name"], event["tag"] if not isinstance(name, str) or not isinstance(tag, str) or not name or not tag: @@ -67,7 +61,7 @@ def read_events() -> tuple[set[str], set[RuleKey], dict[RuleKey, Counter[str]], errors.append(f"Invalid event in {event_file.name}:{number}") else: errors.append(f"Invalid event in {event_file.name}:{number}") - return loaded, defined_rules, dict(hits_by_test), errors + return defined_rules, dict(hits_by_test), errors def run() -> int: @@ -88,42 +82,35 @@ def run() -> int: log.write(f"Could not run cargo: {error}\n") test_status = 1 - loaded, defined_rules, hits_by_test, errors = read_events() + defined_rules, hits_by_test, errors = read_events() matched_rules = set(hits_by_test) - matched = {path for path, _, _ in matched_rules} + rule_files = {path for path, _, _ in defined_rules} + matched_files = {path for path, _, _ in matched_rules} if test_status: errors.insert(0, f"cargo test failed (exit status {test_status}); see test.log") - if not loaded: - errors.append("No loaded YAML events found") - if not matched: - errors.append("No matched pattern events found") if not defined_rules: errors.append("No active rule definitions found") if not matched_rules: errors.append("No matched rule events found") - if matched - loaded: - errors.append("Matched pattern files lack loaded events: " + ", ".join(sorted(matched - loaded))) if matched_rules - defined_rules: errors.append("Matched rules lack definition events") - if {path for path, _, _ in defined_rules} - loaded: - errors.append("Defined rules have no loaded YAML event") status = "Incomplete" if errors else "Complete" report = [ "# Rule Coverage\n", f"Status: **{status}**\n", - f"Pattern files matched: **{coverage(len(matched), len(loaded))}**\n", + f"Rule files matched: **{coverage(len(matched_files), len(rule_files))}**\n", f"Active rules matched: **{coverage(len(matched_rules), len(defined_rules))}**\n", - "Paths are relative to `Rules/`. A pattern file is matched when a rule from it completes its replacement successfully.\n", - "Unicode mapping and definition files are omitted because this report measures pattern-rule coverage.\n", + "Paths are relative to `Rules/`. Files with active rules are coverage targets; " + "a file is matched when a rule from it completes its replacement successfully.\n", ] if errors: report.append("## Problems\n\n" + "\n".join(f"- {error}" for error in errors) + "\n") report.extend( ( - section("Loaded YAML files", loaded), - section("Matched pattern files", matched), - section("Loaded files with no pattern match", loaded - matched), + section("Rule files", rule_files), + section("Matched rule files", matched_files), + section("Rule files with no match", rule_files - matched_files), rule_section("Matched rules", matched_rules, hits_by_test), rule_section("Active rules with no match", defined_rules - matched_rules), ) @@ -131,9 +118,9 @@ def run() -> int: report_path = OUTPUT / "report.md" report_path.write_text("\n".join(report), encoding="utf-8") html_path = OUTPUT / "index.html" - html_path.write_text(render_html(loaded, defined_rules, hits_by_test, errors), encoding="utf-8") + html_path.write_text(render_html(defined_rules, hits_by_test, errors), encoding="utf-8") print( - f"{status}: {len(loaded)} files loaded, {coverage(len(matched), len(loaded))} files matched; " + f"{status}: {coverage(len(matched_files), len(rule_files))} rule files matched; " f"{coverage(len(matched_rules), len(defined_rules))} rules matched; report: {html_path}" ) try: diff --git a/PythonScripts/audit_translations/rulecoverage/rule_coverage_report.py b/PythonScripts/audit_translations/rulecoverage/rule_coverage_report.py index 483badf22..4e8f32048 100644 --- a/PythonScripts/audit_translations/rulecoverage/rule_coverage_report.py +++ b/PythonScripts/audit_translations/rulecoverage/rule_coverage_report.py @@ -45,13 +45,13 @@ def rule_section( def render_html( - loaded: set[str], defined_rules: set[RuleKey], hits_by_test: dict[RuleKey, Counter[str]], errors: list[str], ) -> str: """Show file coverage and searchable rule details in a standalone page.""" - matched = {path for path, _, _ in hits_by_test} + rule_files = {path for path, _, _ in defined_rules} + matched_files = {path for path, _, _ in hits_by_test} rules_by_path: dict[str, list[dict]] = defaultdict(list) for path, name, tag in defined_rules: key = (path, name, tag) @@ -74,12 +74,12 @@ def render_html( ) files = [] - for path in sorted(loaded | matched | rules_by_path.keys()): + for path in sorted(rule_files): rules = sorted(rules_by_path.get(path, []), key=lambda rule: (rule["name"], rule["tag"])) files.append( { "path": path, - "matched": path in matched, + "matched": path in matched_files, "coverage": coverage(sum(rule["matched"] for rule in rules), len(rules)), "rules": rules, } @@ -87,8 +87,8 @@ def render_html( return TEMPLATE.render( status="Incomplete" if errors else "Complete", - loaded_count=len(loaded), - file_coverage=coverage(len(matched), len(loaded)), + rule_file_count=len(rule_files), + file_coverage=coverage(len(matched_files), len(rule_files)), rule_coverage=coverage(len(hits_by_test), len(defined_rules)), errors=errors, files=files, diff --git a/PythonScripts/audit_translations/tests/test_rule_coverage_cli.py b/PythonScripts/audit_translations/tests/test_rule_coverage_cli.py index 2894e95ab..452583464 100644 --- a/PythonScripts/audit_translations/tests/test_rule_coverage_cli.py +++ b/PythonScripts/audit_translations/tests/test_rule_coverage_cli.py @@ -22,11 +22,9 @@ def fake_cargo(command: list[str], **kwargs: object) -> subprocess.CompletedProc assert command == ["cargo", "test", "--features", "rule-coverage"] assert kwargs["cwd"] == tmp_path events = [ - {"kind": "loaded", "path": "Languages/en/SimpleSpeak_Rules.yaml"}, - {"kind": "loaded", "path": "Languages/en/SharedRules/empty.yaml"}, - {"kind": "loaded", "path": "Languages/en/definitions.yaml"}, {"kind": "defined-rule", "path": "Languages/en/SimpleSpeak_Rules.yaml", "name": "simple", "tag": "mi"}, {"kind": "defined-rule", "path": "Languages/en/SimpleSpeak_Rules.yaml", "name": "default", "tag": "mi"}, + {"kind": "defined-rule", "path": "Languages/en/Z_Rules.yaml", "name": "unused", "tag": "mn"}, { "kind": "matched-rule", "path": "Languages/en/SimpleSpeak_Rules.yaml", @@ -67,19 +65,23 @@ def fake_cargo(command: list[str], **kwargs: object) -> subprocess.CompletedProc assert opened == [(output / "index.html").as_uri()] assert "Status: **Complete**" in (output / "report.md").read_text(encoding="utf-8") assert "## Matched rules (1)" in (output / "report.md").read_text(encoding="utf-8") - assert "## Active rules with no match (1)" in (output / "report.md").read_text(encoding="utf-8") + assert "## Active rules with no match (2)" in (output / "report.md").read_text(encoding="utf-8") + assert "Rule files matched: **1/2 (50%)**" in (output / "report.md").read_text(encoding="utf-8") + assert "## Rule files with no match (1)\n- `Languages/en/Z_Rules.yaml`" in (output / "report.md").read_text( + encoding="utf-8" + ) assert "3 hits; tests: `test_alpha` (2), `test_beta` (1)" in (output / "report.md").read_text(encoding="utf-8") html = (output / "index.html").read_text(encoding="utf-8") assert 'data-rule-search="simple mi" data-status="matched"' in html assert 'data-rule-search="default mi" data-status="unmatched"' in html - assert "1/2 (50%) active rules matched" in html + assert "1/3 (33%) active rules matched" in html assert "1/2 (50%) rules" in html assert 'role="tooltip">Tests:\ntest_alpha (2 hits)\ntest_beta (1 hit)' in html assert ".rules li:hover .rule-tooltip, .rules li:focus .rule-tooltip { display: block; }" in html - assert 'aria-describedby="rule-tooltip-1-1"' in html + assert 'aria-describedby="rule-tooltip-0-1"' in html assert "3 hits" in html assert "0 hits" in html - assert "No active pattern rules" in html + assert "2 files with active rules" in html assert (output / "test.log").is_file() @@ -100,7 +102,7 @@ def test_failed_coverage_run_opens_incomplete_report(tmp_path: Path, monkeypatch assert rule_coverage.run() == 1 assert opened == [(output / "index.html").as_uri()] assert "Status: **Incomplete**" in (output / "report.md").read_text(encoding="utf-8") - assert "No loaded YAML events found" in (output / "index.html").read_text(encoding="utf-8") + assert "No active rule definitions found" in (output / "index.html").read_text(encoding="utf-8") def test_jsonl_rule_identity_preserves_separators_and_unicode(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: @@ -115,9 +117,8 @@ def test_jsonl_rule_identity_preserves_separators_and_unicode(tmp_path: Path, mo encoding="utf-8", ) - loaded, defined, hits_by_test, errors = rule_coverage.read_events() + defined, hits_by_test, errors = rule_coverage.read_events() - assert loaded == set() assert hits_by_test == {} assert defined == {(path, name, "mfrac")} assert errors == [] @@ -149,5 +150,6 @@ def fake_cargo(command: list[str], **kwargs: object) -> subprocess.CompletedProc assert rule_coverage.run() == 1 report = (output / "report.md").read_text(encoding="utf-8") assert "Status: **Incomplete**" in report + assert "Invalid event in pid-123.jsonl:1" in report assert "Invalid JSON in pid-123.jsonl:4" in report assert "Matched rules lack definition events" in report diff --git a/src/rule_coverage.rs b/src/rule_coverage.rs index 52fe8c2d9..f0277d5ca 100644 --- a/src/rule_coverage.rs +++ b/src/rule_coverage.rs @@ -6,15 +6,9 @@ use std::io::Write; use std::path::{Path, PathBuf}; use std::sync::{Mutex, OnceLock}; -#[derive(Clone, Copy, Eq, Hash, PartialEq)] -enum OnceKind { - Loaded, - DefinedRule, -} - struct Recorder { output: File, - recorded: HashSet<(OnceKind, PathBuf, String, String)>, + recorded: HashSet<(PathBuf, String, String)>, } static RECORDER: OnceLock> = OnceLock::new(); @@ -42,28 +36,17 @@ fn rule_relative_path(path: &Path) -> Option { Some(relative.to_path_buf()) } -fn record_once(kind: OnceKind, path: &Path, name: &str, tag: &str) { +pub(crate) fn defined_rule(path: &Path, name: &str, tag: &str) { let Some(relative) = rule_relative_path(path) else { return }; let mut recorder = recorder().lock().unwrap_or_else(|poisoned| poisoned.into_inner()); - if !recorder.recorded.insert((kind, relative.clone(), name.to_string(), tag.to_string())) { + if !recorder.recorded.insert((relative.clone(), name.to_string(), tag.to_string())) { return; } let path = relative.to_string_lossy(); - let event = match kind { - OnceKind::Loaded => serde_json::json!({"kind": "loaded", "path": path}), - OnceKind::DefinedRule => serde_json::json!({"kind": "defined-rule", "path": path, "name": name, "tag": tag}), - }; + let event = serde_json::json!({"kind": "defined-rule", "path": path, "name": name, "tag": tag}); writeln!(recorder.output, "{event}").expect("cannot write rule coverage event"); } -pub(crate) fn loaded(path: &Path) { - record_once(OnceKind::Loaded, path, "", ""); -} - -pub(crate) fn defined_rule(path: &Path, name: &str, tag: &str) { - record_once(OnceKind::DefinedRule, path, name, tag); -} - pub(crate) fn matched_rule(path: &Path, name: &str, tag: &str) { let Some(relative) = rule_relative_path(path) else { return }; let mut recorder = recorder().lock().unwrap_or_else(|poisoned| poisoned.into_inner()); diff --git a/src/shim_filesystem.rs b/src/shim_filesystem.rs index e828495bc..d539a6300 100644 --- a/src/shim_filesystem.rs +++ b/src/shim_filesystem.rs @@ -6,14 +6,6 @@ use std::path::{Path, PathBuf}; use crate::errors::*; use cfg_if::cfg_if; -fn record_successful_read(path: &Path, contents: String) -> String { - #[cfg(feature = "rule-coverage")] - crate::rule_coverage::loaded(path); - #[cfg(not(feature = "rule-coverage"))] - let _ = path; // suppress unused variable warning if feature is not enabled - contents -} - #[allow(unused_imports)] use log::{debug}; @@ -227,7 +219,7 @@ cfg_if! { return None; } }) { - return Ok(record_successful_read(&path, contents)); + return Ok(contents); }; let file_name = file_name.replace('\\', "/"); // zip files always use forward slash @@ -264,7 +256,7 @@ cfg_if! { if let Err(e) = file.read_to_string(&mut contents) { bail!("read_to_string: {}", e); } - return Ok(record_successful_read(&path, contents)); + return Ok(contents); }); } @@ -433,7 +425,7 @@ cfg_if! { pub fn read_to_string_shim(path: &Path) -> Result { if let Some(content) = IN_MEMORY_FILES.read().unwrap().as_ref().and_then(|f| f.get(&get_in_memory_key(path)).cloned()) { - return Ok(record_successful_read(path, content)); + return Ok(content); } let path = match path.canonicalize() { @@ -444,12 +436,12 @@ cfg_if! { if should_lock_rules_yaml_path(&path) { let _guard = RULES_ZIP_EXTRACT_LOCK.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); match std::fs::read_to_string(&path) { - Ok(str) => return Ok(record_successful_read(&path, str)), + Ok(str) => return Ok(str), Err(e) => bail!("Read error while trying to read {}: {}", path.display(), e), } } else { match std::fs::read_to_string(&path) { - Ok(str) => return Ok(record_successful_read(&path, str)), + Ok(str) => return Ok(str), Err(e) => bail!("Read error while trying to read {}: {}", path.display(), e), } } From 65897f3dfd373f7ede86b8cc3859b5452a2f3003 Mon Sep 17 00:00:00 2001 From: mgros Date: Sun, 27 Sep 2026 00:47:58 +0200 Subject: [PATCH 17/18] remove MD report and only keep HTML. simplify rest accordingly. --- PythonScripts/README.md | 2 +- .../rulecoverage/rule_coverage.py | 26 ++------------ .../rulecoverage/rule_coverage_report.py | 28 +-------------- .../tests/test_rule_coverage_cli.py | 35 ++++++++++--------- 4 files changed, 23 insertions(+), 68 deletions(-) diff --git a/PythonScripts/README.md b/PythonScripts/README.md index b8393e22b..d79d97dca 100644 --- a/PythonScripts/README.md +++ b/PythonScripts/README.md @@ -17,4 +17,4 @@ uv run --project PythonScripts audit-translations --rule-coverage ``` The tool opens `target/rule-coverage/index.html` in a browser when the run finishes. Expand a YAML file to see each active rule's name, tag, hit count, and match status. Hover over a rule to see which tests hit it. Coverage fractions include percentages. -The Markdown report and test output remain at `target/rule-coverage/report.md` and `target/rule-coverage/test.log`. +The Rust test output remains at `target/rule-coverage/test.log`. diff --git a/PythonScripts/audit_translations/rulecoverage/rule_coverage.py b/PythonScripts/audit_translations/rulecoverage/rule_coverage.py index 5b174bdfb..a06178336 100644 --- a/PythonScripts/audit_translations/rulecoverage/rule_coverage.py +++ b/PythonScripts/audit_translations/rulecoverage/rule_coverage.py @@ -8,7 +8,7 @@ from collections import Counter, defaultdict from pathlib import Path, PurePosixPath -from .rule_coverage_report import RuleKey, coverage, render_html, rule_section, section +from .rule_coverage_report import RuleKey, coverage, render_html ROOT = Path(__file__).resolve().parents[3] OUTPUT = ROOT / "target" / "rule-coverage" @@ -65,8 +65,9 @@ def read_events() -> tuple[set[RuleKey], dict[RuleKey, Counter[str]], list[str]] def run() -> int: - """Generate all reports and open the HTML page when the run finishes.""" + """Generate the HTML report and open it when the run finishes.""" OUTPUT.mkdir(parents=True, exist_ok=True) + (OUTPUT / "report.md").unlink(missing_ok=True) if EVENTS.exists(): shutil.rmtree(EVENTS) EVENTS.mkdir() @@ -96,27 +97,6 @@ def run() -> int: errors.append("Matched rules lack definition events") status = "Incomplete" if errors else "Complete" - report = [ - "# Rule Coverage\n", - f"Status: **{status}**\n", - f"Rule files matched: **{coverage(len(matched_files), len(rule_files))}**\n", - f"Active rules matched: **{coverage(len(matched_rules), len(defined_rules))}**\n", - "Paths are relative to `Rules/`. Files with active rules are coverage targets; " - "a file is matched when a rule from it completes its replacement successfully.\n", - ] - if errors: - report.append("## Problems\n\n" + "\n".join(f"- {error}" for error in errors) + "\n") - report.extend( - ( - section("Rule files", rule_files), - section("Matched rule files", matched_files), - section("Rule files with no match", rule_files - matched_files), - rule_section("Matched rules", matched_rules, hits_by_test), - rule_section("Active rules with no match", defined_rules - matched_rules), - ) - ) - report_path = OUTPUT / "report.md" - report_path.write_text("\n".join(report), encoding="utf-8") html_path = OUTPUT / "index.html" html_path.write_text(render_html(defined_rules, hits_by_test, errors), encoding="utf-8") print( diff --git a/PythonScripts/audit_translations/rulecoverage/rule_coverage_report.py b/PythonScripts/audit_translations/rulecoverage/rule_coverage_report.py index 4e8f32048..357c5930f 100644 --- a/PythonScripts/audit_translations/rulecoverage/rule_coverage_report.py +++ b/PythonScripts/audit_translations/rulecoverage/rule_coverage_report.py @@ -1,4 +1,4 @@ -"""Format file and rule coverage events as Markdown and an interactive HTML report.""" +"""Format file and rule coverage events as an interactive HTML report.""" from collections import Counter, defaultdict from pathlib import Path @@ -13,37 +13,11 @@ ).get_template("rule_coverage.html") -def section(title: str, paths: set[str]) -> str: - """List covered YAML paths in a Markdown section.""" - lines = [f"## {title} ({len(paths)})"] - lines.extend(f"- `{path}`" for path in sorted(paths)) - return "\n".join(lines) + "\n" - - def coverage(count: int, total: int) -> str: """Show the matched fraction and its percentage, including empty groups.""" return f"{count}/{total} ({100 * count / total:.0f}%)" if total else "0/0 (0%)" -def rule_section( - title: str, - rules: set[RuleKey], - hits_by_test: dict[RuleKey, Counter[str]] | None = None, -) -> str: - """List active rules with their source path, name, and MathML tag.""" - lines = [f"## {title} ({len(rules)})"] - for path, name, tag in sorted(rules): - key = (path, name, tag) - line = f"- `{path}`: `{name}` (`{tag}`)" - if hits_by_test is not None: - tests = hits_by_test[key] - line += f" — {sum(tests.values())} hits; tests: " + ", ".join( - f"`{test}` ({count})" for test, count in sorted(tests.items()) - ) - lines.append(line) - return "\n".join(lines) + "\n" - - def render_html( defined_rules: set[RuleKey], hits_by_test: dict[RuleKey, Counter[str]], diff --git a/PythonScripts/audit_translations/tests/test_rule_coverage_cli.py b/PythonScripts/audit_translations/tests/test_rule_coverage_cli.py index 452583464..33709d1e6 100644 --- a/PythonScripts/audit_translations/tests/test_rule_coverage_cli.py +++ b/PythonScripts/audit_translations/tests/test_rule_coverage_cli.py @@ -11,9 +11,11 @@ from ..rulecoverage import rule_coverage -def test_coverage_command_generates_reports_and_opens_browser(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """The CLI uses test events to write both reports and opens the HTML page.""" +def test_coverage_command_generates_html_and_opens_browser(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """The CLI writes only the HTML report, removes an old Markdown report, and opens the page.""" output = tmp_path / "target" / "rule-coverage" + output.mkdir(parents=True) + (output / "report.md").write_text("old report", encoding="utf-8") monkeypatch.setattr(rule_coverage, "ROOT", tmp_path) monkeypatch.setattr(rule_coverage, "OUTPUT", output) monkeypatch.setattr(rule_coverage, "EVENTS", output / "events") @@ -63,15 +65,11 @@ def fake_cargo(command: list[str], **kwargs: object) -> subprocess.CompletedProc assert result.value.code == 0 assert opened == [(output / "index.html").as_uri()] - assert "Status: **Complete**" in (output / "report.md").read_text(encoding="utf-8") - assert "## Matched rules (1)" in (output / "report.md").read_text(encoding="utf-8") - assert "## Active rules with no match (2)" in (output / "report.md").read_text(encoding="utf-8") - assert "Rule files matched: **1/2 (50%)**" in (output / "report.md").read_text(encoding="utf-8") - assert "## Rule files with no match (1)\n- `Languages/en/Z_Rules.yaml`" in (output / "report.md").read_text( - encoding="utf-8" - ) - assert "3 hits; tests: `test_alpha` (2), `test_beta` (1)" in (output / "report.md").read_text(encoding="utf-8") + assert not (output / "report.md").exists() html = (output / "index.html").read_text(encoding="utf-8") + assert "Status: Complete" in html + assert "1/2 (50%) rule files matched" in html + assert "Languages/en/Z_Rules.yaml" in html assert 'data-rule-search="simple mi" data-status="matched"' in html assert 'data-rule-search="default mi" data-status="unmatched"' in html assert "1/3 (33%) active rules matched" in html @@ -101,8 +99,10 @@ def test_failed_coverage_run_opens_incomplete_report(tmp_path: Path, monkeypatch assert rule_coverage.run() == 1 assert opened == [(output / "index.html").as_uri()] - assert "Status: **Incomplete**" in (output / "report.md").read_text(encoding="utf-8") - assert "No active rule definitions found" in (output / "index.html").read_text(encoding="utf-8") + html = (output / "index.html").read_text(encoding="utf-8") + assert "Status: Incomplete" in html + assert "No active rule definitions found" in html + assert not (output / "report.md").exists() def test_jsonl_rule_identity_preserves_separators_and_unicode(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: @@ -148,8 +148,9 @@ def fake_cargo(command: list[str], **kwargs: object) -> subprocess.CompletedProc monkeypatch.setattr(rule_coverage.webbrowser, "open", lambda url: True) assert rule_coverage.run() == 1 - report = (output / "report.md").read_text(encoding="utf-8") - assert "Status: **Incomplete**" in report - assert "Invalid event in pid-123.jsonl:1" in report - assert "Invalid JSON in pid-123.jsonl:4" in report - assert "Matched rules lack definition events" in report + html = (output / "index.html").read_text(encoding="utf-8") + assert "Status: Incomplete" in html + assert "Invalid event in pid-123.jsonl:1" in html + assert "Invalid JSON in pid-123.jsonl:4" in html + assert "Matched rules lack definition events" in html + assert not (output / "report.md").exists() From b3eb093c6c12c1a67c3f743e293ec7785666c223 Mon Sep 17 00:00:00 2001 From: mgros Date: Sun, 27 Sep 2026 00:56:06 +0200 Subject: [PATCH 18/18] uv ruff format --- .../audit_translations/rulecoverage/rule_coverage_report.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/PythonScripts/audit_translations/rulecoverage/rule_coverage_report.py b/PythonScripts/audit_translations/rulecoverage/rule_coverage_report.py index 357c5930f..e266a9cee 100644 --- a/PythonScripts/audit_translations/rulecoverage/rule_coverage_report.py +++ b/PythonScripts/audit_translations/rulecoverage/rule_coverage_report.py @@ -38,10 +38,7 @@ def render_html( "matched": key in hits_by_test, "hits": total_hits, "tooltip": "Tests:\n" - + "\n".join( - f"{test} ({count} {'hit' if count == 1 else 'hits'})" - for test, count in sorted(tests.items()) - ) + + "\n".join(f"{test} ({count} {'hit' if count == 1 else 'hits'})" for test, count in sorted(tests.items())) if total_hits else "No test hits", }