Fail closed on git status and walk deep forms without recursion.

Status records are NUL-terminated, and a git failure returns git's status. .test.cts and .spec.cts are tests. Help lists every skipped directory.
This commit is contained in:
Robert C. Martin
2026-10-03 17:48:35 -05:00
parent c726bf0158
commit 2be6acadf5
8 changed files with 603 additions and 272 deletions
+156 -64
View File
@@ -152,33 +152,7 @@ def _text(node) -> str:
return node_text(_DATA, node) return node_text(_DATA, node)
def _normalize(node, head: bool = False): _PENDING = object()
if node.type in _SKIP:
return None
if node.type in _OPERATORS:
return [K("symbol"), node.type]
if not node.is_named:
return None
if node.type in _IDENTIFIERS:
if head:
return [K("symbol"), _text(node)]
return K("symbol")
if _is_literal(node):
return K("literal")
if node.type == "parenthesized_expression":
named = node.named_children
if len(named) == 1:
return _normalize(named[0], head)
if node.type in _CALLS:
return _normalize_call(node)
if node.type in _ATTRIBUTES:
return _normalize_attr(node, head)
children = []
for child in node.children:
norm = _normalize(child, False)
if norm is not None:
children.append(norm)
return [K(node.type), *children]
def _is_literal(node) -> bool: def _is_literal(node) -> bool:
@@ -204,54 +178,88 @@ def _without_type_args(nodes):
return rest, type_args return rest, type_args
def _extend_normalized(out, nodes) -> None: def _try_leaf(node, mode: str, head: bool):
for node in nodes: if mode == "callee":
norm = _normalize(node, False) if node.type in _IDENTIFIERS:
if norm is not None: return [K("symbol"), _text(node)]
out.append(norm) return _PENDING
if mode == "path":
return _PENDING
if node.type in _SKIP:
return None
if node.type in _OPERATORS:
return [K("symbol"), node.type]
if not node.is_named:
return None
if node.type in _IDENTIFIERS:
if head:
return [K("symbol"), _text(node)]
return K("symbol")
if _is_literal(node):
return K("literal")
return _PENDING
def _normalize_call(node): def _call_jobs(node):
incoming, args = _named_before_args(node) incoming, args = _named_before_args(node)
rest, type_args = _without_type_args(incoming) rest, type_args = _without_type_args(incoming)
out = [K(node.type)] jobs = []
if rest: if rest:
*receivers, callee = rest *receivers, callee = rest
_extend_normalized(out, receivers) jobs.extend((child, "norm", False) for child in receivers)
out.append(_normalize_callee(callee)) jobs.append((callee, "callee", False))
_extend_normalized(out, type_args) jobs.extend((child, "norm", False) for child in type_args)
if args is not None: if args is not None:
_extend_normalized(out, [args]) jobs.append((args, "norm", False))
return out return jobs
def _normalize_callee(node): def _attr_jobs(node, head: bool):
if node.type in _IDENTIFIERS: named = list(node.named_children)
return [K("symbol"), _text(node)] if not named:
if node.type in _ATTRIBUTES: return []
return _normalize_attr(node, True) *objects, name = named
if node.type in _SCOPED or node.type == "generic_type": jobs = [(obj, "norm", False) for obj in objects]
return _normalize_path(node) if name.type in _IDENTIFIERS:
return _normalize(node, True) return jobs
if head:
jobs.append((name, "callee", False))
else:
jobs.append((name, "norm", False))
return jobs
def _normalize_path(node): def _path_jobs(node):
"""A callee path keeps each name in the path. Type arguments stay structural.""" jobs = []
parts = [K(node.type)]
for child in node.named_children: for child in node.named_children:
if child.type in _IDENTIFIERS: if child.type in _IDENTIFIERS:
parts.append([K("symbol"), _text(child)]) continue
elif child.type in _SCOPED or child.type == "generic_type": if child.type in _SCOPED or child.type == "generic_type":
parts.append(_normalize_path(child)) jobs.append((child, "path", False))
else: else:
norm = _normalize(child, False) jobs.append((child, "norm", False))
if norm is not None: return jobs
parts.append(norm)
return parts
def _normalized_name(name, head: bool): def _jobs(node, mode: str, head: bool):
if mode == "callee":
if node.type in _ATTRIBUTES:
return _attr_jobs(node, True)
if node.type in _SCOPED or node.type == "generic_type":
return _path_jobs(node)
return [(node, "norm", True)]
if mode == "path":
return _path_jobs(node)
if node.type == "parenthesized_expression" and len(node.named_children) == 1:
return [(node.named_children[0], "norm", head)]
if node.type in _CALLS:
return _call_jobs(node)
if node.type in _ATTRIBUTES:
return _attr_jobs(node, head)
return [(child, "norm", False) for child in node.children]
def _name_value(name, head: bool, cache):
"""Keep a member's spelling when it is the thing being called.""" """Keep a member's spelling when it is the thing being called."""
if name.type in _IDENTIFIERS: if name.type in _IDENTIFIERS:
@@ -259,21 +267,105 @@ def _normalized_name(name, head: bool):
return [K("symbol"), _text(name)] return [K("symbol"), _text(name)]
return K("symbol") return K("symbol")
if head: if head:
return _normalize_callee(name) return cache[(id(name), "callee", False)]
return _normalize(name, False) return cache[(id(name), "norm", False)]
def _normalize_attr(node, head: bool): def _assemble_call(node, cache):
incoming, args = _named_before_args(node)
rest, type_args = _without_type_args(incoming)
out = [K(node.type)]
if rest:
*receivers, callee = rest
for child in receivers:
norm = cache[(id(child), "norm", False)]
if norm is not None:
out.append(norm)
out.append(cache[(id(callee), "callee", False)])
for child in type_args:
norm = cache[(id(child), "norm", False)]
if norm is not None:
out.append(norm)
if args is not None:
norm = cache[(id(args), "norm", False)]
if norm is not None:
out.append(norm)
return out
def _assemble_attr(node, head: bool, cache):
named = list(node.named_children) named = list(node.named_children)
if not named: if not named:
return [K(node.type)] return [K(node.type)]
*objects, name = named *objects, name = named
parts = [K(node.type)] parts = [K(node.type)]
for obj in objects: for obj in objects:
norm = _normalize(obj, False) norm = cache[(id(obj), "norm", False)]
if norm is not None: if norm is not None:
parts.append(norm) parts.append(norm)
nested = _normalized_name(name, head) nested = _name_value(name, head, cache)
if nested is not None: if nested is not None:
parts.append(nested) parts.append(nested)
return parts return parts
def _assemble_path(node, cache):
"""A callee path keeps each name in the path. Type arguments stay structural."""
parts = [K(node.type)]
for child in node.named_children:
if child.type in _IDENTIFIERS:
parts.append([K("symbol"), _text(child)])
elif child.type in _SCOPED or child.type == "generic_type":
parts.append(cache[(id(child), "path", False)])
else:
norm = cache[(id(child), "norm", False)]
if norm is not None:
parts.append(norm)
return parts
def _assemble(node, mode: str, head: bool, cache):
if mode == "callee":
if node.type in _ATTRIBUTES:
return _assemble_attr(node, True, cache)
if node.type in _SCOPED or node.type == "generic_type":
return _assemble_path(node, cache)
return cache[(id(node), "norm", True)]
if mode == "path":
return _assemble_path(node, cache)
if node.type == "parenthesized_expression" and len(node.named_children) == 1:
child = node.named_children[0]
return cache[(id(child), "norm", head)]
if node.type in _CALLS:
return _assemble_call(node, cache)
if node.type in _ATTRIBUTES:
return _assemble_attr(node, head, cache)
children = []
for child in node.children:
norm = cache[(id(child), "norm", False)]
if norm is not None:
children.append(norm)
return [K(node.type), *children]
def _normalize(node, head: bool = False):
# Post-order. A 1,200-deep expression must not raise RecursionError.
cache = {}
stack = [(node, "norm", head, False)]
while stack:
current, mode, is_head, expanded = stack.pop()
key = (id(current), mode, is_head)
if not expanded:
if key in cache:
continue
leaf = _try_leaf(current, mode, is_head)
if leaf is not _PENDING:
cache[key] = leaf
continue
stack.append((current, mode, is_head, True))
for child, child_mode, child_head in reversed(_jobs(current, mode, is_head)):
stack.append((child, child_mode, child_head, False))
continue
cache[key] = _assemble(current, mode, is_head, cache)
return cache[(id(node), "norm", head)]
+70 -21
View File
@@ -2,16 +2,23 @@
from __future__ import annotations from __future__ import annotations
import os
import subprocess import subprocess
import sys import sys
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from dryer.discover import is_test_file, iter_source_files, language_of from dryer.discover import SKIP_DIRS, TEST_DIRS, is_test_file, iter_source_files, language_of
from dryer.report import format_text, render_edn, write_metrics from dryer.report import format_text, render_edn, write_metrics
from dryer.scan import find_duplicates, scan_files from dryer.scan import find_duplicates, scan_files
HELP = """\
def _skipped_directory_text() -> str:
ordered = sorted(set(SKIP_DIRS) | set(TEST_DIRS))
return ", ".join(ordered[:-1]) + ", and " + ordered[-1]
HELP = f"""\
Usage: dryer [options] [path-or-filter ...] Usage: dryer [options] [path-or-filter ...]
Find candidate duplicate code in Clojure, Java, Go, TypeScript, Rust, and Find candidate duplicate code in Clojure, Java, Go, TypeScript, Rust, and
@@ -48,8 +55,8 @@ Arguments:
path contains this text are compared. path contains this text are compared.
With no paths, source files under the project root are compared. Directories With no paths, source files under the project root are compared. Directories
named test, tests, spec, specs, vendor, node_modules, and target are skipped, named {_skipped_directory_text()} are skipped, as are *_test.go, *.test.cts,
as are *_test.go, *.spec.ts, test_*.py, and *_test.py files. *.spec.cts, *.spec.ts, test_*.py, and *_test.py files.
Clojure compares every top-level form except ns. The other languages compare Clojure compares every top-level form except ns. The other languages compare
functions and methods. A form is only compared with forms in the same language. functions and methods. A form is only compared with forms in the same language.
@@ -159,25 +166,55 @@ def parse_args(argv: list[str] | None = None) -> Options:
return options return options
def _git_status(root: Path): class GitStatusError(Exception):
args = ["git", "status", "--porcelain"] """git status could not be read. `code` is git's own status."""
return subprocess.run(args, cwd=root, check=False, capture_output=True, text=True)
def __init__(self, code: int, message: str):
super().__init__(message)
self.code = code
self.message = message
def _git(root: Path, *args: str) -> bytes:
result = subprocess.run(
["git", "-C", os.fspath(root), *args],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False,
)
if result.returncode != 0:
message = os.fsdecode(result.stderr).strip() or "git status failed"
raise GitStatusError(result.returncode, message)
return result.stdout
def _changed_files(root: Path) -> list[Path]: def _changed_files(root: Path) -> list[Path]:
result = _git_status(root) """Added and modified files under `root`, as paths from the repo root.
if result.returncode != 0:
print(result.stderr.strip() or "git status failed", file=sys.stderr) `-z` keeps spaces and non-ASCII names intact. A missing path, including a
return [] deletion, is left out. A rename is the new file only.
"""
toplevel = Path(os.fsdecode(_git(root, "rev-parse", "--show-toplevel")).strip())
status = _git(
root,
"status",
"--porcelain",
"-z",
"--no-renames",
"--untracked-files=all",
"--",
".",
)
root_resolved = root.resolve()
found: list[Path] = [] found: list[Path] = []
for line in result.stdout.splitlines(): for entry in status.split(b"\0"):
if len(line) < 4: if len(entry) <= 3:
continue continue
path_text = line[3:].strip() path = (toplevel / os.fsdecode(entry[3:])).resolve()
if " -> " in path_text: if not path.is_file() or not path.is_relative_to(root_resolved):
path_text = path_text.split(" -> ", 1)[1] continue
path_text = path_text.strip('"') found.append(path)
found.append((root / path_text).resolve())
return found return found
@@ -195,14 +232,22 @@ def _positionals(root: Path, args: list[str]) -> tuple[list[Path], list[str]]:
return existing, filters return existing, filters
def _tracked_source(path: Path) -> bool: def _tracked_source(path: Path, root: Path | None = None) -> bool:
parts = path.parts
if root is not None:
try:
parts = path.resolve().relative_to(root.resolve()).parts
except ValueError:
return False
if not SKIP_DIRS.isdisjoint(parts):
return False
if language_of(path) is None: if language_of(path) is None:
return False return False
return not is_test_file(path) return not is_test_file(path)
def _changed_source(root: Path) -> list[Path]: def _changed_source(root: Path) -> list[Path]:
return [path for path in _changed_files(root) if _tracked_source(path)] return [path for path in _changed_files(root) if _tracked_source(path, root)]
def _explicit_files(existing: list[Path]) -> list[Path]: def _explicit_files(existing: list[Path]) -> list[Path]:
@@ -249,7 +294,11 @@ def run(argv: list[str] | None = None) -> int:
return options.exit_code return options.exit_code
root = options.project_root.resolve() root = options.project_root.resolve()
files = select_files(options) try:
files = select_files(options)
except GitStatusError as exc:
print(exc.message, file=sys.stderr)
return exc.code
if not files: if not files:
print("No source files to analyze.") print("No source files to analyze.")
return 0 return 0
+201 -102
View File
@@ -64,6 +64,20 @@ class Splice:
self.items = items self.items = items
_NEED = object()
def _close_coll(kind: str, line: int, items: list, fn_star: bool):
if fn_star:
return Coll("list", line, items=[Sym("fn*", line), *items])
if kind == "map":
if len(items) % 2:
raise ReadError(line, "map literal has an odd number of forms")
pairs = [(items[index], items[index + 1]) for index in range(0, len(items), 2)]
return Coll("map", line, pairs=pairs)
return Coll(kind, line, items=list(items))
class Reader: class Reader:
def __init__(self, text: str): def __init__(self, text: str):
self.text = text self.text = text
@@ -146,79 +160,69 @@ class Reader:
return Kw("", line) return Kw("", line)
return Kw(self.read_token(), line) return Kw(self.read_token(), line)
def read_collection(self, end: str, kind: str) -> Coll: def read_form(self):
line = self.line """One form. None and Splice are real results.
self.get()
items: list = [] An explicit stack: a 1,200-deep form must not raise RecursionError.
"""
stack: list[tuple] = []
produced = _NEED
while True: while True:
self.skip_ws() if produced is _NEED:
produced = self._next(stack)
continue
if not stack:
return produced
produced = self._deliver(stack, produced)
def _next(self, stack: list[tuple]):
self.skip_ws()
if stack and stack[-1][0] == "coll":
_tag, end, kind, line, items, fn_star = stack[-1]
if self.eof(): if self.eof():
raise ReadError(line, f"unterminated {kind}") raise ReadError(line, f"unterminated {kind}")
if self.peek() == end: if self.peek() == end:
self.get() self.get()
break stack.pop()
form = self.read_form() return _close_coll(kind, line, items, fn_star)
if form is None: if self.eof():
continue raise ReadError(self.line, "unexpected end of file")
if isinstance(form, Splice): return self._open(stack)
items.extend(form.items)
continue
items.append(form)
if kind == "map":
if len(items) % 2:
raise ReadError(line, "map literal has an odd number of forms")
pairs = [(items[i], items[i + 1]) for i in range(0, len(items), 2)]
return Coll("map", line, pairs=pairs)
return Coll(kind, line, items=items)
def _wrap(self, name: str, line: int) -> Coll: def _deliver(self, stack: list[tuple], produced):
inner = self.read_form() frame = stack[-1]
items = [Sym(name, line)] tag = frame[0]
if inner is not None and not isinstance(inner, Splice): if tag == "coll":
items.append(inner) items = frame[4]
return Coll("list", line, items=items) if produced is None:
return _NEED
def read_dispatch(self): if isinstance(produced, Splice):
line = self.line items.extend(produced.items)
self.get() else:
ch = self.peek() items.append(produced)
if ch is None: return _NEED
raise ReadError(line, "incomplete dispatch") stack.pop()
if ch == "_": if tag == "wrap":
self.get() _tag, name, line = frame
self.read_form() items = [Sym(name, line)]
if produced is not None and not isinstance(produced, Splice):
items.append(produced)
return Coll("list", line, items=items)
if tag == "discard":
return None return None
if ch == "{": if tag == "meta":
return self.read_collection("}", "set") return _NEED
if ch == "(": if tag == "lit":
inner = self.read_collection(")", "list") return Lit(frame[1])
return Coll("list", line, items=[Sym("fn*", line), *inner.items]) if tag == "cond":
if ch == '"': return self._finish_cond(frame[1], produced)
self.read_string() raise AssertionError(tag)
return Lit(line)
if ch == "?":
self.get()
splicing = False
if self.peek() == "@":
self.get()
splicing = True
return self.read_cond(splicing)
if ch == ":":
self.get()
if self.peek() == ":":
self.get()
elif self.peek() not in (None, "{") and self.peek() not in _WHITESPACE:
self.read_token()
return self.read_form()
self.read_token()
self.read_form()
return Lit(line)
def read_cond(self, splicing: bool): def _finish_cond(self, splicing: bool, produced):
form = self.read_form() if not isinstance(produced, Coll) or produced.kind != "list":
if not isinstance(form, Coll) or form.kind != "list":
raise ReadError(self.line, "reader conditional body must be a list") raise ReadError(self.line, "reader conditional body must be a list")
chosen = _chosen_branch(form.items) chosen = _chosen_branch(produced.items)
if chosen is None: if chosen is None:
return None return None
if not splicing: if not splicing:
@@ -228,18 +232,21 @@ class Reader:
raise ReadError(self.line, "splicing reader conditional needs a collection") raise ReadError(self.line, "splicing reader conditional needs a collection")
return spliced return spliced
def read_form(self): def _open(self, stack: list[tuple]):
self.skip_ws()
if self.eof():
raise ReadError(self.line, "unexpected end of file")
ch = self.peek() ch = self.peek()
line = self.line line = self.line
if ch == "(": if ch == "(":
return self.read_collection(")", "list") self.get()
stack.append(("coll", ")", "list", line, [], False))
return _NEED
if ch == "[": if ch == "[":
return self.read_collection("]", "vector") self.get()
stack.append(("coll", "]", "vector", line, [], False))
return _NEED
if ch == "{": if ch == "{":
return self.read_collection("}", "map") self.get()
stack.append(("coll", "}", "map", line, [], False))
return _NEED
if ch == '"': if ch == '"':
return self.read_string() return self.read_string()
if ch == "\\": if ch == "\\":
@@ -248,31 +255,75 @@ class Reader:
return self.read_keyword() return self.read_keyword()
if ch == "'": if ch == "'":
self.get() self.get()
return self._wrap("quote", line) stack.append(("wrap", "quote", line))
return _NEED
if ch == "@": if ch == "@":
self.get() self.get()
return self._wrap("deref", line) stack.append(("wrap", "deref", line))
return _NEED
if ch == "`": if ch == "`":
self.get() self.get()
return self._wrap("syntax-quote", line) stack.append(("wrap", "syntax-quote", line))
return _NEED
if ch == "~": if ch == "~":
self.get() self.get()
name = "unquote" name = "unquote"
if self.peek() == "@": if self.peek() == "@":
self.get() self.get()
name = "unquote-splicing" name = "unquote-splicing"
return self._wrap(name, line) stack.append(("wrap", name, line))
return _NEED
if ch == "^": if ch == "^":
self.get() self.get()
self.read_form() stack.append(("meta",))
return self.read_form() return _NEED
if ch == "#": if ch == "#":
return self.read_dispatch() return self._dispatch(stack, line)
token = self.read_token() token = self.read_token()
if _NUMBER.match(token): if _NUMBER.match(token):
return Lit(line) return Lit(line)
return Sym(token, line) return Sym(token, line)
def _dispatch(self, stack: list[tuple], line: int):
self.get()
ch = self.peek()
if ch is None:
raise ReadError(line, "incomplete dispatch")
if ch == "_":
self.get()
stack.append(("discard",))
return _NEED
if ch == "{":
brace = self.line
self.get()
stack.append(("coll", "}", "set", brace, [], False))
return _NEED
if ch == "(":
self.get()
stack.append(("coll", ")", "list", line, [], True))
return _NEED
if ch == '"':
self.read_string()
return Lit(line)
if ch == "?":
self.get()
splicing = False
if self.peek() == "@":
self.get()
splicing = True
stack.append(("cond", splicing))
return _NEED
if ch == ":":
self.get()
if self.peek() == ":":
self.get()
elif self.peek() not in (None, "{") and self.peek() not in _WHITESPACE:
self.read_token()
return _NEED
self.read_token()
stack.append(("lit", line))
return _NEED
def _branch_name(feature): def _branch_name(feature):
if isinstance(feature, Kw): if isinstance(feature, Kw):
@@ -341,33 +392,18 @@ def _children(form):
def max_line(form) -> int: def max_line(form) -> int:
best = getattr(form, "line", 1) or 1 best = getattr(form, "line", 1) or 1
for child in _children(form): stack = [form]
if child is not None: while stack:
best = max(best, max_line(child)) current = stack.pop()
if current is None:
continue
best = max(best, getattr(current, "line", 1) or 1)
if isinstance(current, Coll):
stack.extend(_children(current))
return best return best
def _normalize_list(form): def _normalize_atom(form, head: bool):
if not form.items:
return [K("list"), K("literal")]
head_form, *args = form.items
return [K("list"), normalize(head_form, True), *[normalize(arg, False) for arg in args]]
def normalize(form, head: bool = False):
"""dry4clj's `normalize-form`. Collection heads are normalized in full."""
if isinstance(form, Coll):
if form.kind == "list":
return _normalize_list(form)
if form.kind == "vector":
return [K("vector"), *[normalize(item, False) for item in form.items]]
if form.kind == "set":
return [K("set"), *[normalize(item, False) for item in form.items]]
if form.kind == "map":
pairs = [[normalize(key, False), normalize(value, False)] for key, value in form.pairs]
return [K("map"), *pairs]
return [K("literal")]
if isinstance(form, Sym): if isinstance(form, Sym):
if head: if head:
return [K("symbol"), form.name] return [K("symbol"), form.name]
@@ -377,6 +413,69 @@ def normalize(form, head: bool = False):
return K("literal") return K("literal")
def _norm_children(form) -> list[tuple[object, bool]]:
if form.kind == "list":
if not form.items:
return []
head_form, *args = form.items
return [(head_form, True), *[(arg, False) for arg in args]]
if form.kind in {"vector", "set"}:
return [(item, False) for item in form.items]
if form.kind == "map":
children = []
for key, value in form.pairs:
children.append((key, False))
children.append((value, False))
return children
return []
def _assemble_form(form, cache):
if form.kind == "list":
if not form.items:
return [K("list"), K("literal")]
head_form, *args = form.items
return [
K("list"),
cache[(id(head_form), True)],
*[cache[(id(arg), False)] for arg in args],
]
if form.kind == "vector":
return [K("vector"), *[cache[(id(item), False)] for item in form.items]]
if form.kind == "set":
return [K("set"), *[cache[(id(item), False)] for item in form.items]]
if form.kind == "map":
pairs = [
[cache[(id(key), False)], cache[(id(value), False)]] for key, value in form.pairs
]
return [K("map"), *pairs]
return [K("literal")]
def normalize(form, head: bool = False):
"""dry4clj's `normalize-form`. Collection heads are normalized in full."""
if not isinstance(form, Coll):
return _normalize_atom(form, head)
cache = {}
stack: list[tuple] = [(form, head, False)]
while stack:
current, is_head, expanded = stack.pop()
key = (id(current), is_head)
if not expanded:
if key in cache:
continue
if not isinstance(current, Coll):
cache[key] = _normalize_atom(current, is_head)
continue
stack.append((current, is_head, True))
for child, child_head in reversed(_norm_children(current)):
stack.append((child, child_head, False))
continue
cache[key] = _assemble_form(current, cache)
return cache[(id(form), head)]
def is_candidate_form(form) -> bool: def is_candidate_form(form) -> bool:
"""A non-empty top-level list whose head is not `ns`.""" """A non-empty top-level list whose head is not `ns`."""
+2
View File
@@ -69,6 +69,8 @@ def is_test_file(path: str | Path) -> bool:
".spec.tsx", ".spec.tsx",
".test.mts", ".test.mts",
".spec.mts", ".spec.mts",
".test.cts",
".spec.cts",
) )
): ):
return True return True
+5 -7
View File
@@ -73,8 +73,9 @@ def _rust_in_tests(data: bytes, node) -> bool:
def _java_nodes(data: bytes, root) -> list: def _java_nodes(data: bytes, root) -> list:
found = [] found = []
stack = [root]
def visit(node) -> None: while stack:
node = stack.pop()
if node.type == "method_declaration": if node.type == "method_declaration":
nested = _inside( nested = _inside(
node, node,
@@ -82,11 +83,8 @@ def _java_nodes(data: bytes, root) -> list:
) )
if _has(node, "block") and not nested: if _has(node, "block") and not nested:
found.append(node) found.append(node)
return continue
for child in node.children: stack.extend(reversed(node.children))
visit(child)
visit(root)
return found return found
+71 -18
View File
@@ -18,38 +18,91 @@ class K:
return ":" + self.name return ":" + self.name
def pr(node) -> str: def _pr_atom(node) -> str:
"""Print a normalized node the way Clojure `pr-str` prints it."""
if isinstance(node, K): if isinstance(node, K):
return ":" + node.name return ":" + node.name
if isinstance(node, str): if isinstance(node, str):
escaped = node.replace("\\", "\\\\").replace('"', '\\"') escaped = node.replace("\\", "\\\\").replace('"', '\\"')
return f'"{escaped}"' return f'"{escaped}"'
if isinstance(node, list):
return "[" + " ".join(pr(child) for child in node) + "]"
raise TypeError(f"cannot print {node!r}") raise TypeError(f"cannot print {node!r}")
def pr(node) -> str:
"""Print a normalized node the way Clojure `pr-str` prints it."""
if not isinstance(node, list):
return _pr_atom(node)
# Post-order. A 1,200-deep tree must not raise RecursionError.
done: dict[int, str] = {}
stack: list[tuple[list, bool]] = [(node, False)]
while stack:
current, expanded = stack.pop()
key = id(current)
if expanded:
pieces = []
for child in current:
if isinstance(child, list):
pieces.append(done[id(child)])
else:
pieces.append(_pr_atom(child))
done[key] = "[" + " ".join(pieces) + "]"
continue
if key in done:
continue
stack.append((current, True))
for child in reversed(current):
if isinstance(child, list):
stack.append((child, False))
return done[id(node)]
def node_count(node) -> int: def node_count(node) -> int:
"""One plus the count of every nested node. Atoms count as one.""" """One plus the count of every nested node. Atoms count as one."""
if isinstance(node, list): if not isinstance(node, list):
return 1 + sum(node_count(child) for child in node) return 1
return 1 total = 0
stack: list = [node]
while stack:
current = stack.pop()
total += 1
if isinstance(current, list):
stack.extend(current)
return total
def fingerprints(node) -> frozenset[str]: def fingerprints(node) -> frozenset[str]:
found: set[str] = set() if not isinstance(node, list):
return frozenset({pr(node)})
def walk(form) -> None: done: dict[int, str] = {}
found.add(pr(form)) stack: list[tuple[list, bool]] = [(node, False)]
if isinstance(form, list): while stack:
for child in form: current, expanded = stack.pop()
walk(child) key = id(current)
if expanded:
walk(node) pieces = []
return frozenset(found) for child in current:
if isinstance(child, list):
pieces.append(done[id(child)])
else:
pieces.append(_pr_atom(child))
done[key] = "[" + " ".join(pieces) + "]"
continue
if key in done:
continue
stack.append((current, True))
for child in reversed(current):
if isinstance(child, list):
stack.append((child, False))
atoms: set[str] = set()
pending: list = [node]
while pending:
current = pending.pop()
if isinstance(current, list):
pending.extend(current)
else:
atoms.add(_pr_atom(current))
return frozenset(set(done.values()) | atoms)
def jaccard(left: frozenset[str], right: frozenset[str]) -> float: def jaccard(left: frozenset[str], right: frozenset[str]) -> float:
+87 -60
View File
@@ -1,8 +1,18 @@
import subprocess
from pathlib import Path from pathlib import Path
import pytest import pytest
from dryer.cli import _changed_files, _count, _tracked_source, main, parse_args, run, select_files from dryer.cli import (
GitStatusError,
_changed_files,
_count,
_tracked_source,
main,
parse_args,
run,
select_files,
)
from dryer.discover import is_test_file, iter_source_files, language_of from dryer.discover import is_test_file, iter_source_files, language_of
@@ -28,6 +38,8 @@ def test_help_does_not_scan(capsys):
assert run(["--help"]) == 0 assert run(["--help"]) == 0
out = capsys.readouterr().out out = capsys.readouterr().out
assert "Usage: dryer" in out assert "Usage: dryer" in out
assert "__pycache__" in out
assert ".test.cts" in out
assert "--threshold" in out assert "--threshold" in out
@@ -153,6 +165,8 @@ def test_names_that_are_tests():
"ui/a.spec.tsx", "ui/a.spec.tsx",
"ui/a.test.mts", "ui/a.test.mts",
"ui/a.spec.mts", "ui/a.spec.mts",
"ui/a.test.cts",
"ui/a.spec.cts",
"tests/conftest.py", "tests/conftest.py",
"pkg/foo_test.py", "pkg/foo_test.py",
"pkg/test_foo.py", "pkg/test_foo.py",
@@ -163,54 +177,71 @@ def test_names_that_are_tests():
assert not is_test_file("src/app.py") assert not is_test_file("src/app.py")
class _Git: def _init_repo(path: Path) -> None:
def __init__(self, returncode, stdout, stderr=""): subprocess.run(["git", "init"], cwd=path, check=True, capture_output=True)
self.returncode = returncode subprocess.run(["git", "config", "user.email", "[email protected]"], cwd=path, check=True)
self.stdout = stdout subprocess.run(["git", "config", "user.name", "Dev"], cwd=path, check=True)
self.stderr = stderr
def test_changed_files_reads_porcelain(tmp_path, monkeypatch): def _commit(path: Path, message: str) -> None:
stdout = ' M src/a.py\nR old.py -> src/b.py\n?? "src/c d.py"\n\nX\n' subprocess.run(["git", "add", "-A"], cwd=path, check=True, capture_output=True)
monkeypatch.setattr( subprocess.run(["git", "commit", "-m", message], cwd=path, check=True, capture_output=True)
"dryer.cli.subprocess.run",
lambda *args, **kwargs: _Git(0, stdout),
)
assert _changed_files(tmp_path) == [
(tmp_path / "src/a.py").resolve(),
(tmp_path / "src/b.py").resolve(),
(tmp_path / "src/c d.py").resolve(),
]
def test_changed_files_reports_git_failure(tmp_path, monkeypatch, capsys): def test_changed_files_reads_real_status(tmp_path):
monkeypatch.setattr( _init_repo(tmp_path)
"dryer.cli.subprocess.run", write_source(tmp_path, "src/a.py", "x = 1\n")
lambda *args, **kwargs: _Git(1, "", "fatal: not a git repository\n"), write_source(tmp_path, "src/old.py", "x = 1\n")
) write_source(tmp_path, "gone.py", "x = 1\n")
assert _changed_files(tmp_path) == [] _commit(tmp_path, "base")
assert capsys.readouterr().err == "fatal: not a git repository\n" (tmp_path / "gone.py").unlink()
subprocess.run(["git", "mv", "src/old.py", "src/b.py"], cwd=tmp_path, check=True, capture_output=True)
monkeypatch.setattr( write_source(tmp_path, "src/c d.py", "x = 1\n")
"dryer.cli.subprocess.run", write_source(tmp_path, "src/café.py", "x = 1\n")
lambda *args, **kwargs: _Git(1, "", " \n"), write_source(tmp_path, "fresh/nested/new.py", "x = 1\n")
) found = set(_changed_files(tmp_path))
assert _changed_files(tmp_path) == [] assert (tmp_path / "src/b.py").resolve() in found
assert capsys.readouterr().err == "git status failed\n" assert (tmp_path / "src/c d.py").resolve() in found
assert (tmp_path / "src/café.py").resolve() in found
assert (tmp_path / "fresh/nested/new.py").resolve() in found
assert (tmp_path / "gone.py").resolve() not in found
assert (tmp_path / "src/old.py").resolve() not in found
def test_changed_limits_the_report(tmp_path, monkeypatch, capsys): def test_changed_files_reports_git_failure(tmp_path, capsys):
with pytest.raises(GitStatusError) as caught:
_changed_files(tmp_path)
assert caught.value.code == 128
assert "not a git repository" in caught.value.message
assert capsys.readouterr().err == ""
code = run(["--changed", "--root", str(tmp_path)])
captured = capsys.readouterr()
assert code == 128
assert "not a git repository" in captured.err
assert "No source files" not in captured.out
def test_an_empty_git_error_uses_the_fallback_text(monkeypatch, tmp_path):
def fail(args, **kwargs):
return subprocess.CompletedProcess(args, 128, b"", b" \n")
monkeypatch.setattr("dryer.cli.subprocess.run", fail)
with pytest.raises(GitStatusError, match="git status failed") as caught:
_changed_files(tmp_path)
assert caught.value.code == 128
def test_changed_limits_the_report(tmp_path, capsys):
body = "def alpha(xs):\n ys = filter(xs, odd)\n zs = map(ys, inc)\n return list(zs)\n" body = "def alpha(xs):\n ys = filter(xs, odd)\n zs = map(ys, inc)\n return list(zs)\n"
other = "def beta(items):\n kept = filter(items, even)\n out = map(kept, dec)\n return list(out)\n" other = "def beta(items):\n kept = filter(items, even)\n out = map(kept, dec)\n return list(out)\n"
_init_repo(tmp_path)
write_source(tmp_path, "src/c.py", body)
_commit(tmp_path, "base")
write_source(tmp_path, "src/a.py", body) write_source(tmp_path, "src/a.py", body)
write_source(tmp_path, "src/b.py", other) write_source(tmp_path, "src/b.py", other)
write_source(tmp_path, "src/c.py", body)
write_source(tmp_path, "tests/test_a.py", other) write_source(tmp_path, "tests/test_a.py", other)
stdout = " M src/a.py\n M src/b.py\n M tests/test_a.py\n ?? README.md\n" write_source(tmp_path, "README.md", "notes\n")
monkeypatch.setattr(
"dryer.cli.subprocess.run",
lambda *args, **kwargs: _Git(0, stdout),
)
assert run(["--root", str(tmp_path), "--changed", "--min-lines", "3", "--min-nodes", "1"]) == 0 assert run(["--root", str(tmp_path), "--changed", "--min-lines", "3", "--min-nodes", "1"]) == 0
out = capsys.readouterr().out out = capsys.readouterr().out
assert "src/a.py" in out assert "src/a.py" in out
@@ -275,35 +306,28 @@ def test_source_root_limits_the_walk(tmp_path):
assert files == ["src/a.py"] assert files == ["src/a.py"]
def test_git_status_asks_for_text_without_checking(tmp_path, monkeypatch): def test_changed_from_a_subdirectory_stays_inside_it(tmp_path):
seen = {} repo = tmp_path / "repo"
sub = repo / "sub"
def fake_run(args, **kwargs): repo.mkdir()
seen["args"] = args _init_repo(repo)
seen["kwargs"] = kwargs write_source(repo, "src/above.py", "x = 1\n")
return _Git(0, " M src/a.py\n") write_source(sub, "src/below.py", "x = 1\n")
chosen = select_files(parse_args(["--root", str(sub), "--changed"]))
monkeypatch.setattr("dryer.cli.subprocess.run", fake_run) assert chosen == [(sub / "src/below.py").resolve()]
assert _changed_files(tmp_path) == [(tmp_path / "src/a.py").resolve()]
assert seen["args"] == ["git", "status", "--porcelain"]
assert seen["kwargs"]["check"] is False
assert seen["kwargs"]["capture_output"] is True
assert seen["kwargs"]["text"] is True
assert seen["kwargs"]["cwd"] == tmp_path
def test_a_status_line_of_four_characters_is_a_path(tmp_path, monkeypatch): def test_a_short_untracked_name_is_a_file(tmp_path):
monkeypatch.setattr( _init_repo(tmp_path)
"dryer.cli.subprocess.run", write_source(tmp_path, "a.py", "x = 1\n")
lambda *args, **kwargs: _Git(0, " M a\n"), assert _changed_files(tmp_path) == [(tmp_path / "a.py").resolve()]
)
assert _changed_files(tmp_path) == [(tmp_path / "a").resolve()]
def test_changed_source_skips_unknown_and_test_files(tmp_path): def test_changed_source_skips_unknown_and_test_files(tmp_path):
assert _tracked_source(tmp_path / "README.md") is False assert _tracked_source(tmp_path / "README.md") is False
assert _tracked_source(tmp_path / "tests" / "test_a.py") is False assert _tracked_source(tmp_path / "tests" / "test_a.py") is False
assert _tracked_source(tmp_path / "src" / "a.py") is True assert _tracked_source(tmp_path / "src" / "a.py") is True
assert _tracked_source(tmp_path / "target" / "app.py") is False
def test_test_files_and_skipped_directories_are_left_out(tmp_path): def test_test_files_and_skipped_directories_are_left_out(tmp_path):
@@ -311,8 +335,11 @@ def test_test_files_and_skipped_directories_are_left_out(tmp_path):
write_source(tmp_path, "src/foo_test.py", "x = 1\n") write_source(tmp_path, "src/foo_test.py", "x = 1\n")
write_source(tmp_path, "target/app.py", "x = 1\n") write_source(tmp_path, "target/app.py", "x = 1\n")
write_source(tmp_path, "tests/app.py", "x = 1\n") write_source(tmp_path, "tests/app.py", "x = 1\n")
write_source(tmp_path, "src/app.test.cts", "export {}\n")
write_source(tmp_path, "src/app.spec.cts", "export {}\n")
write_source(tmp_path, "src/widget.cts", "export {}\n")
relative = [path.relative_to(tmp_path).as_posix() for path in iter_source_files([tmp_path])] relative = [path.relative_to(tmp_path).as_posix() for path in iter_source_files([tmp_path])]
assert relative == ["src/app.py"] assert sorted(relative) == ["src/app.py", "src/widget.cts"]
def test_a_second_report_replaces_the_snapshot(tmp_path, capsys): def test_a_second_report_replaces_the_snapshot(tmp_path, capsys):
+11
View File
@@ -599,3 +599,14 @@ def test_keep_uses_the_line_and_node_minimums():
assert _keep(_entry("a.py", 1, 4, {"q"}, nodes=20), 4, 20) is True assert _keep(_entry("a.py", 1, 4, {"q"}, nodes=20), 4, 20) is True
assert _keep(_entry("a.py", 1, 3, {"q"}, nodes=20), 4, 20) is False assert _keep(_entry("a.py", 1, 3, {"q"}, nodes=20), 4, 20) is False
assert _keep(_entry("a.py", 1, 4, {"q"}, nodes=19), 4, 20) is False assert _keep(_entry("a.py", 1, 4, {"q"}, nodes=19), 4, 20) is False
def test_a_deep_expression_is_read():
expr = " + ".join(["1"] * 1200)
entries, warning = entries_in_source("python", f"def place():\n return {expr}\n", "app.py", "app.py")
assert warning is None
assert entries
nested = "(inc " * 1200 + "x" + ")" * 1200
entries, warning = entries_in_source("clojure", f"(defn place [] {nested})\n", "app.clj", "app.clj")
assert warning is None
assert entries