This is an automated email from the ASF dual-hosted git repository.

wave pushed a commit to branch template-linter
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git

commit 98e08d99e04bb3380552f1476be2e4778808f024
Author: Dave Fisher <[email protected]>
AuthorDate: Fri Feb 6 14:22:49 2026 -0800

    Check templates linter
---
 scripts/check_templates.py | 233 +++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 233 insertions(+)

diff --git a/scripts/check_templates.py b/scripts/check_templates.py
new file mode 100644
index 0000000..6093eaa
--- /dev/null
+++ b/scripts/check_templates.py
@@ -0,0 +1,233 @@
+#!/usr/bin/env python3
+import ast
+import sys
+from pathlib import Path
+from collections import defaultdict
+import re
+import argparse
+
+TEMPLATE_SUFFIXES = {".html", ".htm", ".j2", ".jinja"}
+JINJA_REF_RE = re.compile(
+    r"""{%\s*
+        (?:include|extends|import|from)
+        \s+["']([^"']+)["']
+        """,
+    re.VERBOSE,
+)
+
+
+class TemplateVisitor(ast.NodeVisitor):
+    def __init__(self, filename):
+        self.filename = filename
+        self.found = []  # (template_name, lineno)
+
+    def visit_Call(self, node):
+        if isinstance(node.func, ast.Attribute) and node.func.attr == "render":
+            if node.args:
+                arg = node.args[0]
+                if isinstance(arg, ast.Constant) and isinstance(arg.value, 
str):
+                    self.found.append((arg.value, node.lineno))
+        self.generic_visit(node)
+
+
+def detect_cycles(graph):
+    cycles = []
+    visiting = set()
+    visited = set()
+
+    def visit(node, stack):
+        if node in visiting:
+            cycle = stack[stack.index(node):] + [node]
+            cycles.append(cycle)
+            return
+        if node in visited:
+            return
+
+        visiting.add(node)
+        for nxt in graph.get(node, ()):
+            visit(nxt, stack + [nxt])
+        visiting.remove(node)
+        visited.add(node)
+
+    for node in graph:
+        visit(node, [node])
+
+    return cycles
+
+
+def find_template_references(locations):
+    """
+    locations: {template_name: [Path, ...]}
+    Returns: {template_name: set(referenced_template_names)}
+    """
+    refs = defaultdict(set)
+
+    missing_includes = defaultdict(set)
+
+    known = set(locations.keys())
+
+    for name, paths in locations.items():
+        for path in paths:
+            try:
+                text = path.read_text(encoding="utf-8")
+            except OSError:
+                continue
+
+            for match in JINJA_REF_RE.findall(text):
+                ref = Path(match).name
+                refs[name].add(ref)
+                if ref not in known:
+                    missing_includes[name].add(ref)
+
+    return refs, missing_includes
+
+
+def find_templates_in_code(source_root: Path):
+    used = set()
+    origins = defaultdict(list)
+
+    for pyfile in source_root.rglob("*.py"):
+        try:
+            tree = ast.parse(pyfile.read_text(encoding="utf-8"))
+        except SyntaxError:
+            continue
+
+        visitor = TemplateVisitor(pyfile)
+        visitor.visit(tree)
+
+        for name, lineno in visitor.found:
+            filename_only = Path(name).name
+            used.add(filename_only)
+            origins[filename_only].append((pyfile, lineno))
+
+    used.add("blank.html")
+    origins["blank.html"].append(("atr/template.py", 42))
+        
+    return used, origins
+
+
+def find_template_dirs(source_root: Path):
+    return [p for p in source_root.rglob("templates") if p.is_dir()]
+
+
+def find_templates_on_disk(source_root: Path):
+    present = set()
+    locations = defaultdict(list)
+
+    for tdir in find_template_dirs(source_root):
+        for p in tdir.rglob("*"):
+            if p.suffix in TEMPLATE_SUFFIXES:
+                name = p.name  # filename-only
+                present.add(name)
+                locations[name].append(p)
+
+    return present, locations
+
+
+def resolve_used_templates(python_used, template_refs):
+    reachable = set()
+    stack = list(python_used)
+
+    while stack:
+        current = stack.pop()
+        if current in reachable:
+            continue
+
+        reachable.add(current)
+        for ref in template_refs.get(current, ()):
+            if ref not in reachable:
+                stack.append(ref)
+
+    return reachable
+
+
+def reverse_refs(refs):
+    rev = defaultdict(set)
+    for src, targets in refs.items():
+        for t in targets:
+            rev[t].add(src)
+    return rev
+
+
+def main():
+    parser = argparse.ArgumentParser()
+    parser.add_argument("source_root", help="Source tree root")
+    parser.add_argument(
+        "--usage-map",
+        action="store_true",
+        help="Display template usage map",
+    )
+    args = parser.parse_args()
+
+    root = Path(args.source_root)
+
+    used, origins = find_templates_in_code(root)
+    present, locations = find_templates_on_disk(root)
+    refs, missing_includes = find_template_references(locations)
+    reachable = resolve_used_templates(used, refs)
+    rev_refs = reverse_refs(refs)
+
+    missing = used - present
+    
+    duplicates = {k: v for k, v in locations.items() if len(v) > 1}
+    unreachable = present - reachable
+    cycles = detect_cycles(refs)
+
+    if args.usage_map:
+        print("\n== Template usage map ==")
+        for name in sorted(reachable):
+            print(f"\n{name}")
+
+            if name in origins:
+                for file, line in origins[name]:
+                    print(f"  rendered from {file}:{line}")
+
+            for parent in sorted(rev_refs.get(name, [])):
+                print(f"  included by {parent}")
+
+            for child in sorted(refs.get(name, [])):
+                print(f"  includes {child}")
+
+    errors = False
+
+    if missing:
+        errors = True
+        print("\nMissing templates")
+        for t in sorted(missing):
+            print(f"  {t}")
+            for file, line in origins.get(t, []):
+                print(f"      referenced at {file}:{line}")
+
+    if missing_includes:
+        errors = True
+        print("\nMissing included templates")
+        for src, refs in missing_includes.items():
+            for ref in refs:
+                print(f"  {src} includes missing {ref}")
+
+    if duplicates:
+        errors = True
+        print("\nDuplicate template definitions")
+        for name, paths in duplicates.items():
+            print(f"  {name}")
+            for p in paths:
+                print(f"      {p}")
+
+    if unreachable:
+        errors = True
+        print("\nUnreachable templates")
+        for t in sorted(unreachable):
+            print(f"  {t}")
+            for p in locations[t]:
+                print(f"      {p}")
+
+    if cycles:
+        errors = True
+        print("\nTemplate includes cycles")
+        for cycle in cycles:
+            print("  " + " → ".join(cycle))
+
+    sys.exit(1 if errors else 0)
+
+if __name__ == "__main__":
+    main()


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to