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"") +_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("= 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>
;", + "const view = <><>
;", + "const view = <>
{null}
;", + "const view = <> />
;", + ) + for extension in ("tsx", "jsx") + if not (extension == "jsx" and "" in expression) +] +JSX_MODULE_UNKNOWN_CASES = [ + ( + f"import styles from './Card.module.css';\nconst view = <>
;", + extension, + ) + for accessor in ("selected", '"prefix" + selected') + for extension in ("tsx", "jsx") +] +JSX_TAIL_RUNTIME_CASES = [ + ('const view = <>
{use({className: "live"})};', extension) for extension in ("tsx", "jsx") +] + [('const view = <>
; element.className = "live";', extension) for extension in ("tsx", "jsx")] +ASSIGNMENT_MODULE_CASES = [ + (f"import styles from './Card.module.css';\nelement.className = {accessor};", extension) + for accessor in ("styles.live", 'styles["live"]') + for extension in ("ts", "js") +] + + +def _write(project, content, extension): + (project / ("consumer." + extension)).write_text(content + "\n") + (project / "global.css").write_text(".live {}\n.unusedGlobal {}\n") + (project / "Card.module.css").write_text(":global(.live) {}\n.live {}\n.unusedLocal {}\n") + (project / "Other.module.css").write_text(".live {}\n.unusedLocal {}\n") + + +def _remove(project): + result = CliRunner().invoke(cli, ["-p", str(project), "remove", "-c", "orphaned_css"]) + assert result.exit_code == 0, result.output + + +@pytest.mark.parametrize(("content", "extension"), ASSIGNMENT_RUNTIME_CASES + JSX_TAIL_RUNTIME_CASES) +def test_runtime_class_assignments_and_jsx_tails_keep_live_global_rules(tmp_path, content, extension): + _write(tmp_path, content, extension) + + findings = DeadCodeScanner(tmp_path).scan().orphaned_css + + assert all(finding.removable for finding in findings) + assert not any(finding.file == "global.css" and finding.name == "live" for finding in findings) + _remove(tmp_path) + assert (tmp_path / "global.css").read_text() == ".live {}\n" + assert (tmp_path / "Card.module.css").read_text() == ":global(.live) {}\n" + assert (tmp_path / "Other.module.css").read_text() == "" + + +@pytest.mark.parametrize(("content", "extension"), ASSIGNMENT_UNKNOWN_CASES) +def test_unknown_runtime_class_assignments_keep_only_global_css_uncertain(tmp_path, content, extension): + _write(tmp_path, content, extension) + + findings = DeadCodeScanner(tmp_path).scan().orphaned_css + + assert all(not finding.removable for finding in findings if finding.file == "global.css") + assert all(finding.removable for finding in findings if finding.name == "unusedLocal") + _remove(tmp_path) + assert (tmp_path / "global.css").read_text() == ".live {}\n.unusedGlobal {}\n" + assert (tmp_path / "Card.module.css").read_text() == ":global(.live) {}\n" + assert (tmp_path / "Other.module.css").read_text() == "" + + +@pytest.mark.parametrize(("content", "extension"), JSX_MODULE_CASES + ASSIGNMENT_MODULE_CASES) +def test_jsx_closing_slashes_and_class_writes_keep_css_module_scope(tmp_path, content, extension): + _write(tmp_path, content, extension) + + findings = DeadCodeScanner(tmp_path).scan().orphaned_css + + assert all(finding.removable for finding in findings) + assert not any( + finding.file == "Card.module.css" and not finding.name.startswith("unused") + for finding in findings + if finding.line == 2 + ) + _remove(tmp_path) + assert (tmp_path / "global.css").read_text() == "" + assert (tmp_path / "Card.module.css").read_text() == ".live {}\n" + assert (tmp_path / "Other.module.css").read_text() == "" + + +@pytest.mark.parametrize(("content", "extension"), JSX_MODULE_UNKNOWN_CASES) +def test_jsx_closing_slash_keeps_dynamic_css_module_access_uncertain_only_in_own_stylesheet( + tmp_path, content, extension +): + _write(tmp_path, content, extension) + + findings = DeadCodeScanner(tmp_path).scan().orphaned_css + + assert all(finding.removable for finding in findings if finding.file != "Card.module.css") + assert all(not finding.removable for finding in findings if finding.file == "Card.module.css" and finding.line > 1) + _remove(tmp_path) + assert (tmp_path / "global.css").read_text() == "" + assert (tmp_path / "Card.module.css").read_text() == ".live {}\n.unusedLocal {}\n" + assert (tmp_path / "Other.module.css").read_text() == "" + + +NONRUNTIME_CASES = [ + '// element.className = "live";\nconst unused = 1;', + "const text = 'element.className = \"live\"';", + 'const text = `element.className = "live"`;', + 'const pattern = /element.className = "live"/;', + 'const pattern = />{className:"live"}/;', + 'const equal = element.className == "live";', + 'const equal = element.className === "live";', + 'const identity = className => "live";', + 'type Props = {className: "live"}; interface View {className: string}', +] + + +@pytest.mark.parametrize("content", NONRUNTIME_CASES) +def test_nonruntime_text_comparisons_and_type_properties_do_not_invent_class_assignments(tmp_path, content): + _write(tmp_path, content, "ts") + findings = DeadCodeScanner(tmp_path).scan().orphaned_css + assert all(finding.removable for finding in findings) + _remove(tmp_path) + assert all((tmp_path / name).read_text() == "" for name in ("global.css", "Card.module.css", "Other.module.css")) diff --git a/tests/test_cli_edge_cases.py b/tests/test_cli_edge_cases.py index 0d71e22..f7ac3c6 100644 --- a/tests/test_cli_edge_cases.py +++ b/tests/test_cli_edge_cases.py @@ -42,9 +42,7 @@ def test_non_existent_project_exits_1(self, runner): def test_fail_threshold_exits_high(self, runner, tmp_path): """--fail=0 exits 1 when findings exist (covers fail threshold path).""" (tmp_path / "src" / "unused.ts").parent.mkdir(parents=True, exist_ok=True) - (tmp_path / "src" / "unused.ts").write_text( - "export function unused() { return 1; }\n" - ) + (tmp_path / "src" / "unused.ts").write_text("export function unused() { return 1; }\n") result = runner.invoke(cli, ["-p", str(tmp_path), "scan", "--fail", "0"]) assert result.exit_code == 1 assert "FAIL" in result.output @@ -52,16 +50,10 @@ def test_fail_threshold_exits_high(self, runner, tmp_path): def test_ignore_flag_before_subcommand(self, runner, tmp_path): """--ignore group option rejects submodule patterns (covers _merge_config_ignore).""" (tmp_path / "src" / "used.ts").parent.mkdir(parents=True, exist_ok=True) - (tmp_path / "src" / "used.ts").write_text( - "export function used() { return 1; }\n" - ) + (tmp_path / "src" / "used.ts").write_text("export function used() { return 1; }\n") (tmp_path / "src" / "unused.ts").parent.mkdir(parents=True, exist_ok=True) - (tmp_path / "src" / "unused.ts").write_text( - "export function unused() { return 2; }\n" - ) - result = runner.invoke( - cli, ["-p", str(tmp_path), "--ignore", "**/unused.ts", "scan"] - ) + (tmp_path / "src" / "unused.ts").write_text("export function unused() { return 2; }\n") + result = runner.invoke(cli, ["-p", str(tmp_path), "--ignore", "**/unused.ts", "scan"]) assert result.exit_code == 0 assert "unused" not in result.output @@ -80,10 +72,7 @@ def sample(self, tmp_path): """A tiny TS project with at least one dead export.""" mod = tmp_path / "src" / "mod.ts" mod.parent.mkdir(parents=True, exist_ok=True) - mod.write_text( - "export function usedHelper() { return 1; }\n" - "export function unusedHelper() { return 2; }\n" - ) + mod.write_text("export function usedHelper() { return 1; }\nexport function unusedHelper() { return 2; }\n") return tmp_path def test_format_compact_output(self, runner, sample): @@ -148,9 +137,7 @@ def runner(self): def test_stats_basic(self, runner, tmp_path): """stats command shows scan summary.""" (tmp_path / "src" / "unused.ts").parent.mkdir(parents=True, exist_ok=True) - (tmp_path / "src" / "unused.ts").write_text( - "export function unusedHelper() { return 1; }\n" - ) + (tmp_path / "src" / "unused.ts").write_text("export function unusedHelper() { return 1; }\n") result = runner.invoke(cli, ["-p", str(tmp_path), "stats"]) assert result.exit_code == 0 assert "Files scanned" in result.output diff --git a/tests/test_cli_machine_output.py b/tests/test_cli_machine_output.py new file mode 100644 index 0000000..bb96f01 --- /dev/null +++ b/tests/test_cli_machine_output.py @@ -0,0 +1,51 @@ +"""Machine output preserves source paths without terminal presentation rules.""" + +import json + +import pytest +from click.testing import CliRunner + +from deadcode.cli import cli + + +@pytest.mark.parametrize("option", ["--format=json", "--json-output"]) +def test_json_preserves_long_paths_and_markup(tmp_path, option): + name = "[green]" + "x" * 100 + "[red].ts" + (tmp_path / name).write_text("export const unused = 1;\n") + + result = CliRunner().invoke(cli, ["-p", str(tmp_path), "scan", option]) + + assert result.exit_code == 0 + data = json.loads(result.output) + assert data["files_scanned"] == 1 + assert data["findings"][0]["file"] == name + assert "never imported outside its defining file" in data["findings"][0]["detail"] + + +def test_github_annotations_preserve_long_paths_and_escape_properties(tmp_path): + name = "[green]" + "x" * 100 + "%value,other.ts" + (tmp_path / name).write_text("export const unused = 1;\n") + + result = CliRunner().invoke(cli, ["-p", str(tmp_path), "scan", "--format=github"]) + + assert result.exit_code == 0 + annotations = [line for line in result.output.splitlines() if line.startswith("::error")] + assert len(annotations) == 1 + expected_path = name.replace("%", "%25").replace(",", "%2C") + assert annotations[0].startswith(f"::error file={expected_path},line=1::unused_export: unused") + + +def test_github_annotation_path_cannot_inject_workflow_commands(tmp_path): + import os + + if os.name == "nt": + pytest.skip("Windows does not permit newline characters in file names") + name = "first.ts\n::error file=other.ts::injected.ts" + (tmp_path / name).write_text("export const unused = 1;\n") + + result = CliRunner().invoke(cli, ["-p", str(tmp_path), "scan", "--format=github"]) + + assert result.exit_code == 0 + annotations = [line for line in result.output.splitlines() if line.startswith("::error")] + assert len(annotations) == 1 + assert "first.ts%0A%3A%3Aerror file=other.ts%3A%3Ainjected.ts" in annotations[0] diff --git a/tests/test_config_and_fixes.py b/tests/test_config_and_fixes.py index 035346a..d50150f 100644 --- a/tests/test_config_and_fixes.py +++ b/tests/test_config_and_fixes.py @@ -40,30 +40,21 @@ def sample_project(tmp_path): ) widget = tmp_path / "src" / "components" / "UnusedWidget.tsx" - widget.write_text( - "export function UnusedWidget() {\n return
Unused
;\n}\n" - ) + widget.write_text("export function UnusedWidget() {\n return
Unused
;\n}\n") css = tmp_path / "src" / "styles" / "main.css" css.parent.mkdir(parents=True, exist_ok=True) - css.write_text( - ".btn-primary {\n background: blue;\n}\n.orphaned-class {\n color: red;\n}\n" - ) + css.write_text(".btn-primary {\n background: blue;\n}\n.orphaned-class {\n color: red;\n}\n") page = tmp_path / "src" / "app" / "page.tsx" page.parent.mkdir(parents=True, exist_ok=True) page.write_text( - 'import { Button } from "../components/Button";\n' - "export default function Page() {\n" - " return