Skip to content
Merged
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
10 changes: 10 additions & 0 deletions desloppify/languages/_framework/treesitter/imports/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Comment thread
coderabbitai[bot] marked this conversation as resolved.
# Normalize to absolute path.
if not os.path.isabs(resolved):
resolved = os.path.normpath(os.path.join(scan_path, resolved))
Expand Down
2 changes: 2 additions & 0 deletions desloppify/languages/javascript/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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.
Expand Down
120 changes: 120 additions & 0 deletions desloppify/languages/javascript/test_coverage.py
Original file line number Diff line number Diff line change
@@ -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
57 changes: 57 additions & 0 deletions desloppify/tests/lang/common/test_treesitter_imports_direct.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Empty file.
79 changes: 79 additions & 0 deletions desloppify/tests/lang/javascript/test_javascript_test_coverage.py
Original file line number Diff line number Diff line change
@@ -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

Comment thread
coderabbitai[bot] marked this conversation as resolved.

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"
Loading