Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 19 additions & 3 deletions desloppify/engine/_plan/step_completion.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,20 @@


def auto_complete_steps(plan: dict) -> list[str]:
"""Mark steps done when all their issue_refs are no longer in the queue.
"""Mark steps done when all their issue_refs leave the living plan.

Returns list of human-readable messages for completed steps.
"""
messages: list[str] = []
queue_set = set(plan.get("queue_order", []))
actionable_ids = set(plan.get("queue_order", []))
actionable_ids.update((plan.get("skipped") or {}).keys())
actionable_ids.update(plan.get("promoted_ids") or [])
for cluster in (plan.get("clusters") or {}).values():
if isinstance(cluster, dict):
actionable_ids.update(cluster.get("issue_ids") or [])

for name, cluster in plan.get("clusters", {}).items():
cluster_issue_ids = set(cluster.get("issue_ids") or [])
for i, step in enumerate(cluster.get("action_steps") or []):
if not isinstance(step, dict) or step.get("done"):
continue
Expand All @@ -20,9 +26,19 @@ def auto_complete_steps(plan: dict) -> list[str]:
continue
# Match by suffix: ref "abc123" matches "review::path::abc123"
all_gone = all(
not any(qid.endswith(ref) or qid == ref for qid in queue_set)
not any(
issue_id.endswith(ref) or issue_id == ref
for issue_id in actionable_ids
)
for ref in refs
)
# Some triage runners emit summary hashes as step refs while the
# living plan stores canonical issue IDs. If the cluster still
# has members, an unmatched ref is ambiguous rather than proof
# that the step's finding was resolved. Fail closed and leave
# the step open until its cluster membership is drained.
if all_gone and cluster_issue_ids:
continue
if all_gone:
step["done"] = True
messages.append(
Expand Down
4 changes: 4 additions & 0 deletions desloppify/engine/_work_queue/snapshot.py
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,10 @@ def _subjective_partitions(
candidates = build_subjective_items(
state, scoped_issues, threshold=threshold, plan=plan
)
skipped_ids = set((plan or {}).get("skipped", {}).keys())
candidates = [
item for item in candidates if item.get("id", "") not in skipped_ids
]
initial = [item for item in candidates if item.get("initial_review")]
postflight = [item for item in candidates if not item.get("initial_review")]
return initial, postflight
Expand Down
87 changes: 71 additions & 16 deletions desloppify/languages/typescript/detectors/smells/detector_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
from .helpers import (
_code_text,
_strip_ts_comments,
_track_brace_body,
)

_MONSTER_FUNCTION_LOC = 150
Expand All @@ -19,7 +18,12 @@
_MAX_CATCH_BODY = 1000
_MAX_SWITCH_BODY_SCAN = 5000

_ERROR_HANDLER_BASENAMES = ("logger", "errorpresentation", "errorhandler", "errorreporting")
_ERROR_HANDLER_BASENAMES = (
"logger",
"errorpresentation",
"errorhandler",
"errorreporting",
)
_PRECEDING_SKIP_PATTERNS = re.compile(
r"componentDidCatch|import\.meta\.env\.DEV|process\.env\.NODE_ENV"
)
Expand Down Expand Up @@ -84,7 +88,9 @@ def _find_function_start(line: str, next_lines: list[str]) -> str | None:
if not assignment_match:
return None

combined = "\n".join([stripped] + [next_line.strip() for next_line in next_lines[:2]])
combined = "\n".join(
[stripped] + [next_line.strip() for next_line in next_lines[:2]]
)
eq_pos = combined.find("=", assignment_match.end())
if eq_pos == -1:
return None
Expand All @@ -96,29 +102,78 @@ def _find_function_start(line: str, next_lines: list[str]) -> str | None:
return None


def _find_opening_brace_line(lines: list[str], start: int, *, window: int = 5) -> int | None:
def _find_opening_brace_line(
lines: list[str], start: int, *, window: int = 5
) -> int | None:
for idx in range(start, min(start + window, len(lines))):
if "{" in lines[idx]:
return idx
return None


def _extract_function_body(
lines: list[str], start_line: int, *, max_scan: int = 2000,
lines: list[str],
start_line: int,
*,
max_scan: int = 2000,
) -> str | None:
"""Extract the inner body text of a function starting at start_line."""
brace_line = _find_opening_brace_line(lines, start_line, window=5)
if brace_line is None:
return None
end_line = _track_brace_body(lines, brace_line, max_scan=max_scan)
if end_line is None:
return None
body_text = "\n".join(lines[brace_line : end_line + 1])
first_brace = body_text.find("{")
last_brace = body_text.rfind("}")
if first_brace == -1 or last_brace == -1 or first_brace >= last_brace:
segment = "\n".join(lines[start_line : min(start_line + max_scan, len(lines))])
code = _code_text(segment)
paren_depth = 0
bracket_depth = 0
signature_brace_depth = 0
parameter_list_started = False
parameters_closed = False
previous_code_character = ""
body_start: int | None = None

for index, character in enumerate(code):
if character == "(":
parameter_list_started = True
paren_depth += 1
elif character == ")" and paren_depth > 0:
paren_depth -= 1
if parameter_list_started and paren_depth == 0:
parameters_closed = True
elif character == "[":
bracket_depth += 1
elif character == "]" and bracket_depth > 0:
bracket_depth -= 1
elif character == "{" and paren_depth == 0 and bracket_depth == 0:
if not parameters_closed:
# Generic constraints can contain object types before the parameter list.
# They cannot be executable bodies for the function shapes detected here.
pass
elif signature_brace_depth > 0:
signature_brace_depth += 1
elif previous_code_character in ":<|&,(=[":
# Object-shaped parameter/return types are part of the signature, not
# the executable function body. Keep scanning after their matching brace.
signature_brace_depth = 1
else:
body_start = index
break
elif character == "}" and signature_brace_depth > 0:
signature_brace_depth -= 1

if not character.isspace():
previous_code_character = character

if body_start is None:
return None
return body_text[first_brace + 1 : last_brace]

depth = 0
for index in range(body_start, len(code)):
character = code[index]
if character == "{":
depth += 1
elif character == "}":
depth -= 1
if depth == 0:
return segment[body_start + 1 : index]

return None


def _count_pattern_in_body(body: str, pattern: re.Pattern[str]) -> int:
Expand Down
46 changes: 39 additions & 7 deletions desloppify/languages/typescript/detectors/unused.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,24 +78,44 @@ def _run_tsc_unused_check(
)


def _find_base_tsconfig(path: Path, project_root: Path) -> Path:
"""Find the closest TypeScript project config that owns the scan path."""
scan_path = path.resolve()
root = project_root.resolve()

for directory in (scan_path, *scan_path.parents):
if directory == root.parent:
break
candidate = directory / "tsconfig.json"
if candidate.is_file():
return candidate
if directory == root:
break

app_config = root / "tsconfig.app.json"
return app_config if app_config.is_file() else root / "tsconfig.json"


def detect_unused(path: Path, category: str = "all") -> tuple[list[dict], int]:
ts_files = find_ts_and_tsx_files(path)
total_files = len(ts_files)
if _should_use_deno_fallback(path, ts_files):
return _detect_unused_fallback(path, category)

project_root = get_project_root()
base_tsconfig = _find_base_tsconfig(path, project_root)
tmp_tsconfig = {
"extends": "./tsconfig.app.json",
"extends": f"./{base_tsconfig.name}",
"compilerOptions": {
"noUnusedLocals": True,
"noUnusedParameters": True,
},
}
tmp_path = get_project_root() / "tsconfig.desloppify.json"
tmp_path = base_tsconfig.parent / "tsconfig.desloppify.json"
try:
safe_write_text(tmp_path, json.dumps(tmp_tsconfig, indent=2))
try:
result = _run_tsc_unused_check(get_project_root(), tmp_path)
result = _run_tsc_unused_check(project_root, tmp_path)
except (_proc_runtime.SubprocessError, OSError) as exc:
logger.debug("Falling back to source-based unused detection: %s", exc)
return _detect_unused_fallback(path, category)
Expand Down Expand Up @@ -146,11 +166,19 @@ def detect_unused(path: Path, category: str = "all") -> tuple[list[dict], int]:

def _categorize_unused(filepath: str, lineno: int) -> str:
try:
p = Path(filepath) if Path(filepath).is_absolute() else get_project_root() / filepath
p = (
Path(filepath)
if Path(filepath).is_absolute()
else get_project_root() / filepath
)
lines = p.read_text().splitlines()
if lineno <= len(lines):
src_line = lines[lineno - 1].strip()
if src_line.startswith("import ") or "from '" in src_line or 'from "' in src_line:
if (
src_line.startswith("import ")
or "from '" in src_line
or 'from "' in src_line
):
return "imports"
if src_line.startswith(
(
Expand All @@ -173,7 +201,9 @@ def _categorize_unused(filepath: str, lineno: int) -> str:
if prev.startswith("import "):
return "imports"
if not prev or (
not prev.startswith("{") and not prev.startswith(",") and "," not in prev
not prev.startswith("{")
and not prev.startswith(",")
and "," not in prev
):
break
except (OSError, UnicodeDecodeError) as exc:
Expand All @@ -193,7 +223,9 @@ def cmd_unused(args: argparse.Namespace) -> None:
file=sys.stderr,
)
else:
print(colorize("Running tsc... (this may take a moment)", "dim"), file=sys.stderr)
print(
colorize("Running tsc... (this may take a moment)", "dim"), file=sys.stderr
)

entries, _ = detect_unused(path, args.category)
if args.json:
Expand Down
1 change: 1 addition & 0 deletions desloppify/languages/typescript/test_coverage.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
re.compile(p)
for p in [
r"expect\(",
r"\bexpectTypeOf(?:\s*<[^;()]+>)?\s*\(",
r"assert\.",
r"\bassert(?:[A-Z]\w*)?\(",
r"\.should\.",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Tests for desloppify.languages.typescript.detectors.smells.helpers."""

