From 4343a2bf2cbe7b976d237461c6f93967ecd24e1a Mon Sep 17 00:00:00 2001 From: Jimmy Brancaccio Date: Fri, 18 Sep 2026 15:31:24 -0500 Subject: [PATCH] fix: run ESLint directly for Next.js 16 projects Committed by: Developer --- .../languages/_framework/frameworks/phases.py | 9 +++++- .../_framework/frameworks/specs/nextjs.py | 19 +++++++++++ .../languages/_framework/frameworks/types.py | 1 + .../tests/test_ts_nextjs_framework.py | 32 +++++++++++++++++++ 4 files changed, 60 insertions(+), 1 deletion(-) diff --git a/desloppify/languages/_framework/frameworks/phases.py b/desloppify/languages/_framework/frameworks/phases.py index 65f6c75cf..9f37330f8 100644 --- a/desloppify/languages/_framework/frameworks/phases.py +++ b/desloppify/languages/_framework/frameworks/phases.py @@ -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) diff --git a/desloppify/languages/_framework/frameworks/specs/nextjs.py b/desloppify/languages/_framework/frameworks/specs/nextjs.py index ee662e302..7c3827749 100644 --- a/desloppify/languages/_framework/frameworks/specs/nextjs.py +++ b/desloppify/languages/_framework/frameworks/specs/nextjs.py @@ -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 @@ -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", @@ -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, diff --git a/desloppify/languages/_framework/frameworks/types.py b/desloppify/languages/_framework/frameworks/types.py index 183ad9c91..0c17423b6 100644 --- a/desloppify/languages/_framework/frameworks/types.py +++ b/desloppify/languages/_framework/frameworks/types.py @@ -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) diff --git a/desloppify/languages/typescript/tests/test_ts_nextjs_framework.py b/desloppify/languages/typescript/tests/test_ts_nextjs_framework.py index 57d48d5fc..a10998f25 100644 --- a/desloppify/languages/typescript/tests/test_ts_nextjs_framework.py +++ b/desloppify/languages/typescript/tests/test_ts_nextjs_framework.py @@ -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