diff --git a/desloppify/languages/_framework/treesitter/imports/graph.py b/desloppify/languages/_framework/treesitter/imports/graph.py index 361586356..831f310ec 100644 --- a/desloppify/languages/_framework/treesitter/imports/graph.py +++ b/desloppify/languages/_framework/treesitter/imports/graph.py @@ -75,6 +75,16 @@ def ts_build_dep_graph( if resolved is None: continue + # Check if the resolver returned a relative path that is already + # in the file set (relative to the project root) before converting + # to absolute. This handles cases where resolve_import returns a + # project-root-relative path rather than a scan-root-relative one. + if not os.path.isabs(resolved) and resolved in file_set: + graph[filepath]["imports"].add(resolved) + if resolved in graph: + graph[resolved]["importers"].add(filepath) + continue + # Normalize to absolute path. if not os.path.isabs(resolved): resolved = os.path.normpath(os.path.join(scan_path, resolved)) diff --git a/desloppify/languages/javascript/__init__.py b/desloppify/languages/javascript/__init__.py index dc5b83f2d..86984f2c0 100644 --- a/desloppify/languages/javascript/__init__.py +++ b/desloppify/languages/javascript/__init__.py @@ -9,6 +9,7 @@ ) from desloppify.languages._framework.treesitter import JS_SPEC from desloppify.languages.javascript._zones import JS_ZONE_RULES +from desloppify.languages.javascript import test_coverage as js_test_coverage_hooks cfg = generic_lang( @@ -31,6 +32,7 @@ treesitter_spec=JS_SPEC, zone_rules=JS_ZONE_RULES, frameworks=True, + test_coverage_module=js_test_coverage_hooks, ) # Append Open Paws advocacy phases to the generic config. diff --git a/desloppify/languages/javascript/test_coverage.py b/desloppify/languages/javascript/test_coverage.py new file mode 100644 index 000000000..408ec6b3c --- /dev/null +++ b/desloppify/languages/javascript/test_coverage.py @@ -0,0 +1,120 @@ +"""JavaScript-specific test coverage heuristics. + +Supports Jest/Vitest/Mocha test naming conventions (.test.js, .spec.js) and +Node.js built-in assert module patterns. +""" + +from __future__ import annotations + +import os +import re + +_IMPORT_RE = re.compile( + r"""(?:\bfrom\s+|\bimport\s*\(\s*|\bimport\s+)(?:type\s+)?['"]([^'"]+)['"]|""" + r"""(?:\brequire\s*\(\s*)['"]([^'"]+)['"]""", + re.MULTILINE, +) + +# Patterns for detecting assertion calls in JS test files +ASSERT_PATTERNS = [ + re.compile(p) + for p in [ + r"expect\(", + r"assert\.", + r"\.should\.", + r"\b(?:getBy|findBy|getAllBy|findAllBy)\w+\(", + r"\.toBeInTheDocument\(", + r"\.toBeVisible\(", + r"\.toHaveTextContent\(", + r"\.toHaveAttribute\(", + ] +] + +MOCK_PATTERNS = [ + re.compile(p) + for p in [ + r"jest\.mock\(", + r"jest\.spyOn\(", + r"vi\.mock\(", + r"vi\.spyOn\(", + r"sinon\.", + ] +] + +SNAPSHOT_PATTERNS: list[re.Pattern[str]] = [] + +# Detects test('name', ...) or it('name', ...) call patterns +TEST_FUNCTION_RE = re.compile(r"""(?:it|test)\s*\(\s*['"]""") + + +def parse_test_import_specs(content: str) -> set[str]: + """Extract import/require specs from a JavaScript test file.""" + return { + spec + for m in _IMPORT_RE.finditer(content) + if (spec := m.group(1) or m.group(2)) + } + + +def map_test_to_source(test_path: str, production_set: set[str]) -> str | None: + """Map a JavaScript test file to a production file by naming convention.""" + basename = os.path.basename(test_path) + dirname = os.path.dirname(test_path) + parent = os.path.dirname(dirname) + + candidates: list[str] = [] + + for pattern in (".test.", ".spec."): + if pattern in basename: + src = basename.replace(pattern, ".") + candidates.append(os.path.join(dirname, src)) + if parent: + candidates.append(os.path.join(parent, src)) + + dir_basename = os.path.basename(dirname) + if dir_basename == "__tests__" and parent: + candidates.append(os.path.join(parent, basename)) + + # Exact path match takes priority. + for c in candidates: + if c in production_set: + return c + + # Deterministic basename fallback: build a sorted basename → path mapping, + # then return the first match across sorted candidates to avoid non-determinism + # when multiple production files share the same basename. + prod_base_map: dict[str, list[str]] = {} + for prod in sorted(production_set): + prod_base_map.setdefault(os.path.basename(prod), []).append(prod) + + for c in sorted(candidates): + matches = prod_base_map.get(os.path.basename(c), []) + if matches: + return matches[0] + + return None + + +def strip_test_markers(basename: str) -> str | None: + """Strip JavaScript test naming markers to derive a source basename.""" + for marker in (".test.", ".spec."): + if marker in basename: + return basename.replace(marker, ".") + return None + + +def resolve_import_spec( + spec: str, test_path: str, production_files: set[str] +) -> str | None: + """Resolve a JS import spec to a production file path.""" + if not spec.startswith("."): + return None + + base = os.path.dirname(test_path) + candidate = os.path.normpath(os.path.join(base, spec)) + for ext in ("", ".js", ".jsx", ".mjs", ".cjs", "/index.js", "/index.jsx"): + path = candidate + ext + if path in production_files: + return path + + return None diff --git a/desloppify/tests/lang/common/test_treesitter_imports_direct.py b/desloppify/tests/lang/common/test_treesitter_imports_direct.py index 2a75eb119..85edd85b0 100644 --- a/desloppify/tests/lang/common/test_treesitter_imports_direct.py +++ b/desloppify/tests/lang/common/test_treesitter_imports_direct.py @@ -317,3 +317,60 @@ def test_script_import_cache_reset_invalidates_php_lookup_state(tmp_path: Path) scripts_mod.reset_script_import_caches(str(tmp_path)) assert scripts_mod.resolve_php_import("User", "", str(tmp_path)) == str(second_file) + + +def test_graph_records_project_root_relative_import_without_scan_path_join( + monkeypatch, tmp_path: Path +) -> None: + """Characterization test: resolve_import returning a project-root-relative path + must be added directly to the graph rather than being joined with scan_path. + + Before the fix, ts_build_dep_graph would join scan_path (e.g. ``/project/_ui``) + with the relative path (e.g. ``_ui/server.js``) producing + ``/project/_ui/_ui/server.js``, which is never in file_set, so the import + edge was silently dropped. After the fix, the relative path is detected in + file_set first and added without any join. + """ + # Simulate a sub-directory scan: project root is tmp_path, scan_path is + # tmp_path/_ui, so file_set uses project-root-relative strings. + scan_dir = tmp_path / "_ui" + scan_dir.mkdir() + source_file = scan_dir / "server.test.js" + dep_file = scan_dir / "server.js" + source_file.write_text("const s = require('./server');\n", encoding="utf-8") + dep_file.write_text("module.exports = {};\n", encoding="utf-8") + + # file_set uses project-root-relative paths (as produced by the real scanner). + rel_source = "_ui/server.test.js" + rel_dep = "_ui/server.js" + file_list = [rel_source, rel_dep] + + monkeypatch.setattr(graph_mod, "_get_parser", lambda _grammar: ("parser", "language")) + monkeypatch.setattr(graph_mod, "_make_query", lambda _language, source: source) + monkeypatch.setattr( + graph_mod, + "get_or_parse_tree", + lambda filepath, *_a, **_k: (b"", SimpleNamespace(root_node=filepath)), + ) + matches = { + rel_source: [(0, {"path": FakeNode("string", text="'./server'")})], + rel_dep: [], + } + monkeypatch.setattr(graph_mod, "_run_query", lambda _query, root: matches[root]) + monkeypatch.setattr(graph_mod, "_unwrap_node", lambda node: node) + + # resolve_import returns a project-root-relative path (not absolute). + # Before the fix this would be joined with scan_path → wrong absolute path. + spec = SimpleNamespace( + grammar="javascript", + import_query="imports", + resolve_import=lambda text, filepath, scan_path: rel_dep, + ) + + graph = graph_mod.ts_build_dep_graph(scan_dir, spec, file_list) + + # The import edge must be recorded using the project-root-relative key. + assert rel_dep in graph[rel_source]["imports"], ( + "import edge was not recorded — path was likely incorrectly joined with scan_path" + ) + assert rel_source in graph[rel_dep]["importers"] diff --git a/desloppify/tests/lang/javascript/__init__.py b/desloppify/tests/lang/javascript/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/desloppify/tests/lang/javascript/test_javascript_test_coverage.py b/desloppify/tests/lang/javascript/test_javascript_test_coverage.py new file mode 100644 index 000000000..0ff32a927 --- /dev/null +++ b/desloppify/tests/lang/javascript/test_javascript_test_coverage.py @@ -0,0 +1,79 @@ +"""Tests for JavaScript-specific test coverage detection hooks.""" + +from __future__ import annotations + +import os + +from desloppify.languages.javascript import test_coverage as js_cov + + +def test_parse_test_import_specs_handles_esm_and_cjs() -> None: + content = ( + "import { foo } from './foo.js';\n" + "import './side-effect.js';\n" + "const bar = require('./bar');\n" + "import('./lazy.js');\n" + ) + specs = js_cov.parse_test_import_specs(content) + assert isinstance(specs, set) + assert "./foo.js" in specs + assert "./side-effect.js" in specs + assert "./bar" in specs + assert "./lazy.js" in specs + + +def test_parse_test_import_specs_deduplicates() -> None: + content = "require('./util');\nrequire('./util');\n" + specs = js_cov.parse_test_import_specs(content) + assert specs == {"./util"} + + +def test_parse_test_import_specs_returns_empty_set_for_no_imports() -> None: + assert js_cov.parse_test_import_specs("const x = 1;") == set() + + +def test_strip_test_markers_removes_test_suffix() -> None: + assert js_cov.strip_test_markers("server.test.js") == "server.js" + assert js_cov.strip_test_markers("util.spec.js") == "util.js" + assert js_cov.strip_test_markers("app.js") is None + + +def test_map_test_to_source_finds_sibling_file() -> None: + production_set = {"_ui/server.js", "_ui/github.js", "_ui/public/app.js"} + result = js_cov.map_test_to_source("_ui/server.test.js", production_set) + assert result == "_ui/server.js" + + +def test_map_test_to_source_returns_none_when_no_match() -> None: + production_set = {"_ui/server.js"} + result = js_cov.map_test_to_source("_ui/missing.test.js", production_set) + assert result is None + + +def test_resolve_import_spec_resolves_relative_js_path() -> None: + production = {"_ui/server.js", "_ui/github.js"} + result = js_cov.resolve_import_spec("./server.js", "_ui/server.test.js", production) + assert result == "_ui/server.js" + + +def test_resolve_import_spec_adds_js_extension() -> None: + production = {"_ui/server.js"} + result = js_cov.resolve_import_spec("./server", "_ui/server.test.js", production) + assert result == "_ui/server.js" + + +def test_resolve_import_spec_skips_node_modules() -> None: + production = {"_ui/server.js"} + result = js_cov.resolve_import_spec("express", "_ui/server.test.js", production) + assert result is None + + +def test_map_test_to_source_is_deterministic_on_basename_collision() -> None: + # Two production files with the same basename in different directories. + production = {"a/util.js", "b/util.js"} + result1 = js_cov.map_test_to_source("test/util.test.js", production) + # Call twice — result must be stable (no randomness from set iteration). + result2 = js_cov.map_test_to_source("test/util.test.js", production) + assert result1 == result2 + # Sorted basename fallback must pick the lexicographically first path. + assert result1 == "a/util.js"