#!/usr/bin/env python3 import argparse import csv import json import re import subprocess from pathlib import Path ROOT = Path(__file__).resolve().parents[1] TEMPLATE_ROOT = ROOT / "modules/chart_engine/template/d3-js" CHECKS = { "missing_chart_utils": re.compile(r"chartUtils\."), "missing_color_resolver": re.compile(r"chartUtils\.color\.resolver"), "missing_schema_helper": re.compile(r"chartUtils\.schema\.(?:channel|channels|channelByKey|columns)"), "missing_format_helper": re.compile(r"chartUtils\.format\."), "missing_text_helper": re.compile(r"chartUtils\.text\."), "missing_legend_helper": re.compile(r"chartUtils\.legend\."), } ISSUE_PATTERNS = { "direct_column_index": re.compile( r"(?:dataColumns|data\.data\.columns|jsonData\.data\.columns)" r"\[[^\]]+\](?:\?\.|\.)" r"(?:name|role|unit|label|description|display_name|data_type|type)" r"|(?:dataColumns|data\.data\.columns|jsonData\.data\.columns)" r"\.(?:find|filter)\(" r"|(?:dataColumns|data\.data\.columns|jsonData\.data\.columns)" r"\.filter\([^)]*\.role\s*===\s*['\"][^'\"]+['\"][^)]*\)\[[0-9]+\]\.name" r"|(?:dataColumns|data\.data\.columns|jsonData\.data\.columns)" r"\.find\([^)]*\.role\s*===\s*['\"][^'\"]+['\"][^)]*\)" ), "local_format_value": re.compile(r"\b(?:const|let|var|function)\s+formatValue\b|formatValue\s*="), "local_text_measure": re.compile( r"\b(?:const|let|var|function)\s+(?:getTextWidth|getTextWidthCanvas|measureText)\b" r"|\.getBBox\(|getComputedTextLength\(|\.measureText\(" ), "local_parse_date": re.compile( r"\b(?:const|let|var|function)\s+parseDate\b|parseDate\s*=" r"|new Date\(\s*d\[[^\]]+\]\s*\)" ), "direct_color_access": re.compile( r"\bcolors(?:\.|\?\.)" r"(?:other|field|available_colors|text_color|background_color)\b" r"|\b(?:data|jsonData|dataJSON)(?:\.|\?\.)colors(?:\.|\?\.)" r"(?:other|field|available_colors|text_color|background_color)\b" ), "direct_color_variant": re.compile(r"\.(?:brighter|darker)\("), "legacy_global_helper_call": re.compile( r"(? bool: return "REQUIREMENTS_BEGIN" in text and "REQUIREMENTS_END" in text def node_check(path: Path) -> str: proc = subprocess.run( ["node", "--check", str(path)], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, ) if proc.returncode == 0: return "" return proc.stderr.strip().splitlines()[0] if proc.stderr.strip() else "node --check failed" def audit_file(path: Path, run_node_check: bool) -> dict: text = path.read_text(encoding="utf-8") record = { "path": str(path.relative_to(ROOT)), "has_requirements": has_requirements(text), "syntax_error": node_check(path) if run_node_check else "", } for name, pattern in CHECKS.items(): if name.startswith("missing_"): record[name] = not bool(pattern.search(text)) for name, pattern in ISSUE_PATTERNS.items(): record[name] = bool(pattern.search(text)) record["manual_legend_without_chart_utils_legend"] = ( record["manual_legend"] and record["missing_legend_helper"] ) return record def summarize(records: list[dict]) -> dict: keys = [ "has_requirements", "syntax_error", *CHECKS.keys(), *ISSUE_PATTERNS.keys(), "manual_legend_without_chart_utils_legend", ] summary = {"total_js": len(records)} summary["registered_requirement_files"] = sum(1 for record in records if record["has_requirements"]) for key in keys: if key == "syntax_error": summary[key] = sum(1 for record in records if record[key]) elif key == "has_requirements": continue else: summary[key] = sum(1 for record in records if record.get(key)) return summary def write_csv(path: Path, records: list[dict]) -> None: path.parent.mkdir(parents=True, exist_ok=True) fieldnames = list(records[0].keys()) if records else ["path"] with open(path, "w", newline="", encoding="utf-8") as f: writer = csv.DictWriter(f, fieldnames=fieldnames) writer.writeheader() writer.writerows(records) def print_summary(summary: dict, records: list[dict], max_examples: int) -> None: print(json.dumps(summary, indent=2, ensure_ascii=False)) for key, count in summary.items(): if key in {"total_js", "registered_requirement_files"} or not count: continue examples = [record["path"] for record in records if record.get(key)][:max_examples] if examples: print(f"\n{key} examples:") for item in examples: print(f" {item}") def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Audit D3 template usage of chartUtils standard helpers.") parser.add_argument("--template-root", default=str(TEMPLATE_ROOT)) parser.add_argument("--output-json", default=None) parser.add_argument("--output-csv", default=None) parser.add_argument("--examples", type=int, default=5) parser.add_argument("--node-check", action="store_true") return parser.parse_args() def main() -> None: args = parse_args() template_root = Path(args.template_root) records = [ audit_file(path, run_node_check=args.node_check) for path in sorted(template_root.rglob("*.js")) ] summary = summarize(records) if args.output_json: output_path = Path(args.output_json) output_path.parent.mkdir(parents=True, exist_ok=True) output_path.write_text( json.dumps({"summary": summary, "records": records}, indent=2, ensure_ascii=False), encoding="utf-8", ) if args.output_csv: write_csv(Path(args.output_csv), records) print_summary(summary, records, args.examples) if __name__ == "__main__": main()