diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 042ebc6..1dc1e6e 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -63,4 +63,4 @@ jobs:
run: python -m build
- name: Publish to PyPI
- uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b
\ No newline at end of file
+ uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b
diff --git a/.github/workflows/cowork-auto-pr.yml b/.github/workflows/cowork-auto-pr.yml
index 91690e6..98c0ece 100644
--- a/.github/workflows/cowork-auto-pr.yml
+++ b/.github/workflows/cowork-auto-pr.yml
@@ -12,6 +12,15 @@ jobs:
ensure-pr:
runs-on: ubuntu-latest
steps:
+ # gh pr create requires a local git checkout to diff head against base;
+ # without this step every run failed with "not a git repository" and no
+ # PR was ever opened (fleet-wide defect: 11/11 seeded copies lacked it).
+ - name: Check out the pushed branch
+ uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
+ with:
+ ref: ${{ github.ref_name }}
+ persist-credentials: false
+ fetch-depth: 0
- name: Open PR for this branch if none exists
env:
GH_TOKEN: ${{ github.token }}
diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml
index e64318a..02b6b46 100644
--- a/.github/workflows/pages.yml
+++ b/.github/workflows/pages.yml
@@ -2,7 +2,7 @@ name: Deploy GitHub Pages
on:
push:
- branches: [master, main]
+ branches: [master]
workflow_dispatch:
permissions:
@@ -16,6 +16,7 @@ concurrency:
jobs:
build:
+ if: github.ref == format('refs/heads/{0}', github.event.repository.default_branch)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
@@ -32,6 +33,7 @@ jobs:
uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa
deploy:
+ if: github.ref == format('refs/heads/{0}', github.event.repository.default_branch)
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..6ac8caf
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,19 @@
+{
+ "name": "deadcode-cli",
+ "version": "0.1.1",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "deadcode-cli",
+ "version": "0.1.1",
+ "license": "MIT",
+ "bin": {
+ "deadcode": "cli.js"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
index 560949a..4492ddd 100644
--- a/package.json
+++ b/package.json
@@ -3,7 +3,7 @@
"version": "0.1.1",
"description": "Find unused/dead code in TypeScript, React, and Next.js projects. Scans for orphaned exports, dead routes, unreferenced components, and unused CSS module classes.",
"author": "Coding-Dev-Tools",
- "license"
+ "license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/Coding-Dev-Tools/deadcode.git"
diff --git a/src/deadcode/__main__.py b/src/deadcode/__main__.py
index da2a680..f0f0052 100644
--- a/src/deadcode/__main__.py
+++ b/src/deadcode/__main__.py
@@ -1,4 +1,5 @@
"""Allow running deadcode as: python -m deadcode"""
+
from .cli import cli
if __name__ == "__main__":
diff --git a/src/deadcode/_typescript.py b/src/deadcode/_typescript.py
new file mode 100644
index 0000000..0f8ac60
--- /dev/null
+++ b/src/deadcode/_typescript.py
@@ -0,0 +1,1025 @@
+"""A bounded runtime view for CSS usage, without changing symbol analysis.
+
+This is deliberately not a TypeScript transpiler. Only syntactically established
+type contexts are erased; an unrecognized boundary retains the original source.
+Offsets and newlines survive so the existing JSX/string readers keep their own
+grammar. Runtime class-field initializers are normalized to property syntax.
+"""
+
+from __future__ import annotations
+
+import re
+from dataclasses import dataclass
+
+
+@dataclass(frozen=True)
+class _Token:
+ value: str
+ start: int
+ end: int
+ word: bool = False
+ expressions: tuple[tuple[int, int], ...] = ()
+ complete: bool = True
+ uncertain: bool = False
+ type_spans: tuple[tuple[int, int], ...] = ()
+
+
+_WORD = re.compile(r"[\w$]+", re.UNICODE)
+_JSX_OPENING = re.compile(r"<(?:[^\W\d]|\$|[^\x00-\x7f\s])(?:[\w$:.-]|[^\x00-\x7f\s])*(?=[\s/><])(?!\s*=)")
+_JSX_EXTENDS = re.compile(r"\s+extends\s+(?![=>])")
+_JSX_CLOSING = re.compile(r"(?:(?:[\w$]|[^\x00-\x7f\s])(?:[\w$:.-]|[^\x00-\x7f\s])*\s*)?>")
+_CLASS_VALUE_OPERATORS = {":", "=", "+=", "-=", "*=", "/=", "%=", "**=", "&=", "|=", "^=", "&&=", "||=", "??="}
+_PREFIX = {"keyof", "readonly", "unique", "typeof", "asserts", "abstract", "new"}
+_MODIFIERS = {"public", "private", "protected", "static", "readonly", "abstract", "declare", "override", "accessor"}
+_EXPRESSION_PREFIX = {
+ "return",
+ "throw",
+ "yield",
+ "await",
+ "new",
+ "case",
+ "delete",
+ "void",
+ "typeof",
+ "in",
+ "instanceof",
+ "of",
+ "else",
+ "do",
+ "extends",
+ "export",
+ "default",
+ "const",
+ "let",
+ "var",
+ "function",
+ "class",
+ "interface",
+ "type",
+ "declare",
+ "import",
+ "namespace",
+ "module",
+ "abstract",
+ "as",
+ "satisfies",
+ "keyof",
+ "infer",
+ "is",
+ "asserts",
+}
+_REGEX_PREFIX = {
+ "return",
+ "throw",
+ "yield",
+ "await",
+ "new",
+ "case",
+ "delete",
+ "void",
+ "typeof",
+ "in",
+ "instanceof",
+ "of",
+ "else",
+ "do",
+ "extends",
+ "default",
+ "=",
+ "(",
+ "[",
+ "{",
+ ",",
+ ":",
+ ";",
+ "!",
+ "?",
+ "=>",
+ "~",
+ "+",
+ "-",
+ "*",
+ "/",
+ "%",
+ "**",
+ "<",
+ ">",
+ "&",
+ "|",
+ "^",
+ "&&",
+ "||",
+ "??",
+ "==",
+ "!=",
+ "===",
+ "!==",
+ "+=",
+ "-=",
+ "*=",
+ "/=",
+ "%=",
+ "&=",
+ "|=",
+ "^=",
+ "&&=",
+ "||=",
+ "??=",
+ "**=",
+}
+
+
+def _regex_context(tokens: list[_Token]) -> bool:
+ if not tokens:
+ return True
+ previous = tokens[-1].value
+ if len(tokens) > 1 and tokens[-2].value in {".", "?."}:
+ return False
+ if (
+ previous == "!"
+ and len(tokens) > 1
+ and (tokens[-2].word and tokens[-2].value not in _EXPRESSION_PREFIX or tokens[-2].value in {")", "]"})
+ ):
+ return False # TypeScript's postfix non-null assertion ends an expression.
+ if previous == ")":
+ depth = 1
+ for index in range(len(tokens) - 2, -1, -1):
+ if tokens[index].value == ")":
+ depth += 1
+ elif tokens[index].value == "(":
+ depth -= 1
+ if not depth:
+ return index > 0 and tokens[index - 1].value in {"if", "while", "for", "with", "switch", "catch"}
+ return False
+ return previous in _REGEX_PREFIX
+
+
+def _jsx_type_parameter_head(source: str, index: int) -> bool:
+ opening = _JSX_OPENING.match(source, index)
+ if not opening or not _JSX_EXTENDS.match(source, opening.end()):
+ return False
+ view = _RuntimeView(source[index : index + 8192], jsx=False)
+ end = view.angle_end(0)
+ if end is None or view.value(end) != "(" or end not in view.pairs:
+ return False
+ body = view.pairs[end] + 1
+ if view.value(body) == ":":
+ body = view.type_end(body + 1)
+ return body is not None and view.value(body) in {"=>", "{"}
+
+
+def _jsx_element(source: str, index: int, depth: int) -> _Token | None:
+ """Read one complete element, exposing only its JavaScript expressions."""
+ if depth > 80:
+ return None
+ start = index
+ expressions = []
+ type_spans = []
+ uncertain = False
+ if source.startswith("<>", index):
+ name = ""
+ index += 2
+ else:
+ opening = _JSX_OPENING.match(source, index)
+ if not opening:
+ return None
+ name = source[index + 1 : opening.end()]
+ index = opening.end()
+ if index < len(source) and source[index] == "<":
+ arguments = _RuntimeView(source[index : index + 8192], jsx=False)
+ end = arguments.angle_end(0)
+ if end is None:
+ return None
+ argument_end = index + arguments.tokens[end - 1].end
+ type_spans.append((index, argument_end))
+ index = argument_end
+ while index < len(source):
+ if source.startswith("/>", index):
+ return _Token(
+ source[start : index + 2],
+ start,
+ index + 2,
+ expressions=tuple(expressions),
+ uncertain=uncertain,
+ type_spans=tuple(type_spans),
+ )
+ if source[index] == ">":
+ index += 1
+ break
+ if source[index] in "'\"":
+ end = source.find(source[index], index + 1)
+ if end < 0:
+ return None
+ index = end + 1
+ elif source[index] == "{":
+ _, end, nested_uncertain = _lex(source, index + 1, interpolation=True, depth=depth + 1, jsx=True)
+ if end >= len(source):
+ return None
+ expressions.append((index + 1, end))
+ uncertain |= nested_uncertain
+ index = end + 1
+ elif source[index] == "<":
+ return None
+ else:
+ index += 1
+ else:
+ return None
+ while index < len(source):
+ if source.startswith("", index):
+ closing = _JSX_CLOSING.match(source, index)
+ if not closing or source[index + 2 : closing.end() - 1].strip() != name:
+ return None
+ return _Token(
+ source[start : closing.end()],
+ start,
+ closing.end(),
+ expressions=tuple(expressions),
+ uncertain=uncertain,
+ type_spans=tuple(type_spans),
+ )
+ if source[index] == "<":
+ child = _jsx_element(source, index, depth + 1)
+ if child is None:
+ return None
+ expressions.extend(child.expressions)
+ type_spans.extend(child.type_spans)
+ uncertain |= child.uncertain
+ index = child.end
+ elif source[index] == "{":
+ _, end, nested_uncertain = _lex(source, index + 1, interpolation=True, depth=depth + 1, jsx=True)
+ if end >= len(source):
+ return None
+ expressions.append((index + 1, end))
+ uncertain |= nested_uncertain
+ index = end + 1
+ else:
+ index += 1 # Child text has no JavaScript quotes, comments or templates.
+ return None
+
+
+def _lex(
+ source: str, index: int = 0, *, interpolation: bool = False, depth: int = 0, jsx: bool = True
+) -> tuple[list[_Token], int, bool]:
+ tokens = []
+ braces = 0
+ uncertain = False
+ jsx_tags: list[tuple[int, int]] = []
+ while index < len(source):
+ start = index
+ char = source[index]
+ if char.isspace():
+ index += 1
+ continue
+ type_parameter_head = jsx and char == "<" and _jsx_type_parameter_head(source, index)
+ if (
+ jsx
+ and char == "<"
+ and not type_parameter_head
+ and _regex_context(tokens)
+ and (_JSX_OPENING.match(source, index) or source.startswith("<>", index))
+ ):
+ element = _jsx_element(source, index, depth + 1)
+ if element:
+ tokens.append(element)
+ index = element.end
+ uncertain |= element.uncertain
+ continue
+ # Incomplete JSX cannot establish that a later runtime use is absent.
+ tokens.append(_Token(source[start:], start, len(source), complete=False, uncertain=True))
+ return tokens, len(source), True
+ if source.startswith("//", index):
+ end = source.find("\n", index + 2)
+ index = len(source) if end < 0 else end
+ continue
+ if source.startswith("/*", index):
+ end = source.find("*/", index + 2)
+ index = len(source) if end < 0 else end + 2
+ continue
+ if interpolation and char == "}" and not braces:
+ return tokens, index, uncertain
+ if jsx and char == "<" and not type_parameter_head and (opening := _JSX_OPENING.match(source, index)):
+ jsx_tags.append((braces, opening.end()))
+ elif char == ">" and jsx_tags and jsx_tags[-1][0] == braces:
+ jsx_tags.pop()
+ if char == "{":
+ braces += 1
+ elif char == "}":
+ braces -= 1
+ if char == "`":
+ index += 1
+ expressions = []
+ complete = False
+ template_uncertain = False
+ while index < len(source):
+ if source[index] == "\\":
+ index += 2
+ elif source[index] == "`":
+ index += 1
+ complete = True
+ break
+ elif source.startswith("${", index):
+ if depth > 80:
+ index = len(source)
+ break
+ expression = index + 2
+ _, end, nested_uncertain = _lex(source, expression, interpolation=True, depth=depth + 1, jsx=jsx)
+ template_uncertain |= nested_uncertain
+ if end == len(source):
+ index = end
+ break
+ expressions.append((expression, end))
+ index = end + 1
+ else:
+ index += 1
+ tokens.append(
+ _Token(
+ source[start:index],
+ start,
+ index,
+ expressions=tuple(expressions),
+ complete=complete,
+ uncertain=template_uncertain,
+ )
+ )
+ continue
+ if char in "'\"":
+ # JSX attribute delimiters follow HTML rules: a backslash before
+ # the closing quote is literal. JavaScript expressions inside
+ # braces still use the ordinary escaped-string grammar.
+ raw_attribute = bool(
+ jsx_tags
+ and jsx_tags[-1][0] == braces
+ and len(tokens) >= 2
+ and tokens[-1].value == "="
+ and tokens[-2].word
+ and tokens[-2].start >= jsx_tags[-1][1]
+ )
+ index += 1
+ complete = False
+ while index < len(source):
+ if source[index] == "\\" and not raw_attribute:
+ index += 2
+ elif source[index] == char:
+ index += 1
+ complete = True
+ break
+ elif source[index] in "\r\n" and not raw_attribute:
+ break
+ else:
+ index += 1
+ tokens.append(_Token(source[start:index], start, index, complete=complete))
+ continue
+ # Closing tags/fragments and self-closing delimiters contain no runtime
+ # expressions. A slash after '<' or a generic '>' must not consume the
+ # following sibling's class expressions as an opaque regular expression.
+ closing_jsx = _JSX_CLOSING.match(source, index)
+ if closing_jsx or source.startswith("/>", index) and tokens and tokens[-1].value == ">":
+ index = closing_jsx.end() if closing_jsx else index + 2
+ tokens.append(_Token(source[start:index], start, index))
+ continue
+ # A regex is opaque. Failed recognition leaves the slash as punctuation,
+ # including JSX closing tags and divisions; it never swallows a file tail.
+ if char == "/" and tokens and tokens[-1].value in {")", "}"} and not _regex_context(tokens):
+ uncertain = True
+ if char == "/" and _regex_context(tokens):
+ regex_uncertain = bool(tokens and tokens[-1].value in {">", "!", "await", "yield", "of"})
+ uncertain |= regex_uncertain
+ end = index + 1
+ bracket = False
+ while end < len(source) and source[end] not in "\r\n":
+ if source[end] == "\\":
+ end += 2
+ continue
+ if source[end] == "[":
+ bracket = True
+ elif source[end] == "]":
+ bracket = False
+ elif source[end] == "/" and not bracket:
+ end += 1
+ while end < len(source) and source[end].isalpha():
+ end += 1
+ tokens.append(_Token(source[start:end], start, end, uncertain=regex_uncertain))
+ index = end
+ break
+ end += 1
+ if index != start:
+ continue
+ word = _WORD.match(source, index)
+ if word:
+ index = word.end()
+ tokens.append(_Token(word.group(), start, index, True))
+ continue
+ operator = next(
+ (
+ value
+ for value in (
+ "...",
+ "===",
+ "!==",
+ "&&=",
+ "||=",
+ "??=",
+ "**=",
+ "=>",
+ "?.",
+ "++",
+ "--",
+ "&&",
+ "||",
+ "??",
+ "==",
+ "!=",
+ "**",
+ "+=",
+ "-=",
+ "*=",
+ "/=",
+ "%=",
+ "&=",
+ "|=",
+ "^=",
+ )
+ if source.startswith(value, index)
+ ),
+ char,
+ )
+ index += len(operator)
+ tokens.append(_Token(operator, start, index))
+ return tokens, index, uncertain
+
+
+def _tokens(source: str, *, jsx: bool = True) -> list[_Token]:
+ return _lex(source, jsx=jsx)[0]
+
+
+class _RuntimeView:
+ def __init__(self, source: str, *, jsx: bool = True):
+ self.source = source
+ self.tokens = _tokens(source, jsx=jsx)
+ self.pairs: dict[int, int] = {}
+ self.parents: dict[int, int | None] = {}
+ stack = []
+ for index, token in enumerate(self.tokens):
+ self.parents[index] = stack[-1] if stack else None
+ if token.value in {"{", "[", "("}:
+ stack.append(index)
+ elif token.value in {"}", "]", ")"} and stack:
+ opening = stack[-1]
+ if {"{": "}", "[": "]", "(": ")"}[self.value(opening)] == token.value:
+ stack.pop()
+ self.pairs[opening] = index
+ self.erased: list[tuple[int, int]] = []
+ self.replacements: dict[int, str] = {}
+
+ def value(self, index: int) -> str:
+ return self.tokens[index].value if 0 <= index < len(self.tokens) else ""
+
+ def erase(self, start: int, end: int) -> None:
+ if 0 <= start < end <= len(self.tokens) and not any(token.uncertain for token in self.tokens[start:end]):
+ self.erased.append((self.tokens[start].start, self.tokens[end - 1].end))
+
+ def same_line(self, left: int, right: int) -> bool:
+ return 0 <= left < right < len(self.tokens) and not any(
+ char in self.source[self.tokens[left].end : self.tokens[right].start] for char in "\r\n\u2028\u2029"
+ )
+
+ def angle_end(self, start: int) -> int | None:
+ depth = 0
+ index = start
+ while index < len(self.tokens):
+ value = self.value(index)
+ if value == "<":
+ depth += 1
+ elif value == ">":
+ depth -= 1
+ if depth == 0:
+ return index + 1
+ elif value in {"{", "[", "("}:
+ if index not in self.pairs:
+ return None
+ index = self.pairs[index]
+ elif value in {";", "}", ")", "]"}:
+ return None
+ index += 1
+ return None
+
+ def type_end(self, start: int, *, strict: bool = False, depth: int = 0) -> int | None:
+ if depth > 80 or start >= len(self.tokens):
+ return None
+ value = self.value(start)
+ function_type = value in {"(", "<"}
+ if value in {"|", "&"}:
+ return self.type_end(start + 1, strict=strict, depth=depth + 1)
+ if value in _PREFIX:
+ index = self.type_end(start + 1, strict=strict, depth=depth + 1)
+ elif value == "infer":
+ index = start + 2 if self.tokens[start + 1 : start + 2] and self.tokens[start + 1].word else None
+ elif value == "-":
+ index = start + 2 if self.value(start + 1)[:1].isdigit() else None
+ elif value in {"{", "[", "("}:
+ closing = self.pairs.get(start)
+ if closing is None:
+ return None
+ index = closing + 1
+ if strict and value == "{" and not self.type_members(start, closing, depth + 1):
+ return None
+ if strict and value == "(":
+ if self.value(index) == "=>":
+ if not self.parameters_are_types(start, closing):
+ return None
+ elif self.type_end(start + 1, strict=True, depth=depth + 1) != closing:
+ return None
+ elif value == "<":
+ index = self.angle_end(start)
+ if index is None or self.value(index) != "(" or index not in self.pairs:
+ return None
+ closing = self.pairs[index]
+ if strict and not self.parameters_are_types(index, closing):
+ return None
+ index = closing + 1
+ if self.value(index) != "=>":
+ return None
+ elif self.tokens[start].word or (value[:1] in {"'", '"', "`"} and self.tokens[start].complete):
+ index = start + 1
+ if value == "import" and self.value(index) == "(" and index in self.pairs:
+ index = self.pairs[index] + 1
+ else:
+ return None
+ if index is None:
+ return None
+ while True:
+ value = self.value(index)
+ if value == "." and index + 1 < len(self.tokens) and self.tokens[index + 1].word:
+ index += 2
+ elif value == "<":
+ end = self.angle_end(index)
+ if end is None or (strict and not self.type_arguments(index, end - 1, depth + 1)):
+ return None
+ index = end
+ elif value == "[" and index in self.pairs:
+ index = self.pairs[index] + 1
+ else:
+ break
+ if self.value(index) in {"|", "&", "is"} or (function_type and self.value(index) == "=>"):
+ return self.type_end(index + 1, strict=strict, depth=depth + 1)
+ if self.value(index) == "extends":
+ condition = self.type_end(index + 1, strict=strict, depth=depth + 1)
+ if condition is None or self.value(condition) != "?":
+ return None
+ accepted = self.type_end(condition + 1, strict=strict, depth=depth + 1)
+ if accepted is None or self.value(accepted) != ":":
+ return None
+ return self.type_end(accepted + 1, strict=strict, depth=depth + 1)
+ return index
+
+ def type_arguments(self, opening: int, closing: int, depth: int = 0) -> bool:
+ index = opening + 1
+ while index < closing:
+ end = self.type_end(index, strict=True, depth=depth + 1)
+ if end is None or end > closing:
+ return False
+ if end == closing:
+ return True
+ if self.value(end) != ",":
+ return False
+ index = end + 1
+ return index == closing
+
+ def parameters_are_types(self, opening: int, closing: int) -> bool:
+ index = opening + 1
+ while index < closing:
+ if self.value(index) == "=":
+ return False # An initializer is a runtime expression, not a function type.
+ if index in self.pairs:
+ index = self.pairs[index]
+ index += 1
+ return True
+
+ def type_members(self, opening: int, closing: int, depth: int) -> bool:
+ index = opening + 1
+ while index < closing:
+ if self.value(index) in {";", ",", "readonly", "+", "-", "new"}:
+ index += 1
+ continue
+ if self.value(index) in {"[", "("} and index in self.pairs:
+ index = self.pairs[index] + 1
+ elif self.tokens[index].word or self.value(index)[:1] in {"'", '"'}:
+ index += 1
+ else:
+ return False
+ if self.value(index) == "?":
+ index += 1
+ if self.value(index) == "<":
+ index = self.angle_end(index)
+ if index is None:
+ return False
+ if self.value(index) == "(" and index in self.pairs:
+ index = self.pairs[index] + 1
+ if self.value(index) != ":":
+ return False
+ end = self.type_end(index + 1, strict=True, depth=depth + 1)
+ if end is None or end > closing:
+ return False
+ if (
+ end < closing
+ and self.value(end) not in {";", ","}
+ and "\n" not in self.source[self.tokens[end - 1].end : self.tokens[end].start]
+ ):
+ return False
+ index = end
+ return index == closing
+
+ def declaration_start(self, index: int) -> bool:
+ previous = self.value(index - 1)
+ if not previous or previous in {";", "{", "}", "export", "default", "declare"}:
+ return True
+ return (
+ previous not in {".", "?.", ":", "=", ",", "(", "["}
+ and "\n" in self.source[self.tokens[index - 1].end : self.tokens[index].start]
+ )
+
+ def declarations(self) -> None:
+ for index, token in enumerate(self.tokens):
+ value = token.value
+ if value in {"interface", "type"} and self.declaration_start(index) and self.same_line(index, index + 1):
+ cursor = index + 1
+ if cursor >= len(self.tokens) or not self.tokens[cursor].word:
+ continue
+ cursor += 1
+ if self.value(cursor) == "<":
+ cursor = self.angle_end(cursor)
+ if cursor is None:
+ continue
+ if value == "type" and self.value(cursor) == "=":
+ end = self.type_end(cursor + 1)
+ if end is not None:
+ self.erase(index, end)
+ elif value == "interface":
+ while cursor < len(self.tokens) and self.value(cursor) not in {"{", ";", "}"}:
+ if self.value(cursor) == "<":
+ end = self.angle_end(cursor)
+ if end is None:
+ break
+ cursor = end
+ else:
+ cursor += 1
+ if self.value(cursor) == "{" and cursor in self.pairs:
+ self.erase(index, self.pairs[cursor] + 1)
+ elif value == "declare" and self.declaration_start(index) and self.same_line(index, index + 1):
+ kind = self.value(index + 1)
+ if kind in {"class", "namespace", "module", "global", "enum"}:
+ cursor = index + 2
+ while cursor < len(self.tokens) and self.value(cursor) not in {"{", ";"}:
+ if self.value(cursor) == "<":
+ end = self.angle_end(cursor)
+ if end is None:
+ break
+ cursor = end
+ else:
+ cursor += 1
+ if cursor in self.pairs:
+ self.erase(index, self.pairs[cursor] + 1)
+ elif kind in {"const", "let", "var", "function"}:
+ if kind == "function":
+ cursor = index + 3
+ if self.value(cursor) == "<":
+ cursor = self.angle_end(cursor)
+ if cursor is None or self.value(cursor) != "(" or cursor not in self.pairs:
+ continue
+ cursor = self.pairs[cursor] + 1
+ if self.value(cursor) == ":":
+ cursor = self.type_end(cursor + 1)
+ else:
+ cursor = self.binding(index + 2)
+ if (
+ cursor is not None
+ and cursor > index + 2
+ and (
+ self.value(cursor) in {";", "", "}"}
+ or "\n" in self.source[self.tokens[cursor - 1].end : self.tokens[cursor].start]
+ )
+ ):
+ self.erase(index, cursor + (self.value(cursor) == ";"))
+ elif (
+ value in {"import", "export"}
+ and self.value(index + 1) == "type"
+ and self.value(index + 2) not in {"from", ",", "="}
+ and (value == "import" or self.same_line(index + 1, index + 2))
+ ):
+ cursor = index + 2
+ while cursor < len(self.tokens):
+ if self.value(cursor) == ";":
+ cursor += 1
+ break
+ if (
+ cursor > index + 2
+ and "\n" in self.source[self.tokens[cursor - 1].end : self.tokens[cursor].start]
+ ):
+ break
+ if cursor in self.pairs:
+ cursor = self.pairs[cursor]
+ cursor += 1
+ self.erase(index, cursor)
+
+ def binding(self, index: int) -> int:
+ while self.value(index) in _MODIFIERS or self.value(index) == "...":
+ index += 1
+ if self.value(index) in {"{", "["} and index in self.pairs:
+ index = self.pairs[index] + 1
+ elif index < len(self.tokens) and self.tokens[index].word:
+ index += 1
+ else:
+ return index
+ if self.value(index) in {"?", "!"}:
+ index += 1
+ if self.value(index) == ":":
+ end = self.type_end(index + 1)
+ if end is not None:
+ self.erase(index, end)
+ return end
+ return index
+
+ def parameters(self, opening: int) -> None:
+ closing = self.pairs.get(opening)
+ if closing is None:
+ return
+ cursor = opening + 1
+ while cursor < closing:
+ cursor = self.binding(cursor)
+ while cursor < closing and self.value(cursor) != ",":
+ if cursor in self.pairs:
+ cursor = self.pairs[cursor]
+ cursor += 1
+ cursor += 1
+
+ def annotations(self) -> None:
+ for index, token in enumerate(self.tokens):
+ if token.value in {"const", "let", "var"} and self.value(index - 1) not in {".", "?."}:
+ cursor = index + 1
+ while cursor < len(self.tokens):
+ end = self.binding(cursor)
+ if end == cursor:
+ break
+ cursor = end
+ while cursor < len(self.tokens) and self.value(cursor) not in {",", ";", "}"}:
+ if cursor in self.pairs:
+ cursor = self.pairs[cursor]
+ cursor += 1
+ if self.value(cursor) != ",":
+ break
+ cursor += 1
+ elif token.value == "(" and index in self.pairs:
+ closing = self.pairs[index]
+ end = closing + 1
+ result = self.type_end(end + 1) if self.value(end) == ":" else end
+ arrow = result is not None and self.value(result) == "=>"
+ # Function declarations, function expressions and methods have
+ # a distinct header. Calls/control-flow parentheses are excluded.
+ before = self.value(index - 1)
+ head = index - 2
+ while self.value(head) in {"*", "async", "get", "set"} | _MODIFIERS:
+ head -= 1
+ named_function = before == "function" or self.value(head) == "function"
+ method = (
+ before not in {"if", "for", "while", "switch", "catch", "with"}
+ and index > 0
+ and self.tokens[index - 1].word
+ and self.value(head) in {"{", ",", ";", "}"}
+ and result is not None
+ and self.value(result) == "{"
+ )
+ if arrow or named_function or method:
+ self.parameters(index)
+ if self.value(end) == ":" and result is not None:
+ self.erase(end, result)
+ elif token.value in {"as", "satisfies"} and index > 0:
+ previous = self.tokens[index - 1]
+ if (
+ self.same_line(index - 1, index)
+ and previous.value not in _EXPRESSION_PREFIX | {".", "?."}
+ and (previous.word or previous.value in {")", "]", "}"} or previous.value[:1] in {"'", '"', "`"})
+ ):
+ end = self.type_end(index + 1)
+ if end is not None:
+ self.erase(index, end)
+
+ def generic_arguments(self, *, tsx: bool) -> None:
+ for index, token in enumerate(self.tokens):
+ if token.value != "<":
+ continue
+ end = self.angle_end(index)
+ if end is None:
+ continue
+ before = self.value(index - 1)
+ after = self.value(end)
+ value_call = (
+ index > 0
+ and (self.tokens[index - 1].word or before in {")", "]"})
+ and (after in {"(", ";", ",", ")", "]", "}", ".", "?.", ""} or after.startswith("`"))
+ )
+ jsx_start = index - 1
+ while jsx_start > 0 and (self.tokens[jsx_start].word or self.value(jsx_start) in {".", ":"}):
+ jsx_start -= 1
+ jsx_argument = tsx and self.value(jsx_start) == "<" and jsx_start < index - 1
+ assertion = not tsx and before in {"", "=", "(", "[", ",", ":", "return", "=>", "?"}
+ if (value_call or jsx_argument or assertion) and self.type_arguments(index, end - 1):
+ self.erase(index, end)
+ continue
+ # Generic function headers have declaration parameters rather than
+ # type arguments (constraints/defaults are legal). Require a body.
+ if after == "(" and end in self.pairs:
+ closing = self.pairs[end]
+ body = closing + 1
+ if self.value(body) == ":":
+ body = self.type_end(body + 1)
+ if (
+ body is not None
+ and self.value(body) in {"=>", "{"}
+ and (
+ self.value(index - 2) in {"function", "{", ",", ";", "}"}
+ or before in {"=", "("}
+ or before == "async"
+ and self.value(index - 2) in {"=", "(", ":", ",", "return"}
+ )
+ ):
+ self.erase(index, end)
+ self.parameters(end)
+ if self.value(closing + 1) == ":":
+ self.erase(closing + 1, body)
+
+ def class_fields(self, *, typescript: bool) -> None:
+ for index, token in enumerate(self.tokens):
+ if (
+ token.value != "class"
+ or self.value(index - 1) in {".", "?."}
+ or self.value(index + 1) in {":", "=", "("}
+ ):
+ continue
+ opening = index + 1
+ while opening < len(self.tokens) and self.value(opening) not in {"{", ";"}:
+ if self.value(opening) == "<":
+ end = self.angle_end(opening)
+ if end is None:
+ break
+ if typescript:
+ self.erase(opening, end)
+ opening = end
+ continue
+ if opening in self.pairs:
+ opening = self.pairs[opening]
+ opening += 1
+ closing = self.pairs.get(opening)
+ if closing is None:
+ continue
+ cursor = opening + 1
+ while cursor < closing:
+ start = cursor
+ while self.value(cursor) in _MODIFIERS:
+ cursor += 1
+ name = self.value(cursor)
+ if cursor >= len(self.tokens) or not self.tokens[cursor].word:
+ cursor = self.pairs.get(cursor, cursor) + 1
+ continue
+ cursor += 1
+ if self.value(cursor) in {"?", "!"}:
+ cursor += 1
+ if typescript and self.value(cursor) == ":":
+ end = self.type_end(cursor + 1)
+ if end is not None:
+ self.erase(cursor, end)
+ cursor = end
+ if self.value(cursor) == "=":
+ if name in {"className", "class"}:
+ self.replacements[self.tokens[cursor].start] = ":"
+ elif name in {"className", "class"} and self.value(cursor) != "(":
+ self.erase(start, cursor)
+ # Stop at a peer member, respecting initializer/method bodies.
+ while cursor < closing:
+ if self.value(cursor) == ";":
+ cursor += 1
+ break
+ if cursor in self.pairs:
+ cursor = self.pairs[cursor] + 1
+ if self.value(cursor - 1) == "}" and self.value(cursor) != ";":
+ break
+ continue
+ if cursor > start and "\n" in self.source[self.tokens[cursor - 1].end : self.tokens[cursor].start]:
+ break
+ cursor += 1
+
+ def render(self, path: str, depth: int) -> str:
+ output = list(self.source)
+ for token in self.tokens:
+ for start, end in token.expressions:
+ output[start:end] = class_usage_source(self.source[start:end], path, depth=depth + 1)
+ if path.endswith(".tsx"):
+ for start, end in token.type_spans:
+ for index in range(start, end):
+ if output[index] not in "\r\n":
+ output[index] = " "
+ merged = []
+ for start, end in sorted(self.erased):
+ if merged and start <= merged[-1][1]:
+ merged[-1] = (merged[-1][0], max(end, merged[-1][1]))
+ else:
+ merged.append((start, end))
+ for start, end in merged:
+ for index in range(start, end):
+ if output[index] not in "\r\n":
+ output[index] = " "
+ for index, value in self.replacements.items():
+ if output[index] != " ":
+ output[index] = value
+ return "".join(output)
+
+
+def class_usage_source(source: str, path: str, *, depth: int = 0) -> str:
+ """Return only established runtime class-use syntax; keep uncertain input."""
+ if path.endswith(".d.ts"):
+ return re.sub(r"[^\r\n]", " ", source)
+ if depth > 80:
+ return source
+ view = _RuntimeView(source, jsx=not path.endswith(".ts"))
+ typescript = path.endswith((".ts", ".tsx"))
+ if typescript:
+ view.declarations()
+ view.annotations()
+ view.generic_arguments(tsx=path.endswith(".tsx"))
+ view.class_fields(typescript=typescript)
+ return view.render(path, depth)
+
+
+def class_value_positions(source: str, *, depth: int = 0, jsx: bool = True) -> tuple[set[int], set[int], set[int]]:
+ """Recognize code-token starts, excluding comments, strings and regex bodies."""
+ tokens = _tokens(source, jsx=jsx)
+ property_keys = {
+ token.start
+ for token in tokens
+ if token.value in {"className", "class", "'className'", '"className"', "'class'", '"class"'}
+ }
+ calls = {token.start for token in tokens if token.value in {"classNames", "clsx"}}
+ words = {token.start for token in tokens if token.word}
+ for token in tokens:
+ if token.uncertain or token.value.startswith("`") and (not token.complete or depth > 80):
+ # Broken or excessively nested templates cannot establish absence of
+ # runtime expressions. Retain all possible class-related code starts.
+ words.update(
+ token.start + match.start() for match in _WORD.finditer(token.value) if match.group() != "import"
+ )
+ property_keys.update(words)
+ property_keys.update(
+ token.start + match.start()
+ for match in re.finditer(r"""['"](?:className|class)['"]\s*:""", token.value)
+ )
+ calls.update(words)
+ elif token.expressions:
+ for start, end in token.expressions:
+ nested_properties, nested_calls, nested_words = class_value_positions(
+ source[start:end], depth=depth + 1, jsx=jsx
+ )
+ property_keys.update(start + value for value in nested_properties)
+ calls.update(start + value for value in nested_calls)
+ words.update(start + value for value in nested_words)
+ return property_keys, calls, words
+
+
+def class_property_positions(source: str, *, depth: int = 0, jsx: bool = True) -> list[tuple[str, int, int, bool]]:
+ """Return class property/write keys, offsets and compound uncertainty.
+
+ Comments are already skipped by the lexer. Exact assignment operators avoid
+ treating equality tests or arrows as writes; quoted member keys are decoded
+ by the caller's existing JavaScript string reader.
+ """
+ tokens = _tokens(source, jsx=jsx)
+ assignments = []
+ for index, token in enumerate(tokens):
+ operator = index + 1
+ if token.value[:1] in {"'", '"', "`"}:
+ if (
+ index >= 2
+ and tokens[index - 1].value == "["
+ and tokens[operator : operator + 1]
+ and tokens[operator].value == "]"
+ and (tokens[index - 2].word or tokens[index - 2].value in {")", "]", "!"})
+ ):
+ operator += 1
+ elif operator >= len(tokens) or tokens[operator].value not in {":", "="}:
+ operator = len(tokens)
+ elif token.value not in {"className", "class"}:
+ operator = len(tokens)
+ if operator < len(tokens) and tokens[operator].value in _CLASS_VALUE_OPERATORS:
+ start = tokens[operator + 1].start if operator + 1 < len(tokens) else tokens[operator].end
+ assignments.append((token.value, token.start, start, tokens[operator].value not in {":", "="}))
+ if token.expressions and depth < 80:
+ for start, end in token.expressions:
+ assignments.extend(
+ (key, start + key_offset, start + offset, compound)
+ for key, key_offset, offset, compound in class_property_positions(
+ source[start:end], depth=depth + 1, jsx=jsx
+ )
+ )
+ elif token.uncertain and re.search(r"\b(?:className|class)\b[\s\S]*=", token.value):
+ # An ambiguous lexical range cannot establish absence of a runtime
+ # write. Keep possible global uses uncertain rather than delete CSS.
+ assignments.append(("className", token.start, token.end, True))
+ return assignments
diff --git a/src/deadcode/cli.py b/src/deadcode/cli.py
index a70f78a..61cc663 100644
--- a/src/deadcode/cli.py
+++ b/src/deadcode/cli.py
@@ -27,6 +27,14 @@
FORMAT_CHOICES = click.Choice(["pretty", "compact", "github", "json"])
+def _escape_annotation(value: str, *, property_value: bool = False) -> str:
+ """Escape data for GitHub's workflow-command protocol."""
+ value = value.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A")
+ if property_value:
+ value = value.replace(":", "%3A").replace(",", "%2C")
+ return value
+
+
def _line_self_contained(text: str) -> bool:
"""Return True if a line's brackets/braces/parens are balanced on that line.
@@ -63,9 +71,7 @@ def _line_self_contained(text: str) -> bool:
@click.group()
@click.option("--project", "-p", default=".", help="Project directory to scan")
-@click.option(
- "--ignore", "-i", multiple=True, help="Additional ignore patterns (gitignore-style)"
-)
+@click.option("--ignore", "-i", multiple=True, help="Additional ignore patterns (gitignore-style)")
@click.option(
"--include",
multiple=True,
@@ -73,9 +79,7 @@ def _line_self_contained(text: str) -> bool:
)
@click.version_option(__version__, prog_name="deadcode")
@click.pass_context
-def cli(
- ctx: click.Context, project: str, ignore: tuple[str, ...], include: tuple[str, ...]
-) -> None:
+def cli(ctx: click.Context, project: str, ignore: tuple[str, ...], include: tuple[str, ...]) -> None:
"""DeadCode — Find and remove dead code in TS/React/Next.js projects.
Scans for unused exports, dead routes, orphaned CSS classes,
@@ -114,9 +118,7 @@ def _get_fail_threshold(ctx: click.Context) -> int:
@cli.command()
-@click.option(
- "--json-output", "-j", is_flag=True, help="Alias for --format=json (deprecated)"
-)
+@click.option("--json-output", "-j", is_flag=True, help="Alias for --format=json (deprecated)")
@click.option("--format", type=FORMAT_CHOICES, default="pretty", help=FORMAT_HELP)
@click.option(
"--category",
@@ -149,9 +151,7 @@ def scan(
sys.exit(1)
include_patterns = ctx.obj.get("include")
- scanner = DeadCodeScanner(
- project, ignore_patterns=ignore, include_patterns=include_patterns
- )
+ scanner = DeadCodeScanner(project, ignore_patterns=ignore, include_patterns=include_patterns)
result = scanner.scan()
# Filter by category
@@ -183,7 +183,9 @@ def scan(
],
"errors": result.errors,
}
- console.print(json.dumps(output, indent=2, default=str))
+ # Machine output must preserve literal paths and remain valid at any
+ # terminal width; Rich interprets markup and wraps long JSON strings.
+ click.echo(json.dumps(output, indent=2, default=str))
elif effective_format == "compact":
if not findings:
console.print("OK — 0 findings")
@@ -195,20 +197,19 @@ def scan(
# GitHub Actions annotation syntax
# ::warning file={name},line={line},endLine={line}::{message}
if not findings:
- console.print("deadcode: 0 findings")
+ click.echo("deadcode: 0 findings")
else:
for f in findings:
level = "error" if f.removable else "warning"
msg = f"{f.category}: {f.name}"
if f.detail:
msg += f" ({f.detail[:120]})"
- console.print(f"::{level} file={f.file},line={f.line}::{msg}")
- console.print(f"\n::notice::deadcode: {len(findings)} findings")
+ file = _escape_annotation(f.file, property_value=True)
+ click.echo(f"::{level} file={file},line={f.line}::{_escape_annotation(msg)}")
+ click.echo(f"\n::notice::deadcode: {len(findings)} findings")
else:
# Summary
- console.print(
- f"\n[bold]DeadCode Scan[/bold] — {result.files_scanned} files scanned\n"
- )
+ console.print(f"\n[bold]DeadCode Scan[/bold] — {result.files_scanned} files scanned\n")
if not findings:
console.print("[green]✓ No dead code found![/green]")
@@ -227,9 +228,7 @@ def scan(
for cat, cat_findings in by_category.items():
label = category_labels.get(cat, cat)
- console.print(
- f"\n[bold yellow]{label}[/bold yellow] ({len(cat_findings)})"
- )
+ console.print(f"\n[bold yellow]{label}[/bold yellow] ({len(cat_findings)})")
table = Table(show_header=True)
table.add_column("File", style="cyan")
@@ -246,24 +245,16 @@ def scan(
# Total
removable = sum(1 for f in findings if f.removable)
- console.print(
- f"\n[bold]Total:[/bold] {len(findings)} findings ({removable} removable)"
- )
+ console.print(f"\n[bold]Total:[/bold] {len(findings)} findings ({removable} removable)")
if result.errors:
- console.print(
- f"\n[yellow]{len(result.errors)} scan errors (use --json-output to see)[/yellow]"
- )
+ console.print(f"\n[yellow]{len(result.errors)} scan errors (use --json-output to see)[/yellow]")
# CI fail threshold
- effective_threshold = (
- fail_threshold if fail_threshold is not None else _get_fail_threshold(ctx)
- )
+ effective_threshold = fail_threshold if fail_threshold is not None else _get_fail_threshold(ctx)
if effective_threshold >= 0 and len(findings) >= effective_threshold:
if effective_format not in ("json", "github"):
- console.print(
- f"\n[red]FAIL: {len(findings)} findings >= threshold {effective_threshold}[/red]"
- )
+ console.print(f"\n[red]FAIL: {len(findings)} findings >= threshold {effective_threshold}[/red]")
sys.exit(1)
@@ -298,18 +289,14 @@ def remove(ctx: click.Context, dry_run: bool, category: str | None) -> None:
sys.exit(1)
if not dry_run:
- console.print(
- "[red]WARNING: This will modify files. Use --dry-run first![/red]"
- )
+ console.print("[red]WARNING: This will modify files. Use --dry-run first![/red]")
console.print("[dim]Press Ctrl+C to abort. Running in 3 seconds...[/dim]")
import time
time.sleep(3)
include_patterns = ctx.obj.get("include")
- scanner = DeadCodeScanner(
- project, ignore_patterns=ignore, include_patterns=include_patterns
- )
+ scanner = DeadCodeScanner(project, ignore_patterns=ignore, include_patterns=include_patterns)
result = scanner.scan()
findings = result.findings
@@ -342,9 +329,7 @@ def remove(ctx: click.Context, dry_run: bool, category: str | None) -> None:
continue
try:
- lines = filepath.read_text(encoding="utf-8", errors="replace").splitlines(
- keepends=True
- )
+ lines = filepath.read_text(encoding="utf-8", errors="replace").splitlines(keepends=True)
except Exception as e:
console.print(f"[red]Error reading {rel_file}: {e}[/red]")
continue
@@ -357,19 +342,13 @@ def remove(ctx: click.Context, dry_run: bool, category: str | None) -> None:
# balanced on that line (it's a self-contained one-liner). Anything that
# opens an unclosed block is skipped and reported for manual removal.
candidate_lines = sorted(set(f.line for f in file_findings), reverse=True)
- safe_lines = [
- n
- for n in candidate_lines
- if 0 < n <= len(lines) and _line_self_contained(lines[n - 1])
- ]
+ safe_lines = [n for n in candidate_lines if 0 < n <= len(lines) and _line_self_contained(lines[n - 1])]
skipped_lines = [n for n in candidate_lines if n not in safe_lines]
if dry_run:
for line_num in sorted(safe_lines):
content = lines[line_num - 1].strip()
- console.print(
- f"[yellow]WOULD REMOVE[/yellow] {rel_file}:{line_num} — {content[:80]}"
- )
+ console.print(f"[yellow]WOULD REMOVE[/yellow] {rel_file}:{line_num} — {content[:80]}")
removed_count += len(safe_lines)
else:
for line_num in safe_lines:
@@ -377,15 +356,12 @@ def remove(ctx: click.Context, dry_run: bool, category: str | None) -> None:
if safe_lines:
filepath.write_text("".join(lines), encoding="utf-8")
removed_count += len(safe_lines)
- console.print(
- f"[green]✓[/green] Cleaned {rel_file} ({len(safe_lines)} lines)"
- )
+ console.print(f"[green]✓[/green] Cleaned {rel_file} ({len(safe_lines)} lines)")
for line_num in sorted(skipped_lines):
content = lines[line_num - 1].strip() if 0 < line_num <= len(lines) else ""
console.print(
- f"[yellow]⚠ SKIPPED (multi-line — remove manually)[/yellow] "
- f"{rel_file}:{line_num} — {content[:80]}"
+ f"[yellow]⚠ SKIPPED (multi-line — remove manually)[/yellow] {rel_file}:{line_num} — {content[:80]}"
)
action = "Would remove" if dry_run else "Removed"
@@ -402,22 +378,14 @@ def stats(ctx: click.Context) -> None:
project = ctx.obj["project"]
ignore = _merge_config_ignore(ctx)
include_patterns = ctx.obj.get("include")
- scanner = DeadCodeScanner(
- project, ignore_patterns=ignore, include_patterns=include_patterns
- )
+ scanner = DeadCodeScanner(project, ignore_patterns=ignore, include_patterns=include_patterns)
result = scanner.scan()
console.print(f"Files scanned: [bold]{result.files_scanned}[/bold]")
- console.print(
- f"Unused exports: [bold yellow]{len(result.unused_exports)}[/bold yellow]"
- )
+ console.print(f"Unused exports: [bold yellow]{len(result.unused_exports)}[/bold yellow]")
console.print(f"Dead routes: [bold red]{len(result.dead_routes)}[/bold red]")
- console.print(
- f"Orphaned CSS: [bold magenta]{len(result.orphaned_css)}[/bold magenta]"
- )
- console.print(
- f"Unreferenced components: [bold cyan]{len(result.unreferenced_components)}[/bold cyan]"
- )
+ console.print(f"Orphaned CSS: [bold magenta]{len(result.orphaned_css)}[/bold magenta]")
+ console.print(f"Unreferenced components: [bold cyan]{len(result.unreferenced_components)}[/bold cyan]")
console.print(f"Total findings: [bold]{len(result.findings)}[/bold]")
if result.errors:
diff --git a/src/deadcode/scanner.py b/src/deadcode/scanner.py
index 9ec2726..3e75cb6 100644
--- a/src/deadcode/scanner.py
+++ b/src/deadcode/scanner.py
@@ -11,11 +11,15 @@
import os
import re
-from dataclasses import dataclass, field
+from bisect import bisect_right
+from dataclasses import dataclass, field, replace
+from html.entities import name2codepoint
from pathlib import Path
import pathspec
+from ._typescript import class_property_positions, class_usage_source, class_value_positions
+
# ── Data structures ───────────────────────────────────────────────────
@@ -31,6 +35,16 @@ class Finding:
removable: bool = False # Whether safe auto-removal is possible
+@dataclass(frozen=True)
+class _CssDefinition:
+ file: str
+ line: int
+ global_scope: bool
+ rule: int | None = None
+ multiline_header: bool = False
+ single_line_rule: bool = True
+
+
@dataclass
class ScanResult:
"""Aggregated scan results."""
@@ -89,11 +103,182 @@ def unreferenced_components(self) -> list[Finding]:
r"(?:app|src/app|pages|src/pages)/(.*?)/(?:page|route)\.(?:tsx|ts|jsx|js)$",
)
-# CSS class selectors (supports Tailwind utility classes with colon-separated segments like hover:bg-red)
+# CSS escapes include up to six hexadecimal digits and their optional single
+# whitespace terminator. Consume them inside the identifier before interpreting
+# punctuation as selector syntax (for example, .foo\\:bar and .foo\\.bar).
+_CSS_ESCAPE = r"\\(?:[0-9a-fA-F]{1,6}(?:\r\n|[ \t\r\n\f])?|[^\r\n\f0-9a-fA-F])"
+_CSS_NAME_START = rf"(?:[a-zA-Z_]|[^\x00-\x7f]|{_CSS_ESCAPE})"
+_CSS_NAME_CHAR = rf"(?:[a-zA-Z0-9_-]|[^\x00-\x7f]|{_CSS_ESCAPE})"
+_CSS_IDENTIFIER = rf"(?:--|-?{_CSS_NAME_START}){_CSS_NAME_CHAR}*"
+_CSS_IDENTIFIER_PATTERN = re.compile(_CSS_IDENTIFIER)
+_CSS_ESCAPE_PATTERN = re.compile(_CSS_ESCAPE)
+
+# Preserve the existing global utility-prefix handling as well as escaped names.
_CSS_CLASS_PATTERN = re.compile(
- r"\.([a-zA-Z_][\w-]*(?::[\w-]+)*)\s*(?:\{|,|\[)",
+ rf"\.({_CSS_IDENTIFIER}(?::{_CSS_IDENTIFIER})*)\s*(?:\{{|,|\[)",
)
+_CSS_NONCODE_PATTERN = re.compile(_CSS_ESCAPE + r"|/\*[\s\S]*?\*/|'(?:\\[\s\S]|[^'\\])*'|\"(?:\\[\s\S]|[^\"\\])*\"")
+_CSS_SELECTOR_TOKEN_PATTERN = re.compile(
+ rf"{_CSS_ESCAPE}|:(global|local)(?![\w-]|[^\x00-\x7f]|\\)|\.({_CSS_IDENTIFIER})|[(),\[\]]"
+)
+_CSS_BLOCK_TOKEN_PATTERN = re.compile(_CSS_ESCAPE + r"|[{};()\[\]]")
+
+
+def _decode_css_identifier(identifier: str) -> str:
+ """Decode CSS Syntax escapes, replacing invalid escaped Unicode scalars."""
+
+ def unescape(match: re.Match) -> str:
+ value = match.group()[1:]
+ if value[0] in "0123456789abcdefABCDEF":
+ codepoint = int(value.rstrip(" \t\r\n\f"), 16)
+ return chr(codepoint) if 0 < codepoint <= 0x10FFFF and not 0xD800 <= codepoint <= 0xDFFF else "�"
+ return value
+
+ return _CSS_ESCAPE_PATTERN.sub(unescape, identifier)
+
+
+def _module_selector_classes(selector: str, inherited_global: bool):
+ """Yield each class's own scope, respecting functional and bare switches."""
+ global_scope = inherited_global
+ parentheses: list[tuple[bool, bool]] = []
+ pending_scope: bool | None = None
+ brackets = 0
+ for match in _CSS_SELECTOR_TOKEN_PATTERN.finditer(selector):
+ token = match.group()
+ if token == "[":
+ brackets += 1
+ elif token == "]":
+ brackets = max(0, brackets - 1)
+ elif brackets:
+ continue
+ elif match.group(1):
+ scope = match.group(1) == "global"
+ if selector[match.end() :].lstrip().startswith("("):
+ pending_scope = scope
+ else:
+ global_scope = scope
+ elif token == "(":
+ inner_scope = global_scope if pending_scope is None else pending_scope
+ parentheses.append((global_scope, inner_scope))
+ global_scope = inner_scope
+ pending_scope = None
+ elif token == ")":
+ if parentheses:
+ global_scope = parentheses.pop()[0]
+ elif token == ",":
+ global_scope = parentheses[-1][1] if parentheses else inherited_global
+ elif match.group(2):
+ yield _decode_css_identifier(match.group(2)), match.start(), global_scope
+
+
+def _module_css_definitions(content: str, rel_path: str):
+ """Read selector headers without mistaking declaration strings for classes."""
+ masked = _CSS_NONCODE_PATTERN.sub(
+ lambda match: match.group() if match.group().startswith("\\") else re.sub(r"[^\n]", " ", match.group()), content
+ )
+ lines = [0, *(match.end() for match in re.finditer("\n", content))]
+ scopes = [False]
+ # Removal deletes the finding's line, so wait for the complete rule boundary.
+ definitions: list[tuple[str, _CssDefinition]] = []
+ rule_members: list[list[int]] = [[]]
+ start = 0
+ parentheses = brackets = 0
+ for match in _CSS_BLOCK_TOKEN_PATTERN.finditer(masked):
+ token = match.group()
+ if token.startswith("\\"):
+ continue
+ if token == "(":
+ parentheses += 1
+ elif token == ")":
+ parentheses = max(0, parentheses - 1)
+ elif token == "[":
+ brackets += 1
+ elif token == "]":
+ brackets = max(0, brackets - 1)
+ elif parentheses or brackets:
+ continue
+ elif token == "{":
+ selector = masked[start : match.start()]
+ selector_start = start + len(selector) - len(selector.lstrip())
+ multiline_header = bisect_right(lines, selector_start) != bisect_right(lines, match.start())
+ members = []
+ if not selector.lstrip().startswith("@"):
+ for name, offset, global_scope in _module_selector_classes(selector, scopes[-1]):
+ members.append(len(definitions))
+ definitions.append(
+ (
+ name,
+ _CssDefinition(
+ rel_path,
+ bisect_right(lines, start + offset),
+ global_scope,
+ match.start(),
+ multiline_header,
+ single_line_rule=False,
+ ),
+ )
+ )
+ block_scope = {":global": True, ":local": False}.get(selector.strip(), scopes[-1])
+ scopes.append(block_scope)
+ rule_members.append(members)
+ start = match.end()
+ elif token == "}":
+ if len(scopes) > 1:
+ scopes.pop()
+ closing_line = bisect_right(lines, match.start())
+ for member in rule_members.pop():
+ name, definition = definitions[member]
+ definitions[member] = (name, replace(definition, single_line_rule=definition.line == closing_line))
+ start = match.end()
+ else:
+ start = match.end()
+ yield from definitions
+
+
+def _global_css_rule_spans(content: str):
+ """Map existing global findings to complete rules without adding selectors."""
+ masked = _CSS_NONCODE_PATTERN.sub(
+ lambda match: match.group() if match.group().startswith("\\") else re.sub(r"[^\n]", " ", match.group()), content
+ )
+ lines = [0, *(match.end() for match in re.finditer("\n", content))]
+ spans: list[tuple[int, int, int | None]] = []
+ blocks: list[int | None] = []
+ start = 0
+ parentheses = brackets = 0
+ for match in _CSS_BLOCK_TOKEN_PATTERN.finditer(masked):
+ token = match.group()
+ if token.startswith("\\"):
+ continue
+ if token == "(":
+ parentheses += 1
+ elif token == ")":
+ parentheses = max(0, parentheses - 1)
+ elif token == "[":
+ brackets += 1
+ elif token == "]":
+ brackets = max(0, brackets - 1)
+ elif parentheses or brackets:
+ continue
+ elif token == "{":
+ selector = masked[start : match.start()]
+ selector_start = start + len(selector) - len(selector.lstrip())
+ if selector.strip() and not selector.lstrip().startswith("@"):
+ blocks.append(len(spans))
+ spans.append((selector_start, match.start(), None))
+ else:
+ blocks.append(None)
+ start = match.end()
+ elif token == "}":
+ if blocks and (index := blocks.pop()) is not None:
+ header_start, opening, _ = spans[index]
+ spans[index] = (header_start, opening, bisect_right(lines, match.start()))
+ start = match.end()
+ else:
+ start = match.end()
+ return spans, lines
+
+
# import statements
# Handles: import {Foo} from ..., import Foo from ..., import type {Foo} from ...,
# import Default, {Named} from ..., import {type Foo} from ...
@@ -126,15 +311,11 @@ def unreferenced_components(self) -> list[Finding]:
# the whole export surface of the target module counts as consumed; otherwise
# exports used only via a namespace are falsely reported as unused with
# removable=True — live code queued for deletion.
-_NAMESPACE_IMPORT_PATTERN = re.compile(
- r"import\s+\*\s+as\s+\w+\s+from\s*['\"]([^'\"]+)['\"]"
-)
+_NAMESPACE_IMPORT_PATTERN = re.compile(r"import\s+\*\s+as\s+\w+\s+from\s*['\"]([^'\"]+)['\"]")
# Bare side-effect import: `import './polyfill';` — executes the module without
# binding any names, consuming its entire export surface.
-_SIDE_EFFECT_IMPORT_PATTERN = re.compile(
- r"^\s*import\s*['\"]([^'\"]+)['\"]", re.MULTILINE
-)
+_SIDE_EFFECT_IMPORT_PATTERN = re.compile(r"^\s*import\s*['\"]([^'\"]+)['\"]", re.MULTILINE)
# Dynamic import: `const m = await import('./heavy');` — lazily loads the whole
# module at runtime; individual consumed names are invisible statically, so the
@@ -145,32 +326,307 @@ def unreferenced_components(self) -> list[Finding]:
# consumption semantics as a dynamic import.
_REQUIRE_PATTERN = re.compile(r"(?])")
+_JSX_ATTRIBUTE_PATTERN = re.compile(r"(?:[^\W\d]|[$:]|[^\x00-\x7f\s])(?:[\w$:.-]|[^\x00-\x7f\s])*")
+_CLASS_PROPERTY_PATTERN = re.compile(r"""(? int | None:
+ """Find a closing JS delimiter without treating quoted braces as syntax."""
+ closing = {"{": "}", "[": "]", "(": ")"}
+ stack = [closing[content[start]]]
+ index = start + 1
+ limit = len(content) if limit is None else limit
+ while index < limit:
+ skipped = _JS_LITERAL_PATTERN.match(content, index, limit) or _JS_COMMENT_PATTERN.match(content, index, limit)
+ if skipped:
+ index = skipped.end()
+ continue
+ char = content[index]
+ if char in closing:
+ stack.append(closing[char])
+ elif char in "}])":
+ if char != stack.pop():
+ return None
+ if not stack:
+ return index + 1
+ index += 1
+ return None
+
+
+def _class_property_value(content: str, start: int) -> str | None:
+ """Read a complete property expression, retaining uncertainty for operators."""
+ index = start
+ while index < len(content):
+ skipped = _JS_LITERAL_PATTERN.match(content, index) or _JS_COMMENT_PATTERN.match(content, index)
+ if skipped:
+ index = skipped.end()
+ continue
+ if content[index] in "{[(":
+ end = _js_delimited_end(content, index)
+ if end is None:
+ return None
+ index = end
+ continue
+ if content[index] in ",};)]":
+ break
+ index += 1
+ return content[start:index]
+
+
+def _decode_jsx_attribute(value: str) -> str | None:
+ """Decode semicolon-terminated JSX entities once, without HTML error recovery."""
+ valid = True
+
+ def entity(match: re.Match) -> str:
+ nonlocal valid
+ raw = match.group()
+ if not raw.startswith(""):
+ if raw == "'":
+ return "'"
+ number = name2codepoint.get(raw[1:-1])
+ return chr(number) if number is not None else raw
+ code = raw[2:-1]
+ hexadecimal = code[:1].lower() == "x"
+ digits = (code[1:] if hexadecimal else code).lstrip("0") or "0"
+ if len(digits) > (6 if hexadecimal else 7):
+ valid = False
+ return raw
+ number = int(digits, 16 if hexadecimal else 10)
+ if not 0 < number <= 0x10FFFF or 0xD800 <= number <= 0xDFFF:
+ valid = False
+ return raw
+ # JSX numeric entities represent their code point directly; unlike
+ # html.unescape, C1 controls must not be changed to Windows-1252 text.
+ return chr(number)
+
+ decoded = _JSX_ENTITY_PATTERN.sub(entity, value)
+ return decoded if valid else None
+
+
+def _jsx_class_attributes(content: str, attribute_positions: set[int] | None = None):
+ """Yield raw JSX values or JS expressions from possible opening tags.
+
+ Looking at every possible tag intentionally over-approximates tags inside
+ source strings rather than missing nested JSX after quoted child text.
+ """
+ for tag in _JSX_TAG_PATTERN.finditer(content):
+ index = tag.end()
+ limit = min(len(content), tag.start() + 8192)
+ while index < limit:
+ if content[index].isspace() or content[index] == "/":
+ index += 1
+ continue
+ if content[index] == ">":
+ break
+ if content[index] == "{":
+ end = _js_delimited_end(content, index, limit=limit)
+ if end is None:
+ yield False, None
+ break
+ if content[index + 1 : end - 1].lstrip().startswith("..."):
+ yield False, None # Spread props may supply any global class.
+ index = end
+ continue
+ name = _JSX_ATTRIBUTE_PATTERN.match(content, index, limit)
+ if name is None:
+ break # Not an attribute: do not walk the remaining file per candidate.
+ wanted = name.group() in {"className", "class"}
+ if wanted and attribute_positions is not None:
+ attribute_positions.add(name.start())
+ index = name.end()
+ while index < limit and content[index].isspace():
+ index += 1
+ if index >= limit or content[index] != "=":
+ continue
+ index += 1
+ while index < limit and content[index].isspace():
+ index += 1
+ if index == limit:
+ if wanted:
+ yield False, None
+ break
+ if content[index] in "'\"":
+ end = content.find(content[index], index + 1, limit)
+ if end < 0:
+ if wanted or limit < len(content):
+ yield False, None
+ break
+ if wanted:
+ yield True, content[index + 1 : end]
+ index = end + 1
+ elif content[index] == "{":
+ end = _js_delimited_end(content, index, limit=limit)
+ if wanted:
+ yield False, content[index + 1 : end - 1] if end is not None else None
+ if end is None:
+ if not wanted:
+ yield False, None
+ break
+ index = end
+ else:
+ if wanted:
+ yield False, None
+ index += 1
+ if index >= limit and limit < len(content):
+ yield False, None # An oversized header leaves possible global uses uncertain.
+
# tsconfig paths for path aliases
_TSCONFIG_PATHS_PATTERN = re.compile(
r'"([^"]+)"\s*:\s*\["([^"]+)"\]',
)
-# CSS-module accessor usage, two forms::
-# - dot: `styles.card` -> capture group 1 empty, full = "styles.card"
-# - bracket: `styles['card-hover']` / `styles["card"]` -> capture group 1 = name
-# We deliberately match either form after a binding identifier so both
-# `styles.card` and `styles['card-hover']` register the class as used.
+# Computed keys are full quoted JavaScript literals, not identifier prefixes.
+# Keep non-ASCII continuation characters intact, including marks and ZWJ/ZWNJ.
+# Runtime token starts and the actual imported name still establish ownership.
+_JS_MODULE_BINDING = r"(?:[^\W\d]|[$])(?:[\w$]|[^\x00-\x7f\s])*"
_CSS_MODULE_USAGE_PATTERN = re.compile(
- r"""\b\w+\.(?:_?[\w$]+)|(\w+)\[['"]([\w$-]+)['"]\]""",
+ rf"""(? str | None:
+ """Decode a computed literal; retain uncertainty for interpolation or invalid escapes."""
+ body = literal[1:-1]
+ decoded = []
+ index = 0
+ while index < len(body):
+ char = body[index]
+ index += 1
+ if char != "\\":
+ if literal[0] == "`" and char == "$" and body[index : index + 1] == "{":
+ return None
+ decoded.append(char)
+ continue
+ if index == len(body):
+ return None
+ char = body[index]
+ index += 1
+ if char in "\r\n\u2028\u2029":
+ if char == "\r" and body[index : index + 1] == "\n":
+ index += 1
+ continue
+ if char in "xu":
+ width = 2 if char == "x" else 4
+ if char == "u" and body[index : index + 1] == "{":
+ end = body.find("}", index + 1)
+ digits = body[index + 1 : end] if end >= 0 else ""
+ index = end + 1
+ else:
+ digits = body[index : index + width]
+ index += width
+ if len(digits) != width:
+ return None
+ if not re.fullmatch(r"[0-9a-fA-F]{1,6}", digits) or int(digits, 16) > 0x10FFFF:
+ return None
+ decoded.append(chr(int(digits, 16)))
+ elif char.isdecimal() and (char != "0" or body[index : index + 1].isdigit()):
+ return None # Legacy octal escapes are invalid in ECMAScript modules.
+ else:
+ decoded.append(
+ {"n": "\n", "r": "\r", "t": "\t", "b": "\b", "f": "\f", "v": "\v", "0": "\0"}.get(char, char)
+ )
+ try:
+ # JavaScript Unicode escapes use UTF-16; pairs must match astral CSS names.
+ return "".join(decoded).encode("utf-16-le", "surrogatepass").decode("utf-16-le")
+ except UnicodeDecodeError:
+ return None
+
+
+def _class_expression_values(expression: str, module_bindings: set[str]) -> tuple[set[str], bool]:
+ """Recognize literal values and owned module access; keep other expressions uncertain."""
+ expression = expression.strip()
+ while expression.startswith("(") and _js_delimited_end(expression, 0) == len(expression):
+ expression = expression[1:-1].strip()
+ if _JS_LITERAL_PATTERN.fullmatch(expression):
+ decoded = _decode_js_string(expression)
+ return ({decoded}, True) if decoded is not None else (set(), False)
+ if expression in {"null", "undefined", "false", "true"}:
+ return set(), True
+ accessor = _CSS_MODULE_USAGE_PATTERN.fullmatch(expression)
+ if accessor and accessor.group(1) in module_bindings:
+ return set(), True # The separate module pass retains stylesheet scope.
+ computed = re.match(r"([\w$]+)\s*(?:\?\.)?\s*\[", expression)
+ if (
+ computed
+ and computed.group(1) in module_bindings
+ and _js_delimited_end(expression, computed.end() - 1) == len(expression)
+ ):
+ return set(), True
+ call = _CLASSNAMES_CALL_PATTERN.match(expression)
+ if call and _js_delimited_end(expression, call.end()) == len(expression):
+ arguments = expression[call.end() + 1 : -1]
+ values = set()
+ complete = True
+ for literal in _JS_LITERAL_PATTERN.finditer(arguments):
+ decoded = _decode_js_string(literal.group())
+ if decoded is None:
+ complete = False
+ else:
+ values.add(decoded)
+ remainder = _JS_COMMENT_PATTERN.sub("", _JS_LITERAL_PATTERN.sub("", arguments))
+ return values, complete and not remainder.strip(" \t\r\n\f,")
+ # Literal branches of conditions are possible uses, but cannot prove any
+ # other global class unused. Do not execute or guess variable bindings.
+ return {
+ decoded
+ for literal in _JS_LITERAL_PATTERN.finditer(expression)
+ if (decoded := _decode_js_string(literal.group())) is not None
+ }, False
+
+
# Detect a CSS-module import so we know which source-file class accessors
# correspond to module classes (``import styles from './x.module.css'``).
_CSS_MODULE_IMPORT_PATTERN = re.compile(
- r"""import\s+(?:type\s+)?(\w+)\s+from\s+['"]([^'"]*\.module\.css)['"]""",
+ rf"""import\s+(?:\*\s+as\s+|\{{\s*default\s+as\s+)?({_JS_MODULE_BINDING})(?:\s*\}}|\s*,\s*\{{[^}}]*\}})?\s+from\s+['"]([^'"]*\.module\.css)['"]""",
+)
+_JS_IMPORT_GAP = rf"(?:\s|{_JS_COMMENT_PATTERN.pattern})*"
+_CSS_NAMED_IMPORT_PATTERN = re.compile(rf"import{_JS_IMPORT_GAP}\{{")
+_CSS_IMPORT_SOURCE_PATTERN = re.compile(rf"""{_JS_IMPORT_GAP}from{_JS_IMPORT_GAP}(['"])([^'"]*\.module\.css)\1""")
+_JS_IMPORT_NONCODE_PATTERN = re.compile(_JS_LITERAL_PATTERN.pattern + "|" + _JS_COMMENT_PATTERN.pattern)
+_CSS_IMPORT_PART_PATTERN = re.compile(_JS_LITERAL_PATTERN.pattern + "|,")
+_CSS_DEFAULT_SPECIFIER_PATTERN = re.compile(
+ rf"""(default|'(?:\\[\s\S]|[^'\\\r\n])*'|"(?:\\[\s\S]|[^"\\\r\n])*")\s+as\s+({_JS_MODULE_BINDING})"""
)
+_CSS_IMPORT_KEYWORD_PATTERN = re.compile(r"\bimport\b")
+
+
+def _css_module_imports(content: str, code_starts: set[int]):
+ """Read runtime default bindings from complete import-specifier lists."""
+ for keyword in _CSS_IMPORT_KEYWORD_PATTERN.finditer(content):
+ if keyword.start() not in code_starts:
+ continue
+ match = _CSS_MODULE_IMPORT_PATTERN.match(content, keyword.start())
+ if match:
+ yield match.group(1), match.group(2)
+ match = _CSS_NAMED_IMPORT_PATTERN.match(content, keyword.start())
+ if match is None:
+ continue
+ end = _js_delimited_end(content, match.end() - 1)
+ source = _CSS_IMPORT_SOURCE_PATTERN.match(content, end) if end is not None else None
+ if source is None:
+ continue
+ specifiers = _JS_IMPORT_NONCODE_PATTERN.sub(
+ lambda token: " " if token.group().startswith(("//", "/*")) else token.group(),
+ content[match.end() : end - 1],
+ )
+ start = 0
+ # A comma inside a quoted import name is not a specifier separator.
+ ends = [token.start() for token in _CSS_IMPORT_PART_PATTERN.finditer(specifiers) if token.group() == ","]
+ for stop in [*ends, len(specifiers)]:
+ specifier = _CSS_DEFAULT_SPECIFIER_PATTERN.fullmatch(specifiers[start:stop].strip())
+ if specifier and (specifier.group(1) == "default" or _decode_js_string(specifier.group(1)) == "default"):
+ yield specifier.group(2), source.group(2)
+ start = stop + 1
# ── Scanner ───────────────────────────────────────────────────────────
@@ -226,11 +682,14 @@ def scan(self) -> ScanResult:
# Phase 1: Build export map and import map
exports: dict[str, list[tuple[str, int]]] = {} # name -> [(file, line)]
imports: dict[str, set[str]] = {} # name -> set of files that import it
- css_classes: dict[str, list[tuple[str, int]]] = {} # class -> [(file, line)]
- used_css_classes: set[str] = set()
+ css_classes: dict[str, list[_CssDefinition]] = {}
+ used_css_classes: set[str | None] = set()
+ used_css_module_classes: dict[Path, set[str | None]] = {}
+ unresolved_css_module_classes: set[str | None] = set()
components: dict[str, str] = {} # ComponentName -> file
routes: list[tuple[str, str]] = [] # (route_path, file)
star_reexports: list[tuple[str, str]] = [] # (barrel_file, module_spec)
+ file_set = {str(f.relative_to(self.project_dir)).replace("\\", "/") for f in all_files}
for filepath in all_files:
try:
@@ -255,14 +714,24 @@ def scan(self) -> ScanResult:
if self._is_css_file(rel_path):
self._parse_css_classes(content, rel_path, css_classes)
- # Parse className usage in TSX/JSX files
- if rel_path.endswith((".tsx", ".jsx")):
- self._parse_classname_usage(content, used_css_classes)
- # CSS-module accessor usage (styles.card / styles['card']).
- # Only register accessors imported as a CSS module so we don't
- # treat every object property access (e.g. `user.name`) as a
- # CSS class.
- self._parse_css_module_usage(content, used_css_classes)
+ # Plain JS/TS can also export class props or classNames() values.
+ if not self._is_css_file(rel_path):
+ class_source = class_usage_source(content, rel_path)
+ self._parse_classname_usage(
+ class_source, used_css_classes, jsx=not rel_path.endswith(".ts"), import_source=content
+ )
+
+ # CSS modules can also be consumed by plain TypeScript/JavaScript
+ # files, for example when exporting class-name maps to components.
+ if not self._is_css_file(rel_path):
+ self._parse_css_module_usage(
+ class_source,
+ rel_path,
+ file_set,
+ used_css_module_classes,
+ unresolved_css_module_classes,
+ import_source=content,
+ )
# Parse components
if rel_path.endswith((".tsx", ".jsx")):
@@ -278,7 +747,6 @@ def scan(self) -> ScanResult:
# Resolve `export * from './mod'` specifiers to scanned files so that
# every export forwarded by a barrel is treated as part of the public
# API surface (never reported as removable).
- file_set = {str(f.relative_to(self.project_dir)).replace("\\", "/") for f in all_files}
star_reexported_files: set[str] = set()
for barrel_file, module_spec in star_reexports:
resolved = self._resolve_relative_module(barrel_file, module_spec, file_set)
@@ -292,7 +760,9 @@ def scan(self) -> ScanResult:
self._find_dead_routes(routes, all_files, result)
# 2c. Orphaned CSS
- self._find_orphaned_css(css_classes, used_css_classes, result)
+ self._find_orphaned_css(
+ css_classes, used_css_classes, used_css_module_classes, unresolved_css_module_classes, result
+ )
# 2d. Unreferenced components
# Collect all names that are imported somewhere (i.e., actually used)
@@ -312,11 +782,9 @@ def _collect_files(self) -> list[Path]:
d for d in dirs if not self.ignore_spec.match_file(f"{rel_root}/{d}/" if rel_root != "." else f"{d}/")
]
- # Filter out non-included directories when include_spec is set
- if self.include_spec:
- dirs[:] = [
- d for d in dirs if self.include_spec.match_file(f"{rel_root}/{d}/" if rel_root != "." else f"{d}/")
- ]
+ # A file whitelist need not match its parent directories (for
+ # example, "src/components/*.tsx"). Only ignored directories can
+ # be pruned; apply include patterns to the individual files below.
for fname in filenames:
rel_path = f"{rel_root}/{fname}" if rel_root != "." else fname
@@ -325,9 +793,17 @@ def _collect_files(self) -> list[Path]:
if self.include_spec and not self.include_spec.match_file(rel_path):
continue
+ if not self._is_scannable_file(rel_path):
+ continue
+
filepath = Path(root) / fname
- if self._is_scannable_file(rel_path):
- files.append(filepath)
+ # A source-file symlink may resolve outside the project. Such
+ # findings could otherwise make `remove` modify external files.
+ try:
+ filepath.resolve().relative_to(self.project_dir)
+ except (ValueError, OSError, RuntimeError):
+ continue
+ files.append(filepath)
return files
@@ -493,22 +969,98 @@ def _resolve_relative_module(importer_rel: str, spec: str, file_set: set[str]) -
return candidate
return None
- def _parse_css_classes(self, content: str, rel_path: str, css_classes: dict[str, list[tuple[str, int]]]) -> None:
+ def _parse_css_classes(self, content: str, rel_path: str, css_classes: dict[str, list[_CssDefinition]]) -> None:
"""Extract CSS class names defined in a stylesheet."""
- for i, line in enumerate(content.splitlines(), 1):
+ if rel_path.endswith(".module.css"):
+ for cls, definition in _module_css_definitions(content, rel_path):
+ css_classes.setdefault(cls, []).append(definition)
+ return
+ spans, lines = _global_css_rule_spans(content)
+ starts = [span[0] for span in spans]
+ offset = 0
+ for i, line in enumerate(content.splitlines(keepends=True), 1):
for m in _CSS_CLASS_PATTERN.finditer(line):
- cls = m.group(1)
- css_classes.setdefault(cls, []).append((rel_path, i))
-
- def _parse_classname_usage(self, content: str, used_css_classes: set[str]) -> None:
- """Extract CSS class names used in JSX className attributes."""
- for m in _CLASSNAME_PATTERN.finditer(content):
- for group in m.groups():
- if group:
- for cls in group.split():
- used_css_classes.add(cls)
-
- def _parse_css_module_usage(self, content: str, used_css_classes: set[str]) -> None:
+ identifier = m.group(1)
+ if "\\" in identifier:
+ # An unescaped colon following an escaped name begins a
+ # pseudo-selector, not part of that class's decoded key.
+ identifier = _CSS_IDENTIFIER_PATTERN.match(identifier).group()
+ cls = _decode_css_identifier(identifier)
+ index = bisect_right(starts, offset + m.start()) - 1
+ definition = _CssDefinition(rel_path, i, True, single_line_rule=False)
+ if index >= 0:
+ header_start, opening, closing_line = spans[index]
+ if offset + m.start() < opening:
+ definition = _CssDefinition(
+ rel_path,
+ i,
+ True,
+ opening,
+ bisect_right(lines, header_start) != bisect_right(lines, opening),
+ single_line_rule=closing_line == i,
+ )
+ css_classes.setdefault(cls, []).append(definition)
+ offset += len(line)
+
+ def _parse_classname_usage(
+ self,
+ content: str,
+ used_css_classes: set[str | None],
+ *,
+ jsx: bool = True,
+ import_source: str | None = None,
+ ) -> None:
+ """Read raw JSX attributes and JavaScript literals using their own grammar."""
+ properties, calls, code_starts = class_value_positions(content, jsx=jsx)
+ module_bindings = {
+ binding
+ for binding, _ in _css_module_imports(content if import_source is None else import_source, code_starts)
+ }
+
+ def consume(raw_jsx: bool, value: str | None) -> None:
+ if value is None:
+ used_css_classes.add(None)
+ return
+ if raw_jsx:
+ decoded = _decode_jsx_attribute(value)
+ values, complete = ({decoded}, True) if decoded is not None else (set(), False)
+ else:
+ values, complete = _class_expression_values(value, module_bindings)
+ for text in values:
+ # HTML class tokens split on ASCII whitespace, not NBSP or
+ # other Unicode spaces produced by JSX entity references.
+ used_css_classes.update(filter(None, _HTML_CLASS_SPACE_PATTERN.split(text)))
+ if not complete:
+ used_css_classes.add(None)
+
+ jsx_properties: set[int] = set()
+ for raw_jsx, value in _jsx_class_attributes(content, jsx_properties):
+ consume(raw_jsx, value)
+ for property_match in _CLASS_PROPERTY_PATTERN.finditer(content):
+ if property_match.start() in properties:
+ consume(False, _class_property_value(content, property_match.end()))
+ for key, key_start, start, compound in class_property_positions(content, jsx=jsx):
+ if key_start in jsx_properties:
+ continue # The JSX reader has already consumed its own grammar.
+ name = _decode_js_string(key) if key[:1] in {"'", '"', "`"} else key
+ if name in {"className", "class"}:
+ consume(False, None if compound else _class_property_value(content, start))
+ for call in _CLASSNAMES_CALL_PATTERN.finditer(content):
+ if call.start() not in calls:
+ continue
+ end = _js_delimited_end(content, call.end())
+ consume(False, content[call.start() : end] if end is not None else None)
+
+ def _parse_css_module_usage(
+ self,
+ content: str,
+ rel_path: str,
+ file_set: set[str],
+ used_css_module_classes: dict[Path, set[str | None]],
+ unresolved_css_module_classes: set[str | None],
+ *,
+ import_source: str | None = None,
+ ) -> None:
"""Extract CSS-module class names consumed via object accessors.
The canonical Next.js/React pattern is::
@@ -516,33 +1068,49 @@ def _parse_css_module_usage(self, content: str, used_css_classes: set[str]) -> N
import styles from './Card.module.css';
...
- We first find any ``*.module.css`` imports (binding name ``styles``)
- so that accessor use ``styles.card`` registers ``card`` as a used
- class. Bracket access ``styles['card-hover']`` is also captured.
+ Resolve each relative ``*.module.css`` import from its importing file
+ so that ``styles.card`` protects ``card`` only in that stylesheet.
+ Bracket access ``styles['card-hover']`` is also captured. Canonical
+ paths keep internal stylesheet symlinks and their targets consistent.
+ Classes used through unresolved imports remain uncertain so alias or
+ bundler-specific resolution cannot make live module classes removable.
Without this, every CSS-module class is falsely reported as orphaned
and marked removable=True, risking deletion of live styles.
"""
# Map the local binding (e.g. ``styles``) to a CSS-module import so
# only module accessors are treated as class names.
- module_bindings = {m.group(1) for m in _CSS_MODULE_IMPORT_PATTERN.finditer(content)}
+ module_bindings: dict[str, Path | None] = {}
+ _, _, code_starts = class_value_positions(content, jsx=not rel_path.endswith(".ts"))
+ # Type erasure preserves offsets and decides whether an import is live.
+ # Its original clause retains quoted export names and their aliases.
+ for binding, specifier in _css_module_imports(content if import_source is None else import_source, code_starts):
+ stylesheet = self._resolve_relative_module(rel_path, specifier, file_set)
+ if stylesheet is not None and stylesheet.endswith(".module.css"):
+ module_bindings[binding] = (self.project_dir / stylesheet).resolve()
+ else:
+ module_bindings[binding] = None
if not module_bindings:
return
+ recognized = set()
for m in _CSS_MODULE_USAGE_PATTERN.finditer(content):
- groups = m.groups()
- # Bracket form `styles['card-hover']` -> groups = (binding, name).
- # Dot form `styles.card` -> groups = (None, None); the whole match
- # is "binding.attr". Distinguish by whether a bracket name exists.
- bracket_binding, bracket_name = groups
- if bracket_name is not None:
- if bracket_binding in module_bindings:
- used_css_classes.add(bracket_name)
+ if m.start() not in code_starts:
continue
- # Dot form `styles.card` -> the whole match is "binding.attr".
- full = m.group(0)
- binding, _, attr = full.partition(".")
- if binding in module_bindings and attr and attr[0] != "_":
- # Skip internal/private-ish accessors (e.g. styles.toString).
- used_css_classes.add(attr)
+ binding, dot_name, bracket_literal = m.groups()
+ if binding in module_bindings:
+ recognized.add(m.start())
+ name = dot_name if dot_name is not None else _decode_js_string(bracket_literal)
+ stylesheet_path = module_bindings[binding]
+ if stylesheet_path is None:
+ unresolved_css_module_classes.add(name)
+ else:
+ used_css_module_classes.setdefault(stylesheet_path, set()).add(name)
+ for binding, stylesheet_path in module_bindings.items():
+ for match in re.finditer(rf"(? None:
"""Extract React component definitions."""
@@ -667,11 +1235,17 @@ def _find_dead_routes(
def _find_orphaned_css(
self,
- css_classes: dict[str, list[tuple[str, int]]],
- used_css_classes: set[str],
+ css_classes: dict[str, list[_CssDefinition]],
+ used_css_classes: set[str | None],
+ used_css_module_classes: dict[Path, set[str | None]],
+ unresolved_css_module_classes: set[str | None],
result: ScanResult,
) -> None:
"""Find CSS classes defined but never used in JSX."""
+ module_usage_by_file: dict[str, set[str | None]] = {}
+ protected_lines: set[tuple[str, int]] = set()
+ protected_rules: set[tuple[str, int]] = set()
+ unused: list[tuple[str, _CssDefinition, bool]] = []
for cls, locations in css_classes.items():
# Skip common utility classes and pseudo-selectors
if cls.startswith(
@@ -687,20 +1261,64 @@ def _find_orphaned_css(
"xl:",
)
):
+ # Skipped utility classes still protect a shared rule/line from
+ # whole-line removal through a separate unused selector.
+ protected_lines.update((definition.file, definition.line) for definition in locations)
+ protected_rules.update(
+ (definition.file, definition.rule) for definition in locations if definition.rule is not None
+ )
continue
- if cls in used_css_classes:
- continue
- for file, line in locations:
- result.findings.append(
- Finding(
- file=file,
- line=line,
- name=cls,
- category="orphaned_css",
- detail=f"CSS class '.{cls}' is not used in any JSX className",
- removable=True,
- )
+ for definition in locations:
+ file, line = definition.file, definition.line
+ if definition.global_scope:
+ used = cls in used_css_classes
+ else:
+ if file not in module_usage_by_file:
+ module_usage_by_file[file] = used_css_module_classes.get(
+ (self.project_dir / file).resolve(), set()
+ )
+ used = cls in module_usage_by_file[file]
+ uncertain = (
+ None in used_css_classes
+ if definition.global_scope
+ else cls in unresolved_css_module_classes
+ or None in unresolved_css_module_classes
+ or None in module_usage_by_file[file]
+ )
+ if used or uncertain:
+ protected_lines.add((file, line))
+ if definition.rule is not None:
+ protected_rules.add((file, definition.rule))
+ if not used:
+ unused.append((cls, definition, uncertain))
+ for cls, definition, uncertain in unused:
+ shared = (definition.file, definition.line) in protected_lines or (
+ definition.rule is not None and (definition.file, definition.rule) in protected_rules
+ )
+ result.findings.append(
+ Finding(
+ file=definition.file,
+ line=definition.line,
+ name=cls,
+ category="orphaned_css",
+ detail=(
+ f"CSS class '.{cls}' may be used through a computed class expression"
+ if uncertain and definition.global_scope
+ else f"CSS module class '.{cls}' may be used through an unresolved stylesheet import or computed key"
+ if uncertain
+ else f"CSS class '.{cls}' shares a rule or line with a used selector; remove manually"
+ if shared
+ else f"CSS class '.{cls}' has a selector across multiple lines; remove manually"
+ if definition.multiline_header
+ else f"CSS class '.{cls}' has no complete one-line rule; remove manually"
+ if not definition.single_line_rule
+ else f"CSS class '.{cls}' is not used in any JSX className"
+ ),
+ removable=not (
+ uncertain or shared or definition.multiline_header or not definition.single_line_rule
+ ),
)
+ )
def _find_unreferenced_components(
self,
diff --git a/tests/test_class_assignment_jsx_boundaries.py b/tests/test_class_assignment_jsx_boundaries.py
new file mode 100644
index 0000000..bc74502
--- /dev/null
+++ b/tests/test_class_assignment_jsx_boundaries.py
@@ -0,0 +1,178 @@
+"""Runtime class writes and JSX delimiters must never hide live CSS evidence."""
+
+import time
+
+import pytest
+from click.testing import CliRunner
+
+from deadcode.cli import cli
+from deadcode.scanner import DeadCodeScanner
+
+
+@pytest.fixture(autouse=True)
+def no_countdown(monkeypatch):
+ monkeypatch.setattr(time, "sleep", lambda _: None)
+
+
+ASSIGNMENT_RUNTIME_CASES = [
+ (f'{target} = "live";', extension)
+ for target in ("element.className", "element.class", "className", "element['className']", 'element["class"]')
+ for extension in ("ts", "js", "tsx", "jsx")
+] + [
+ ('element.className = "live" as string;', "ts"),
+ ('element.className = ("live" satisfies string);', "ts"),
+ ('const apply = () => (element.className = "live");', "js"),
+ ('function apply() { return element.className = "live"; }', "ts"),
+ ('element.className /* separator */ = "live";', "js"),
+ ('element.className /* separator */ = /* value */ "live";', "ts"),
+ ('element[/* key */ "className"] /* separator */ = "live";', "ts"),
+ ('element["class\\u004eame"] = "live";', "js"),
+ ('element[`className`] = "live";', "js"),
+ ('const text = `${element.className = "live"}`;', "ts"),
+ ('class Props {className /* separator */ = "live";}', "js"),
+ ('const attrs = {className /* separator */ : "live"};', "ts"),
+ ('const attrs = {"className" /* separator */ : "live"};', "js"),
+]
+ASSIGNMENT_UNKNOWN_CASES = [
+ (f"{target} {operator} {value};", extension)
+ for target in ("element.className", "element['className']")
+ for operator, value in (
+ ("=", "selected"),
+ ("=", '"prefix" + selected'),
+ ("+=", '"live"'),
+ ("||=", '"live"'),
+ ("&&=", '"live"'),
+ ("??=", '"live"'),
+ )
+ for extension in ("ts", "js")
+] + [
+ ("element.className /* separator */ = selected;", "js"),
+ ('element["className"] /* separator */ = selected;', "ts"),
+ ("const text = `${element.className = selected}`;", "ts"),
+]
+JSX_MODULE_CASES = [
+ ("import styles from './Card.module.css';\n" + expression, extension)
+ for expression in (
+ "const view = <>>;",
+ 'const view = <>>;',
+ "const view = <>>;",
+ "const view = <><Élément>Élément>>;",
+ "const view = <><>>>;",
+ "const view = <>{null}>;",
+ "const view = <>