Skip to content
Closed
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
7 changes: 7 additions & 0 deletions desloppify/engine/_state/schema_scores.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,19 @@

from __future__ import annotations

from dataclasses import asdict, is_dataclass
from enum import Enum
from pathlib import Path
from typing import Any


def json_default(obj: Any) -> Any:
"""JSON serializer fallback for known non-JSON-native state values."""
if is_dataclass(obj) and not isinstance(obj, type):
return asdict(obj)
if isinstance(obj, Enum):
value = obj.value
return value if isinstance(value, str | int | float | bool | None) else obj.name
if isinstance(obj, set):
return sorted(obj)
if isinstance(obj, Path):
Expand Down
33 changes: 31 additions & 2 deletions desloppify/languages/_framework/frameworks/detection.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,32 @@
_CACHE_PREFIX = "frameworks.ecosystem.present"


def _encode_detection(result: EcosystemFrameworkDetection) -> dict[str, Any]:
return {
"ecosystem": result.ecosystem,
"package_root": str(result.package_root).replace("\\", "/"),
"package_json_relpath": result.package_json_relpath,
"present": result.present,
}


def _decode_detection(payload: object) -> EcosystemFrameworkDetection | None:
if not isinstance(payload, dict):
return None
eco = payload.get("ecosystem")
package_root = payload.get("package_root")
present = payload.get("present")
package_json_relpath = payload.get("package_json_relpath")
if not isinstance(eco, str) or not isinstance(package_root, str) or not isinstance(present, dict):
return None
return EcosystemFrameworkDetection(
ecosystem=eco,
package_root=Path(package_root),
package_json_relpath=(str(package_json_relpath) if package_json_relpath is not None else None),
present=present,
)


def _find_nearest_package_json(scan_path: Path, project_root: Path) -> Path | None:
resolved = scan_path if scan_path.is_absolute() else (project_root / scan_path)
resolved = resolved.resolve()
Expand Down Expand Up @@ -140,6 +166,9 @@ def detect_ecosystem_frameworks(
cached = cache.get(cache_key)
if isinstance(cached, EcosystemFrameworkDetection):
return cached
decoded = _decode_detection(cached)
if decoded is not None:
return decoded

project_root = get_project_root()

Expand All @@ -151,7 +180,7 @@ def detect_ecosystem_frameworks(
present={},
)
if lang is not None and isinstance(getattr(lang, "review_cache", None), dict):
lang.review_cache[cache_key] = result
lang.review_cache[cache_key] = _encode_detection(result)
return result

package_json = _find_nearest_package_json(resolved_scan_path, project_root)
Expand Down Expand Up @@ -204,7 +233,7 @@ def detect_ecosystem_frameworks(
if lang is not None:
cache = getattr(lang, "review_cache", None)
if isinstance(cache, dict):
cache[cache_key] = result
cache[cache_key] = _encode_detection(result)

return result

Expand Down
31 changes: 30 additions & 1 deletion desloppify/languages/_framework/frameworks/specs/nextjs.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,42 @@
_NEXTJS_INFO_CACHE_PREFIX = "framework.nextjs.info"


def _encode_nextjs_info(info: NextjsFrameworkInfo) -> dict[str, object]:
return {
"package_root": str(info.package_root).replace("\\", "/"),
"package_json_relpath": info.package_json_relpath,
"app_roots": list(info.app_roots),
"pages_roots": list(info.pages_roots),
}


def _decode_nextjs_info(payload: object) -> NextjsFrameworkInfo | None:
if not isinstance(payload, dict):
return None
package_root = payload.get("package_root")
package_json_relpath = payload.get("package_json_relpath")
app_roots = payload.get("app_roots")
pages_roots = payload.get("pages_roots")
if not isinstance(package_root, str) or not isinstance(app_roots, list) or not isinstance(pages_roots, list):
return None
return NextjsFrameworkInfo(
package_root=Path(package_root),
package_json_relpath=str(package_json_relpath) if package_json_relpath is not None else None,
app_roots=tuple(str(v) for v in app_roots if isinstance(v, str)),
pages_roots=tuple(str(v) for v in pages_roots if isinstance(v, str)),
)


def _nextjs_info(scan_root: Path, lang: LangRuntimeContract) -> NextjsFrameworkInfo:
key = f"{_NEXTJS_INFO_CACHE_PREFIX}:{scan_root.resolve().as_posix()}"
cache = getattr(lang, "review_cache", None)
if isinstance(cache, dict):
cached = cache.get(key)
if isinstance(cached, NextjsFrameworkInfo):
return cached
decoded = _decode_nextjs_info(cached)
if decoded is not None:
return decoded

from desloppify.languages._framework.frameworks.detection import (
detect_ecosystem_frameworks,
Expand All @@ -65,7 +94,7 @@ def _nextjs_info(scan_root: Path, lang: LangRuntimeContract) -> NextjsFrameworkI
)

if isinstance(cache, dict):
cache[key] = info
cache[key] = _encode_nextjs_info(info)
return info


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,25 @@ def test_nextjs_smells_phase_emits_smells_when_next_is_present(tmp_path: Path):

cfg = get_lang("javascript")
phase = next(p for p in cfg.phases if getattr(p, "label", "") == "Next.js framework smells")
issues, potentials = phase.run(tmp_path, _FakeLang())
lang = _FakeLang()
issues, potentials = phase.run(tmp_path, lang)
detectors = {issue.get("detector") for issue in issues}
assert "nextjs" in detectors
assert potentials.get("nextjs", 0) >= 1
assert any("server_import_in_client" in str(issue.get("id", "")) for issue in issues)

framework_cache = {
k: v for k, v in lang.review_cache.items() if str(k).startswith("frameworks.ecosystem.present:")
}
assert framework_cache
assert all(isinstance(value, dict) for value in framework_cache.values())

info_cache = {
k: v for k, v in lang.review_cache.items() if str(k).startswith("framework.nextjs.info:")
}
assert info_cache
assert all(isinstance(value, dict) for value in info_cache.values())


def test_nextjs_smells_phase_scans_jsx_error_and_js_middleware(tmp_path: Path):
_write(
Expand Down
35 changes: 35 additions & 0 deletions desloppify/tests/state/test_state_internal_direct.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,41 @@ def test_state_persistence_honors_monkeypatched_state_file(monkeypatch, tmp_path
assert loaded["issues"] == {}


def test_state_persistence_serializes_dataclass_values_in_review_cache(tmp_path):
from desloppify.languages._framework.frameworks.types import EcosystemFrameworkDetection

state_path = tmp_path / "state.json"
state = schema_mod.empty_state()
state["review_cache"] = {
"holistic": {
"ecosystems": {
"framework_detections": [
EcosystemFrameworkDetection(
ecosystem="node",
package_root=tmp_path,
package_json_relpath=None,
present={"nextjs": {"deps": ["next"]}},
)
]
}
}
}

persistence_mod.save_state(state, state_path)

loaded = persistence_mod.load_state(state_path)
detections = (
loaded.get("review_cache", {})
.get("holistic", {})
.get("ecosystems", {})
.get("framework_detections", [])
)
assert isinstance(detections, list)
assert detections
assert isinstance(detections[0], dict)
assert detections[0]["ecosystem"] == "node"


def test_match_and_resolve_issues_updates_state():
state = schema_mod.empty_state()
open_issue = filtering_mod.make_issue(
Expand Down
Loading