#!/usr/bin/env python3
"""Lint check for DAG-bypass patterns in card builder files.
Detects `if component_name ==` and `if component_name in` patterns in
card builder source files. These patterns indicate special-case branching
that should ideally go through the NetworkX DAG via get_field_for_child().
Existing occurrences are legitimate special cases (Image URL handling,
Grid columnCount, etc.) that can't easily move to the DAG. This lint
surfaces them as warnings so NEW additions are visible in PR diffs.
Error code: DSL002
Usage:
python scripts/lint_no_dag_bypass.py
python scripts/lint_no_dag_bypass.py --verbose
Exit codes:
0 - Always (warnings only, never blocks)
"""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
# Files to scan for DAG-bypass patterns
TARGET_FILES: list[str] = [
"gchat/card_builder/builder_v2.py",
]
# Pattern: `if component_name ==` or `if component_name in`
# Also matches `elif component_name ==` / `elif component_name in`
DAG_BYPASS_RE = re.compile(r"\b(?:el)?if\s+component_name\s+(?:==|in\b)")
def scan_file(rel_path: Path) -> list[tuple[int, str]]:
"""Scan a file for DAG-bypass patterns.
Returns list of (line_number, stripped_line).
"""
abs_path = PROJECT_ROOT / rel_path
violations: list[tuple[int, str]] = []
try:
text = abs_path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
return violations
in_docstring = False
for lineno, line in enumerate(text.splitlines(), start=1):
stripped = line.strip()
# Track triple-quote docstrings (simple toggle — sufficient for
# single-file linting where we only care about code lines)
triple_count = stripped.count('"""') + stripped.count("'''")
if triple_count % 2 == 1:
in_docstring = not in_docstring
continue
if in_docstring:
continue
# Skip comment lines
if stripped.startswith("#"):
continue
if DAG_BYPASS_RE.search(line):
violations.append((lineno, stripped))
return violations
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--verbose", "-v", action="store_true")
args = parser.parse_args()
targets = [Path(p) for p in TARGET_FILES]
scannable = [p for p in targets if (PROJECT_ROOT / p).exists()]
missing = [p for p in targets if p not in scannable]
if missing:
for p in missing:
print(f"WARNING: target file not found: {p}")
if args.verbose:
print(f"Scanning {len(scannable)} file(s) for DAG-bypass patterns:")
for p in scannable:
print(f" {p}")
total = 0
for rel_path in scannable:
hits = scan_file(rel_path)
for lineno, line in hits:
print(f"{rel_path}:{lineno}: DSL002 DAG-bypass pattern: {line}")
total += 1
if total:
print(f"\n{total} DAG-bypass pattern(s) found (warning only).")
elif args.verbose:
print("No DAG-bypass patterns found.")
# Always exit 0 — this lint is advisory, not blocking
return 0
if __name__ == "__main__":
raise SystemExit(main())