fix(typescript): prefer the src subtree over an adjacent tool config when warming up an additional workspace (#2097)

TypeScriptLanguageServer._find_representative_source_file scanned files directly
adjacent to tsconfig.json before checking a src/ subdirectory, so a same-level tool
config that the tsconfig excludes (vitest.config.ts, jest.config.ts, etc.) could be
picked as the file used to trigger project activation for an additional workspace
folder. The wrong inferred TypeScript project then loads, and cross-package
find_referencing_symbols queries silently return {} instead of raising or warning.

Reorder the scan to walk the src/ subtree first (recursively, respecting the usual
ignored-directory rules) and only fall back to a same-level file when no src/
directory exists, matching the fallback the reporter's issue proposed.

Fixes #2090

Signed-off-by: Amir Fathi <amirfathi.me@gmail.com>
This commit is contained in:
Amir Fathi authored and GitHub committed 2026-09-23 15:18:05 +02:00
1 parent 714c260e1f
commit a4dff9e0fc
3 files changed
+84 -7

No files matched your search

+4
View File
@@ -130,6 +130,10 @@ Status of the `main` branch. Changes prior to the next official version change w
VTS initialization options now override defaults per top-level key rather than replacing the
entire configuration; a user-provided `typescript` block replaces the ATA default too.
`initializationOptions` takes precedence over the legacy `initialization_options` alias.
- Fix: activating an additional TypeScript workspace folder could open a root-level tool
config (`vitest.config.ts`, `jest.config.ts`, etc.) adjacent to `tsconfig.json` instead of
a real source file, starting the wrong inferred project and silently losing cross-package
references (#2090)
- Fix: a C# file created after the project was already indexed was analyzed by Roslyn as a
standalone Miscellaneous Files document instead of being folded into the loaded project,
causing phantom diagnostics on the new file and on files referencing its symbols (#1961)
@@ -516,20 +516,25 @@ class TypeScriptLanguageServer(SolidLanguageServer):
def _find_representative_source_file(self, directory: str) -> str | None:
"""Find a TypeScript file suitable for triggering project loading.
Prefers a file adjacent to tsconfig.json (indicating the project root),
then falls back to the first .ts/.tsx file found.
Prefers a file under a `src` subdirectory adjacent to tsconfig.json (the
conventional source root), so a root-level tool config that tsconfig excludes
(vitest.config.ts, jest.config.ts, etc.) is not picked over the project's real
source tree. Falls back to a file directly adjacent to tsconfig.json, then to
the first .ts/.tsx file found anywhere in the directory.
"""
for root, dirs, files in os.walk(directory):
dirs[:] = [d for d in dirs if not self.is_ignored_dirname(d)]
if "tsconfig.json" in files:
src_dir = os.path.join(root, "src")
if os.path.isdir(src_dir):
for src_root, src_dirs, src_files in os.walk(src_dir):
src_dirs[:] = [d for d in src_dirs if not self.is_ignored_dirname(d)]
for f in src_files:
if f.endswith((".ts", ".tsx")) and not f.endswith(".d.ts"):
return os.path.join(src_root, f)
for f in files:
if f.endswith((".ts", ".tsx")) and not f.endswith(".d.ts"):
return os.path.join(root, f)
src_dir = os.path.join(root, "src")
if os.path.isdir(src_dir):
for f in os.listdir(src_dir):
if f.endswith((".ts", ".tsx")) and not f.endswith(".d.ts"):
return os.path.join(src_dir, f)
for root, dirs, files in os.walk(directory):
dirs[:] = [d for d in dirs if not self.is_ignored_dirname(d)]
@@ -0,0 +1,68 @@
"""Regression test for oraios/serena#2090: additional-workspace activation must not pick a
root-level tool config (vitest.config.ts, jest.config.ts, ...) adjacent to tsconfig.json over
the project's actual source tree.
Exercises TypeScriptLanguageServer._find_representative_source_file directly against a real
filesystem layout, without spawning a language server process; same technique as
test_typescript_timeout_policy.py's _bare_ts_server.
"""
from solidlsp.language_servers.typescript_language_server import TypeScriptLanguageServer
def _bare_ts_server() -> TypeScriptLanguageServer:
return object.__new__(TypeScriptLanguageServer)
class TestFindRepresentativeSourceFile:
def test_prefers_nested_src_file_over_adjacent_tool_config(self, tmp_path) -> None:
"""The issue's own minimal layout: a tsconfig-adjacent vitest.config.ts must lose to a
real source file nested under src/, even when that source file is not directly inside
src/ itself (it is one level further down, under src/routes/).
"""
pkg = tmp_path / "apps" / "api"
routes = pkg / "src" / "routes"
routes.mkdir(parents=True)
(pkg / "tsconfig.json").write_text("{}")
(pkg / "vitest.config.ts").write_text("export default {};")
(routes / "money.ts").write_text("export const x = 1;")
result = _bare_ts_server()._find_representative_source_file(str(tmp_path))
assert result == str(routes / "money.ts")
def test_falls_back_to_adjacent_file_when_no_src_dir_exists(self, tmp_path) -> None:
"""Projects with source files directly next to tsconfig.json (no src/ subdirectory)
must keep working exactly as before.
"""
pkg = tmp_path / "pkg"
pkg.mkdir()
(pkg / "tsconfig.json").write_text("{}")
(pkg / "index.ts").write_text("export const x = 1;")
result = _bare_ts_server()._find_representative_source_file(str(tmp_path))
assert result == str(pkg / "index.ts")
def test_ignores_node_modules_under_src(self, tmp_path) -> None:
"""The src/ subtree walk must still respect is_ignored_dirname, or a vendored .ts file
under src/node_modules could be selected instead of real project source.
"""
pkg = tmp_path / "pkg"
vendored = pkg / "src" / "node_modules" / "dep"
real_src = pkg / "src" / "lib"
vendored.mkdir(parents=True)
real_src.mkdir(parents=True)
(pkg / "tsconfig.json").write_text("{}")
(pkg / "vitest.config.ts").write_text("export default {};")
(vendored / "vendored.ts").write_text("export const y = 1;")
(real_src / "app.ts").write_text("export const x = 1;")
result = _bare_ts_server()._find_representative_source_file(str(tmp_path))
assert result == str(real_src / "app.ts")
def test_returns_none_when_nothing_matches(self, tmp_path) -> None:
(tmp_path / "README.md").write_text("no typescript here")
assert _bare_ts_server()._find_representative_source_file(str(tmp_path)) is None