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)
def _normalize(node, head: bool = False):
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]
_PENDING = object()
def _is_literal(node) -> bool:
@@ -204,54 +178,88 @@ def _without_type_args(nodes):
return rest, type_args
def _extend_normalized(out, nodes) -> None:
for node in nodes:
norm = _normalize(node, False)
if norm is not None:
out.append(norm)
def _try_leaf(node, mode: str, head: bool):
if mode == "callee":
if node.type in _IDENTIFIERS:
return [K("symbol"), _text(node)]
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)
rest, type_args = _without_type_args(incoming)
out = [K(node.type)]
jobs = []
if rest:
*receivers, callee = rest
_extend_normalized(out, receivers)
out.append(_normalize_callee(callee))
_extend_normalized(out, type_args)
jobs.extend((child, "norm", False) for child in receivers)
jobs.append((callee, "callee", False))
jobs.extend((child, "norm", False) for child in type_args)
if args is not None:
_extend_normalized(out, [args])
return out
jobs.append((args, "norm", False))
return jobs
def _normalize_callee(node):
if node.type in _IDENTIFIERS:
return [K("symbol"), _text(node)]
if node.type in _ATTRIBUTES:
return _normalize_attr(node, True)
if node.type in _SCOPED or node.type == "generic_type":
return _normalize_path(node)
return _normalize(node, True)
def _attr_jobs(node, head: bool):
named = list(node.named_children)
if not named:
return []
*objects, name = named
jobs = [(obj, "norm", False) for obj in objects]
if name.type in _IDENTIFIERS:
return jobs
if head:
jobs.append((name, "callee", False))
else:
jobs.append((name, "norm", False))
return jobs
def _normalize_path(node):
"""A callee path keeps each name in the path. Type arguments stay structural."""
parts = [K(node.type)]
def _path_jobs(node):
jobs = []
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(_normalize_path(child))
continue
if child.type in _SCOPED or child.type == "generic_type":
jobs.append((child, "path", False))
else:
norm = _normalize(child, False)
if norm is not None:
parts.append(norm)
return parts
jobs.append((child, "norm", False))
return jobs
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."""
if name.type in _IDENTIFIERS:
@@ -259,21 +267,105 @@ def _normalized_name(name, head: bool):
return [K("symbol"), _text(name)]
return K("symbol")
if head:
return _normalize_callee(name)
return _normalize(name, False)
return cache[(id(name), "callee", 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)
if not named:
return [K(node.type)]
*objects, name = named
parts = [K(node.type)]
for obj in objects:
norm = _normalize(obj, False)
norm = cache[(id(obj), "norm", False)]
if norm is not None:
parts.append(norm)
nested = _normalized_name(name, head)
nested = _name_value(name, head, cache)
if nested is not None:
parts.append(nested)
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
import os
import subprocess
import sys
from dataclasses import dataclass, field
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.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 ...]
Find candidate duplicate code in Clojure, Java, Go, TypeScript, Rust, and
@@ -48,8 +55,8 @@ Arguments:
path contains this text are compared.
With no paths, source files under the project root are compared. Directories
named test, tests, spec, specs, vendor, node_modules, and target are skipped,
as are *_test.go, *.spec.ts, test_*.py, and *_test.py files.
named {_skipped_directory_text()} are skipped, as are *_test.go, *.test.cts,
*.spec.cts, *.spec.ts, test_*.py, and *_test.py files.
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.
@@ -159,25 +166,55 @@ def parse_args(argv: list[str] | None = None) -> Options:
return options
def _git_status(root: Path):
args = ["git", "status", "--porcelain"]
return subprocess.run(args, cwd=root, check=False, capture_output=True, text=True)
class GitStatusError(Exception):
"""git status could not be read. `code` is git's own status."""
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]:
result = _git_status(root)
if result.returncode != 0:
print(result.stderr.strip() or "git status failed", file=sys.stderr)
return []
"""Added and modified files under `root`, as paths from the repo root.
`-z` keeps spaces and non-ASCII names intact. A missing path, including a
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] = []
for line in result.stdout.splitlines():
if len(line) < 4:
for entry in status.split(b"\0"):
if len(entry) <= 3:
continue
path_text = line[3:].strip()
if " -> " in path_text:
path_text = path_text.split(" -> ", 1)[1]
path_text = path_text.strip('"')
found.append((root / path_text).resolve())
path = (toplevel / os.fsdecode(entry[3:])).resolve()
if not path.is_file() or not path.is_relative_to(root_resolved):
continue
found.append(path)
return found
@@ -195,14 +232,22 @@ def _positionals(root: Path, args: list[str]) -> tuple[list[Path], list[str]]:
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:
return False
return not is_test_file(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]:
@@ -249,7 +294,11 @@ def run(argv: list[str] | None = None) -> int:
return options.exit_code
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:
print("No source files to analyze.")
return 0
+201 -102
View File
@@ -64,6 +64,20 @@ class Splice:
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:
def __init__(self, text: str):
self.text = text
@@ -146,79 +160,69 @@ class Reader:
return Kw("", line)
return Kw(self.read_token(), line)
def read_collection(self, end: str, kind: str) -> Coll:
line = self.line
self.get()
items: list = []
def read_form(self):
"""One form. None and Splice are real results.
An explicit stack: a 1,200-deep form must not raise RecursionError.
"""
stack: list[tuple] = []
produced = _NEED
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():
raise ReadError(line, f"unterminated {kind}")
if self.peek() == end:
self.get()
break
form = self.read_form()
if form is None:
continue
if isinstance(form, Splice):
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)
stack.pop()
return _close_coll(kind, line, items, fn_star)
if self.eof():
raise ReadError(self.line, "unexpected end of file")
return self._open(stack)
def _wrap(self, name: str, line: int) -> Coll:
inner = self.read_form()
items = [Sym(name, line)]
if inner is not None and not isinstance(inner, Splice):
items.append(inner)
return Coll("list", line, items=items)
def read_dispatch(self):
line = self.line
self.get()
ch = self.peek()
if ch is None:
raise ReadError(line, "incomplete dispatch")
if ch == "_":
self.get()
self.read_form()
def _deliver(self, stack: list[tuple], produced):
frame = stack[-1]
tag = frame[0]
if tag == "coll":
items = frame[4]
if produced is None:
return _NEED
if isinstance(produced, Splice):
items.extend(produced.items)
else:
items.append(produced)
return _NEED
stack.pop()
if tag == "wrap":
_tag, name, line = frame
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
if ch == "{":
return self.read_collection("}", "set")
if ch == "(":
inner = self.read_collection(")", "list")
return Coll("list", line, items=[Sym("fn*", line), *inner.items])
if ch == '"':
self.read_string()
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)
if tag == "meta":
return _NEED
if tag == "lit":
return Lit(frame[1])
if tag == "cond":
return self._finish_cond(frame[1], produced)
raise AssertionError(tag)
def read_cond(self, splicing: bool):
form = self.read_form()
if not isinstance(form, Coll) or form.kind != "list":
def _finish_cond(self, splicing: bool, produced):
if not isinstance(produced, Coll) or produced.kind != "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:
return None
if not splicing:
@@ -228,18 +232,21 @@ class Reader:
raise ReadError(self.line, "splicing reader conditional needs a collection")
return spliced
def read_form(self):
self.skip_ws()
if self.eof():
raise ReadError(self.line, "unexpected end of file")
def _open(self, stack: list[tuple]):
ch = self.peek()
line = self.line
if ch == "(":
return self.read_collection(")", "list")
self.get()
stack.append(("coll", ")", "list", line, [], False))
return _NEED
if ch == "[":
return self.read_collection("]", "vector")
self.get()
stack.append(("coll", "]", "vector", line, [], False))
return _NEED
if ch == "{":
return self.read_collection("}", "map")
self.get()
stack.append(("coll", "}", "map", line, [], False))
return _NEED
if ch == '"':
return self.read_string()
if ch == "\\":
@@ -248,31 +255,75 @@ class Reader:
return self.read_keyword()
if ch == "'":
self.get()
return self._wrap("quote", line)
stack.append(("wrap", "quote", line))
return _NEED
if ch == "@":
self.get()
return self._wrap("deref", line)
stack.append(("wrap", "deref", line))
return _NEED
if ch == "`":
self.get()
return self._wrap("syntax-quote", line)
stack.append(("wrap", "syntax-quote", line))
return _NEED
if ch == "~":
self.get()
name = "unquote"
if self.peek() == "@":
self.get()
name = "unquote-splicing"
return self._wrap(name, line)
stack.append(("wrap", name, line))
return _NEED
if ch == "^":
self.get()
self.read_form()
return self.read_form()
stack.append(("meta",))
return _NEED
if ch == "#":
return self.read_dispatch()
return self._dispatch(stack, line)
token = self.read_token()
if _NUMBER.match(token):
return Lit(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):
if isinstance(feature, Kw):
@@ -341,33 +392,18 @@ def _children(form):
def max_line(form) -> int:
best = getattr(form, "line", 1) or 1
for child in _children(form):
if child is not None:
best = max(best, max_line(child))
stack = [form]
while stack:
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
def _normalize_list(form):
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")]
def _normalize_atom(form, head: bool):
if isinstance(form, Sym):
if head:
return [K("symbol"), form.name]
@@ -377,6 +413,69 @@ def normalize(form, head: bool = False):
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:
"""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",
".test.mts",
".spec.mts",
".test.cts",
".spec.cts",
)
):
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:
found = []
def visit(node) -> None:
stack = [root]
while stack:
node = stack.pop()
if node.type == "method_declaration":
nested = _inside(
node,
@@ -82,11 +83,8 @@ def _java_nodes(data: bytes, root) -> list:
)
if _has(node, "block") and not nested:
found.append(node)
return
for child in node.children:
visit(child)
visit(root)
continue
stack.extend(reversed(node.children))
return found
+71 -18
View File
@@ -18,38 +18,91 @@ class K:
return ":" + self.name
def pr(node) -> str:
"""Print a normalized node the way Clojure `pr-str` prints it."""
def _pr_atom(node) -> str:
if isinstance(node, K):
return ":" + node.name
if isinstance(node, str):
escaped = node.replace("\\", "\\\\").replace('"', '\\"')
return f'"{escaped}"'
if isinstance(node, list):
return "[" + " ".join(pr(child) for child in node) + "]"
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:
"""One plus the count of every nested node. Atoms count as one."""
if isinstance(node, list):
return 1 + sum(node_count(child) for child in node)
return 1
if not isinstance(node, list):
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]:
found: set[str] = set()
def walk(form) -> None:
found.add(pr(form))
if isinstance(form, list):
for child in form:
walk(child)
walk(node)
return frozenset(found)
if not isinstance(node, list):
return frozenset({pr(node)})
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))
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:
+87 -60
View File
@@ -1,8 +1,18 @@
import subprocess
from pathlib import Path
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
@@ -28,6 +38,8 @@ def test_help_does_not_scan(capsys):
assert run(["--help"]) == 0
out = capsys.readouterr().out
assert "Usage: dryer" in out
assert "__pycache__" in out
assert ".test.cts" in out
assert "--threshold" in out
@@ -153,6 +165,8 @@ def test_names_that_are_tests():
"ui/a.spec.tsx",
"ui/a.test.mts",
"ui/a.spec.mts",
"ui/a.test.cts",
"ui/a.spec.cts",
"tests/conftest.py",
"pkg/foo_test.py",
"pkg/test_foo.py",
@@ -163,54 +177,71 @@ def test_names_that_are_tests():
assert not is_test_file("src/app.py")
class _Git:
def __init__(self, returncode, stdout, stderr=""):
self.returncode = returncode
self.stdout = stdout
self.stderr = stderr
def _init_repo(path: Path) -> None:
subprocess.run(["git", "init"], cwd=path, check=True, capture_output=True)
subprocess.run(["git", "config", "user.email", "[email protected]"], cwd=path, check=True)
subprocess.run(["git", "config", "user.name", "Dev"], cwd=path, check=True)
def test_changed_files_reads_porcelain(tmp_path, monkeypatch):
stdout = ' M src/a.py\nR old.py -> src/b.py\n?? "src/c d.py"\n\nX\n'
monkeypatch.setattr(
"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 _commit(path: Path, message: str) -> None:
subprocess.run(["git", "add", "-A"], cwd=path, check=True, capture_output=True)
subprocess.run(["git", "commit", "-m", message], cwd=path, check=True, capture_output=True)
def test_changed_files_reports_git_failure(tmp_path, monkeypatch, capsys):
monkeypatch.setattr(
"dryer.cli.subprocess.run",
lambda *args, **kwargs: _Git(1, "", "fatal: not a git repository\n"),
)
assert _changed_files(tmp_path) == []
assert capsys.readouterr().err == "fatal: not a git repository\n"
monkeypatch.setattr(
"dryer.cli.subprocess.run",
lambda *args, **kwargs: _Git(1, "", " \n"),
)
assert _changed_files(tmp_path) == []
assert capsys.readouterr().err == "git status failed\n"
def test_changed_files_reads_real_status(tmp_path):
_init_repo(tmp_path)
write_source(tmp_path, "src/a.py", "x = 1\n")
write_source(tmp_path, "src/old.py", "x = 1\n")
write_source(tmp_path, "gone.py", "x = 1\n")
_commit(tmp_path, "base")
(tmp_path / "gone.py").unlink()
subprocess.run(["git", "mv", "src/old.py", "src/b.py"], cwd=tmp_path, check=True, capture_output=True)
write_source(tmp_path, "src/c d.py", "x = 1\n")
write_source(tmp_path, "src/café.py", "x = 1\n")
write_source(tmp_path, "fresh/nested/new.py", "x = 1\n")
found = set(_changed_files(tmp_path))
assert (tmp_path / "src/b.py").resolve() in found
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"
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/b.py", other)
write_source(tmp_path, "src/c.py", body)
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"
monkeypatch.setattr(
"dryer.cli.subprocess.run",
lambda *args, **kwargs: _Git(0, stdout),
)
write_source(tmp_path, "README.md", "notes\n")
assert run(["--root", str(tmp_path), "--changed", "--min-lines", "3", "--min-nodes", "1"]) == 0
out = capsys.readouterr().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"]
def test_git_status_asks_for_text_without_checking(tmp_path, monkeypatch):
seen = {}
def fake_run(args, **kwargs):
seen["args"] = args
seen["kwargs"] = kwargs
return _Git(0, " M src/a.py\n")
monkeypatch.setattr("dryer.cli.subprocess.run", fake_run)
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_changed_from_a_subdirectory_stays_inside_it(tmp_path):
repo = tmp_path / "repo"
sub = repo / "sub"
repo.mkdir()
_init_repo(repo)
write_source(repo, "src/above.py", "x = 1\n")
write_source(sub, "src/below.py", "x = 1\n")
chosen = select_files(parse_args(["--root", str(sub), "--changed"]))
assert chosen == [(sub / "src/below.py").resolve()]
def test_a_status_line_of_four_characters_is_a_path(tmp_path, monkeypatch):
monkeypatch.setattr(
"dryer.cli.subprocess.run",
lambda *args, **kwargs: _Git(0, " M a\n"),
)
assert _changed_files(tmp_path) == [(tmp_path / "a").resolve()]
def test_a_short_untracked_name_is_a_file(tmp_path):
_init_repo(tmp_path)
write_source(tmp_path, "a.py", "x = 1\n")
assert _changed_files(tmp_path) == [(tmp_path / "a.py").resolve()]
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 / "tests" / "test_a.py") is False
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):
@@ -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, "target/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])]
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):
+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, 3, {"q"}, nodes=20), 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