From 43dca464d6a8bb8716dc7e534ebabb750f523c47 Mon Sep 17 00:00:00 2001 From: takeokunn Date: Sat, 7 Feb 2026 19:31:07 +0900 Subject: [PATCH 01/14] feat(config): add system-global ignored_paths support - Introduce ignored_paths to SerenaConfig for global ignore patterns - Merge global and project ignored_paths additively in Project logic - Update CLI and config loading to thread global_ignored_paths correctly - Add tests for global ignored_paths, additive merge, glob patterns, and compatibility - Update config templates and docstrings for new global ignored_paths option --- src/serena/cli.py | 11 +- src/serena/config/serena_config.py | 39 ++- src/serena/project.py | 33 ++- src/serena/resources/project.template.yml | 5 +- .../resources/serena_config.template.yml | 5 + .../config/test_global_ignored_paths.py | 256 ++++++++++++++++++ 6 files changed, 328 insertions(+), 21 deletions(-) create mode 100644 test/serena/config/test_global_ignored_paths.py diff --git a/src/serena/cli.py b/src/serena/cli.py index 73aeb6c9..3a1b968e 100644 --- a/src/serena/cli.py +++ b/src/serena/cli.py @@ -567,7 +567,7 @@ class ProjectCommands(AutoRegisteringGroup): serena_config = SerenaConfig.from_config_file() registered_project = serena_config.get_registered_project(str(project_root)) if registered_project is None: - registered_project = RegisteredProject(str(project_root), generated_conf) + registered_project = RegisteredProject(str(project_root), generated_conf, global_ignored_paths=serena_config.ignored_paths) serena_config.add_registered_project(registered_project) return registered_project @@ -690,7 +690,8 @@ class ProjectCommands(AutoRegisteringGroup): :param path: The path to check. :param project: The path to the project directory, defaults to the current working directory. """ - proj = Project.load(os.path.abspath(project)) + serena_config = SerenaConfig.from_config_file() + proj = Project.load(os.path.abspath(project), global_ignored_paths=serena_config.ignored_paths) if os.path.isabs(path): path = os.path.relpath(path, start=proj.project_root) is_ignored = proj.is_ignored_path(path) @@ -712,7 +713,8 @@ class ProjectCommands(AutoRegisteringGroup): :param project: path to the project directory, defaults to the current working directory. :param verbose: if set, prints detailed information about the indexed symbols. """ - proj = Project.load(os.path.abspath(project)) + serena_config = SerenaConfig.from_config_file() + proj = Project.load(os.path.abspath(project), global_ignored_paths=serena_config.ignored_paths) if os.path.isabs(file): file = os.path.relpath(file, start=proj.project_root) if proj.is_ignored_path(file, ignore_non_source_files=True): @@ -749,7 +751,8 @@ class ProjectCommands(AutoRegisteringGroup): # NOTE: completely written by Claude Code, only functionality was reviewed, not implementation logging.configure(level=logging.INFO) project_path = os.path.abspath(project) - proj = Project.load(project_path) + serena_config = SerenaConfig.from_config_file() + proj = Project.load(project_path, global_ignored_paths=serena_config.ignored_paths) # Create log file with timestamp timestamp = datetime_tag() diff --git a/src/serena/config/serena_config.py b/src/serena/config/serena_config.py index 622fbf8b..ecfb3a2b 100644 --- a/src/serena/config/serena_config.py +++ b/src/serena/config/serena_config.py @@ -397,16 +397,25 @@ class ProjectConfig(ToolInclusionDefinition, ModeSelectionDefinition, ToStringMi class RegisteredProject(ToStringMixin): - def __init__(self, project_root: str, project_config: "ProjectConfig", project_instance: Optional["Project"] = None) -> None: + def __init__( + self, + project_root: str, + project_config: "ProjectConfig", + project_instance: Optional["Project"] = None, + global_ignored_paths: Sequence[str] = (), + ) -> None: """ Represents a registered project in the Serena configuration. :param project_root: the root directory of the project :param project_config: the configuration of the project + :param project_instance: an existing project instance (if already loaded) + :param global_ignored_paths: global ignored paths from SerenaConfig, merged additively with project-level patterns """ self.project_root = Path(project_root).resolve() self.project_config = project_config self._project_instance = project_instance + self._global_ignored_paths = global_ignored_paths def _tostring_exclude_private(self) -> bool: return True @@ -416,19 +425,21 @@ class RegisteredProject(ToStringMixin): return self.project_config.project_name @classmethod - def from_project_instance(cls, project_instance: "Project") -> "RegisteredProject": + def from_project_instance(cls, project_instance: "Project", global_ignored_paths: Sequence[str] = ()) -> "RegisteredProject": return RegisteredProject( project_root=project_instance.project_root, project_config=project_instance.project_config, project_instance=project_instance, + global_ignored_paths=global_ignored_paths, ) @classmethod - def from_project_root(cls, project_root: str | Path) -> "RegisteredProject": + def from_project_root(cls, project_root: str | Path, global_ignored_paths: Sequence[str] = ()) -> "RegisteredProject": project_config = ProjectConfig.load(project_root) return RegisteredProject( project_root=str(project_root), project_config=project_config, + global_ignored_paths=global_ignored_paths, ) def matches_root_path(self, path: str | Path) -> bool: @@ -448,7 +459,11 @@ class RegisteredProject(ToStringMixin): from ..project import Project with LogTime(f"Loading project instance for {self}", logger=log): - self._project_instance = Project(project_root=str(self.project_root), project_config=self.project_config) + self._project_instance = Project( + project_root=str(self.project_root), + project_config=self.project_config, + global_ignored_paths=self._global_ignored_paths, + ) return self._project_instance @@ -493,6 +508,10 @@ class SerenaConfig(ToolInclusionDefinition, ModeSelectionDefinition, ToStringMix ls_specific_settings: dict = field(default_factory=dict) """Advanced configuration option allowing to configure language server implementation specific options, see SolidLSPSettings for more info.""" + ignored_paths: list[str] = field(default_factory=list) + """List of paths to ignore across all projects. Same syntax as gitignore, so you can use * and **. + These patterns are merged additively with each project's own ignored_paths.""" + # settings with overridden defaults default_modes: Sequence[str] | None = ("interactive", "editing") @@ -610,6 +629,7 @@ class SerenaConfig(ToolInclusionDefinition, ModeSelectionDefinition, ToStringMix project = RegisteredProject( project_root=str(path), project_config=project_config, + global_ignored_paths=instance.ignored_paths, ) instance.projects.append(project) @@ -700,7 +720,7 @@ class SerenaConfig(ToolInclusionDefinition, ModeSelectionDefinition, ToStringMix if autoregister: config_path = ProjectConfig.path_to_project_yml(project_root_or_name) if os.path.isfile(config_path): - registered_project = RegisteredProject.from_project_root(project_root_or_name) + registered_project = RegisteredProject.from_project_root(project_root_or_name, global_ignored_paths=self.ignored_paths) self.add_registered_project(registered_project) return registered_project # nothing found @@ -745,8 +765,13 @@ class SerenaConfig(ToolInclusionDefinition, ModeSelectionDefinition, ToStringMix project_config = ProjectConfig.load(project_root, autogenerate=True) - new_project = Project(project_root=str(project_root), project_config=project_config, is_newly_created=True) - self.add_registered_project(RegisteredProject.from_project_instance(new_project)) + new_project = Project( + project_root=str(project_root), + project_config=project_config, + is_newly_created=True, + global_ignored_paths=self.ignored_paths, + ) + self.add_registered_project(RegisteredProject.from_project_instance(new_project, global_ignored_paths=self.ignored_paths)) return new_project diff --git a/src/serena/project.py b/src/serena/project.py index ea3674ad..ae7bcc0f 100644 --- a/src/serena/project.py +++ b/src/serena/project.py @@ -2,6 +2,7 @@ import json import logging import os import threading +from collections.abc import Sequence from pathlib import Path from typing import Any @@ -56,7 +57,13 @@ class MemoriesManager: class Project(ToStringMixin): - def __init__(self, project_root: str, project_config: ProjectConfig, is_newly_created: bool = False): + def __init__( + self, + project_root: str, + project_config: ProjectConfig, + is_newly_created: bool = False, + global_ignored_paths: Sequence[str] = (), + ): self.project_root = project_root self.project_config = project_config self.memories_manager = MemoriesManager(project_root) @@ -72,6 +79,7 @@ class Project(ToStringMixin): f.write(f"/{SolidLanguageServer.CACHE_FOLDER_NAME}\n") # prepare ignore spec asynchronously, ensuring immediate project activation. + self._global_ignored_paths = global_ignored_paths self.__ignored_patterns: list[str] self.__ignore_spec: pathspec.PathSpec self._ignore_spec_available = threading.Event() @@ -80,11 +88,15 @@ class Project(ToStringMixin): def _gather_ignorespec(self) -> None: with LogTime(f"Gathering ignore spec for project {self.project_config.project_name}", logger=log): - # gather ignored paths from the project configuration and gitignore files - ignored_patterns = list(self.project_config.ignored_paths) - if len(ignored_patterns) > 0: - log.info(f"Using {len(ignored_patterns)} ignored paths from the explicit project configuration.") - log.debug(f"Ignored paths: {ignored_patterns}") + # gather ignored paths from the global configuration, project configuration, and gitignore files + ignored_patterns = list(self._global_ignored_paths) + list(self.project_config.ignored_paths) + if len(self._global_ignored_paths) > 0: + log.info(f"Using {len(self._global_ignored_paths)} ignored paths from the global configuration.") + log.debug(f"Global ignored paths: {list(self._global_ignored_paths)}") + if len(self.project_config.ignored_paths) > 0: + log.info(f"Using {len(self.project_config.ignored_paths)} ignored paths from the project configuration.") + log.debug(f"Project ignored paths: {self.project_config.ignored_paths}") + log.debug(f"Combined ignored patterns: {ignored_patterns}") if self.project_config.ignore_all_files_in_gitignore: gitignore_parser = GitignoreParser(self.project_root) for spec in gitignore_parser.get_ignore_specs(): @@ -115,12 +127,17 @@ class Project(ToStringMixin): return self.project_config.project_name @classmethod - def load(cls, project_root: str | Path, autogenerate: bool = True) -> "Project": + def load( + cls, + project_root: str | Path, + autogenerate: bool = True, + global_ignored_paths: Sequence[str] = (), + ) -> "Project": project_root = Path(project_root).resolve() if not project_root.exists(): raise FileNotFoundError(f"Project root not found: {project_root}") project_config = ProjectConfig.load(project_root, autogenerate=autogenerate) - return Project(project_root=str(project_root), project_config=project_config) + return Project(project_root=str(project_root), project_config=project_config, global_ignored_paths=global_ignored_paths) def save_config(self) -> None: """ diff --git a/src/serena/resources/project.template.yml b/src/serena/resources/project.template.yml index 9472ba95..c314bc82 100644 --- a/src/serena/resources/project.template.yml +++ b/src/serena/resources/project.template.yml @@ -34,8 +34,9 @@ encoding: "utf-8" # whether to use project's .gitignore files to ignore files ignore_all_files_in_gitignore: true -# list of additional paths to ignore in all projects -# same syntax as gitignore, so you can use * and ** +# list of additional paths to ignore in this project. +# Same syntax as gitignore, so you can use * and **. +# Note: global ignored_paths from serena_config.yml are also applied additively. ignored_paths: [] # whether the project is in read-only mode diff --git a/src/serena/resources/serena_config.template.yml b/src/serena/resources/serena_config.template.yml index 7a660e64..ac11088e 100644 --- a/src/serena/resources/serena_config.template.yml +++ b/src/serena/resources/serena_config.template.yml @@ -63,6 +63,11 @@ trace_lsp_communication: False # No documentation on options means no options are available. ls_specific_settings: {} +# list of paths to ignore across all projects. +# Same syntax as gitignore, so you can use * and **. +# These patterns are merged additively with each project's own ignored_paths. +ignored_paths: [] + # timeout, in seconds, after which tool executions are terminated tool_timeout: 240 diff --git a/test/serena/config/test_global_ignored_paths.py b/test/serena/config/test_global_ignored_paths.py new file mode 100644 index 00000000..f699915d --- /dev/null +++ b/test/serena/config/test_global_ignored_paths.py @@ -0,0 +1,256 @@ +import os +import shutil +import tempfile +from pathlib import Path + +from serena.config.serena_config import ProjectConfig, RegisteredProject, SerenaConfig +from serena.project import Project +from solidlsp.ls_config import Language + + +def _create_test_project( + project_root: Path, + project_ignored_paths: list[str] | None = None, + global_ignored_paths: list[str] | None = None, +) -> Project: + """Helper to create a Project with the given ignored paths configuration.""" + config = ProjectConfig( + project_name="test_project", + languages=[Language.PYTHON], + ignored_paths=project_ignored_paths or [], + ignore_all_files_in_gitignore=False, + ) + return Project( + project_root=str(project_root), + project_config=config, + global_ignored_paths=global_ignored_paths or [], + ) + + +class TestGlobalIgnoredPaths: + """Tests for system-global ignored_paths feature.""" + + def setup_method(self) -> None: + self.test_dir = tempfile.mkdtemp() + self.project_path = Path(self.test_dir) + # Create some test files and directories + (self.project_path / "main.py").write_text("print('hello')") + os.makedirs(self.project_path / "node_modules" / "pkg", exist_ok=True) + (self.project_path / "node_modules" / "pkg" / "index.js").write_text("module.exports = {}") + os.makedirs(self.project_path / "build", exist_ok=True) + (self.project_path / "build" / "output.js").write_text("compiled") + os.makedirs(self.project_path / "src", exist_ok=True) + (self.project_path / "src" / "app.py").write_text("def app(): pass") + (self.project_path / "debug.log").write_text("log data") + + def teardown_method(self) -> None: + shutil.rmtree(self.test_dir) + + def test_global_ignored_paths_are_applied(self) -> None: + """Global ignored_paths from SerenaConfig are respected by Project.is_ignored_path().""" + project = _create_test_project( + self.project_path, + global_ignored_paths=["node_modules"], + ) + assert project.is_ignored_path(str(self.project_path / "node_modules" / "pkg" / "index.js")) + assert not project.is_ignored_path(str(self.project_path / "src" / "app.py")) + + def test_additive_merge_of_global_and_project_patterns(self) -> None: + """Global + project patterns are merged additively (both applied).""" + project = _create_test_project( + self.project_path, + project_ignored_paths=["build"], + global_ignored_paths=["node_modules"], + ) + # Global pattern should be applied + assert project.is_ignored_path(str(self.project_path / "node_modules" / "pkg" / "index.js")) + # Project pattern should also be applied + assert project.is_ignored_path(str(self.project_path / "build" / "output.js")) + # Non-ignored files should not be affected + assert not project.is_ignored_path(str(self.project_path / "src" / "app.py")) + + def test_empty_global_ignored_paths_has_no_effect(self) -> None: + """Empty global ignored_paths (default) has no effect on existing behavior.""" + project = _create_test_project( + self.project_path, + project_ignored_paths=["build"], + global_ignored_paths=[], + ) + # Project pattern still works + assert project.is_ignored_path(str(self.project_path / "build" / "output.js")) + # Non-ignored files still accessible + assert not project.is_ignored_path(str(self.project_path / "node_modules" / "pkg" / "index.js")) + + def test_default_global_ignored_paths_backward_compatible(self) -> None: + """Project created without global_ignored_paths parameter works as before.""" + config = ProjectConfig( + project_name="test_project", + languages=[Language.PYTHON], + ignored_paths=["build"], + ignore_all_files_in_gitignore=False, + ) + project = Project( + project_root=str(self.project_path), + project_config=config, + ) + assert project.is_ignored_path(str(self.project_path / "build" / "output.js")) + assert not project.is_ignored_path(str(self.project_path / "node_modules" / "pkg" / "index.js")) + + def test_duplicate_patterns_across_global_and_project(self) -> None: + """Duplicate patterns across global and project do not cause errors.""" + project = _create_test_project( + self.project_path, + project_ignored_paths=["node_modules", "build"], + global_ignored_paths=["node_modules", "build"], + ) + assert project.is_ignored_path(str(self.project_path / "node_modules" / "pkg" / "index.js")) + assert project.is_ignored_path(str(self.project_path / "build" / "output.js")) + assert not project.is_ignored_path(str(self.project_path / "src" / "app.py")) + + def test_glob_patterns_in_global_ignored_paths(self) -> None: + """Global ignored_paths support gitignore-style glob patterns.""" + project = _create_test_project( + self.project_path, + global_ignored_paths=["*.log"], + ) + assert project.is_ignored_path(str(self.project_path / "debug.log")) + assert not project.is_ignored_path(str(self.project_path / "main.py")) + + +class TestRegisteredProjectGlobalIgnoredPaths: + """RegisteredProject.get_project_instance() correctly passes global patterns to Project.""" + + def setup_method(self) -> None: + self.test_dir = tempfile.mkdtemp() + self.project_path = Path(self.test_dir).resolve() + (self.project_path / "main.py").write_text("print('hello')") + os.makedirs(self.project_path / "node_modules", exist_ok=True) + (self.project_path / "node_modules" / "pkg.js").write_text("module") + + def teardown_method(self) -> None: + shutil.rmtree(self.test_dir) + + def test_get_project_instance_passes_global_ignored_paths(self) -> None: + """RegisteredProject.get_project_instance() passes global_ignored_paths to Project.""" + config = ProjectConfig( + project_name="test_project", + languages=[Language.PYTHON], + ignored_paths=[], + ignore_all_files_in_gitignore=False, + ) + registered = RegisteredProject( + project_root=str(self.project_path), + project_config=config, + global_ignored_paths=["node_modules"], + ) + project = registered.get_project_instance() + assert project.is_ignored_path(str(self.project_path / "node_modules" / "pkg.js")) + + def test_get_project_instance_without_global_ignored_paths(self) -> None: + """RegisteredProject without global_ignored_paths defaults to empty.""" + config = ProjectConfig( + project_name="test_project", + languages=[Language.PYTHON], + ignored_paths=[], + ignore_all_files_in_gitignore=False, + ) + registered = RegisteredProject( + project_root=str(self.project_path), + project_config=config, + ) + project = registered.get_project_instance() + assert not project.is_ignored_path(str(self.project_path / "node_modules" / "pkg.js")) + + def test_from_project_root_passes_global_ignored_paths(self) -> None: + """RegisteredProject.from_project_root() threads global_ignored_paths to Project.""" + # Create a minimal project.yml so from_project_root can load config + serena_dir = self.project_path / ".serena" + serena_dir.mkdir(exist_ok=True) + (serena_dir / "project.yml").write_text( + 'project_name: "test_project"\nlanguages: ["python"]\nignored_paths: []\nignore_all_files_in_gitignore: false\n' + ) + registered = RegisteredProject.from_project_root( + str(self.project_path), + global_ignored_paths=["node_modules"], + ) + project = registered.get_project_instance() + assert project.is_ignored_path(str(self.project_path / "node_modules" / "pkg.js")) + + def test_from_project_instance_passes_global_ignored_paths(self) -> None: + """RegisteredProject.from_project_instance() threads global_ignored_paths to Project.""" + config = ProjectConfig( + project_name="test_project", + languages=[Language.PYTHON], + ignored_paths=[], + ignore_all_files_in_gitignore=False, + ) + project = Project( + project_root=str(self.project_path), + project_config=config, + global_ignored_paths=["node_modules"], + ) + registered = RegisteredProject.from_project_instance(project, global_ignored_paths=["node_modules"]) + # The registered project already has a project_instance, so get_project_instance() returns it directly + retrieved = registered.get_project_instance() + assert retrieved.is_ignored_path(str(self.project_path / "node_modules" / "pkg.js")) + + +class TestGlobalIgnoredPathsWithGitignore: + """Global ignored_paths combined with ignore_all_files_in_gitignore produces correct three-way merge.""" + + def setup_method(self) -> None: + self.test_dir = tempfile.mkdtemp() + self.project_path = Path(self.test_dir).resolve() + # Create test files + (self.project_path / "main.py").write_text("print('hello')") + os.makedirs(self.project_path / "node_modules", exist_ok=True) + (self.project_path / "node_modules" / "pkg.js").write_text("module") + os.makedirs(self.project_path / "dist", exist_ok=True) + (self.project_path / "dist" / "bundle.js").write_text("bundled") + os.makedirs(self.project_path / "build", exist_ok=True) + (self.project_path / "build" / "output.js").write_text("compiled") + # Create .gitignore that ignores dist/ + (self.project_path / ".gitignore").write_text("dist/\n") + + def teardown_method(self) -> None: + shutil.rmtree(self.test_dir) + + def test_three_way_merge_global_project_and_gitignore(self) -> None: + """Global patterns, project patterns, and .gitignore patterns are all applied together.""" + config = ProjectConfig( + project_name="test_project", + languages=[Language.PYTHON], + ignored_paths=["build"], + ignore_all_files_in_gitignore=True, + ) + project = Project( + project_root=str(self.project_path), + project_config=config, + global_ignored_paths=["node_modules"], + ) + # Global pattern: node_modules + assert project.is_ignored_path(str(self.project_path / "node_modules" / "pkg.js")) + # Project pattern: build + assert project.is_ignored_path(str(self.project_path / "build" / "output.js")) + # Gitignore pattern: dist/ + assert project.is_ignored_path(str(self.project_path / "dist" / "bundle.js")) + # Non-ignored file + assert not project.is_ignored_path(str(self.project_path / "main.py")) + + +class TestSerenaConfigIgnoredPaths: + """Config loading with ignored_paths in serena_config.yml works correctly.""" + + def test_serena_config_default_ignored_paths(self) -> None: + """SerenaConfig defaults to empty ignored_paths.""" + config = SerenaConfig(gui_log_window=False, web_dashboard=False) + assert config.ignored_paths == [] + + def test_serena_config_with_ignored_paths(self) -> None: + """SerenaConfig can be created with explicit ignored_paths.""" + config = SerenaConfig( + gui_log_window=False, + web_dashboard=False, + ignored_paths=["node_modules", "*.log", "build"], + ) + assert config.ignored_paths == ["node_modules", "*.log", "build"] From f2beb3bb16c725051cb64068a1ccdf188366e296 Mon Sep 17 00:00:00 2001 From: takeokunn Date: Fri, 13 Feb 2026 15:37:29 +0900 Subject: [PATCH 02/14] Project: accept SerenaConfig instead of global_ignored_paths Replace `global_ignored_paths: Sequence[str]` with `serena_config: SerenaConfig | None` in Project.__init__ and propagate through RegisteredProject for more general and future-proof interfaces. --- src/serena/cli.py | 8 +++---- src/serena/config/serena_config.py | 24 +++++++++---------- src/serena/project.py | 23 ++++++++++-------- .../config/test_global_ignored_paths.py | 18 +++++++++----- 4 files changed, 41 insertions(+), 32 deletions(-) diff --git a/src/serena/cli.py b/src/serena/cli.py index 3a1b968e..ee36be22 100644 --- a/src/serena/cli.py +++ b/src/serena/cli.py @@ -567,7 +567,7 @@ class ProjectCommands(AutoRegisteringGroup): serena_config = SerenaConfig.from_config_file() registered_project = serena_config.get_registered_project(str(project_root)) if registered_project is None: - registered_project = RegisteredProject(str(project_root), generated_conf, global_ignored_paths=serena_config.ignored_paths) + registered_project = RegisteredProject(str(project_root), generated_conf, serena_config=serena_config) serena_config.add_registered_project(registered_project) return registered_project @@ -691,7 +691,7 @@ class ProjectCommands(AutoRegisteringGroup): :param project: The path to the project directory, defaults to the current working directory. """ serena_config = SerenaConfig.from_config_file() - proj = Project.load(os.path.abspath(project), global_ignored_paths=serena_config.ignored_paths) + proj = Project.load(os.path.abspath(project), serena_config=serena_config) if os.path.isabs(path): path = os.path.relpath(path, start=proj.project_root) is_ignored = proj.is_ignored_path(path) @@ -714,7 +714,7 @@ class ProjectCommands(AutoRegisteringGroup): :param verbose: if set, prints detailed information about the indexed symbols. """ serena_config = SerenaConfig.from_config_file() - proj = Project.load(os.path.abspath(project), global_ignored_paths=serena_config.ignored_paths) + proj = Project.load(os.path.abspath(project), serena_config=serena_config) if os.path.isabs(file): file = os.path.relpath(file, start=proj.project_root) if proj.is_ignored_path(file, ignore_non_source_files=True): @@ -752,7 +752,7 @@ class ProjectCommands(AutoRegisteringGroup): logging.configure(level=logging.INFO) project_path = os.path.abspath(project) serena_config = SerenaConfig.from_config_file() - proj = Project.load(project_path, global_ignored_paths=serena_config.ignored_paths) + proj = Project.load(project_path, serena_config=serena_config) # Create log file with timestamp timestamp = datetime_tag() diff --git a/src/serena/config/serena_config.py b/src/serena/config/serena_config.py index ecfb3a2b..79b8dab0 100644 --- a/src/serena/config/serena_config.py +++ b/src/serena/config/serena_config.py @@ -402,7 +402,7 @@ class RegisteredProject(ToStringMixin): project_root: str, project_config: "ProjectConfig", project_instance: Optional["Project"] = None, - global_ignored_paths: Sequence[str] = (), + serena_config: "SerenaConfig | None" = None, ) -> None: """ Represents a registered project in the Serena configuration. @@ -410,12 +410,12 @@ class RegisteredProject(ToStringMixin): :param project_root: the root directory of the project :param project_config: the configuration of the project :param project_instance: an existing project instance (if already loaded) - :param global_ignored_paths: global ignored paths from SerenaConfig, merged additively with project-level patterns + :param serena_config: the SerenaConfig instance for global settings """ self.project_root = Path(project_root).resolve() self.project_config = project_config self._project_instance = project_instance - self._global_ignored_paths = global_ignored_paths + self._serena_config = serena_config def _tostring_exclude_private(self) -> bool: return True @@ -425,21 +425,21 @@ class RegisteredProject(ToStringMixin): return self.project_config.project_name @classmethod - def from_project_instance(cls, project_instance: "Project", global_ignored_paths: Sequence[str] = ()) -> "RegisteredProject": + def from_project_instance(cls, project_instance: "Project", serena_config: "SerenaConfig | None" = None) -> "RegisteredProject": return RegisteredProject( project_root=project_instance.project_root, project_config=project_instance.project_config, project_instance=project_instance, - global_ignored_paths=global_ignored_paths, + serena_config=serena_config, ) @classmethod - def from_project_root(cls, project_root: str | Path, global_ignored_paths: Sequence[str] = ()) -> "RegisteredProject": + def from_project_root(cls, project_root: str | Path, serena_config: "SerenaConfig | None" = None) -> "RegisteredProject": project_config = ProjectConfig.load(project_root) return RegisteredProject( project_root=str(project_root), project_config=project_config, - global_ignored_paths=global_ignored_paths, + serena_config=serena_config, ) def matches_root_path(self, path: str | Path) -> bool: @@ -462,7 +462,7 @@ class RegisteredProject(ToStringMixin): self._project_instance = Project( project_root=str(self.project_root), project_config=self.project_config, - global_ignored_paths=self._global_ignored_paths, + serena_config=self._serena_config, ) return self._project_instance @@ -629,7 +629,7 @@ class SerenaConfig(ToolInclusionDefinition, ModeSelectionDefinition, ToStringMix project = RegisteredProject( project_root=str(path), project_config=project_config, - global_ignored_paths=instance.ignored_paths, + serena_config=instance, ) instance.projects.append(project) @@ -720,7 +720,7 @@ class SerenaConfig(ToolInclusionDefinition, ModeSelectionDefinition, ToStringMix if autoregister: config_path = ProjectConfig.path_to_project_yml(project_root_or_name) if os.path.isfile(config_path): - registered_project = RegisteredProject.from_project_root(project_root_or_name, global_ignored_paths=self.ignored_paths) + registered_project = RegisteredProject.from_project_root(project_root_or_name, serena_config=self) self.add_registered_project(registered_project) return registered_project # nothing found @@ -769,9 +769,9 @@ class SerenaConfig(ToolInclusionDefinition, ModeSelectionDefinition, ToStringMix project_root=str(project_root), project_config=project_config, is_newly_created=True, - global_ignored_paths=self.ignored_paths, + serena_config=self, ) - self.add_registered_project(RegisteredProject.from_project_instance(new_project, global_ignored_paths=self.ignored_paths)) + self.add_registered_project(RegisteredProject.from_project_instance(new_project, serena_config=self)) return new_project diff --git a/src/serena/project.py b/src/serena/project.py index ae7bcc0f..c0d4203b 100644 --- a/src/serena/project.py +++ b/src/serena/project.py @@ -2,9 +2,8 @@ import json import logging import os import threading -from collections.abc import Sequence from pathlib import Path -from typing import Any +from typing import TYPE_CHECKING, Any import pathspec from sensai.util.logging import LogTime @@ -19,6 +18,9 @@ from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_utils import FileUtils +if TYPE_CHECKING: + from serena.config.serena_config import SerenaConfig + log = logging.getLogger(__name__) @@ -62,7 +64,7 @@ class Project(ToStringMixin): project_root: str, project_config: ProjectConfig, is_newly_created: bool = False, - global_ignored_paths: Sequence[str] = (), + serena_config: "SerenaConfig | None" = None, ): self.project_root = project_root self.project_config = project_config @@ -79,7 +81,7 @@ class Project(ToStringMixin): f.write(f"/{SolidLanguageServer.CACHE_FOLDER_NAME}\n") # prepare ignore spec asynchronously, ensuring immediate project activation. - self._global_ignored_paths = global_ignored_paths + self._serena_config = serena_config self.__ignored_patterns: list[str] self.__ignore_spec: pathspec.PathSpec self._ignore_spec_available = threading.Event() @@ -89,10 +91,11 @@ class Project(ToStringMixin): with LogTime(f"Gathering ignore spec for project {self.project_config.project_name}", logger=log): # gather ignored paths from the global configuration, project configuration, and gitignore files - ignored_patterns = list(self._global_ignored_paths) + list(self.project_config.ignored_paths) - if len(self._global_ignored_paths) > 0: - log.info(f"Using {len(self._global_ignored_paths)} ignored paths from the global configuration.") - log.debug(f"Global ignored paths: {list(self._global_ignored_paths)}") + global_ignored_paths = self._serena_config.ignored_paths if self._serena_config else [] + ignored_patterns = list(global_ignored_paths) + list(self.project_config.ignored_paths) + if len(global_ignored_paths) > 0: + log.info(f"Using {len(global_ignored_paths)} ignored paths from the global configuration.") + log.debug(f"Global ignored paths: {list(global_ignored_paths)}") if len(self.project_config.ignored_paths) > 0: log.info(f"Using {len(self.project_config.ignored_paths)} ignored paths from the project configuration.") log.debug(f"Project ignored paths: {self.project_config.ignored_paths}") @@ -131,13 +134,13 @@ class Project(ToStringMixin): cls, project_root: str | Path, autogenerate: bool = True, - global_ignored_paths: Sequence[str] = (), + serena_config: "SerenaConfig | None" = None, ) -> "Project": project_root = Path(project_root).resolve() if not project_root.exists(): raise FileNotFoundError(f"Project root not found: {project_root}") project_config = ProjectConfig.load(project_root, autogenerate=autogenerate) - return Project(project_root=str(project_root), project_config=project_config, global_ignored_paths=global_ignored_paths) + return Project(project_root=str(project_root), project_config=project_config, serena_config=serena_config) def save_config(self) -> None: """ diff --git a/test/serena/config/test_global_ignored_paths.py b/test/serena/config/test_global_ignored_paths.py index f699915d..230a950d 100644 --- a/test/serena/config/test_global_ignored_paths.py +++ b/test/serena/config/test_global_ignored_paths.py @@ -20,10 +20,13 @@ def _create_test_project( ignored_paths=project_ignored_paths or [], ignore_all_files_in_gitignore=False, ) + serena_config: SerenaConfig | None = None + if global_ignored_paths: + serena_config = SerenaConfig(gui_log_window=False, web_dashboard=False, ignored_paths=global_ignored_paths) return Project( project_root=str(project_root), project_config=config, - global_ignored_paths=global_ignored_paths or [], + serena_config=serena_config, ) @@ -138,10 +141,11 @@ class TestRegisteredProjectGlobalIgnoredPaths: ignored_paths=[], ignore_all_files_in_gitignore=False, ) + serena_config = SerenaConfig(gui_log_window=False, web_dashboard=False, ignored_paths=["node_modules"]) registered = RegisteredProject( project_root=str(self.project_path), project_config=config, - global_ignored_paths=["node_modules"], + serena_config=serena_config, ) project = registered.get_project_instance() assert project.is_ignored_path(str(self.project_path / "node_modules" / "pkg.js")) @@ -171,7 +175,7 @@ class TestRegisteredProjectGlobalIgnoredPaths: ) registered = RegisteredProject.from_project_root( str(self.project_path), - global_ignored_paths=["node_modules"], + serena_config=SerenaConfig(gui_log_window=False, web_dashboard=False, ignored_paths=["node_modules"]), ) project = registered.get_project_instance() assert project.is_ignored_path(str(self.project_path / "node_modules" / "pkg.js")) @@ -184,12 +188,13 @@ class TestRegisteredProjectGlobalIgnoredPaths: ignored_paths=[], ignore_all_files_in_gitignore=False, ) + serena_config = SerenaConfig(gui_log_window=False, web_dashboard=False, ignored_paths=["node_modules"]) project = Project( project_root=str(self.project_path), project_config=config, - global_ignored_paths=["node_modules"], + serena_config=serena_config, ) - registered = RegisteredProject.from_project_instance(project, global_ignored_paths=["node_modules"]) + registered = RegisteredProject.from_project_instance(project, serena_config=serena_config) # The registered project already has a project_instance, so get_project_instance() returns it directly retrieved = registered.get_project_instance() assert retrieved.is_ignored_path(str(self.project_path / "node_modules" / "pkg.js")) @@ -223,10 +228,11 @@ class TestGlobalIgnoredPathsWithGitignore: ignored_paths=["build"], ignore_all_files_in_gitignore=True, ) + serena_config = SerenaConfig(gui_log_window=False, web_dashboard=False, ignored_paths=["node_modules"]) project = Project( project_root=str(self.project_path), project_config=config, - global_ignored_paths=["node_modules"], + serena_config=serena_config, ) # Global pattern: node_modules assert project.is_ignored_path(str(self.project_path / "node_modules" / "pkg.js")) From 43902782fa47f5fd107fca812d7058154ef557d5 Mon Sep 17 00:00:00 2001 From: takeokunn Date: Fri, 13 Feb 2026 15:37:59 +0900 Subject: [PATCH 03/14] Project.load: accept SerenaConfig instead of global_ignored_paths Use `serena_config: SerenaConfig | None` without a default value, requiring callers to explicitly pass the configuration. --- src/serena/project.py | 2 +- test/conftest.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/serena/project.py b/src/serena/project.py index c0d4203b..c176dddb 100644 --- a/src/serena/project.py +++ b/src/serena/project.py @@ -133,8 +133,8 @@ class Project(ToStringMixin): def load( cls, project_root: str | Path, + serena_config: "SerenaConfig | None", autogenerate: bool = True, - serena_config: "SerenaConfig | None" = None, ) -> "Project": project_root = Path(project_root).resolve() if not project_root.exists(): diff --git a/test/conftest.py b/test/conftest.py index d774a328..5aa1ee58 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -109,7 +109,7 @@ def start_default_ls_context(language: Language) -> Iterator[SolidLanguageServer def _create_default_project(language: Language) -> Project: repo_path = str(get_repo_path(language)) - return Project.load(repo_path) + return Project.load(repo_path, serena_config=None) @pytest.fixture(scope="session") From 0025402f37057c7fd41812249bff79bcd01961a9 Mon Sep 17 00:00:00 2001 From: takeokunn Date: Fri, 13 Feb 2026 15:39:52 +0900 Subject: [PATCH 04/14] RegisteredProject: pass SerenaConfig through get_project_instance Remove serena_config storage from RegisteredProject; instead accept it as an explicit parameter in get_project_instance() for cleaner separation of concerns. --- src/serena/cli.py | 4 ++-- src/serena/config/serena_config.py | 20 +++++++------------ .../config/test_global_ignored_paths.py | 13 ++++++------ 3 files changed, 15 insertions(+), 22 deletions(-) diff --git a/src/serena/cli.py b/src/serena/cli.py index ee36be22..33595e70 100644 --- a/src/serena/cli.py +++ b/src/serena/cli.py @@ -567,7 +567,7 @@ class ProjectCommands(AutoRegisteringGroup): serena_config = SerenaConfig.from_config_file() registered_project = serena_config.get_registered_project(str(project_root)) if registered_project is None: - registered_project = RegisteredProject(str(project_root), generated_conf, serena_config=serena_config) + registered_project = RegisteredProject(str(project_root), generated_conf) serena_config.add_registered_project(registered_project) return registered_project @@ -637,7 +637,7 @@ class ProjectCommands(AutoRegisteringGroup): lvl = logging.getLevelNamesMapping()[log_level.upper()] logging.configure(level=lvl) serena_config = SerenaConfig.from_config_file() - proj = registered_project.get_project_instance() + proj = registered_project.get_project_instance(serena_config=serena_config) click.echo(f"Indexing symbols in {proj} …") ls_mgr = proj.create_language_server_manager( log_level=lvl, ls_timeout=timeout, ls_specific_settings=serena_config.ls_specific_settings diff --git a/src/serena/config/serena_config.py b/src/serena/config/serena_config.py index 79b8dab0..be7faaff 100644 --- a/src/serena/config/serena_config.py +++ b/src/serena/config/serena_config.py @@ -402,7 +402,6 @@ class RegisteredProject(ToStringMixin): project_root: str, project_config: "ProjectConfig", project_instance: Optional["Project"] = None, - serena_config: "SerenaConfig | None" = None, ) -> None: """ Represents a registered project in the Serena configuration. @@ -410,12 +409,10 @@ class RegisteredProject(ToStringMixin): :param project_root: the root directory of the project :param project_config: the configuration of the project :param project_instance: an existing project instance (if already loaded) - :param serena_config: the SerenaConfig instance for global settings """ self.project_root = Path(project_root).resolve() self.project_config = project_config self._project_instance = project_instance - self._serena_config = serena_config def _tostring_exclude_private(self) -> bool: return True @@ -425,21 +422,19 @@ class RegisteredProject(ToStringMixin): return self.project_config.project_name @classmethod - def from_project_instance(cls, project_instance: "Project", serena_config: "SerenaConfig | None" = None) -> "RegisteredProject": + def from_project_instance(cls, project_instance: "Project") -> "RegisteredProject": return RegisteredProject( project_root=project_instance.project_root, project_config=project_instance.project_config, project_instance=project_instance, - serena_config=serena_config, ) @classmethod - def from_project_root(cls, project_root: str | Path, serena_config: "SerenaConfig | None" = None) -> "RegisteredProject": + def from_project_root(cls, project_root: str | Path) -> "RegisteredProject": project_config = ProjectConfig.load(project_root) return RegisteredProject( project_root=str(project_root), project_config=project_config, - serena_config=serena_config, ) def matches_root_path(self, path: str | Path) -> bool: @@ -451,7 +446,7 @@ class RegisteredProject(ToStringMixin): """ return self.project_root.samefile(Path(path).resolve()) - def get_project_instance(self) -> "Project": + def get_project_instance(self, serena_config: "SerenaConfig | None") -> "Project": """ Returns the project instance for this registered project, loading it if necessary. """ @@ -462,7 +457,7 @@ class RegisteredProject(ToStringMixin): self._project_instance = Project( project_root=str(self.project_root), project_config=self.project_config, - serena_config=self._serena_config, + serena_config=serena_config, ) return self._project_instance @@ -629,7 +624,6 @@ class SerenaConfig(ToolInclusionDefinition, ModeSelectionDefinition, ToStringMix project = RegisteredProject( project_root=str(path), project_config=project_config, - serena_config=instance, ) instance.projects.append(project) @@ -720,7 +714,7 @@ class SerenaConfig(ToolInclusionDefinition, ModeSelectionDefinition, ToStringMix if autoregister: config_path = ProjectConfig.path_to_project_yml(project_root_or_name) if os.path.isfile(config_path): - registered_project = RegisteredProject.from_project_root(project_root_or_name, serena_config=self) + registered_project = RegisteredProject.from_project_root(project_root_or_name) self.add_registered_project(registered_project) return registered_project # nothing found @@ -731,7 +725,7 @@ class SerenaConfig(ToolInclusionDefinition, ModeSelectionDefinition, ToStringMix if registered_project is None: return None else: - return registered_project.get_project_instance() + return registered_project.get_project_instance(serena_config=self) def add_registered_project(self, registered_project: RegisteredProject) -> None: """ @@ -771,7 +765,7 @@ class SerenaConfig(ToolInclusionDefinition, ModeSelectionDefinition, ToStringMix is_newly_created=True, serena_config=self, ) - self.add_registered_project(RegisteredProject.from_project_instance(new_project, serena_config=self)) + self.add_registered_project(RegisteredProject.from_project_instance(new_project)) return new_project diff --git a/test/serena/config/test_global_ignored_paths.py b/test/serena/config/test_global_ignored_paths.py index 230a950d..23481373 100644 --- a/test/serena/config/test_global_ignored_paths.py +++ b/test/serena/config/test_global_ignored_paths.py @@ -145,9 +145,8 @@ class TestRegisteredProjectGlobalIgnoredPaths: registered = RegisteredProject( project_root=str(self.project_path), project_config=config, - serena_config=serena_config, ) - project = registered.get_project_instance() + project = registered.get_project_instance(serena_config=serena_config) assert project.is_ignored_path(str(self.project_path / "node_modules" / "pkg.js")) def test_get_project_instance_without_global_ignored_paths(self) -> None: @@ -162,7 +161,7 @@ class TestRegisteredProjectGlobalIgnoredPaths: project_root=str(self.project_path), project_config=config, ) - project = registered.get_project_instance() + project = registered.get_project_instance(serena_config=None) assert not project.is_ignored_path(str(self.project_path / "node_modules" / "pkg.js")) def test_from_project_root_passes_global_ignored_paths(self) -> None: @@ -173,11 +172,11 @@ class TestRegisteredProjectGlobalIgnoredPaths: (serena_dir / "project.yml").write_text( 'project_name: "test_project"\nlanguages: ["python"]\nignored_paths: []\nignore_all_files_in_gitignore: false\n' ) + serena_config = SerenaConfig(gui_log_window=False, web_dashboard=False, ignored_paths=["node_modules"]) registered = RegisteredProject.from_project_root( str(self.project_path), - serena_config=SerenaConfig(gui_log_window=False, web_dashboard=False, ignored_paths=["node_modules"]), ) - project = registered.get_project_instance() + project = registered.get_project_instance(serena_config=serena_config) assert project.is_ignored_path(str(self.project_path / "node_modules" / "pkg.js")) def test_from_project_instance_passes_global_ignored_paths(self) -> None: @@ -194,9 +193,9 @@ class TestRegisteredProjectGlobalIgnoredPaths: project_config=config, serena_config=serena_config, ) - registered = RegisteredProject.from_project_instance(project, serena_config=serena_config) + registered = RegisteredProject.from_project_instance(project) # The registered project already has a project_instance, so get_project_instance() returns it directly - retrieved = registered.get_project_instance() + retrieved = registered.get_project_instance(serena_config=serena_config) assert retrieved.is_ignored_path(str(self.project_path / "node_modules" / "pkg.js")) From d69f53cd5ac945410785b111581a0ed52565d411 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Fri, 13 Feb 2026 23:05:27 +0100 Subject: [PATCH 05/14] Hotfix for serena-jetbrains-plugin/issues/13 --- src/serena/tools/jetbrains_tools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/serena/tools/jetbrains_tools.py b/src/serena/tools/jetbrains_tools.py index 096cd1e2..72e0db9c 100644 --- a/src/serena/tools/jetbrains_tools.py +++ b/src/serena/tools/jetbrains_tools.py @@ -116,7 +116,7 @@ class JetBrainsFindReferencingSymbolsTool(Tool, ToolMarkerSymbolicRead, ToolMark response_dict = client.find_references( name_path=name_path, relative_path=relative_path, - include_quick_info=include_info, + include_quick_info=False, # TODO: Hotfix for serena-jetbrains-plugin/issues/13; revert once fixed ) result = self._to_json(response_dict) return self._limit_length(result, max_answer_chars) From 614b0356216dd837b407b9e053701bd336d0cab1 Mon Sep 17 00:00:00 2001 From: takeokunn Date: Sat, 14 Feb 2026 17:30:59 +0900 Subject: [PATCH 06/14] fix(tests): auto-skip Phpactor tests if PHP is unavailable - Add check in conftest.py to disable PHP_PHPACTOR tests when php binary is not found - Refactor test_php_basic.py to use dynamic PHP server list based on availability - Ensure tests for PHP_PHPACTOR are only parametrized if PHP is present --- test/conftest.py | 5 +++++ test/solidlsp/php/test_php_basic.py | 17 +++++++++++------ 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/test/conftest.py b/test/conftest.py index 6d2cf211..822006f2 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -236,6 +236,11 @@ def _determine_disabled_languages() -> list[Language]: if not clangd_tests_enabled: result.append(Language.CPP) + # Disable PHP_PHPACTOR tests if php is not available + php_tests_enabled = _sh.which("php") is not None + if not php_tests_enabled: + result.append(Language.PHP_PHPACTOR) + al_tests_enabled = True if not al_tests_enabled: result.append(Language.AL) diff --git a/test/solidlsp/php/test_php_basic.py b/test/solidlsp/php/test_php_basic.py index 5396687d..fc465da0 100644 --- a/test/solidlsp/php/test_php_basic.py +++ b/test/solidlsp/php/test_php_basic.py @@ -5,18 +5,23 @@ import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language +from test.conftest import language_tests_enabled + +_php_servers: list[Language] = [Language.PHP] +if language_tests_enabled(Language.PHP_PHPACTOR): + _php_servers.append(Language.PHP_PHPACTOR) @pytest.mark.php class TestPhpLanguageServers: - @pytest.mark.parametrize("language_server", [Language.PHP, Language.PHP_PHPACTOR], indirect=True) + @pytest.mark.parametrize("language_server", _php_servers, indirect=True) @pytest.mark.parametrize("repo_path", [Language.PHP], indirect=True) def test_ls_is_running(self, language_server: SolidLanguageServer, repo_path: Path) -> None: """Test that the language server starts and stops successfully.""" assert language_server.is_running() assert Path(language_server.language_server.repository_root_path).resolve() == repo_path.resolve() - @pytest.mark.parametrize("language_server", [Language.PHP, Language.PHP_PHPACTOR], indirect=True) + @pytest.mark.parametrize("language_server", _php_servers, indirect=True) @pytest.mark.parametrize("repo_path", [Language.PHP], indirect=True) def test_find_definition_within_file(self, language_server: SolidLanguageServer, repo_path: Path) -> None: @@ -46,7 +51,7 @@ class TestPhpLanguageServers: if language_server.language_server.language != Language.PHP_PHPACTOR: assert definition_location["range"]["start"]["character"] == 0 - @pytest.mark.parametrize("language_server", [Language.PHP, Language.PHP_PHPACTOR], indirect=True) + @pytest.mark.parametrize("language_server", _php_servers, indirect=True) @pytest.mark.parametrize("repo_path", [Language.PHP], indirect=True) def test_find_definition_across_files(self, language_server: SolidLanguageServer, repo_path: Path) -> None: # Intelephense uses line 12 (0-indexed), Phpactor uses line 13 (0-indexed) @@ -66,7 +71,7 @@ class TestPhpLanguageServers: if language_server.language_server.language != Language.PHP_PHPACTOR: assert definition_location["range"]["start"]["character"] == 0 - @pytest.mark.parametrize("language_server", [Language.PHP, Language.PHP_PHPACTOR], indirect=True) + @pytest.mark.parametrize("language_server", _php_servers, indirect=True) @pytest.mark.parametrize("repo_path", [Language.PHP], indirect=True) def test_find_definition_simple_variable(self, language_server: SolidLanguageServer, repo_path: Path) -> None: file_path = str(repo_path / "simple_var.php") @@ -91,7 +96,7 @@ class TestPhpLanguageServers: if language_server.language_server.language != Language.PHP_PHPACTOR: assert definition_location["range"]["start"]["character"] == 0 # $localVar (0-indexed) - @pytest.mark.parametrize("language_server", [Language.PHP, Language.PHP_PHPACTOR], indirect=True) + @pytest.mark.parametrize("language_server", _php_servers, indirect=True) @pytest.mark.parametrize("repo_path", [Language.PHP], indirect=True) def test_find_references_within_file(self, language_server: SolidLanguageServer, repo_path: Path) -> None: if language_server.language == Language.PHP_PHPACTOR and platform.system() == "Windows": @@ -142,7 +147,7 @@ class TestPhpLanguageServers: assert actual_locations == expected_locations - @pytest.mark.parametrize("language_server", [Language.PHP, Language.PHP_PHPACTOR], indirect=True) + @pytest.mark.parametrize("language_server", _php_servers, indirect=True) @pytest.mark.parametrize("repo_path", [Language.PHP], indirect=True) def test_find_references_across_files(self, language_server: SolidLanguageServer, repo_path: Path) -> None: if language_server.language_server.language == Language.PHP_PHPACTOR and platform.system() == "Windows": From 558caa0ad55c2e1499ba76a55ae26dda2085bbfb Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sat, 14 Feb 2026 13:25:45 +0100 Subject: [PATCH 07/14] Disable Phpactor tests in Windows CI #1039 --- test/conftest.py | 3 +++ test/solidlsp/php/test_php_basic.py | 12 ++++-------- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/test/conftest.py b/test/conftest.py index 85d38a05..565d40b7 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -1,5 +1,6 @@ import logging import os +import platform import shutil as _sh from collections.abc import Iterator from contextlib import contextmanager @@ -209,6 +210,8 @@ is_ci = os.getenv("CI") == "true" or os.getenv("GITHUB_ACTIONS") == "true" Flag indicating whether the tests are running in the GitHub CI environment. """ +is_windows = platform.system() == "Windows" + def _determine_disabled_languages() -> list[Language]: """ diff --git a/test/solidlsp/php/test_php_basic.py b/test/solidlsp/php/test_php_basic.py index fc465da0..d10f1468 100644 --- a/test/solidlsp/php/test_php_basic.py +++ b/test/solidlsp/php/test_php_basic.py @@ -1,17 +1,19 @@ -import platform from pathlib import Path import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language -from test.conftest import language_tests_enabled +from test.conftest import is_ci, is_windows, language_tests_enabled _php_servers: list[Language] = [Language.PHP] if language_tests_enabled(Language.PHP_PHPACTOR): _php_servers.append(Language.PHP_PHPACTOR) +@pytest.mark.skipif( + is_ci and is_windows, reason="Tests are flaky" +) # TODO: Re-enable once we have a solution for running Phpactor tests on Windows CI #1039 @pytest.mark.php class TestPhpLanguageServers: @pytest.mark.parametrize("language_server", _php_servers, indirect=True) @@ -99,9 +101,6 @@ class TestPhpLanguageServers: @pytest.mark.parametrize("language_server", _php_servers, indirect=True) @pytest.mark.parametrize("repo_path", [Language.PHP], indirect=True) def test_find_references_within_file(self, language_server: SolidLanguageServer, repo_path: Path) -> None: - if language_server.language == Language.PHP_PHPACTOR and platform.system() == "Windows": - pytest.skip("Phpactor references test not working on Windows, skipping for now") - index_php_path = str(repo_path / "index.php") # In index.php (0-indexed lines): @@ -150,9 +149,6 @@ class TestPhpLanguageServers: @pytest.mark.parametrize("language_server", _php_servers, indirect=True) @pytest.mark.parametrize("repo_path", [Language.PHP], indirect=True) def test_find_references_across_files(self, language_server: SolidLanguageServer, repo_path: Path) -> None: - if language_server.language_server.language == Language.PHP_PHPACTOR and platform.system() == "Windows": - pytest.skip("Phpactor references test not working on Windows, skipping for now") - helper_php_path = str(repo_path / "helper.php") # In index.php (0-indexed lines): # Line 13: helperFunction(); // Usage of helperFunction From 9e95663cd71e387714fbaca91e685b3a61ef09db Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sat, 14 Feb 2026 13:29:50 +0100 Subject: [PATCH 08/14] Disable TestFSharpLanguageServer::test_find_referencing_symbols_across_files in CI #1039 --- test/solidlsp/fsharp/test_fsharp_basic.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/solidlsp/fsharp/test_fsharp_basic.py b/test/solidlsp/fsharp/test_fsharp_basic.py index 15de4ad7..4e4e03c7 100644 --- a/test/solidlsp/fsharp/test_fsharp_basic.py +++ b/test/solidlsp/fsharp/test_fsharp_basic.py @@ -10,6 +10,7 @@ from solidlsp import SolidLanguageServer from solidlsp.language_servers.fsharp_language_server import FSharpLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_utils import SymbolUtils +from test.conftest import is_ci @pytest.mark.fsharp @@ -87,6 +88,7 @@ class TestFSharpLanguageServer: for expected in expected_symbols: assert expected in symbol_names, f"{expected} not found in Person.fs symbols" + @pytest.mark.skipif(is_ci, reason="Test is flaky") # TODO: Re-enable if the LS can be made more reliable #1039 @pytest.mark.parametrize("language_server", [Language.FSHARP], indirect=True) def test_find_referencing_symbols_across_files(self, language_server: SolidLanguageServer) -> None: """Test finding references to Calculator functions across files.""" From 2e2384020a009c7d8ce7f3bcd8a89c70f8c7bfa8 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sat, 14 Feb 2026 13:32:21 +0100 Subject: [PATCH 09/14] Disable TestNixLanguageServer::test_hover_information in CI #1039 --- test/solidlsp/nix/test_nix_basic.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/solidlsp/nix/test_nix_basic.py b/test/solidlsp/nix/test_nix_basic.py index 5c4e3293..9bfedbf3 100644 --- a/test/solidlsp/nix/test_nix_basic.py +++ b/test/solidlsp/nix/test_nix_basic.py @@ -10,6 +10,7 @@ import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language +from test.conftest import is_ci # Skip all Nix tests on Windows as Nix doesn't support Windows pytestmark = pytest.mark.skipif(platform.system() == "Windows", reason="Nix and nil are not available on Windows") @@ -115,6 +116,7 @@ class TestNixLanguageServer: # Check if we found the inherit (line 67, 0-indexed: 66) assert 66 in ref_lines, f"Should find makeGreeting inherit at line 67, found at lines {[l+1 for l in ref_lines]}" + @pytest.mark.skipif(is_ci, reason="Test is flaky") # TODO: Re-enable if the hover test becomes more stable (#1039) @pytest.mark.parametrize("language_server", [Language.NIX], indirect=True) def test_hover_information(self, language_server: SolidLanguageServer) -> None: """Test hover information for symbols.""" From 3ab0720ff49c34f14680a72a44f46756d89798cc Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sat, 14 Feb 2026 13:43:14 +0100 Subject: [PATCH 10/14] Disable TestSerenaAgent::test_find_symbol for FSHARP in CI #1039 --- test/serena/test_serena_agent.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/test/serena/test_serena_agent.py b/test/serena/test_serena_agent.py index b43be9e8..63fb03f9 100644 --- a/test/serena/test_serena_agent.py +++ b/test/serena/test_serena_agent.py @@ -15,7 +15,7 @@ from serena.project import Project from serena.tools import SUCCESS_RESULT, FindReferencingSymbolsTool, FindSymbolTool, ReplaceContentTool, ReplaceSymbolBodyTool from solidlsp.ls_config import Language from solidlsp.ls_types import SymbolKind -from test.conftest import get_repo_path, language_tests_enabled +from test.conftest import get_repo_path, is_ci, language_tests_enabled from test.solidlsp import clojure as clj @@ -124,6 +124,12 @@ class TestSerenaAgent: indirect=["serena_agent"], ) def test_find_symbol(self, serena_agent: SerenaAgent, symbol_name: str, expected_kind: str, expected_file: str): + # skip flaky tests in CI + # TODO: Revisit the flaky tests and re-enable once the LS issues are resolved #1039 + flaky_languages = {Language.FSHARP} + if set(serena_agent.get_active_lsp_languages()).intersection(flaky_languages) and is_ci: + pytest.skip("Test is flaky and thus skipped in CI environment.") + agent = serena_agent find_symbol_tool = agent.get_tool(FindSymbolTool) result = find_symbol_tool.apply_ex(name_path_pattern=symbol_name, include_info=True) From ee992690f2d3cd4e6a5567321253e8ce30416be2 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sat, 14 Feb 2026 17:43:35 +0100 Subject: [PATCH 11/14] Add sleep time to fix flaky test in Windows CI --- test/solidlsp/test_ls_common.py | 1 + 1 file changed, 1 insertion(+) diff --git a/test/solidlsp/test_ls_common.py b/test/solidlsp/test_ls_common.py index f98fd05d..ca3e05e0 100644 --- a/test/solidlsp/test_ls_common.py +++ b/test/solidlsp/test_ls_common.py @@ -36,6 +36,7 @@ class TestLanguageServerCommonFunctionality: if is_ci and platform.system() == "Windows": now = time.time() os.utime(file_path, (now, now)) + time.sleep(5) # check that the file buffer has been invalidated and reloaded assert fb.contents == test_string2 From 31d458583fa737a195a172bf5628ebd8e7fa219b Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sat, 14 Feb 2026 18:05:08 +0100 Subject: [PATCH 12/14] Remove 'on CI' suffix from CI task name [skip ci] --- .github/workflows/pytest.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 42dce26b..52268702 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -1,4 +1,4 @@ -name: Tests on CI +name: Tests on: pull_request: From 2024747e7ffcaa3600f630970f14a99828c7b6a5 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sat, 14 Feb 2026 19:10:25 +0100 Subject: [PATCH 13/14] Disable TestSerenaAgent::test_find_symbol_references for TYPESCRIPT in CI #1039 --- test/serena/test_serena_agent.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test/serena/test_serena_agent.py b/test/serena/test_serena_agent.py index 63fb03f9..abd6bc2c 100644 --- a/test/serena/test_serena_agent.py +++ b/test/serena/test_serena_agent.py @@ -199,6 +199,12 @@ class TestSerenaAgent: indirect=["serena_agent"], ) def test_find_symbol_references(self, serena_agent: SerenaAgent, symbol_name: str, def_file: str, ref_file: str) -> None: + # skip flaky tests in CI + # TODO: Revisit the flaky tests and re-enable once the LS issues are resolved #1039 + flaky_languages = {Language.TYPESCRIPT} + if set(serena_agent.get_active_lsp_languages()).intersection(flaky_languages) and is_ci: + pytest.skip("Test is flaky and thus skipped in CI environment.") + agent = serena_agent # Find the symbol location first From e20af1a328fdedc2d2a68d2a489660c250e95eec Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sat, 14 Feb 2026 19:13:02 +0100 Subject: [PATCH 14/14] Disable TestLanguageServerCommonFunctionality::test_open_file_cache_invalidate in Windows CI #1039 --- test/solidlsp/test_ls_common.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/test/solidlsp/test_ls_common.py b/test/solidlsp/test_ls_common.py index ca3e05e0..d43cf836 100644 --- a/test/solidlsp/test_ls_common.py +++ b/test/solidlsp/test_ls_common.py @@ -1,17 +1,18 @@ import os -import platform -import time import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language -from test.conftest import is_ci +from test.conftest import is_ci, is_windows class TestLanguageServerCommonFunctionality: """Test common functionality of SolidLanguageServer base implementation (not language-specific behaviour).""" + @pytest.mark.skipif( + is_ci and is_windows, reason="This test is flaky in Windows CI (file system does not update modified time reliably)." + ) @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) def test_open_file_cache_invalidate(self, language_server: SolidLanguageServer) -> None: """ @@ -32,12 +33,6 @@ class TestLanguageServerCommonFunctionality: with open(file_path, "w") as f: f.write(test_string2) - # ensure that mtime is updated for Windows CI (odd FS behaviour, updates lazily) - if is_ci and platform.system() == "Windows": - now = time.time() - os.utime(file_path, (now, now)) - time.sleep(5) - # check that the file buffer has been invalidated and reloaded assert fb.contents == test_string2