Files
2026-08-03 22:40:29 +01:00

151 lines
4.4 KiB
Python

"""Prevent new unsafe casts in frontend root-state composition."""
from pathlib import Path
from architecture_lint.model import Finding, Rule
RULE_ID = "ARCH-FRONTEND-ROOT-STATE-CAST"
STORE_PATH = "apps/web/lib/state/store.ts"
def applies_to(path: str) -> bool:
return path == STORE_PATH
def normalize_marker(line: str) -> str:
return " ".join(line.strip().split())
def _line_number(source: str, index: int) -> int:
return source.count("\n", 0, index) + 1
def _skip_quoted(source: str, index: int, quote: str) -> int:
index += 1
while index < len(source):
if source[index] == "\\":
index += 2
elif source[index] == quote:
return index + 1
else:
index += 1
return index
def _skip_comment(source: str, index: int) -> int:
if source.startswith("//", index):
newline = source.find("\n", index + 2)
return len(source) if newline < 0 else newline
end = source.find("*/", index + 2)
return len(source) if end < 0 else end + 2
def _scan_template(source: str, index: int, tokens: list[tuple[str, int]]) -> int:
"""Skip template text while recursively tokenizing `${...}` expressions."""
index += 1
while index < len(source):
char = source[index]
if char == "\\":
index += 2
elif char == "`":
return index + 1
elif source.startswith("${", index):
index = _scan_code(source, index + 2, tokens, stop_at_brace=True)
else:
index += 1
return index
def _scan_code(
source: str,
index: int,
tokens: list[tuple[str, int]],
*,
stop_at_brace: bool = False,
) -> int:
brace_depth = 0
while index < len(source):
char = source[index]
if char.isspace():
index += 1
elif source.startswith("//", index) or source.startswith("/*", index):
index = _skip_comment(source, index)
elif char in {'"', "'"}:
index = _skip_quoted(source, index, char)
elif char == "`":
index = _scan_template(source, index, tokens)
elif char == "{":
brace_depth += 1
index += 1
elif char == "}":
if stop_at_brace and brace_depth == 0:
return index + 1
brace_depth = max(0, brace_depth - 1)
index += 1
elif char.isalpha() or char in "_$":
start = index
index += 1
while index < len(source) and (source[index].isalnum() or source[index] in "_$"):
index += 1
tokens.append((source[start:index], _line_number(source, start)))
else:
index += 1
return index
def unsafe_casts(source: str) -> list[tuple[int, str]]:
tokens: list[tuple[str, int]] = []
_scan_code(source, 0, tokens)
findings: list[tuple[int, str]] = []
for index, (token, line) in enumerate(tokens):
if token == "as" and index + 1 < len(tokens) and tokens[index + 1][0] == "any":
findings.append((line, "as any"))
elif (
token == "as"
and index + 2 < len(tokens)
and tokens[index + 1][0] == "unknown"
and tokens[index + 2][0] == "as"
):
findings.append((line, "as unknown as"))
return findings
def scan(path: str, source: str) -> list[Finding]:
findings: list[Finding] = []
source_lines = source.splitlines()
occurrences: dict[tuple[str, str], int] = {}
for line_number, escape in unsafe_casts(source):
marker = normalize_marker(source_lines[line_number - 1])
occurrence_key = (marker, escape)
occurrences[occurrence_key] = occurrences.get(occurrence_key, 0) + 1
findings.append(
Finding.create(
RULE_ID,
path,
line_number,
{
"path": path,
"marker": marker,
"escape": escape,
"occurrence": occurrences[occurrence_key],
},
(
f"unsafe root-state composition escape `{escape}`; derive typed domain "
"state, actions, and defaults instead"
),
)
)
return findings
RULE = Rule(
id=RULE_ID,
slug="frontend_root_state_cast",
baseline_path=Path("config/architecture-lint/frontend_root_state_cast.json"),
applies_to=applies_to,
scan=scan,
)