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
9 changes: 8 additions & 1 deletion desloppify/languages/_framework/frameworks/phases.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,14 @@ def run(path: Path, lang: LangRuntimeContract) -> tuple[list[Issue], dict[str, i
return [], {}

scan_root = detection.package_root
return tool_phase.run(scan_root, lang)
command = tool.cmd_resolver(scan_root) if tool.cmd_resolver else None
if command is None or command == tool.cmd:
return tool_phase.run(scan_root, lang)
resolved_phase = make_tool_phase(
tool.label, command, tool.fmt, tool.id, tool.tier,
confidence=tool.confidence,
)
return resolved_phase.run(scan_root, lang)

return DetectorPhase(tool_phase.label, run, slow=tool_phase.slow)

Expand Down
19 changes: 19 additions & 0 deletions desloppify/languages/_framework/frameworks/specs/nextjs.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

from __future__ import annotations

import json
import re

from collections.abc import Callable
from pathlib import Path
from typing import Any
Expand Down Expand Up @@ -522,6 +525,21 @@ def _make_line_issue(
)


def _next_lint_command(scan_root: Path) -> str | None:
"""Use ESLint directly when the installed Next.js no longer provides lint."""
try:
package = json.loads((scan_root / "node_modules/next/package.json").read_text())
except (OSError, UnicodeError, json.JSONDecodeError):
return None
if not isinstance(package, dict):
return None
version = package.get("version")
match = re.match(r"^v?(\d+)\.", version) if isinstance(version, str) else None
if match and int(match.group(1)) >= 16:
return "npx --no-install eslint . --format json"
return None


NEXTJS_SPEC = FrameworkSpec(
id="nextjs",
label="Next.js",
Expand All @@ -545,6 +563,7 @@ def _make_line_issue(
id="next_lint",
label="next lint",
cmd="npx --no-install next lint --format json",
cmd_resolver=_next_lint_command,
fmt="next_lint",
tier=2,
slow=True,
Expand Down
1 change: 1 addition & 0 deletions desloppify/languages/_framework/frameworks/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ class ToolIntegration:
tier: int
slow: bool = False
confidence: str = "medium"
cmd_resolver: Callable[[Path], str | None] | None = None


@dataclass(frozen=True)
Expand Down
32 changes: 32 additions & 0 deletions desloppify/languages/typescript/tests/test_ts_nextjs_framework.py
Original file line number Diff line number Diff line change
Expand Up @@ -227,3 +227,35 @@ def test_next_lint_phase_is_skipped_when_include_slow_false():
labels = [getattr(p, "label", "") for p in selected]
assert "Next.js framework smells" in labels
assert "next lint" not in labels


@pytest.mark.parametrize("version,eslint", [("15.5.0", False), ("16.0.0", True), ("17.0.0-canary.1", True), ("v16.0.0", True)])
def test_next_lint_uses_installed_version(tmp_path, monkeypatch, version, eslint):
import json
from desloppify.languages._framework.frameworks import phases
from desloppify.languages._framework.frameworks.specs.nextjs import NEXTJS_SPEC

_write(tmp_path, "package.json", '{"dependencies":{"next":"*"}}')
_write(tmp_path, "app/page.tsx", "export default function Page(){return null}")
_write(tmp_path, "node_modules/next/package.json", json.dumps({"version": version}))
calls = []

def fake_phase(label, cmd, *args, **kwargs):
def run(root, lang):
calls.append((cmd, root))
return [], {"next_lint": 1}
return SimpleNamespace(label=label, run=run, slow=False)

monkeypatch.setattr(phases, "make_tool_phase", fake_phase)
phase = phases._framework_tool_phase(NEXTJS_SPEC, NEXTJS_SPEC.tools[0])
assert phase.run(tmp_path, _FakeLang()) == ([], {"next_lint": 1})
expected = "npx --no-install eslint . --format json" if eslint else "npx --no-install next lint --format json"
assert calls == [(expected, tmp_path.resolve())]


@pytest.mark.parametrize("content", [None, "invalid", "[]", '{"version": null}', '{"version":"unknown"}'])
def test_next_lint_unknown_installation_keeps_legacy_command(tmp_path, content):
from desloppify.languages._framework.frameworks.specs.nextjs import _next_lint_command
if content is not None:
_write(tmp_path, "node_modules/next/package.json", content)
assert _next_lint_command(tmp_path) is None