151 lines
4.4 KiB
Python
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,
|
|
)
|