Fix: Ensure that local overrides from project.local.yml are never written to project.yml

Previously, only project.yml resave due to incomplete config accounted for this,
but dynamic language changes triggered via the dashboard (which triggered a save
to update the language list) did not.

Since ProjectConfig can always contain local overrides,
* we handle configuration updates by explicitly updating on disk only the
  keys known to have been changed
* we explicitly represent the presence of local overrides, such that
  inadmissible save operations are detectable.
This commit is contained in:
Dominik Jain committed 2026-03-17 01:37:57 +01:00
1 parent b659f915f0
commit e357323051
3 files changed
+60 -16

No files matched your search

+48 -9
View File
@@ -245,6 +245,10 @@ class ProjectConfig(SharedConfig):
initial_prompt: str = ""
encoding: str = DEFAULT_SOURCE_FILE_ENCODING
# internal fields which are not mapped to/from the configuration file (must start with "_")
_has_local_overrides: bool = field(default=False, repr=False, compare=False)
# class-level constants
SERENA_PROJECT_FILE = "project.yml"
SERENA_LOCAL_PROJECT_FILE = "project.local.yml"
FIELDS_WITHOUT_DEFAULTS = {"project_name", "languages"}
@@ -339,7 +343,8 @@ class ProjectConfig(SharedConfig):
save_yaml(project_yml_path, config_with_comments)
project_local_yml_path = os.path.join(os.path.dirname(project_yml_path), cls.SERENA_LOCAL_PROJECT_FILE)
shutil.copy(PROJECT_LOCAL_TEMPLATE_FILE, project_local_yml_path)
return cls._from_dict(config_with_comments)
return cls._from_dict(config_with_comments, has_local_overrides=False)
@classmethod
def default_project_yml_path(cls, project_root: str | Path) -> str:
@@ -378,6 +383,8 @@ class ProjectConfig(SharedConfig):
if apply_defaults:
for field_info in dataclasses.fields(cls):
key = field_info.name
if key.startswith("_"):
continue
if key in cls.FIELDS_WITHOUT_DEFAULTS:
continue
if key not in data:
@@ -395,9 +402,13 @@ class ProjectConfig(SharedConfig):
return data, was_complete
@classmethod
def _from_dict(cls, data: dict[str, Any]) -> Self:
def _from_dict(cls, data: dict[str, Any], has_local_overrides: bool) -> Self:
"""
Create a ProjectConfig instance from a (full) configuration dictionary
:param data: the configuration dictionary; must contain all required fields and use the same field names as
the ProjectConfig dataclass
:param has_local_overrides: whether this configuration contains overrides from a project.local.yml file
"""
lang_name_mapping = {"javascript": "typescript"}
languages: list[Language] = []
@@ -448,6 +459,7 @@ class ProjectConfig(SharedConfig):
base_modes=data["base_modes"],
default_modes=data["default_modes"],
symbol_info_budget=symbol_info_budget,
_has_local_overrides=has_local_overrides,
)
def _to_yaml_dict(self) -> dict:
@@ -455,9 +467,18 @@ class ProjectConfig(SharedConfig):
:return: a yaml-serializable dictionary representation of this configuration
"""
d = dataclasses.asdict(self)
# drop internal fields starting with underscore
keys = list(d.keys())
for k in keys:
if k.startswith("_"):
del d[k]
# map fields using non-primitive types to a YAML-compatible representation
d["languages"] = [lang.value for lang in self.languages]
d["language_backend"] = self.language_backend.value if self.language_backend is not None else None
d["line_ending"] = self.line_ending.value if self.line_ending is not None else None
return d
@classmethod
@@ -488,12 +509,13 @@ class ProjectConfig(SharedConfig):
# if the configuration was incomplete, re-save it to disk
if not was_complete:
project_config = cls._from_dict(yaml_data)
project_config = cls._from_dict(yaml_data, has_local_overrides=False)
log.info("Project configuration in %s was incomplete, re-saving with default values for missing fields", yaml_path)
project_config.save(str(yaml_path))
project_config.save(str(yaml_path), keys=None)
# apply overrides from project.local.yml, if present
local_yaml_path = os.path.join(os.path.dirname(yaml_path), cls.SERENA_LOCAL_PROJECT_FILE)
has_local_overrides = False
if os.path.exists(local_yaml_path):
local_yaml_data, _ = cls._load_yaml_dict(local_yaml_path, apply_defaults=False)
if local_yaml_data:
@@ -503,24 +525,41 @@ class ProjectConfig(SharedConfig):
list(local_yaml_data.keys()),
)
yaml_data.update(local_yaml_data)
has_local_overrides = True
# instantiate the ProjectConfig
project_config = cls._from_dict(yaml_data)
project_config = cls._from_dict(yaml_data, has_local_overrides=has_local_overrides)
return project_config
def save(self, project_yml_path: str) -> None:
def save(self, project_yml_path: str, keys: list[str] | None) -> None:
"""
Saves the project configuration to disk.
Saves the project configuration to disk, updating the specified keys.
:param project_yml_path: the path to the project.yml file
:param keys: the list of keys to update in the file; if None, all keys are updated
(which is admissible only if no local overrides have been applied to the current configuration)
"""
config_path = project_yml_path
log.info("Saving updated project configuration to %s", config_path)
# load original commented map and update it with current values
# load commented map from the original file
config_with_comments, _ = self._load_yaml_dict(config_path, self.YAML_COMMENT_NORMALISATION)
config_with_comments.update(self._to_yaml_dict())
# update the requested keys from the current in-memory configuration
cur_dict = self._to_yaml_dict()
if keys is None:
if self._has_local_overrides:
raise ValueError(
"Cannot save the project configuration with all keys updated when there are overrides from project.local.yml. Must specify the keys to update explicitly."
)
config_with_comments.update(cur_dict)
else:
for key in keys:
if key in cur_dict:
config_with_comments[key] = cur_dict[key]
else:
raise ValueError(f"Invalid configuration key '{key}' for project configuration.")
# transfer missing comments from the template file
template_config, _ = self._load_yaml_dict(PROJECT_TEMPLATE_FILE, self.YAML_COMMENT_NORMALISATION)
+10 -5
View File
@@ -334,11 +334,16 @@ class Project(ToStringMixin):
project_config = ProjectConfig.load(project_root, serena_config=serena_config, autogenerate=autogenerate)
return Project(project_root=str(project_root), project_config=project_config, serena_config=serena_config)
def save_config(self) -> None:
def save_config(self, keys: list[str]) -> None:
"""
Saves the current project configuration to disk.
Saves the current project configuration (project.yml) to disk.
:param keys: the keys of the project configuration that were changed and need to be updated on disk.
Specifying these keys is necessary, because we cannot just save the full configuration, since
it may be subject to local overrides (from project.local.yml), which must never be saved to
project.yml.
"""
self.project_config.save(self.path_to_project_yml())
self.project_config.save(self.path_to_project_yml(), keys=keys)
def path_to_serena_data_folder(self) -> str:
return self._serena_data_folder
@@ -673,7 +678,7 @@ class Project(ToStringMixin):
# update the project configuration
self.project_config.languages.append(language)
self.save_config()
self.save_config(keys=["languages"])
def remove_language(self, language: Language) -> None:
"""
@@ -688,7 +693,7 @@ class Project(ToStringMixin):
return
# update the project configuration
self.project_config.languages.remove(language)
self.save_config()
self.save_config(keys=["languages"])
# stop the language server (if the LS manager is active)
if self.language_server_manager is None:
+2 -2
View File
@@ -187,7 +187,7 @@ class TestProjectConfigLanguageBackend:
data["project_name"] = "test"
data["languages"] = ["python"]
data["language_backend"] = "JetBrains"
config = ProjectConfig._from_dict(data)
config = ProjectConfig._from_dict(data, has_local_overrides=False)
assert config.language_backend == LanguageBackend.JETBRAINS
def test_language_backend_none_when_missing_from_dict(self):
@@ -197,7 +197,7 @@ class TestProjectConfigLanguageBackend:
data["project_name"] = "test"
data["languages"] = ["python"]
data.pop("language_backend", None)
config = ProjectConfig._from_dict(data)
config = ProjectConfig._from_dict(data, has_local_overrides=False)
assert config.language_backend is None