diff --git a/desloppify/engine/detectors/coverage/mapping.py b/desloppify/engine/detectors/coverage/mapping.py index aff00f5ad..c40e12e3f 100644 --- a/desloppify/engine/detectors/coverage/mapping.py +++ b/desloppify/engine/detectors/coverage/mapping.py @@ -157,6 +157,24 @@ def _map_test_to_source( return None +def _map_test_to_sources( + test_path: str, + production_set: set[str], + lang_name: str, +) -> set[str]: + """Match a test file to every production file it covers by language scope. + + Optional plural hook for languages whose test scope is wider than one + name-paired file (Go tests cover their whole package). Returns an empty + set when the language module does not define it. + """ + mod = _load_lang_test_coverage_module(lang_name) + mapper = getattr(mod, "map_test_to_sources", None) + if callable(mapper): + return mapper(test_path, production_set) + return set() + + def naming_based_mapping( test_files: set[str], production_files: set[str], @@ -171,6 +189,8 @@ def naming_based_mapping( prod_by_basename.setdefault(bn, []).append(p) for tf in test_files: + tested |= _map_test_to_sources(tf, production_files, lang_name) + matched = _map_test_to_source(tf, production_files, lang_name) if matched: tested.add(matched) @@ -224,6 +244,7 @@ def get_test_files_for_prod( parsed_imports_by_test, parse_test_imports_fn=_parse_test_imports, map_test_to_source_fn=_map_test_to_source, + map_test_to_sources_fn=_map_test_to_sources, project_root=str(get_project_root()), ) diff --git a/desloppify/engine/detectors/coverage/mapping_analysis.py b/desloppify/engine/detectors/coverage/mapping_analysis.py index 390af6eb3..acb1e8968 100644 --- a/desloppify/engine/detectors/coverage/mapping_analysis.py +++ b/desloppify/engine/detectors/coverage/mapping_analysis.py @@ -184,6 +184,7 @@ def get_test_files_for_prod( *, parse_test_imports_fn: Callable[[str, set[str], dict[str, str], str], set[str]], map_test_to_source_fn: Callable[[str, set[str], str], str | None], + map_test_to_sources_fn: Callable[[str, set[str], str], set[str]] | None = None, project_root: str, ) -> list[str]: """Find which test files exercise a given production file.""" @@ -217,6 +218,11 @@ def get_test_files_for_prod( continue if map_test_to_source_fn(test_path, {prod_file}, lang_name) == prod_file: result.append(test_path) + continue + if map_test_to_sources_fn is not None and prod_file in map_test_to_sources_fn( + test_path, {prod_file}, lang_name + ): + result.append(test_path) return result diff --git a/desloppify/languages/go/test_coverage.py b/desloppify/languages/go/test_coverage.py index c64789b67..a0f120638 100644 --- a/desloppify/languages/go/test_coverage.py +++ b/desloppify/languages/go/test_coverage.py @@ -99,6 +99,26 @@ def map_test_to_source(test_path: str, production_set: set[str]) -> str | None: return None +def map_test_to_sources(test_path: str, production_set: set[str]) -> set[str]: + """Map a Go test file to every production file in its package. + + Go compiles all files of a directory as one package and `go test` + reports coverage package-wide: a test file can exercise any identifier + in its package regardless of which file defines it. Name-pairing alone + (foo_test.go -> foo.go) therefore under-reports Go coverage and flags + files as untested_module even when the package's test suite covers + them (e.g. ingest_test.go covering plan.go, verify.go, run.go). + """ + if not test_path.endswith("_test.go"): + return set() + test_dir = os.path.dirname(test_path) + return { + p + for p in production_set + if os.path.dirname(p) == test_dir and p.endswith(".go") + } + + def strip_test_markers(basename: str) -> str | None: """Strip Go test naming marker to derive source basename.""" if basename.endswith("_test.go"): diff --git a/desloppify/tests/detectors/coverage/test_test_coverage_mapping_import_and_logic.py b/desloppify/tests/detectors/coverage/test_test_coverage_mapping_import_and_logic.py index 4a17e2326..12bb77817 100644 --- a/desloppify/tests/detectors/coverage/test_test_coverage_mapping_import_and_logic.py +++ b/desloppify/tests/detectors/coverage/test_test_coverage_mapping_import_and_logic.py @@ -910,3 +910,29 @@ def test_deno_serve_file_classified_as_runtime_entrypoint(self, tmp_path): entries, _ = detect_test_coverage(graph, zone_map, "typescript") assert len(entries) == 1 assert entries[0]["detail"]["kind"] == "runtime_entrypoint_no_direct_tests" + + +def test_naming_based_mapping_go_credits_whole_package(): + """Go tests are package-scoped: one _test.go file covers every sibling + production file, not just its name-paired counterpart.""" + production = { + "internal/ingest/plan.go", + "internal/ingest/verify.go", + "internal/ingest/fetch.go", + "internal/other/other.go", + } + tests = {"internal/ingest/ingest_test.go", "internal/ingest/fetch_test.go"} + tested = naming_based_mapping(tests, production, "go") + assert tested == { + "internal/ingest/plan.go", + "internal/ingest/verify.go", + "internal/ingest/fetch.go", + } + + +def test_naming_based_mapping_plural_hook_absent_is_noop(): + """Languages without map_test_to_sources keep name-paired behavior only.""" + production = {"src/widget.ts", "src/other.ts"} + tests = {"src/widget.test.ts"} + tested = naming_based_mapping(tests, production, "typescript") + assert "src/other.ts" not in tested diff --git a/desloppify/tests/lang/go/test_go_test_coverage.py b/desloppify/tests/lang/go/test_go_test_coverage.py index 2ff6309ea..8eb6ffbce 100644 --- a/desloppify/tests/lang/go/test_go_test_coverage.py +++ b/desloppify/tests/lang/go/test_go_test_coverage.py @@ -24,3 +24,25 @@ def test_resolve_import_spec_matches_module_prefixed_path_by_suffix(): def test_resolve_import_spec_skips_special_imports(): production = {"pkg/service/handler.go"} assert go_cov.resolve_import_spec("unsafe", "pkg/service/handler_test.go", production) is None + + +def test_map_test_to_sources_covers_whole_package(): + # Go compiles a directory as one package and `go test` reports coverage + # package-wide, so one test file covers every sibling production file. + production = { + "internal/ingest/plan.go", + "internal/ingest/verify.go", + "internal/ingest/run.go", + "internal/other/other.go", + } + covered = go_cov.map_test_to_sources("internal/ingest/ingest_test.go", production) + assert covered == { + "internal/ingest/plan.go", + "internal/ingest/verify.go", + "internal/ingest/run.go", + } + + +def test_map_test_to_sources_ignores_non_test_files(): + production = {"internal/ingest/plan.go"} + assert go_cov.map_test_to_sources("internal/ingest/notes.md", production) == set()