356 lines
13 KiB
Python
356 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Z-index layer policy checker.
|
|
|
|
Enforces the policy defined in agent-config/rules/ui/z-index-layer-policy.mdc:
|
|
- Global layers must use var(--meld-z-*).
|
|
- Only small local values (1, 2, 3) are allowed for component-scoped stacking.
|
|
- Magic numbers (e.g., 10, 11, 1001) are forbidden.
|
|
|
|
Supports ignore comments: /* z-index-check-ignore */ or // z-index-check-ignore
|
|
on the same line or the line immediately before the z-index usage.
|
|
"""
|
|
|
|
import os
|
|
import re
|
|
import sys
|
|
|
|
# Match z-index: <value> or zIndex: <value> (standalone CSS/JS property only)
|
|
# Lookbehind ensures we don't match suffixes like "--my-z-index"; includes comma for compact literals
|
|
# Captures the value; stops at comma, semicolon, brace, or newline so object literals work
|
|
ZINDEX_PATTERN = re.compile(r"(?:(?<=^)|(?<=[\s{;.,]))(?:z-index|zIndex)(?=\s*:)\s*:\s*(?P<value>[^,;}\n]+)")
|
|
|
|
# Allowed: var(--meld-z-*) token exactly; if quoted, opening and closing quote must match
|
|
VAR_TOKEN_PATTERN = re.compile(r"^(?:var\(--meld-z-[A-Za-z0-9-]+\)|(['\"])var\(--meld-z-[A-Za-z0-9-]+\)\1)$")
|
|
|
|
# Policy: component-scoped local stacking values (1, 2, 3) are allowed
|
|
ALLOWED_LOCAL = {1, 2, 3}
|
|
|
|
IGNORE_MARKER = "z-index-check-ignore"
|
|
|
|
# Regex to test if a line is "standalone" ignore (line is only comment-wrapped marker, no bare marker)
|
|
_STANDALONE_IGNORE_PATTERN = re.compile(
|
|
rf"^\s*(?:/\*\s*{re.escape(IGNORE_MARKER)}\s*\*/"
|
|
rf"|//\s*{re.escape(IGNORE_MARKER)}\s*)\s*$"
|
|
)
|
|
|
|
|
|
class _JsScanner:
|
|
"""
|
|
Scans JavaScript/TypeScript/CSS source for strings and comments.
|
|
Tracks state for single/double/backtick strings, block/line comments, and escapes.
|
|
"""
|
|
|
|
def __init__(self, s: str) -> None:
|
|
self.s = s
|
|
self.i = 0
|
|
self.in_single = False
|
|
self.in_double = False
|
|
self.in_backtick = False
|
|
self.in_block_comment = False
|
|
self.in_line_comment = False
|
|
self.escape_next = False
|
|
|
|
def _reset(self) -> None:
|
|
"""Reset position and state."""
|
|
self.i = 0
|
|
self.in_single = False
|
|
self.in_double = False
|
|
self.in_backtick = False
|
|
self.in_block_comment = False
|
|
self.in_line_comment = False
|
|
self.escape_next = False
|
|
|
|
def _advance_one(self, *, escape_in_code: bool = True) -> bool:
|
|
"""
|
|
Advance one character, update state. Returns False if at end.
|
|
escape_in_code: when True, treat \\ as escape in code; when False, only in strings.
|
|
"""
|
|
if self.i >= len(self.s):
|
|
return False
|
|
c = self.s[self.i]
|
|
if self.escape_next:
|
|
self.escape_next = False
|
|
self.i += 1
|
|
return True
|
|
if c == "\\":
|
|
if escape_in_code or self.in_single or self.in_double or self.in_backtick:
|
|
self.escape_next = True
|
|
self.i += 1
|
|
return True
|
|
if self.in_backtick:
|
|
if c == "`":
|
|
self.in_backtick = False
|
|
self.i += 1
|
|
return True
|
|
if self.in_single:
|
|
if c == "'":
|
|
self.in_single = False
|
|
self.i += 1
|
|
return True
|
|
if self.in_double:
|
|
if c == '"':
|
|
self.in_double = False
|
|
self.i += 1
|
|
return True
|
|
if self.in_block_comment:
|
|
if c == "*" and self.i + 1 < len(self.s) and self.s[self.i + 1] == "/":
|
|
self.in_block_comment = False
|
|
self.i += 2
|
|
else:
|
|
self.i += 1
|
|
return True
|
|
if self.in_line_comment:
|
|
if c == "\n":
|
|
self.in_line_comment = False
|
|
self.i += 1
|
|
return True
|
|
|
|
# In code: check for string/comment starters
|
|
if c == "`":
|
|
self.in_backtick = True
|
|
self.i += 1
|
|
return True
|
|
if c == "'":
|
|
self.in_single = True
|
|
self.i += 1
|
|
return True
|
|
if c == '"':
|
|
self.in_double = True
|
|
self.i += 1
|
|
return True
|
|
if c == "/" and self.i + 1 < len(self.s):
|
|
n = self.s[self.i + 1]
|
|
if n == "*":
|
|
self.in_block_comment = True
|
|
self.i += 2
|
|
return True
|
|
if n == "/":
|
|
self.in_line_comment = True
|
|
self.i += 2
|
|
return True
|
|
self.i += 1
|
|
return True
|
|
|
|
def advance_until(self, pos: int, *, escape_in_code: bool = True) -> None:
|
|
"""Advance from current position to pos (exclusive), updating state."""
|
|
while self.i < pos and self.i < len(self.s):
|
|
self._advance_one(escape_in_code=escape_in_code)
|
|
|
|
def is_pos_within_string(self, pos: int) -> bool:
|
|
"""Return True if position pos is inside a string."""
|
|
if pos <= 0 or pos >= len(self.s):
|
|
return False
|
|
self._reset()
|
|
self.advance_until(pos)
|
|
return self.in_double or self.in_single or self.in_backtick
|
|
|
|
def find_string_end_from(self, pos: int) -> int:
|
|
"""
|
|
Given we're inside a string at pos, return the position after the closing quote.
|
|
Assumes advance_until(pos) has been called (or will scan from 0 to pos first).
|
|
"""
|
|
self._reset()
|
|
self.advance_until(pos)
|
|
target = '"' if self.in_double else ("'" if self.in_single else "`")
|
|
self.escape_next = False
|
|
while self.i < len(self.s):
|
|
c = self.s[self.i]
|
|
if self.escape_next:
|
|
self.escape_next = False
|
|
self.i += 1
|
|
continue
|
|
if c == "\\":
|
|
self.escape_next = True
|
|
self.i += 1
|
|
continue
|
|
if c == target:
|
|
return self.i + 1
|
|
self.i += 1
|
|
return len(self.s)
|
|
|
|
def search_marker_within_comments(self, marker: str) -> bool:
|
|
"""Return True if marker appears anywhere inside a comment (block or line)."""
|
|
self._reset()
|
|
while self.i < len(self.s):
|
|
if self.in_block_comment or self.in_line_comment:
|
|
if self.s[self.i : self.i + len(marker)] == marker:
|
|
return True
|
|
if not self._advance_one(escape_in_code=False):
|
|
break
|
|
return False
|
|
|
|
|
|
def _marker_in_comment(line: str) -> bool:
|
|
"""Return True if IGNORE_MARKER appears inside a comment (not in a string)."""
|
|
scanner = _JsScanner(line)
|
|
return scanner.search_marker_within_comments(IGNORE_MARKER)
|
|
|
|
|
|
def _is_within_string(s: str, pos: int) -> bool:
|
|
"""Return True if position pos in s is inside a single-, double-, or backtick-quoted string."""
|
|
scanner = _JsScanner(s)
|
|
return scanner.is_pos_within_string(pos)
|
|
|
|
|
|
def _find_string_end(s: str, pos: int) -> int:
|
|
"""Given we're inside a string at pos, return the position after the closing quote."""
|
|
scanner = _JsScanner(s)
|
|
return scanner.find_string_end_from(pos)
|
|
|
|
|
|
def check_file(filepath: str) -> list[tuple[int, str, str]]:
|
|
"""Scan a file for z-index policy violations. Returns list of (line_num, line_content, reason)."""
|
|
errors: list[tuple[int, str, str]] = []
|
|
try:
|
|
with open(filepath, encoding="utf-8") as f:
|
|
lines = f.readlines()
|
|
|
|
prev_was_standalone_ignore = False
|
|
in_block_comment = False
|
|
for i, line in enumerate(lines, 1):
|
|
stripped = line.strip()
|
|
|
|
# Inline vs standalone ignore marker:
|
|
# - Inline: marker appears alongside other code (e.g. zIndex: 2, /* z-index-check-ignore */)
|
|
# -> skip only the current line, do NOT set prev_was_standalone_ignore
|
|
# - Standalone: line is only the marker or comment-wrapped marker -> next line is skipped
|
|
# - Only accept marker when it appears inside a comment (not in strings like "z-index-check-ignore")
|
|
if _marker_in_comment(line):
|
|
is_standalone = bool(_STANDALONE_IGNORE_PATTERN.match(stripped))
|
|
prev_was_standalone_ignore = is_standalone
|
|
continue
|
|
|
|
if prev_was_standalone_ignore:
|
|
prev_was_standalone_ignore = False
|
|
continue
|
|
|
|
# Multi-line block comment handling: track state across lines
|
|
code_part = ""
|
|
idx = 0
|
|
remaining = line
|
|
while idx < len(remaining):
|
|
if in_block_comment:
|
|
end_idx = remaining.find("*/", idx)
|
|
if end_idx != -1:
|
|
idx = end_idx + 2
|
|
in_block_comment = False
|
|
else:
|
|
idx = len(remaining)
|
|
else:
|
|
# Check for block start before single-line comment (/* can appear in // line)
|
|
block_start = remaining.find("/*", idx)
|
|
slash_start = remaining.find("//", idx)
|
|
if block_start != -1 and (slash_start == -1 or block_start < slash_start):
|
|
if _is_within_string(remaining, block_start):
|
|
# /* is inside a string (e.g. template literal), skip the string
|
|
string_end = _find_string_end(remaining, block_start)
|
|
code_part += remaining[idx:string_end]
|
|
idx = string_end
|
|
else:
|
|
code_part += remaining[idx:block_start]
|
|
end_idx = remaining.find("*/", block_start + 2)
|
|
if end_idx != -1:
|
|
idx = end_idx + 2
|
|
else:
|
|
idx = len(remaining)
|
|
in_block_comment = True
|
|
elif slash_start != -1:
|
|
if _is_within_string(remaining, slash_start):
|
|
# // is inside a string (e.g. "http://..."), include the whole string
|
|
string_end = _find_string_end(remaining, slash_start)
|
|
code_part += remaining[idx:string_end]
|
|
idx = string_end
|
|
else:
|
|
code_part += remaining[idx:slash_start]
|
|
idx = len(remaining)
|
|
else:
|
|
code_part += remaining[idx:]
|
|
idx = len(remaining)
|
|
|
|
prev_was_standalone_ignore = False
|
|
|
|
for match in ZINDEX_PATTERN.finditer(code_part):
|
|
value = match.group("value").strip().rstrip(",")
|
|
value = re.sub(r"\s*!important\s*$", "", value).strip()
|
|
|
|
# Allow: var(--meld-z-*) token exactly
|
|
if VAR_TOKEN_PATTERN.fullmatch(value):
|
|
continue
|
|
|
|
# Allow: non-negative integer in ALLOWED_LOCAL (1, 2, 3)
|
|
if value.isdigit():
|
|
num = int(value)
|
|
if num in ALLOWED_LOCAL:
|
|
continue
|
|
errors.append(
|
|
(
|
|
i,
|
|
stripped,
|
|
f"Magic number z-index: {num} (use var(--meld-z-*) or local 1/2/3 only)",
|
|
)
|
|
)
|
|
continue
|
|
|
|
# Reject: auto, inherit, negative numbers, calc(...), var(--other), etc.
|
|
errors.append(
|
|
(
|
|
i,
|
|
stripped,
|
|
f"Invalid z-index value: {value!r} (use var(--meld-z-*) or local 1/2/3 only)",
|
|
)
|
|
)
|
|
|
|
except Exception as e:
|
|
errors.append(
|
|
(0, filepath, f"Error reading file: {e}"),
|
|
)
|
|
return errors
|
|
|
|
|
|
def main() -> None:
|
|
target_dir = os.path.join("ui", "src")
|
|
has_errors = False
|
|
|
|
extensions = (".css", ".ts", ".tsx")
|
|
ignore_files = {"types.d.ts"}
|
|
|
|
base_dir = os.getcwd()
|
|
search_path = os.path.join(base_dir, target_dir)
|
|
|
|
if not os.path.exists(search_path):
|
|
print(f"Directory not found: {search_path}")
|
|
sys.exit(1)
|
|
|
|
print(f"Checking z-index policy in {target_dir}...")
|
|
|
|
for root, _, files in os.walk(search_path):
|
|
for file in files:
|
|
if file.endswith(extensions) and file not in ignore_files:
|
|
filepath = os.path.join(root, file)
|
|
rel_path = os.path.relpath(filepath, base_dir)
|
|
errors = check_file(filepath)
|
|
if errors:
|
|
has_errors = True
|
|
print(f"\n[!] Z-index policy violation in {rel_path}:")
|
|
for line_num, line_content, reason in errors:
|
|
if line_num == 0:
|
|
print(f" File read error: {line_content}")
|
|
else:
|
|
print(f" Line {line_num}: {line_content}")
|
|
print(f" -> {reason}")
|
|
|
|
if has_errors:
|
|
print("\nError: Z-index policy violations detected!")
|
|
print("1. Use var(--meld-z-*) tokens for global layers (see ui/src/styles/gallery/base.css :root).")
|
|
print("2. Only z-index: 1, 2, or 3 are allowed for local component-scoped stacking.")
|
|
print("3. Add /* z-index-check-ignore */ for temporary exceptions during migration.")
|
|
sys.exit(1)
|
|
else:
|
|
print("Z-index policy check passed.")
|
|
sys.exit(0)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|