from desloppify.languages.typescript.detectors.smells import TS_SMELL_CHECKS
from desloppify.languages.typescript.detectors.smells.detector_core import (
_find_function_start,
)
Expand All @@ -25,7 +26,6 @@
_track_brace_body,
_ts_match_is_in_string,
)
from desloppify.languages.typescript.detectors.smells import TS_SMELL_CHECKS


def _ctx(content: str, filepath: str = "test.ts") -> _FileContext:
Expand Down Expand Up @@ -329,6 +329,66 @@ def test_skips_async_with_await(self):
_detect_async_no_await(_ctx(content), counts)
assert len(counts["async_no_await"]) == 0

def test_skips_await_after_multiline_object_parameter_type(self):
content = """async function load(
tx: Transaction,
args: {
readonly accountId: string;
readonly customerId: string;
},
): Promise<Result> {
const result = await tx.execute(args);
return result;
}
"""
counts = _make_counts()
_detect_async_no_await(_ctx(content), counts)
assert len(counts["async_no_await"]) == 0

def test_skips_await_after_extract_parameter_type(self):
content = """async function load(
reference: Extract<EventReference, { readonly kind: 'checkout' }>,
): Promise<Result> {
return await resolveReference(reference);
}
"""
counts = _make_counts()
_detect_async_no_await(_ctx(content), counts)
assert len(counts["async_no_await"]) == 0

