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 e07193031..a51507536 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ exclude = ["src/main.rs", "docs", "BrailleDocs", "PythonScripts"] # should ha [features] +"rule-coverage" = ["dep:serde_json"] "include-zip" = [] "enable-logs" = ["android_logger"] "tts" = [ "natural-tts" ] @@ -38,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/README.md b/PythonScripts/README.md index 9eb6ed44e..d79d97dca 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, hit count, and match status. Hover over a rule to see which tests hit it. Coverage fractions include percentages. +The Rust test output remains at `target/rule-coverage/test.log`. diff --git a/PythonScripts/audit_translations/README.md b/PythonScripts/audit_translations/README.md index 5ebf83203..3e6d67add 100644 --- a/PythonScripts/audit_translations/README.md +++ b/PythonScripts/audit_translations/README.md @@ -67,11 +67,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:** @@ -82,6 +84,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 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/cli.py b/PythonScripts/audit_translations/cli.py index 6916a636e..81cac0dd0 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 .rulecoverage.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,11 @@ 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/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/rulecoverage/rule_coverage.html b/PythonScripts/audit_translations/rulecoverage/rule_coverage.html new file mode 100644 index 000000000..c5235df79 --- /dev/null +++ b/PythonScripts/audit_translations/rulecoverage/rule_coverage.html @@ -0,0 +1,93 @@ + + + + + +Rule YAML coverage + + + +

Rule YAML coverage

+

Status: {{ status }}

+
{{ rule_file_count }} files with active rules{{ file_coverage }} rule files matched +{{ rule_coverage }} active rules matched
+

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 %} +
+{% endif %} +
+
+
+{% for file in files %} +
+{{ 'Matched' if file.matched else 'No match' }} +{{ file.path }} {{ file.coverage }} rules +

{{ 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.hits }} {{ 'hit' if rule.hits == 1 else 'hits' }} +{{ rule.tooltip }}
  • +{% endfor %} +
+
+{% endfor %} +
+ + + + diff --git a/PythonScripts/audit_translations/rulecoverage/rule_coverage.py b/PythonScripts/audit_translations/rulecoverage/rule_coverage.py new file mode 100644 index 000000000..a06178336 --- /dev/null +++ b/PythonScripts/audit_translations/rulecoverage/rule_coverage.py @@ -0,0 +1,116 @@ +"""Run the full Rust test suite and report which rule YAML files it exercises.""" + +import json +import shutil +import subprocess +import sys +import webbrowser +from collections import Counter, defaultdict +from pathlib import Path, PurePosixPath + +from .rule_coverage_report import RuleKey, coverage, render_html + +ROOT = Path(__file__).resolve().parents[3] +OUTPUT = ROOT / "target" / "rule-coverage" +EVENTS = OUTPUT / "events" + + +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] = [] + for event_file in sorted(EVENTS.glob("*.jsonl")): + for number, line in enumerate(event_file.read_text(encoding="utf-8").splitlines(), 1): + 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 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" 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) + 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}") + return defined_rules, dict(hits_by_test), errors + + +def run() -> int: + """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() + + 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 + + defined_rules, hits_by_test, errors = read_events() + matched_rules = set(hits_by_test) + 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 defined_rules: + errors.append("No active rule definitions found") + if not matched_rules: + errors.append("No matched rule events found") + if matched_rules - defined_rules: + errors.append("Matched rules lack definition events") + + status = "Incomplete" if errors else "Complete" + html_path = OUTPUT / "index.html" + html_path.write_text(render_html(defined_rules, hits_by_test, errors), encoding="utf-8") + print( + 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: + 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/rulecoverage/rule_coverage_report.py b/PythonScripts/audit_translations/rulecoverage/rule_coverage_report.py new file mode 100644 index 000000000..e266a9cee --- /dev/null +++ b/PythonScripts/audit_translations/rulecoverage/rule_coverage_report.py @@ -0,0 +1,66 @@ +"""Format file and rule coverage events as an interactive HTML report.""" + +from collections import Counter, defaultdict +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 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 render_html( + 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.""" + 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) + tests = hits_by_test.get(key, {}) + total_hits = sum(tests.values()) + rules_by_path[path].append( + { + "name": name, + "tag": tag, + "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())) + if total_hits + else "No test hits", + } + ) + + files = [] + 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_files, + "coverage": coverage(sum(rule["matched"] for rule in rules), len(rules)), + "rules": rules, + } + ) + + return TEMPLATE.render( + status="Incomplete" if errors else "Complete", + 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 new file mode 100644 index 000000000..33709d1e6 --- /dev/null +++ b/PythonScripts/audit_translations/tests/test_rule_coverage_cli.py @@ -0,0 +1,156 @@ +"""Checks the rule coverage command without rerunning the Rust test suite.""" + +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +from .. import cli +from ..rulecoverage import rule_coverage + + +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") + + def fake_cargo(command: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]: + assert command == ["cargo", "test", "--features", "rule-coverage"] + assert kwargs["cwd"] == tmp_path + events = [ + {"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", + "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", + 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 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 + 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-0-1"' in html + assert "3 hits" in html + assert "0 hits" in html + assert "2 files with active 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()] + 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: + """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", + ) + + defined, hits_by_test, errors = rule_coverage.read_events() + + 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 + 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() 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" }, 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..f0277d5ca --- /dev/null +++ b/src/rule_coverage.rs @@ -0,0 +1,58 @@ +//! Per-process YAML rule coverage events, enabled only by `rule-coverage`. + +use std::collections::HashSet; +use std::fs::{self, File, OpenOptions}; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; + +struct Recorder { + output: File, + recorded: HashSet<(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"); + 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()) +} + +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((relative.clone(), name.to_string(), tag.to_string())) { + return; + } + let path = relative.to_string_lossy(); + 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 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()); + 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"); +} diff --git a/src/speech.rs b/src/speech.rs index 4b6691bec..b667cb612 100644 --- a/src/speech.rs +++ b/src/speech.rs @@ -2291,6 +2291,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); } @@ -2511,6 +2519,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) )