From 85fc9a817be2c3c36d8c04fd78b3a9c103cd473d Mon Sep 17 00:00:00 2001 From: Ibo Sy Date: Mon, 17 Aug 2026 23:45:09 +0200 Subject: [PATCH 1/2] add snippet validation scripts Signed-off-by: Ibo Sy --- package.json | 2 + scripts/validate_snippet_files.py | 233 +++++++++++++++++ scripts/validate_snippet_sources.py | 338 +++++++++++++++++++++++++ tests/test_validate_snippet_files.py | 139 ++++++++++ tests/test_validate_snippet_sources.py | 168 ++++++++++++ 5 files changed, 880 insertions(+) create mode 100644 scripts/validate_snippet_files.py create mode 100644 scripts/validate_snippet_sources.py create mode 100644 tests/test_validate_snippet_files.py create mode 100644 tests/test_validate_snippet_sources.py diff --git a/package.json b/package.json index 03f9f1a4d..eb0808001 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,8 @@ "generate:network-variable-tabs": "python3 scripts/generate_network_variable_tabs.py", "validate:network-variable-tabs": "python3 scripts/validate_network_variable_tabs.py", "validate:splice-mintlify-openapi-nav": "python3 scripts/validate_splice_mintlify_openapi_nav.py", + "validate-snippet-files": "python3 scripts/validate_snippet_files.py", + "validate-snippet-sources": "python3 scripts/validate_snippet_sources.py", "test:external-snippets": "python3 -m pytest tests/test_generate_external_snippets.py", "generate:typescript-bindings-reference": "python3 scripts/generate_typescript_bindings_reference.py", "dev": "cd docs-main && mintlify dev" diff --git a/scripts/validate_snippet_files.py b/scripts/validate_snippet_files.py new file mode 100644 index 000000000..1d9f06ae5 --- /dev/null +++ b/scripts/validate_snippet_files.py @@ -0,0 +1,233 @@ +#!/usr/bin/env python3 +"""Audit snippet imports in docs-main content pages.""" + +from __future__ import annotations + +import argparse +import re +import sys +from dataclasses import dataclass +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] +DOCS_MAIN = REPO_ROOT / "docs-main" +SNIPPETS_ROOT = DOCS_MAIN / "snippets" + +IMPORT_RE = re.compile( + r"^\s*import\s+(?:[A-Za-z_$][\w$]*|\*\s+as\s+[A-Za-z_$][\w$]*|\{[^}]+\})\s+" + r"from\s+[\"'](?P[^\"']+)[\"']\s*;?\s*$", + re.MULTILINE, +) +NETWORKVARS_SOURCE_RE = re.compile( + r"\{/\*\s*NETWORKVARS_START\s+source=\"(?P[^\"]+)\"\s*\*/\}" +) + +MISSING_LOG_NAME = "snippets-missing.log" +ORPHAN_LOG_NAME = "snippets-orphan.log" + + +@dataclass(frozen=True) +class AuditResult: + content_pages: int + referenced: frozenset[str] + existing: frozenset[str] + missing: tuple[str, ...] + orphans: tuple[str, ...] + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--quiet", + action="store_true", + help="Only print errors (missing or orphan snippets).", + ) + parser.add_argument( + "--no-fail", + action="store_true", + help="Exit 0 even when missing or orphan snippets are found.", + ) + parser.add_argument( + "--output-path", + type=Path, + default=None, + help="Directory for snippets-missing.log and snippets-orphan.log (default: repo root).", + ) + parser.add_argument( + "--delete-orphan-snippets", + action="store_true", + help="After writing snippets-orphan.log, delete orphan snippet files.", + ) + return parser.parse_args(argv) + + +def content_pages(docs_main: Path = DOCS_MAIN, snippets_root: Path = SNIPPETS_ROOT) -> list[Path]: + return sorted( + path + for path in docs_main.rglob("*.mdx") + if snippets_root not in path.parents and path.is_file() + ) + + +def existing_snippets(snippets_root: Path = SNIPPETS_ROOT, docs_main: Path = DOCS_MAIN) -> set[str]: + if not snippets_root.is_dir(): + return set() + return { + snippet_ref_for_path(path, docs_main) + for path in snippets_root.rglob("*.mdx") + if path.is_file() + } + + +def snippet_ref_for_path(path: Path, docs_main: Path = DOCS_MAIN) -> str: + return "/" + path.relative_to(docs_main).as_posix() + + +def resolve_snippet_ref(snippet_ref: str, docs_main: Path = DOCS_MAIN) -> Path: + if snippet_ref.startswith("/"): + return docs_main / snippet_ref.removeprefix("/") + return docs_main / snippet_ref + + +def normalize_snippet_ref(path: str) -> str | None: + if not path.startswith("/snippets/"): + return None + if not path.endswith(".mdx"): + path = f"{path}.mdx" + return path + + +def snippet_refs_in_text(text: str) -> list[str]: + refs: list[str] = [] + seen: set[str] = set() + for match in IMPORT_RE.finditer(text): + ref = normalize_snippet_ref(match.group("path")) + if ref and ref not in seen: + seen.add(ref) + refs.append(ref) + for match in NETWORKVARS_SOURCE_RE.findall(text): + ref = normalize_snippet_ref(match) + if ref and ref not in seen: + seen.add(ref) + refs.append(ref) + return refs + + +def collect_referenced_snippets( + pages: list[Path], + docs_main: Path = DOCS_MAIN, +) -> set[str]: + referenced: set[str] = set() + queue: list[str] = [] + + def add_ref(ref: str) -> None: + if ref not in referenced: + referenced.add(ref) + queue.append(ref) + + for page in pages: + text = page.read_text(encoding="utf-8") + for ref in snippet_refs_in_text(text): + add_ref(ref) + + while queue: + ref = queue.pop() + snippet_path = resolve_snippet_ref(ref, docs_main) + if not snippet_path.is_file(): + continue + nested_text = snippet_path.read_text(encoding="utf-8") + for nested_ref in snippet_refs_in_text(nested_text): + add_ref(nested_ref) + + return referenced + + +def audit( + docs_main: Path = DOCS_MAIN, + snippets_root: Path = SNIPPETS_ROOT, +) -> AuditResult: + pages = content_pages(docs_main, snippets_root) + referenced = collect_referenced_snippets(pages, docs_main) + existing = existing_snippets(snippets_root, docs_main) + missing = tuple(sorted(referenced - existing)) + orphans = tuple(sorted(existing - referenced)) + return AuditResult( + content_pages=len(pages), + referenced=frozenset(referenced), + existing=frozenset(existing), + missing=missing, + orphans=orphans, + ) + + +def write_log(path: Path, entries: tuple[str, ...]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + if entries: + path.write_text("\n".join(entries) + "\n", encoding="utf-8") + else: + path.write_text("", encoding="utf-8") + + +def delete_orphan_snippets(orphans: tuple[str, ...], docs_main: Path = DOCS_MAIN) -> list[str]: + deleted: list[str] = [] + for ref in orphans: + path = resolve_snippet_ref(ref, docs_main) + if not path.is_file(): + continue + try: + path.relative_to(docs_main / "snippets") + except ValueError: + continue + path.unlink() + deleted.append(ref) + return deleted + + +def print_report(result: AuditResult, quiet: bool) -> None: + has_errors = bool(result.missing or result.orphans) + stream = sys.stderr if quiet else sys.stdout + if not quiet: + print( + f"Scanned {result.content_pages} content pages; " + f"{len(result.referenced)} referenced snippets; " + f"{len(result.existing)} snippet files.", + file=stream, + ) + print(f"Missing snippets: {len(result.missing)}", file=stream) + print(f"Orphan snippets: {len(result.orphans)}", file=stream) + if not has_errors: + print( + "All referenced snippets exist and all snippet files are linked.", + file=stream, + ) + return + + if result.missing: + print("Missing snippets:", file=stream) + for ref in result.missing: + print(ref, file=stream) + if result.orphans: + print("Orphan snippets:", file=stream) + for ref in result.orphans: + print(ref, file=stream) + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + result = audit(docs_main=DOCS_MAIN, snippets_root=SNIPPETS_ROOT) + output_dir = args.output_path.resolve() if args.output_path else REPO_ROOT + write_log(output_dir / MISSING_LOG_NAME, result.missing) + write_log(output_dir / ORPHAN_LOG_NAME, result.orphans) + if args.delete_orphan_snippets and result.orphans: + deleted = delete_orphan_snippets(result.orphans, docs_main=DOCS_MAIN) + if not args.quiet: + print(f"Deleted {len(deleted)} orphan snippet files.") + print_report(result, quiet=args.quiet) + if (result.missing or result.orphans) and not args.no_fail: + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/validate_snippet_sources.py b/scripts/validate_snippet_sources.py new file mode 100644 index 000000000..d3608e31a --- /dev/null +++ b/scripts/validate_snippet_sources.py @@ -0,0 +1,338 @@ +#!/usr/bin/env python3 +"""Validate remote snippet configs against local source-repo checkouts. + +Examples: + python3 scripts/validate_snippet_sources.py + python3 scripts/validate_snippet_sources.py daml --source-dir ../daml + python3 scripts/validate_snippet_sources.py canton --source-dir ../canton-new-2 +""" + +from __future__ import annotations + +import argparse +import json +import sys +from dataclasses import dataclass +from pathlib import Path + +from generate_external_snippets import REPOS, SnippetRepo, find_source_dir + + +REPO_ROOT = Path(__file__).resolve().parents[1] +CONFIG_DIR = REPO_ROOT / "config" / "snippet-config" +REMOTE_LISTS_FILE = "remote-snippet-lists.json" +ERROR_LOG_NAME = "snippet-source-errors.log" + + +@dataclass(frozen=True) +class SourceError: + repo: str + snippet_name: str + message: str + + def format(self) -> str: + name = self.snippet_name or "(repo)" + return f"{self.repo} {name}: {self.message}" + + +@dataclass(frozen=True) +class AuditResult: + snippet_count: int + repo_count: int + errors: tuple[SourceError, ...] + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "repo", + nargs="?", + help="Only validate this remote snippet repo (e.g. daml, canton, splice). " + "Omit to check every repo in remote-snippet-lists.json.", + ) + parser.add_argument( + "--quiet", + action="store_true", + help="Only print source validation errors.", + ) + parser.add_argument( + "--no-fail", + action="store_true", + help="Exit 0 even when source validation errors are found.", + ) + parser.add_argument( + "--output-path", + type=Path, + default=None, + help="Directory for snippet-source-errors.log (default: repo root).", + ) + parser.add_argument( + "--source-dir", + type=Path, + help="Path to the git checkout for the selected repo. Required with a repo name " + "if autodiscovery cannot find a unique checkout.", + ) + return parser.parse_args(argv) + + +def repo_name_from_config_filename(filename: str) -> str: + suffix = "-snippet-list-remote.json" + if filename.endswith(suffix): + return filename[: -len(suffix)] + return Path(filename).stem + + +def load_remote_list_files(config_dir: Path = CONFIG_DIR) -> list[str]: + path = config_dir / REMOTE_LISTS_FILE + payload = json.loads(path.read_text(encoding="utf-8")) + lists = payload.get("snippetLists") + if not isinstance(lists, list) or not lists: + raise SystemExit(f"No snippetLists found in {path}") + return [str(name) for name in lists] + + +def load_snippets(config_path: Path) -> list[dict]: + payload = json.loads(config_path.read_text(encoding="utf-8")) + snippets = payload.get("snippets", []) + if not isinstance(snippets, list): + raise SystemExit(f"Expected snippets array in {config_path}") + return snippets + + +def resolve_repo_key(name: str) -> str: + lowered = name.lower() + if lowered in REPOS: + return lowered + for key, repo in REPOS.items(): + if lowered in {alias.lower() for alias in repo.aliases}: + return key + return lowered + + +def selected_repo_files( + *, + config_dir: Path, + repo: str | None, +) -> list[tuple[str, Path]]: + files: list[tuple[str, Path]] = [] + for filename in load_remote_list_files(config_dir): + repo_key = resolve_repo_key(repo_name_from_config_filename(filename)) + files.append((repo_key, config_dir / filename)) + if repo is None: + return files + wanted = resolve_repo_key(repo) + matched = [(key, path) for key, path in files if key == wanted] + if not matched: + available = ", ".join(key for key, _ in files) + raise SystemExit(f"Unknown snippet repo {repo!r}. Available: {available}") + return matched + + +def split_lines(content: str) -> list[str]: + return content.replace("\r\n", "\n").replace("\r", "\n").split("\n") + + +def validate_lines(location: dict, line_count: int) -> str | None: + try: + start = int(location["start"]) + end = int(location["end"]) + except (KeyError, TypeError, ValueError): + return "invalid line range: start/end must be integers" + if start < 1 or end < 1: + return f"line range {start}-{end} is invalid (lines are 1-based)" + if start > end: + return f"line range {start}-{end} is invalid (start must be <= end)" + if start > line_count or end > line_count: + return f"line range {start}-{end} is out of bounds (file has {line_count} lines)" + return None + + +def validate_string_markers(location: dict, content: str) -> str | None: + start = location.get("start") + end = location.get("end") + if not start or not end: + return "stringMarker requires start and end markers" + start_index = content.find(str(start)) + if start_index < 0: + return f"start marker not found: {start!r}" + newline = content.find("\n", start_index) + content_start = newline + 1 if newline >= 0 else start_index + len(str(start)) + if content.find(str(end), content_start) < 0: + return f"end marker not found: {end!r}" + return None + + +def validate_json_index(location: dict, content: str) -> str | None: + try: + payload = json.loads(content) + except json.JSONDecodeError as error: + return f"file is not valid JSON: {error.msg}" + if not isinstance(payload, list): + return "JSON root must be an array for location type jsonIndex" + try: + start = int(location["start"]) + end = int(location.get("end", location["start"])) + except (KeyError, TypeError, ValueError): + return "invalid jsonIndex range: start/end must be integers" + length = len(payload) + if start < 0 or end < 0 or start >= length or end >= length: + return f"jsonIndex {start}-{end} is out of bounds (array length {length})" + if start > end: + return f"jsonIndex {start}-{end} is invalid (start must be <= end)" + return None + + +def validate_regex_wrap(location: dict, content: str) -> str | None: + import re + + start = location.get("start") + end = location.get("end") + if not start or not end: + return "regexWrap requires start and end patterns" + start_match = re.search(str(start), content) + if start_match is None: + return f"start regex not found: {start!r}" + remaining = content[start_match.end() :] + if re.search(str(end), remaining) is None: + return f"end regex not found: {end!r}" + return None + + +def validate_location(location: dict | None, content: str) -> str | None: + if not location or not location.get("type"): + return "missing location type" + location_type = str(location["type"]) + if location_type == "fullFile": + return None + if location_type == "lines": + return validate_lines(location, len(split_lines(content))) + if location_type == "stringMarker": + return validate_string_markers(location, content) + if location_type == "jsonIndex": + return validate_json_index(location, content) + if location_type == "regexWrap": + return validate_regex_wrap(location, content) + return f"unsupported location type: {location_type}" + + +def validate_snippet(snippet: dict, source_dir: Path) -> str | None: + source_filepath = str(snippet.get("sourceFilepath") or "").lstrip("/") + if not source_filepath: + return "missing sourceFilepath" + source_path = source_dir / source_filepath + if not source_path.is_file(): + return f"source file not found: {source_filepath}" + content = source_path.read_text(encoding="utf-8") + return validate_location(snippet.get("location"), content) + + +def resolve_source_dirs( + repo_keys: list[str], + source_dir: Path | None, +) -> tuple[dict[str, Path], list[SourceError]]: + resolved: dict[str, Path] = {} + errors: list[SourceError] = [] + unique_keys = list(dict.fromkeys(repo_keys)) + if source_dir is not None: + if len(unique_keys) != 1: + raise SystemExit("Pass a repo name when using --source-dir.") + path = source_dir.expanduser().resolve() + if not path.is_dir(): + raise SystemExit(f"Source directory does not exist: {path}") + resolved[unique_keys[0]] = path + return resolved, errors + + for key in unique_keys: + repo = REPOS.get(key) + if repo is None: + repo = SnippetRepo(name=key, config_name="", aliases=(key,)) + try: + resolved[key] = find_source_dir(repo, None) + except SystemExit as error: + errors.append( + SourceError(repo=key, snippet_name="", message=str(error)) + ) + return resolved, errors + + +def audit( + *, + config_dir: Path = CONFIG_DIR, + repo: str | None = None, + source_dir: Path | None = None, +) -> AuditResult: + repo_files = selected_repo_files(config_dir=config_dir, repo=repo) + source_dirs, errors = resolve_source_dirs( + [key for key, _ in repo_files], + source_dir, + ) + snippet_count = 0 + for repo_key, config_path in repo_files: + if not config_path.is_file(): + errors.append( + SourceError( + repo=repo_key, + snippet_name="", + message=f"missing config file: {config_path.name}", + ) + ) + continue + snippets = load_snippets(config_path) + checkout = source_dirs.get(repo_key) + if checkout is None: + continue + for snippet in snippets: + snippet_count += 1 + name = str(snippet.get("snippetName") or "") + message = validate_snippet(snippet, checkout) + if message: + errors.append(SourceError(repo=repo_key, snippet_name=name, message=message)) + return AuditResult( + snippet_count=snippet_count, + repo_count=len(repo_files), + errors=tuple(errors), + ) + + +def write_log(path: Path, errors: tuple[SourceError, ...]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + if errors: + path.write_text("\n".join(error.format() for error in errors) + "\n", encoding="utf-8") + else: + path.write_text("", encoding="utf-8") + + +def print_report(result: AuditResult, quiet: bool) -> None: + stream = sys.stderr if quiet else sys.stdout + if not quiet: + print( + f"Checked {result.snippet_count} snippets across {result.repo_count} repos; " + f"{len(result.errors)} errors.", + file=stream, + ) + if not result.errors: + print("All remote snippet sources are valid.", file=stream) + return + if result.errors: + print("Snippet source errors:", file=stream) + for error in result.errors: + print(error.format(), file=stream) + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + result = audit( + config_dir=CONFIG_DIR, + repo=args.repo, + source_dir=args.source_dir, + ) + output_dir = args.output_path.resolve() if args.output_path else REPO_ROOT + write_log(output_dir / ERROR_LOG_NAME, result.errors) + print_report(result, quiet=args.quiet) + if result.errors and not args.no_fail: + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_validate_snippet_files.py b/tests/test_validate_snippet_files.py new file mode 100644 index 000000000..4a8c11fce --- /dev/null +++ b/tests/test_validate_snippet_files.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path +from types import ModuleType + + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +def load_script_module() -> ModuleType: + script_path = REPO_ROOT / "scripts" / "validate_snippet_files.py" + scripts_dir = str(script_path.parent) + if scripts_dir not in sys.path: + sys.path.insert(0, scripts_dir) + spec = importlib.util.spec_from_file_location(script_path.stem, script_path) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[script_path.stem] = module + spec.loader.exec_module(module) + return module + + +def write(path: Path, text: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + + +def layout(tmp_path: Path) -> tuple[Path, Path]: + docs_main = tmp_path / "docs-main" + snippets = docs_main / "snippets" + snippets.mkdir(parents=True) + return docs_main, snippets + + +def test_audit_reports_missing_and_orphan_snippets(tmp_path: Path) -> None: + module = load_script_module() + docs_main, snippets = layout(tmp_path) + write( + docs_main / "page.mdx", + 'import Used from "/snippets/used.mdx";\n\n\n', + ) + write(snippets / "used.mdx", "```text\nused\n```\n") + write(snippets / "orphan.mdx", "```text\norphan\n```\n") + write( + docs_main / "missing.mdx", + 'import Missing from "/snippets/missing.mdx";\n\n\n', + ) + + result = module.audit(docs_main, snippets) + + assert result.content_pages == 2 + assert result.missing == ("/snippets/missing.mdx",) + assert result.orphans == ("/snippets/orphan.mdx",) + + +def test_audit_follows_nested_and_networkvars_references(tmp_path: Path) -> None: + module = load_script_module() + docs_main, snippets = layout(tmp_path) + write( + docs_main / "page.mdx", + '{/* NETWORKVARS_START source="/snippets/networkvars/block.mdx" */}\n', + ) + write( + snippets / "networkvars" / "block.mdx", + 'import Nested from "/snippets/internal/nested.mdx";\n\n\n', + ) + write(snippets / "internal" / "nested.mdx", "```bash\necho nested\n```\n") + write( + docs_main / "named.mdx", + "import { networkData } from '/snippets/generated/data.mdx';\n", + ) + write(snippets / "generated" / "data.mdx", "export const networkData = {};\n") + + result = module.audit(docs_main, snippets) + + assert result.missing == () + assert result.orphans == () + assert "/snippets/networkvars/block.mdx" in result.referenced + assert "/snippets/internal/nested.mdx" in result.referenced + assert "/snippets/generated/data.mdx" in result.referenced + + +def test_main_writes_logs_and_respects_no_fail( + tmp_path: Path, monkeypatch, capsys +) -> None: + module = load_script_module() + docs_main, snippets = layout(tmp_path) + write( + docs_main / "page.mdx", + 'import Missing from "/snippets/missing.mdx";\n', + ) + write(snippets / "orphan.mdx", "orphan\n") + output_dir = tmp_path / "logs" + + monkeypatch.setattr(module, "DOCS_MAIN", docs_main) + monkeypatch.setattr(module, "SNIPPETS_ROOT", snippets) + monkeypatch.setattr(module, "REPO_ROOT", tmp_path) + + failing = module.main(["--output-path", str(output_dir)]) + assert failing == 1 + assert (output_dir / "snippets-missing.log").read_text(encoding="utf-8") == ( + "/snippets/missing.mdx\n" + ) + assert (output_dir / "snippets-orphan.log").read_text(encoding="utf-8") == ( + "/snippets/orphan.mdx\n" + ) + + capsys.readouterr() + quiet = module.main(["--quiet", "--no-fail", "--output-path", str(output_dir)]) + assert quiet == 0 + captured = capsys.readouterr() + assert captured.out == "" + assert "/snippets/missing.mdx" in captured.err + assert "/snippets/orphan.mdx" in captured.err + + +def test_delete_orphan_snippets_removes_only_orphans(tmp_path: Path, monkeypatch) -> None: + module = load_script_module() + docs_main, snippets = layout(tmp_path) + used = snippets / "used.mdx" + orphan = snippets / "orphan.mdx" + write(docs_main / "page.mdx", 'import Used from "/snippets/used.mdx";\n') + write(used, "used\n") + write(orphan, "orphan\n") + + monkeypatch.setattr(module, "DOCS_MAIN", docs_main) + monkeypatch.setattr(module, "SNIPPETS_ROOT", snippets) + monkeypatch.setattr(module, "REPO_ROOT", tmp_path) + + exit_code = module.main( + ["--delete-orphan-snippets", "--no-fail", "--output-path", str(tmp_path / "logs")] + ) + + assert exit_code == 0 + assert used.is_file() + assert not orphan.exists() diff --git a/tests/test_validate_snippet_sources.py b/tests/test_validate_snippet_sources.py new file mode 100644 index 000000000..63a0c1185 --- /dev/null +++ b/tests/test_validate_snippet_sources.py @@ -0,0 +1,168 @@ +from __future__ import annotations + +import importlib.util +import json +import sys +from pathlib import Path +from types import ModuleType + + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +def load_script_module() -> ModuleType: + script_path = REPO_ROOT / "scripts" / "validate_snippet_sources.py" + scripts_dir = str(script_path.parent) + if scripts_dir not in sys.path: + sys.path.insert(0, scripts_dir) + spec = importlib.util.spec_from_file_location(script_path.stem, script_path) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[script_path.stem] = module + spec.loader.exec_module(module) + return module + + +def write_json(path: Path, payload: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + + +def write_text(path: Path, text: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + + +def snippet( + name: str, + filepath: str, + location: dict, + repo: str = "daml", +) -> dict: + return { + "snippetName": name, + "sourceRepo": repo, + "sourceFilepath": filepath, + "location": location, + "description": "", + "options": {"language": "text"}, + } + + +def config_layout(tmp_path: Path, snippets: list[dict], repo: str = "daml") -> Path: + config_dir = tmp_path / "config" + filename = f"{repo}-snippet-list-remote.json" + write_json(config_dir / "remote-snippet-lists.json", {"snippetLists": [filename]}) + write_json(config_dir / filename, {"snippets": snippets}) + return config_dir + + +def test_full_file_and_line_and_marker_validation(tmp_path: Path) -> None: + module = load_script_module() + source = tmp_path / "daml" + write_text(source / "examples" / "full.txt", "alpha\nbeta\ngamma\n") + write_text( + source / "examples" / "marked.txt", + "header\n-- BEGIN\nbody\n-- END\nfooter\n", + ) + config_dir = config_layout( + tmp_path, + [ + snippet("full-ok", "examples/full.txt", {"type": "fullFile"}), + snippet("lines-ok", "examples/full.txt", {"type": "lines", "start": 1, "end": 3}), + snippet("lines-oob", "examples/full.txt", {"type": "lines", "start": 2, "end": 9}), + snippet("marker-ok", "examples/marked.txt", { + "type": "stringMarker", + "start": "-- BEGIN", + "end": "-- END", + }), + snippet("marker-missing", "examples/marked.txt", { + "type": "stringMarker", + "start": "-- BEGIN", + "end": "-- NOPE", + }), + snippet("missing-file", "examples/absent.txt", {"type": "fullFile"}), + ], + ) + + result = module.audit(config_dir=config_dir, repo="daml", source_dir=source) + messages = [error.format() for error in result.errors] + assert result.snippet_count == 6 + assert any("line range 2-9 is out of bounds" in message for message in messages) + assert any("end marker not found: '-- NOPE'" in message for message in messages) + assert any("source file not found: examples/absent.txt" in message for message in messages) + assert not any("full-ok:" in message or "lines-ok:" in message or "marker-ok:" in message for message in messages) + + +def test_json_index_out_of_bounds(tmp_path: Path) -> None: + module = load_script_module() + source = tmp_path / "canton" + write_text(source / "snippets.json", json.dumps(["one", "two"])) + config_dir = config_layout( + tmp_path, + [snippet("json-oob", "snippets.json", {"type": "jsonIndex", "start": 0, "end": 4}, repo="canton")], + repo="canton", + ) + + result = module.audit(config_dir=config_dir, repo="canton", source_dir=source) + assert len(result.errors) == 1 + assert "jsonIndex 0-4 is out of bounds (array length 2)" in result.errors[0].message + + +def test_main_writes_log_and_respects_flags(tmp_path: Path, monkeypatch, capsys) -> None: + module = load_script_module() + source = tmp_path / "dpm" + write_text(source / "docs" / "file.rst", "only one line\n") + config_dir = config_layout( + tmp_path, + [snippet("oob", "docs/file.rst", {"type": "lines", "start": 5, "end": 6}, repo="dpm")], + repo="dpm", + ) + output_dir = tmp_path / "logs" + + monkeypatch.setattr(module, "CONFIG_DIR", config_dir) + monkeypatch.setattr(module, "REPO_ROOT", tmp_path) + + failing = module.main( + ["dpm", "--source-dir", str(source), "--output-path", str(output_dir)] + ) + assert failing == 1 + log = (output_dir / "snippet-source-errors.log").read_text(encoding="utf-8") + assert "dpm oob:" in log + assert "out of bounds" in log + + capsys.readouterr() + quiet = module.main( + [ + "--quiet", + "--no-fail", + "dpm", + "--source-dir", + str(source), + "--output-path", + str(output_dir), + ] + ) + assert quiet == 0 + captured = capsys.readouterr() + assert captured.out == "" + assert "out of bounds" in captured.err + + +def test_source_dir_requires_single_repo(tmp_path: Path) -> None: + module = load_script_module() + config_dir = tmp_path / "config" + write_json( + config_dir / "remote-snippet-lists.json", + {"snippetLists": ["daml-snippet-list-remote.json", "dpm-snippet-list-remote.json"]}, + ) + write_json(config_dir / "daml-snippet-list-remote.json", {"snippets": []}) + write_json(config_dir / "dpm-snippet-list-remote.json", {"snippets": []}) + + try: + module.audit(config_dir=config_dir, source_dir=tmp_path / "somewhere") + except SystemExit as error: + assert "Pass a repo name when using --source-dir" in str(error) + else: + raise AssertionError("expected SystemExit") From b3963779e2b3e9ca2ed125830500a81ee2f361eb Mon Sep 17 00:00:00 2001 From: Ibo Sy Date: Tue, 18 Aug 2026 13:41:32 +0200 Subject: [PATCH 2/2] extend test coverage for generateOutputDocs.js script Signed-off-by: Ibo Sy --- tests/test_generate_external_snippets.py | 279 +++++++++++++++++++++++ 1 file changed, 279 insertions(+) diff --git a/tests/test_generate_external_snippets.py b/tests/test_generate_external_snippets.py index fce205163..80b85cb98 100644 --- a/tests/test_generate_external_snippets.py +++ b/tests/test_generate_external_snippets.py @@ -1,6 +1,8 @@ from __future__ import annotations +import json import shutil +import subprocess from pathlib import Path import pytest @@ -8,6 +10,48 @@ from scripts import generate_external_snippets as generator +def run_generate_output_docs( + tmp_path: Path, + *, + snippets: list[dict], + sources: dict[str, str], + extra_config: dict | None = None, + extra_args: list[str] | None = None, +) -> tuple[subprocess.CompletedProcess[str], Path]: + helper = tmp_path / "generateOutputDocs.js" + shutil.copy2(generator.helper_path(), helper) + repo_root = tmp_path / "repo" + output_dir = tmp_path / "docs-output" + config_path = tmp_path / "exportConfig.json" + for relative, content in sources.items(): + path = repo_root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + config = {"snippets": snippets, **(extra_config or {})} + config_path.write_text(json.dumps(config), encoding="utf-8") + result = subprocess.run( + [ + "node", + str(helper), + "--repo-root", + str(repo_root), + "--export-config", + str(config_path), + "--output", + str(output_dir), + *(extra_args or []), + ], + capture_output=True, + text=True, + check=False, + ) + return result, output_dir + + +def read_output(output_dir: Path, snippet_name: str) -> str: + return (output_dir / f"{snippet_name}.mdx").read_text(encoding="utf-8") + + def test_copy_helper_and_config_copies_helper(tmp_path: Path) -> None: source_dir = tmp_path / "daml-shell" helper = generator.copy_helper_and_config( @@ -139,3 +183,238 @@ def test_wrapper_copies_helper_runs_extraction_and_copies_output( "```text\nhello\n```" ) assert (target / "example.mdx").read_text(encoding="utf-8") == "```text\nhello\n```" + + +def test_generate_output_docs_help_and_unknown_flag(tmp_path: Path) -> None: + helper = tmp_path / "generateOutputDocs.js" + shutil.copy2(generator.helper_path(), helper) + + help_result = subprocess.run( + ["node", str(helper), "--help"], + capture_output=True, + text=True, + check=False, + ) + assert help_result.returncode == 0 + assert "Usage: node generateOutputDocs.js" in help_result.stdout + assert "--repo-root" in help_result.stdout + assert "--export-config" in help_result.stdout + assert "--output" in help_result.stdout + assert "--verbose" in help_result.stdout + + unknown = subprocess.run( + ["node", str(helper), "--bogus"], + capture_output=True, + text=True, + check=False, + ) + assert unknown.returncode == 1 + assert "Unknown argument: --bogus" in unknown.stderr + + +def test_generate_output_docs_location_indent_transform_and_cli(tmp_path: Path) -> None: + snippets = [ + { + "snippetName": "lines-default-indent", + "sourceFilepath": "docs/indent.conf", + "location": {"type": "lines", "start": 1, "end": 3}, + "options": {"language": "conf"}, + }, + { + "snippetName": "lines-preserve-indent", + "sourceFilepath": "docs/indent.conf", + "location": {"type": "lines", "start": 1, "end": 3}, + "options": {"language": "conf", "normalizeIndent": False}, + }, + { + "snippetName": "lines-baseline-indent", + "sourceFilepath": "docs/indent.conf", + "location": {"type": "lines", "start": 1, "end": 3}, + "options": {"language": "conf", "normalizeIndent": "baseline"}, + }, + { + "snippetName": "bash-false-uses-baseline", + "sourceFilepath": "docs/bash.sh", + "location": {"type": "lines", "start": 1, "end": 1}, + "options": {"language": "bash", "normalizeIndent": False}, + }, + { + "snippetName": "json-index-single", + "sourceFilepath": "docs/items.json", + "location": {"type": "jsonIndex", "start": 1, "end": 1}, + "options": {"language": "text", "normalizeIndent": False}, + }, + { + "snippetName": "json-index-range", + "sourceFilepath": "docs/items.json", + "location": {"type": "jsonIndex", "start": 0, "end": 1}, + "options": {"language": "text", "normalizeIndent": False}, + }, + { + "snippetName": "full-file", + "sourceFilepath": "docs/full.yaml", + "location": {"type": "fullFile"}, + "options": {"language": "yaml"}, + }, + { + "snippetName": "regex-wrap", + "sourceFilepath": "docs/regex.txt", + "location": {"type": "regexWrap", "start": "BEGIN", "end": "END"}, + "options": {"language": "text", "normalizeIndent": False}, + }, + { + "snippetName": "language-none", + "sourceFilepath": "docs/plain.txt", + "location": {"type": "lines", "start": 1, "end": 1}, + "options": {"language": "none", "normalizeIndent": False}, + }, + { + "snippetName": "unescape-rst-quotes", + "sourceFilepath": "docs/console.txt", + "location": {"type": "lines", "start": 1, "end": 1}, + "options": { + "language": "haskell", + "normalizeIndent": False, + "unescapeRstQuotes": True, + }, + }, + { + "snippetName": "rstinclude-warning", + "sourceFilepath": "docs/include.rst", + "location": {"type": "fullFile"}, + "options": {"transform": "rstinclude"}, + }, + { + "snippetName": "rstjson-code-block", + "sourceFilepath": "docs/sphinx.json.rst", + "location": {"type": "fullFile"}, + "options": {"transform": "rstjson", "language": "python"}, + }, + { + "snippetName": "url-substituted", + "sourceFilepath": "docs/urls.txt", + "location": {"type": "fullFile"}, + "options": {"language": "text", "normalizeIndent": False}, + }, + ] + sources = { + "docs/indent.conf": " canton {\n storage = memory\n }\n", + "docs/bash.sh": " echo hi\n", + "docs/items.json": '["alpha", "beta", "gamma"]\n', + "docs/full.yaml": " foo: 1\n bar: 2\n", + "docs/regex.txt": "prefix BEGIN\nhello\nEND suffix\n", + "docs/plain.txt": "plain body\n", + "docs/console.txt": "participant.dars.upload(\\'file.dar\\')\n", + "docs/include.rst": ( + ".. Copyright (c) 2026\n" + "\n" + ".. warning::\n" + "\n" + " Do not skip :ref:`the guide `.\n" + ), + "docs/sphinx.json.rst": ( + "prefix\n" + ".. code-block:: python\n" + "\n" + " print(\"hi\")\n" + " print(\"there\")\n" + "after\n" + ), + "docs/urls.txt": "# see https://docs.daml.com/old\n", + } + + result, output_dir = run_generate_output_docs( + tmp_path, + snippets=snippets, + sources=sources, + extra_config={ + "rstIncludeRefTargets": {"guide": "/docs/guide"}, + "urlSubstitutions": { + "https://docs.daml.com/old": "https://docs.canton.network/new" + }, + }, + extra_args=["--verbose"], + ) + + assert result.returncode == 0, result.stderr + assert "Processing snippet: lines-default-indent" in result.stdout + assert "Processing complete: 13 succeeded, 0 failed" in result.stdout + + assert read_output(output_dir, "lines-default-indent") == ( + "```conf\n canton {\n storage = memory\n }\n```" + ) + assert read_output(output_dir, "lines-preserve-indent") == ( + "```conf\n canton {\n storage = memory\n }\n```" + ) + assert read_output(output_dir, "lines-baseline-indent") == ( + "```conf\ncanton {\n storage = memory\n}\n```" + ) + assert read_output(output_dir, "bash-false-uses-baseline") == "```bash\necho hi\n```" + assert read_output(output_dir, "json-index-single") == "```text\nbeta\n```" + assert read_output(output_dir, "json-index-range") == "```text\nalpha\nbeta\n```" + assert read_output(output_dir, "full-file") == "```yaml\nfoo: 1\nbar: 2\n```" + assert read_output(output_dir, "regex-wrap") == "```text\nhello\n```" + assert read_output(output_dir, "language-none") == "```\nplain body\n```" + assert read_output(output_dir, "unescape-rst-quotes") == ( + "```haskell\nparticipant.dars.upload('file.dar')\n```" + ) + assert read_output(output_dir, "rstinclude-warning") == ( + "\n\nDo not skip [the guide](/docs/guide).\n\n" + ) + assert read_output(output_dir, "rstjson-code-block") == ( + "```python\nprint(\"hi\")\nprint(\"there\")\n```" + ) + assert read_output(output_dir, "url-substituted") == ( + "```text\n# see https://docs.canton.network/new\n```" + ) + + +def test_generate_output_docs_reports_extraction_errors(tmp_path: Path) -> None: + snippets = [ + { + "snippetName": "missing-marker", + "sourceFilepath": "docs/example.txt", + "location": { + "type": "stringMarker", + "start": "MISSING_START", + "end": "MISSING_END", + }, + "options": {"language": "text"}, + }, + { + "snippetName": "oob-lines", + "sourceFilepath": "docs/example.txt", + "location": {"type": "lines", "start": 1, "end": 99}, + "options": {"language": "text"}, + }, + { + "snippetName": "invalid-json-index", + "sourceFilepath": "docs/not-array.json", + "location": {"type": "jsonIndex", "start": 0, "end": 0}, + "options": {"language": "text"}, + }, + { + "snippetName": "good-snippet", + "sourceFilepath": "docs/example.txt", + "location": {"type": "lines", "start": 1, "end": 1}, + "options": {"language": "text", "normalizeIndent": False}, + }, + ] + result, output_dir = run_generate_output_docs( + tmp_path, + snippets=snippets, + sources={ + "docs/example.txt": "hello\n", + "docs/not-array.json": '{"not": "an-array"}\n', + }, + ) + + assert result.returncode == 1 + assert "Start marker not found: \"MISSING_START\"" in result.stderr + assert "Line numbers out of range" in result.stderr + assert "JSON root must be an array for location type jsonIndex" in result.stderr + assert "Processing complete: 1 succeeded, 3 failed" in result.stdout + assert read_output(output_dir, "good-snippet") == "```text\nhello\n```" + assert not (output_dir / "missing-marker.mdx").exists() + assert not (output_dir / "oob-lines.mdx").exists() + assert not (output_dir / "invalid-json-index.mdx").exists()