def test_skips_await_after_object_return_type(self):
content = """async function load(): Promise<{
readonly accountId: string;
}> {
return await resolveAccount();
}
"""
counts = _make_counts()
_detect_async_no_await(_ctx(content), counts)
assert len(counts["async_no_await"]) == 0

def test_skips_await_after_generic_object_constraint(self):
content = """async function load<T extends { readonly id: string }>(args: {
readonly value: T;
}): Promise<T> {
return await resolveValue(args.value);
}
"""
counts = _make_counts()
_detect_async_no_await(_ctx(content), counts)
assert len(counts["async_no_await"]) == 0

def test_flags_multiline_object_parameter_without_await(self):
content = """async function load(args: {
readonly accountId: string;
}): Promise<string> {
return args.accountId;
}
"""
counts = _make_counts()
_detect_async_no_await(_ctx(content), counts)
assert len(counts["async_no_await"]) == 1

def test_arrow_async_without_await(self):
content = "const fn = async () => {\n return 42;\n}\n"
counts = _make_counts()
Expand All @@ -337,7 +397,9 @@ def test_arrow_async_without_await(self):

def test_await_in_comment_still_flagged(self):
"""'await' inside a comment should not count — function is still flagged."""
content = "async function fetchData() {\n // await fetch('/');\n return 1;\n}\n"
content = (
"async function fetchData() {\n // await fetch('/');\n return 1;\n}\n"
)
counts = _make_counts()
_detect_async_no_await(_ctx(content), counts)
assert len(counts["async_no_await"]) == 1
Expand Down
Loading