Skip to content

Commit a756701

Browse files
authored
Merge pull request #1974 from codeflash-ai/fix/false-positive-test-discovery
Fix false positive test discovery from substring matching
2 parents accb245 + b77896d commit a756701

2 files changed

Lines changed: 139 additions & 13 deletions

File tree

codeflash/languages/javascript/support.py

Lines changed: 30 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from __future__ import annotations
88

99
import logging
10+
import re
1011
import subprocess
1112
import xml.etree.ElementTree as ET
1213
from pathlib import Path
@@ -230,11 +231,14 @@ def discover_tests(
230231
"""
231232
result: dict[str, list[TestInfo]] = {}
232233

233-
# Build index: function_name → qualified_name for O(1) lookup
234-
# This avoids iterating all functions for every test file (was O(NxM), now O(N+M))
234+
# Build indices for O(1) lookup per imported name (avoids O(NxM) loop)
235235
function_name_to_qualified: dict[str, str] = {}
236+
class_name_to_qualified_names: dict[str, list[str]] = {}
236237
for func in source_functions:
237238
function_name_to_qualified[func.function_name] = func.qualified_name
239+
for parent in func.parents:
240+
if parent.type == "ClassDef":
241+
class_name_to_qualified_names.setdefault(parent.name, []).append(func.qualified_name)
238242

239243
# Find all test files using language-specific patterns
240244
test_patterns = self._get_test_patterns()
@@ -249,28 +253,41 @@ def discover_tests(
249253
analyzer = get_analyzer_for_file(test_file)
250254
imports = analyzer.find_imports(source)
251255

252-
# Build a set of imported function names
256+
# Build a set of imported names, resolving aliases and namespace member access
253257
imported_names: set[str] = set()
254258
for imp in imports:
255259
if imp.default_import:
256260
imported_names.add(imp.default_import)
261+
# Extract member access patterns: e.g. `math.calculate(...)` → "calculate"
262+
for m in re.finditer(rf"\b{re.escape(imp.default_import)}\.(\w+)", source):
263+
imported_names.add(m.group(1))
264+
if imp.namespace_import:
265+
imported_names.add(imp.namespace_import)
266+
for m in re.finditer(rf"\b{re.escape(imp.namespace_import)}\.(\w+)", source):
267+
imported_names.add(m.group(1))
257268
for name, alias in imp.named_imports:
258-
imported_names.add(alias or name)
269+
imported_names.add(name)
270+
if alias:
271+
imported_names.add(alias)
259272

260273
# Find test functions (describe/it/test blocks)
261274
test_functions = self._find_jest_tests(source, analyzer)
262275

263-
# Match source functions to tests using the index
264-
# Only check functions that are actually imported in this test file
276+
# Match via indices: function names and class names → qualified names
277+
matched_qualified_names: set[str] = set()
265278
for imported_name in imported_names:
266279
if imported_name in function_name_to_qualified:
267-
qualified_name = function_name_to_qualified[imported_name]
268-
if qualified_name not in result:
269-
result[qualified_name] = []
270-
for test_name in test_functions:
271-
result[qualified_name].append(
272-
TestInfo(test_name=test_name, test_file=test_file, test_class=None)
273-
)
280+
matched_qualified_names.add(function_name_to_qualified[imported_name])
281+
if imported_name in class_name_to_qualified_names:
282+
matched_qualified_names.update(class_name_to_qualified_names[imported_name])
283+
284+
for qualified_name in matched_qualified_names:
285+
if qualified_name not in result:
286+
result[qualified_name] = []
287+
for test_name in test_functions:
288+
result[qualified_name].append(
289+
TestInfo(test_name=test_name, test_file=test_file, test_class=None)
290+
)
274291
except Exception as e:
275292
logger.debug("Failed to analyze test file %s: %s", test_file, e)
276293

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
"""Test for false positive test discovery bug (Bug #4)."""
2+
3+
from pathlib import Path
4+
from tempfile import TemporaryDirectory
5+
6+
import pytest
7+
8+
from codeflash.discovery.functions_to_optimize import FunctionToOptimize
9+
from codeflash.languages.javascript.support import TypeScriptSupport
10+
from codeflash.models.models import CodePosition
11+
12+
13+
def test_discover_tests_should_not_match_mocked_functions():
14+
"""Test that functions mentioned only in mocks are not matched as test targets.
15+
16+
Regression test for Bug #4: False positive test discovery due to substring matching.
17+
18+
When a test file mocks a function (e.g., vi.mock("./restart-request.js", () => ({...}))),
19+
that function should NOT be considered as tested by that file, since it's only mocked,
20+
not actually called or tested.
21+
"""
22+
support = TypeScriptSupport()
23+
24+
with TemporaryDirectory() as tmpdir:
25+
test_root = Path(tmpdir)
26+
27+
# Create a test file that MOCKS parseRestartRequestParams but doesn't test it
28+
test_file = test_root / "update.test.ts"
29+
test_file.write_text(
30+
'''
31+
import { updateSomething } from "./update.js";
32+
33+
vi.mock("./restart-request.js", () => ({
34+
parseRestartRequestParams: (params: any) => ({ sessionKey: undefined }),
35+
}));
36+
37+
describe("updateSomething", () => {
38+
it("should update successfully", () => {
39+
const result = updateSomething();
40+
expect(result).toBe(true);
41+
});
42+
});
43+
'''
44+
)
45+
46+
# Source function that is only mocked, not tested
47+
source_function = FunctionToOptimize(
48+
qualified_name="parseRestartRequestParams",
49+
function_name="parseRestartRequestParams",
50+
file_path=test_root / "restart-request.ts",
51+
starting_line=1,
52+
ending_line=10,
53+
function_signature="",
54+
code_position=CodePosition(line_no=1, col_no=0),
55+
file_path_relative_to_project_root="restart-request.ts",
56+
)
57+
58+
# Discover tests
59+
result = support.discover_tests(test_root, [source_function])
60+
61+
# The bug: discovers update.test.ts as a test for parseRestartRequestParams
62+
# because "parseRestartRequestParams" appears as a substring in the mock
63+
# Expected: should NOT match (empty result)
64+
assert (
65+
source_function.qualified_name not in result or len(result[source_function.qualified_name]) == 0
66+
), f"Should not match mocked function, but found: {result.get(source_function.qualified_name, [])}"
67+
68+
69+
def test_discover_tests_should_match_actually_imported_functions():
70+
"""Test that functions actually imported and tested ARE correctly matched.
71+
72+
This is the positive case to ensure we don't break legitimate test discovery.
73+
"""
74+
support = TypeScriptSupport()
75+
76+
with TemporaryDirectory() as tmpdir:
77+
test_root = Path(tmpdir)
78+
79+
# Create a test file that ACTUALLY imports and tests the function
80+
test_file = test_root / "restart-request.test.ts"
81+
test_file.write_text(
82+
'''
83+
import { parseRestartRequestParams } from "./restart-request.js";
84+
85+
describe("parseRestartRequestParams", () => {
86+
it("should parse valid params", () => {
87+
const result = parseRestartRequestParams({ sessionKey: "abc" });
88+
expect(result.sessionKey).toBe("abc");
89+
});
90+
});
91+
'''
92+
)
93+
94+
source_function = FunctionToOptimize(
95+
qualified_name="parseRestartRequestParams",
96+
function_name="parseRestartRequestParams",
97+
file_path=test_root / "restart-request.ts",
98+
starting_line=1,
99+
ending_line=10,
100+
function_signature="",
101+
code_position=CodePosition(line_no=1, col_no=0),
102+
file_path_relative_to_project_root="restart-request.ts",
103+
)
104+
105+
result = support.discover_tests(test_root, [source_function])
106+
107+
# Should match: function is imported and tested
108+
assert source_function.qualified_name in result, f"Should match imported function, but got: {result}"
109+
assert len(result[source_function.qualified_name]) > 0, "Should find at least one test"

0 commit comments

Comments
 (0)