fix(config): remove undocumented LOCAL scope from ConfigLevel/ConfigScope enums and resolve() chain

The specification defines exactly 5 configuration precedence levels:
  1. CLI flag
  2. Environment variable
  3. Project-scoped config (config.toml in project root)
  4. Global config file (~/.cleveragents/config.toml)
  5. Built-in default

The implementation had an undocumented 6th level (LOCAL scope / config.local.toml)
inserted between env var and project-scoped config. This creates a spec deviation
that may cause user confusion and hidden reliance on undocumented behavior.

Changes:
- Remove LOCAL = 'local' from ConfigLevel enum (now has 5 spec-defined levels)
- Remove LOCAL = 'local' from ConfigScope enum (now has 2 spec-defined scopes)
- Remove local_config_path property from ConfigService
- Remove read_local_config() method from ConfigService
- Update read_merged_config() to merge only global and project scopes
- Update write_scoped_config() to remove LOCAL scope branch
- Update set_value() to remove LOCAL scope branch
- Update resolve() to implement 5-level chain (remove Level 3 local config)
- Update config_set CLI to reject --scope local as invalid
- Remove config.local.toml from DEFAULT_IGNORE_PATTERNS in context_service
- Update all BDD feature files and step definitions to reflect 5-level chain

Closes #3432

---
**Automated by CleverAgents Bot**
Supervisor: Implementation | Agent: ca-issue-worker
This commit is contained in:
2026-04-05 18:03:36 +00:00
committed by drew
parent e201aa07c9
commit 00951dd750
15 changed files with 192 additions and 517 deletions
@@ -138,11 +138,11 @@ Feature: Config CLI safety-net coverage
# _resolution_chain (L177-209)
# =====================================================================
Scenario: safety-net _resolution_chain returns six-entry list
Scenario: safety-net _resolution_chain returns five-entry list
Given a safety-net isolated temp config directory
When the safety-net chain builder builds chain for key "core.log.level"
Then the safety-net chain should have exactly 6 entries
And the safety-net chain sources should be "cli_flag, env_var, local, project, global, default"
Then the safety-net chain should have exactly 5 entries
And the safety-net chain sources should be "cli_flag, env_var, project, global, default"
# =====================================================================
# config_set - type coercion paths (L241-248)
+12 -21
View File
@@ -1,19 +1,19 @@
Feature: Config CLI scope coverage (cfcov3)
Supplementary scenarios targeting uncovered lines 224-229 and 252-259
in cleveragents/cli/commands/config.py the --scope flag handling
Supplementary scenarios targeting uncovered lines in
cleveragents/cli/commands/config.py the --scope flag handling
in the config_set command.
Background:
Given a cfcov3 isolated temp config directory
# ── Lines 224-229: invalid scope raises typer.BadParameter ──────────
# ── Invalid scope raises typer.BadParameter ──────────────────────────
Scenario: config set with invalid --scope raises BadParameter
When I invoke cfcov3 config set "plan.concurrency" "8" with scope "invalid"
Then the cfcov3 CLI result should have a non-zero exit code
And the cfcov3 CLI output should contain "Invalid scope"
# ── Lines 252-253, 258-259: --scope global branch ───────────────────
# ── --scope global branch ─────────────────────────────────────────────
Scenario: config set with --scope global writes via set_value
When I invoke cfcov3 config set "plan.concurrency" "16" with scope "global"
@@ -21,7 +21,7 @@ Feature: Config CLI scope coverage (cfcov3)
And the cfcov3 CLI output should contain "plan.concurrency"
And the cfcov3 CLI output should contain "global"
# ── Lines 254-255, 258-259: --scope project branch ─────────────────
# ── --scope project branch ────────────────────────────────────────────
Scenario: config set with --scope project writes via set_value
When I invoke cfcov3 config set "plan.concurrency" "12" with scope "project"
@@ -29,15 +29,7 @@ Feature: Config CLI scope coverage (cfcov3)
And the cfcov3 CLI output should contain "plan.concurrency"
And the cfcov3 CLI output should contain "project"
# ── Lines 257-259: --scope local branch ─────────────────────────────
Scenario: config set with --scope local writes via set_value
When I invoke cfcov3 config set "plan.concurrency" "20" with scope "local"
Then the cfcov3 CLI result should have exit code 0
And the cfcov3 CLI output should contain "plan.concurrency"
And the cfcov3 CLI output should contain "local"
# ── Lines 252-253 with pre-existing global value ────────────────────
# ── --scope global with pre-existing value ────────────────────────────
Scenario: config set --scope global shows previous value from config_data
Given the cfcov3 global config has "plan.concurrency" set to 4
@@ -45,7 +37,7 @@ Feature: Config CLI scope coverage (cfcov3)
Then the cfcov3 CLI result should have exit code 0
And the cfcov3 set_value mock should have been called with scope GLOBAL
# ── Lines 254-255 reading previous from project config ──────────────
# ── --scope project reads previous from project config ────────────────
Scenario: config set --scope project reads previous from project config
Given the cfcov3 project config returns "plan.concurrency" as 7
@@ -53,10 +45,9 @@ Feature: Config CLI scope coverage (cfcov3)
Then the cfcov3 CLI result should have exit code 0
And the cfcov3 read_project_config mock should have been called
# ── Lines 257-259 reading previous from local config ────────────────
# ── --scope local is rejected as invalid ─────────────────────────────
Scenario: config set --scope local reads previous from local config
Given the cfcov3 local config returns "plan.concurrency" as 3
When I invoke cfcov3 config set "plan.concurrency" "30" with scope "local"
Then the cfcov3 CLI result should have exit code 0
And the cfcov3 read_local_config mock should have been called
Scenario: config set with --scope local is rejected as invalid scope
When I invoke cfcov3 config set "plan.concurrency" "20" with scope "local"
Then the cfcov3 CLI result should have a non-zero exit code
And the cfcov3 CLI output should contain "Invalid scope"
+1 -27
View File
@@ -1,13 +1,11 @@
Feature: ConfigService coverage round 3 — path accessors, write_scoped_config, set_value, and resolve local
Feature: ConfigService coverage round 3 — path accessors, write_scoped_config, set_value
Exercises uncovered lines in config_service.py:
- Line 1195: project_root property
- Line 1201: project_config_path returns None
- Line 1208: local_config_path returns None
- Lines 1284-1285, 1289: write_scoped_config ValueError for missing project root
- Lines 1297-1298: write_scoped_config reads existing target file
- Lines 1336-1337: set_value redacts sensitive keys
- Lines 1351-1352: set_value catches event_bus emit failure
- Lines 1507-1508, 1510-1511: resolve verbose with local config winning
Background:
Given cfscov3 a fresh temporary directory
@@ -28,12 +26,6 @@ Feature: ConfigService coverage round 3 — path accessors, write_scoped_config,
Given cfscov3 a ConfigService with no project root
Then cfscov3 project_config_path should return None
# -- local_config_path returns None (line 1208) --
Scenario: local_config_path returns None when project root is absent
Given cfscov3 a ConfigService with no project root
Then cfscov3 local_config_path should return None
# -- write_scoped_config raises ValueError (lines 1284-1285, 1289) --
Scenario: write_scoped_config raises ValueError for PROJECT scope without project root
@@ -41,11 +33,6 @@ Feature: ConfigService coverage round 3 — path accessors, write_scoped_config,
When cfscov3 I call write_scoped_config with PROJECT scope
Then cfscov3 a ValueError mentioning "no project root" should be stored
Scenario: write_scoped_config raises ValueError for LOCAL scope without project root
Given cfscov3 a ConfigService with no project root
When cfscov3 I call write_scoped_config with LOCAL scope
Then cfscov3 a ValueError mentioning "no project root" should be stored
# -- write_scoped_config reads existing target (lines 1297-1298) --
Scenario: write_scoped_config merges into existing project config.toml
@@ -53,11 +40,6 @@ Feature: ConfigService coverage round 3 — path accessors, write_scoped_config,
When cfscov3 I call write_scoped_config with PROJECT scope adding a new key
Then cfscov3 the project config.toml should contain both the old and new keys
Scenario: write_scoped_config merges into existing local config.local.toml
Given cfscov3 a ConfigService with a project root containing an existing config.local.toml
When cfscov3 I call write_scoped_config with LOCAL scope adding a new key
Then cfscov3 the local config.local.toml should contain both the old and new keys
# -- set_value redacts sensitive keys (lines 1336-1337) --
Scenario: set_value redacts old and new values for a sensitive key
@@ -71,11 +53,3 @@ Feature: ConfigService coverage round 3 — path accessors, write_scoped_config,
Given cfscov3 a ConfigService with a failing event bus
When cfscov3 I call set_value with key "core.log.level" and value "DEBUG"
Then cfscov3 no error should have been raised
# -- resolve verbose with local config winning (lines 1507-1508, 1510-1511) --
Scenario: resolve in verbose mode returns local config value with chain entry
Given cfscov3 a ConfigService with a local config containing "core.log.level" set to "TRACE"
When cfscov3 I resolve "core.log.level" with verbose True
Then cfscov3 the resolved value should be "TRACE" from LOCAL source
And cfscov3 the chain should contain a local entry with value "TRACE" and a path
-119
View File
@@ -1,119 +0,0 @@
Feature: Three-scope configuration resolution
As a developer working on a CleverAgents project
I want configuration to resolve across global, project, and local scopes
So that I can have developer-specific overrides without affecting shared config
Background:
Given a clean three-scope config environment
# ── Scope precedence ──────────────────────────────────────────────────
Scenario: Local scope overrides project scope
Given a project config value "core.automation-profile" = "supervised"
And a local config value "core.automation-profile" = "manual"
When I resolve three-scope key "core.automation-profile"
Then the three-scope resolved value should be "manual"
And the three-scope resolved source should be "local"
Scenario: Project scope overrides global scope
Given a global config value for three-scope "core.automation-profile" = "full-auto"
And a project config value "core.automation-profile" = "supervised"
When I resolve three-scope key "core.automation-profile"
Then the three-scope resolved value should be "supervised"
And the three-scope resolved source should be "project"
Scenario: Local scope overrides both project and global scopes
Given a global config value for three-scope "core.automation-profile" = "full-auto"
And a project config value "core.automation-profile" = "supervised"
And a local config value "core.automation-profile" = "manual"
When I resolve three-scope key "core.automation-profile"
Then the three-scope resolved value should be "manual"
And the three-scope resolved source should be "local"
Scenario: Global scope used when no project or local override exists
Given a global config value for three-scope "core.automation-profile" = "supervised"
When I resolve three-scope key "core.automation-profile"
Then the three-scope resolved value should be "supervised"
And the three-scope resolved source should be "global"
Scenario: Default used when no scope provides a value
When I resolve three-scope key "core.automation-profile"
Then the three-scope resolved source should be "default"
# ── Deep merge ────────────────────────────────────────────────────────
Scenario: Deep merge combines nested keys from all scopes
Given a global config with nested data "section_a.key1" = "global_val1"
And a project config with nested data "section_a.key2" = "project_val2"
And a local config with nested data "section_a.key3" = "local_val3"
When I read the three-scope merged config
Then the merged config "section_a" should contain key "key1" with value "global_val1"
And the merged config "section_a" should contain key "key2" with value "project_val2"
And the merged config "section_a" should contain key "key3" with value "local_val3"
Scenario: Deep merge local overrides project on conflict
Given a project config with nested data "section_a.key1" = "project_val"
And a local config with nested data "section_a.key1" = "local_val"
When I read the three-scope merged config
Then the merged config "section_a" should contain key "key1" with value "local_val"
# ── Project root discovery ────────────────────────────────────────────
Scenario: Project root discovered via cleveragents.toml marker
Given a directory tree with "cleveragents.toml" marker at the root
When I discover the project root from a subdirectory
Then the discovered project root should match the marker directory
Scenario: Project root discovered via .cleveragents directory marker
Given a directory tree with ".cleveragents" directory marker at the root
When I discover the project root from a subdirectory
Then the discovered project root should match the marker directory
Scenario: No project root returns None when no markers found
Given a directory tree with no project markers
When I discover the project root
Then the discovered project root should be None
# ── config.local.toml file loading ────────────────────────────────────
Scenario: Local config file is loaded when present
Given a local config file with "plan.concurrency" = "16"
When I resolve three-scope key "plan.concurrency"
Then the three-scope resolved value should be "16"
And the three-scope resolved source should be "local"
Scenario: Missing local config file is handled gracefully
When I read the local config
Then the local config should be an empty dict
# ── Scoped write ──────────────────────────────────────────────────────
Scenario: Set value with local scope writes to config.local.toml
When I set three-scope value "core.automation-profile" to "manual" with scope "local"
Then the local config file should contain "core.automation-profile" = "manual"
Scenario: Set value with project scope writes to project config.toml
When I set three-scope value "core.automation-profile" to "supervised" with scope "project"
Then the project config file should contain "core.automation-profile" = "supervised"
Scenario: Set value with global scope writes to global config.toml
When I set three-scope value "core.automation-profile" to "full-auto" with scope "global"
Then the global config file should contain "core.automation-profile" = "full-auto"
# ── ConfigScope enum ──────────────────────────────────────────────────
Scenario: ConfigScope enum has all three values
Then ConfigScope should have value "global"
And ConfigScope should have value "project"
And ConfigScope should have value "local"
# ── ConfigLevel enum includes LOCAL ───────────────────────────────────
Scenario: ConfigLevel enum includes LOCAL between ENV_VAR and PROJECT
Then ConfigLevel should have value "local"
And ConfigLevel "local" should exist between "env_var" and "project"
# ── .gitignore template includes config.local.toml ────────────────────
Scenario: Default ignore patterns include config.local.toml
Then the default ignore patterns should include "config.local.toml"
+89
View File
@@ -0,0 +1,89 @@
Feature: Two-scope configuration resolution
As a developer working on a CleverAgents project
I want configuration to resolve across global and project scopes
So that I can have project-specific overrides without affecting global config
Background:
Given a clean two-scope config environment
# ── Scope precedence ──────────────────────────────────────────────────
Scenario: Project scope overrides global scope
Given a global config value for two-scope "core.automation-profile" = "full-auto"
And a project config value "core.automation-profile" = "supervised"
When I resolve two-scope key "core.automation-profile"
Then the two-scope resolved value should be "supervised"
And the two-scope resolved source should be "project"
Scenario: Global scope used when no project override exists
Given a global config value for two-scope "core.automation-profile" = "supervised"
When I resolve two-scope key "core.automation-profile"
Then the two-scope resolved value should be "supervised"
And the two-scope resolved source should be "global"
Scenario: Default used when no scope provides a value
When I resolve two-scope key "core.automation-profile"
Then the two-scope resolved source should be "default"
# ── Deep merge ────────────────────────────────────────────────────────
Scenario: Deep merge combines nested keys from global and project scopes
Given a global config with nested data "section_a.key1" = "global_val1"
And a project config with nested data "section_a.key2" = "project_val2"
When I read the two-scope merged config
Then the merged config "section_a" should contain key "key1" with value "global_val1"
And the merged config "section_a" should contain key "key2" with value "project_val2"
Scenario: Deep merge project overrides global on conflict
Given a global config with nested data "section_a.key1" = "global_val"
And a project config with nested data "section_a.key1" = "project_val"
When I read the two-scope merged config
Then the merged config "section_a" should contain key "key1" with value "project_val"
# ── Project root discovery ────────────────────────────────────────────
Scenario: Project root discovered via cleveragents.toml marker
Given a directory tree with "cleveragents.toml" marker at the root
When I discover the project root from a subdirectory
Then the discovered project root should match the marker directory
Scenario: Project root discovered via .cleveragents directory marker
Given a directory tree with ".cleveragents" directory marker at the root
When I discover the project root from a subdirectory
Then the discovered project root should match the marker directory
Scenario: No project root returns None when no markers found
Given a directory tree with no project markers
When I discover the project root
Then the discovered project root should be None
# ── Scoped write ──────────────────────────────────────────────────────
Scenario: Set value with project scope writes to project config.toml
When I set two-scope value "core.automation-profile" to "supervised" with scope "project"
Then the project config file should contain "core.automation-profile" = "supervised"
Scenario: Set value with global scope writes to global config.toml
When I set two-scope value "core.automation-profile" to "full-auto" with scope "global"
Then the global config file should contain "core.automation-profile" = "full-auto"
# ── ConfigScope enum ──────────────────────────────────────────────────
Scenario: ConfigScope enum has exactly two spec-defined values
Then ConfigScope should have value "global"
And ConfigScope should have value "project"
# ── ConfigLevel enum matches spec 5-level chain ───────────────────────
Scenario: ConfigLevel enum has exactly five spec-defined levels
Then ConfigLevel should have value "cli_flag"
And ConfigLevel should have value "env_var"
And ConfigLevel should have value "project"
And ConfigLevel should have value "global"
And ConfigLevel should have value "default"
Scenario: ConfigLevel enum does not include undocumented local level
Then ConfigLevel should not have value "local"
Scenario: ConfigScope enum does not include undocumented local scope
Then ConfigScope should not have value "local"
+4 -5
View File
@@ -172,10 +172,9 @@ Feature: Consolidated Config
Scenario: Verbose mode returns full resolution chain
Given a clean config service test environment
When I resolve config key "core.log.level" with verbose mode
Then the resolution chain should have 6 entries
Then the resolution chain should have 5 entries
And the chain should include source "cli_flag"
And the chain should include source "env_var"
And the chain should include source "local"
And the chain should include source "project"
And the chain should include source "global"
And the chain should include source "default"
@@ -294,8 +293,8 @@ Feature: Consolidated Config
# Feature: ConfigService full coverage
# ============================================================
Scenario: ConfigLevel enum has six precedence levels
Then ConfigLevel should have values CLI_FLAG ENV_VAR LOCAL PROJECT GLOBAL DEFAULT
Scenario: ConfigLevel enum has five spec-defined precedence levels
Then ConfigLevel should have values CLI_FLAG ENV_VAR PROJECT GLOBAL DEFAULT
# ---------- ConfigEntry and ResolvedValue dataclasses ----------
@@ -547,7 +546,7 @@ Feature: Consolidated Config
Given a temporary directory for config service
And a ConfigService with empty config for resolve tests
When I resolve key "core.log.level" with verbose True and no overrides
Then the resolved chain should have 6 entries covering all levels
Then the resolved chain should have 5 entries covering all levels
Scenario: resolve verbose chain includes env_name for ENV_VAR level
@@ -1,11 +1,10 @@
"""Step definitions for config_cli_scope_coverage.feature (cfcov3).
Targets uncovered lines in cleveragents/cli/commands/config.py:
- Lines 224-229: invalid --scope triggers ValueError typer.BadParameter
- Lines 252-253: --scope global reads previous from config_data
- Lines 254-255: --scope project reads previous from read_project_config()
- Lines 257-259: --scope local reads previous from read_local_config(),
then calls svc.set_value() and sets scope_display
- Invalid --scope triggers ValueError typer.BadParameter
- --scope global reads previous from config_data
- --scope project reads previous from read_project_config()
- --scope local is rejected as invalid (not in spec)
All step text uses a 'cfcov3' prefix to avoid collisions with other
step definition files.
@@ -68,7 +67,6 @@ def _teardown_cfcov3_temp(context: Context) -> None:
def _build_mock_svc(
config_data: dict[str, Any] | None = None,
project_config: dict[str, Any] | None = None,
local_config: dict[str, Any] | None = None,
) -> MagicMock:
"""Build a MagicMock that behaves like ConfigService for config_set."""
mock_svc = MagicMock()
@@ -76,9 +74,6 @@ def _build_mock_svc(
mock_svc.read_project_config.return_value = (
project_config if project_config is not None else {}
)
mock_svc.read_local_config.return_value = (
local_config if local_config is not None else {}
)
# validate_type should pass through the value, coercing to int when possible
def _validate_type(key: str, value: Any) -> Any:
@@ -110,7 +105,7 @@ def step_cfcov3_temp_dir(context: Context) -> None:
# ===================================================================
# Lines 224-229: invalid scope
# Scope invocation
# ===================================================================
@@ -162,7 +157,7 @@ def step_cfcov3_output_contains(context: Context, text: str) -> None:
# ===================================================================
# Lines 252-253: --scope global with pre-existing value
# --scope global with pre-existing value
# ===================================================================
@@ -185,7 +180,7 @@ def step_cfcov3_set_value_global(context: Context) -> None:
# ===================================================================
# Lines 254-255: --scope project reads from read_project_config
# --scope project reads from read_project_config
# ===================================================================
@@ -200,21 +195,3 @@ def step_cfcov3_read_project_called(context: Context) -> None:
mock_svc = context.cfcov3_mock_svc
assert mock_svc is not None, "No mock service was set up"
mock_svc.read_project_config.assert_called_once()
# ===================================================================
# Lines 257-259: --scope local reads from read_local_config
# ===================================================================
@given('the cfcov3 local config returns "{key}" as {value:d}')
def step_cfcov3_local_preset(context: Context, key: str, value: int) -> None:
mock_svc = _build_mock_svc(local_config={key: value})
context.cfcov3_mock_svc = mock_svc
@then("the cfcov3 read_local_config mock should have been called")
def step_cfcov3_read_local_called(context: Context) -> None:
mock_svc = context.cfcov3_mock_svc
assert mock_svc is not None, "No mock service was set up"
mock_svc.read_local_config.assert_called_once()
@@ -3,12 +3,10 @@
Targets uncovered lines in config_service.py:
- Line 1195: project_root property
- Line 1201: project_config_path returns None
- Line 1208: local_config_path returns None
- Lines 1284-1285, 1289: write_scoped_config ValueError (no project root)
- Lines 1297-1298: write_scoped_config reads existing target file
- Lines 1336-1337: set_value redacts sensitive keys
- Lines 1351-1352: set_value catches event_bus emit failure
- Lines 1507-1508, 1510-1511: resolve verbose with local config winning
"""
from __future__ import annotations
@@ -17,13 +15,12 @@ import tempfile
import tomllib
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock, patch
from unittest.mock import MagicMock
import tomlkit
from behave import given, then, when
from cleveragents.application.services.config_service import (
ConfigLevel,
ConfigScope,
ConfigService,
)
@@ -92,28 +89,6 @@ def step_cfscov3_svc_with_existing_project_config(context: Any) -> None:
)
@given(
"cfscov3 a ConfigService with a project root containing an existing config.local.toml"
)
def step_cfscov3_svc_with_existing_local_config(context: Any) -> None:
project_root = context.cfscov3_tmpdir / "proj_existing_local"
project_root.mkdir(parents=True, exist_ok=True)
context.cfscov3_project_root = project_root
# Write an initial config.local.toml with an existing key
target = project_root / "config.local.toml"
doc = tomlkit.document()
doc["local_existing"] = "local_value"
with open(target, "w") as fh:
tomlkit.dump(doc, fh)
context.cfscov3_svc = ConfigService(
config_dir=context.cfscov3_config_dir,
config_path=context.cfscov3_config_path,
project_root=project_root,
)
@given("cfscov3 a ConfigService with a mock event bus")
def step_cfscov3_svc_with_mock_event_bus(context: Any) -> None:
bus = MagicMock()
@@ -139,28 +114,6 @@ def step_cfscov3_svc_with_failing_event_bus(context: Any) -> None:
)
@given(
'cfscov3 a ConfigService with a local config containing "core.log.level" set to "TRACE"'
)
def step_cfscov3_svc_with_local_config_log_level(context: Any) -> None:
project_root = context.cfscov3_tmpdir / "proj_local_resolve"
project_root.mkdir(parents=True, exist_ok=True)
context.cfscov3_project_root = project_root
# Write config.local.toml with the key
local_cfg = project_root / "config.local.toml"
doc = tomlkit.document()
doc["core.log.level"] = "TRACE"
with open(local_cfg, "w") as fh:
tomlkit.dump(doc, fh)
context.cfscov3_svc = ConfigService(
config_dir=context.cfscov3_config_dir,
config_path=context.cfscov3_config_path,
project_root=project_root,
)
# ---------------------------------------------------------------------------
# When steps
# ---------------------------------------------------------------------------
@@ -174,14 +127,6 @@ def step_cfscov3_write_scoped_project(context: Any) -> None:
context.cfscov3_error = exc
@when("cfscov3 I call write_scoped_config with LOCAL scope")
def step_cfscov3_write_scoped_local(context: Any) -> None:
try:
context.cfscov3_svc.write_scoped_config({"key": "val"}, ConfigScope.LOCAL)
except (ValueError, TypeError) as exc:
context.cfscov3_error = exc
@when("cfscov3 I call write_scoped_config with PROJECT scope adding a new key")
def step_cfscov3_write_scoped_project_add_key(context: Any) -> None:
try:
@@ -192,16 +137,6 @@ def step_cfscov3_write_scoped_project_add_key(context: Any) -> None:
context.cfscov3_error = exc
@when("cfscov3 I call write_scoped_config with LOCAL scope adding a new key")
def step_cfscov3_write_scoped_local_add_key(context: Any) -> None:
try:
context.cfscov3_svc.write_scoped_config(
{"new_key": "new_value"}, ConfigScope.LOCAL
)
except (ValueError, TypeError) as exc:
context.cfscov3_error = exc
@when('cfscov3 I call set_value with sensitive key "{key}" and value "{value}"')
def step_cfscov3_set_value_sensitive(context: Any, key: str, value: str) -> None:
try:
@@ -218,24 +153,6 @@ def step_cfscov3_set_value(context: Any, key: str, value: str) -> None:
context.cfscov3_error = exc
@when('cfscov3 I resolve "{key}" with verbose True')
def step_cfscov3_resolve_verbose(context: Any, key: str) -> None:
# Ensure no env var interferes with resolution
entry = ConfigService.get_entry(key)
env_var_name = entry.env_var if entry else ""
patcher = patch.dict("os.environ", {}, clear=False)
patcher.start()
context.add_cleanup(patcher.stop)
# Remove the specific env var if present to ensure local config wins
import os
saved = os.environ.pop(env_var_name, None)
if saved is not None:
context.add_cleanup(lambda: os.environ.__setitem__(env_var_name, saved))
context.cfscov3_result = context.cfscov3_svc.resolve(key, verbose=True)
# ---------------------------------------------------------------------------
# Then steps
# ---------------------------------------------------------------------------
@@ -260,12 +177,6 @@ def step_cfscov3_project_config_path_none(context: Any) -> None:
assert actual is None, f"Expected project_config_path=None, got {actual}"
@then("cfscov3 local_config_path should return None")
def step_cfscov3_local_config_path_none(context: Any) -> None:
actual = context.cfscov3_svc.local_config_path
assert actual is None, f"Expected local_config_path=None, got {actual}"
@then('cfscov3 a ValueError mentioning "no project root" should be stored')
def step_cfscov3_valueerror_no_project_root(context: Any) -> None:
assert context.cfscov3_error is not None, (
@@ -299,25 +210,6 @@ def step_cfscov3_verify_merged_project_config(context: Any) -> None:
assert data["new_key"] == "new_value"
@then("cfscov3 the local config.local.toml should contain both the old and new keys")
def step_cfscov3_verify_merged_local_config(context: Any) -> None:
assert context.cfscov3_error is None, (
f"write_scoped_config raised: {context.cfscov3_error}"
)
target = context.cfscov3_project_root / "config.local.toml"
assert target.exists(), "config.local.toml should exist"
with open(target, "rb") as fh:
data = tomllib.load(fh)
assert "local_existing" in data, (
f"Expected 'local_existing' in config, got keys: {list(data.keys())}"
)
assert data["local_existing"] == "local_value"
assert "new_key" in data, (
f"Expected 'new_key' in config, got keys: {list(data.keys())}"
)
assert data["new_key"] == "new_value"
@then("cfscov3 the event bus should have received redacted values")
def step_cfscov3_verify_redacted_event(context: Any) -> None:
assert context.cfscov3_error is None, f"set_value raised: {context.cfscov3_error}"
@@ -339,31 +231,3 @@ def step_cfscov3_no_error(context: Any) -> None:
assert context.cfscov3_error is None, (
f"Expected no error, but got: {context.cfscov3_error}"
)
@then('cfscov3 the resolved value should be "{expected}" from LOCAL source')
def step_cfscov3_resolved_local(context: Any, expected: str) -> None:
result = context.cfscov3_result
assert result.value == expected, (
f"Expected resolved value '{expected}', got '{result.value}'"
)
assert result.source == ConfigLevel.LOCAL, (
f"Expected source LOCAL, got {result.source}"
)
@then(
'cfscov3 the chain should contain a local entry with value "{expected}" and a path'
)
def step_cfscov3_chain_local_entry(context: Any, expected: str) -> None:
chain = context.cfscov3_result.chain
local_entries = [e for e in chain if e["source"] == ConfigLevel.LOCAL.value]
assert len(local_entries) == 1, (
f"Expected 1 LOCAL chain entry, got {len(local_entries)}: {chain}"
)
entry = local_entries[0]
assert entry["value"] == expected, (
f"Expected LOCAL chain value '{expected}', got '{entry['value']}'"
)
assert "path" in entry, "Expected 'path' key in LOCAL chain entry"
assert entry["path"], "Expected non-empty path in LOCAL chain entry"
@@ -25,15 +25,17 @@ from cleveragents.application.services.config_service import (
# ---------------------------------------------------------------------------
@then("ConfigLevel should have values CLI_FLAG ENV_VAR LOCAL PROJECT GLOBAL DEFAULT")
@then("ConfigLevel should have values CLI_FLAG ENV_VAR PROJECT GLOBAL DEFAULT")
def step_config_level_enum(context: Any) -> None:
assert ConfigLevel.CLI_FLAG.value == "cli_flag"
assert ConfigLevel.ENV_VAR.value == "env_var"
assert ConfigLevel.LOCAL.value == "local"
assert ConfigLevel.PROJECT.value == "project"
assert ConfigLevel.GLOBAL.value == "global"
assert ConfigLevel.DEFAULT.value == "default"
assert len(ConfigLevel) == 6
assert len(ConfigLevel) == 5
# Verify LOCAL is not present (spec defines only 5 levels)
values = [m.value for m in ConfigLevel]
assert "local" not in values, f"LOCAL should not be in ConfigLevel: {values}"
# ---------------------------------------------------------------------------
@@ -680,15 +682,14 @@ def step_resolve_verbose(context: Any) -> None:
context.result = context.svc.resolve("core.log.level", verbose=True)
@then("the resolved chain should have 6 entries covering all levels")
@then("the resolved chain should have 5 entries covering all levels")
def step_verify_verbose_chain_length(context: Any) -> None:
chain = context.result.chain
assert len(chain) == 6, f"Expected 6 chain entries, got {len(chain)}"
assert len(chain) == 5, f"Expected 5 chain entries, got {len(chain)}"
sources = [entry["source"] for entry in chain]
assert sources == [
ConfigLevel.CLI_FLAG.value,
ConfigLevel.ENV_VAR.value,
ConfigLevel.LOCAL.value,
ConfigLevel.PROJECT.value,
ConfigLevel.GLOBAL.value,
ConfigLevel.DEFAULT.value,
@@ -1,4 +1,4 @@
"""Step definitions for config_three_scope.feature — three-scope resolution."""
"""Step definitions for config_two_scope.feature — two-scope resolution."""
from __future__ import annotations
@@ -27,7 +27,7 @@ from cleveragents.application.services.config_service import (
def _svc(context: Context) -> ConfigService:
return context._three_scope_service
return context._two_scope_service
def _write_toml(path: Path, data: dict[str, Any]) -> None:
@@ -48,7 +48,7 @@ def _read_toml(path: Path) -> dict[str, Any]:
def _cleanup(context: Context) -> None:
tmpdir = getattr(context, "_three_scope_tmpdir", None)
tmpdir = getattr(context, "_two_scope_tmpdir", None)
if tmpdir and os.path.isdir(tmpdir):
shutil.rmtree(tmpdir, ignore_errors=True)
@@ -58,10 +58,10 @@ def _cleanup(context: Context) -> None:
# ---------------------------------------------------------------------------
@given("a clean three-scope config environment")
def step_clean_three_scope_env(context: Context) -> None:
@given("a clean two-scope config environment")
def step_clean_two_scope_env(context: Context) -> None:
tmpdir = tempfile.mkdtemp()
context._three_scope_tmpdir = tmpdir
context._two_scope_tmpdir = tmpdir
base = Path(tmpdir)
# Global config dir
@@ -73,10 +73,10 @@ def step_clean_three_scope_env(context: Context) -> None:
project_root.mkdir()
(project_root / "cleveragents.toml").touch()
context._three_scope_global_dir = global_dir
context._three_scope_project_root = project_root
context._two_scope_global_dir = global_dir
context._two_scope_project_root = project_root
context._three_scope_service = ConfigService(
context._two_scope_service = ConfigService(
config_dir=global_dir,
config_path=global_dir / "config.toml",
project_root=project_root,
@@ -92,7 +92,7 @@ def step_clean_three_scope_env(context: Context) -> None:
# ---------------------------------------------------------------------------
@given('a global config value for three-scope "{key}" = "{value}"')
@given('a global config value for two-scope "{key}" = "{value}"')
def step_global_value(context: Context, key: str, value: str) -> None:
svc = _svc(context)
data = svc.read_config()
@@ -102,15 +102,7 @@ def step_global_value(context: Context, key: str, value: str) -> None:
@given('a project config value "{key}" = "{value}"')
def step_project_value(context: Context, key: str, value: str) -> None:
path = context._three_scope_project_root / "config.toml"
data = _read_toml(path)
data[key] = value
_write_toml(path, data)
@given('a local config value "{key}" = "{value}"')
def step_local_value(context: Context, key: str, value: str) -> None:
path = context._three_scope_project_root / "config.local.toml"
path = context._two_scope_project_root / "config.toml"
data = _read_toml(path)
data[key] = value
_write_toml(path, data)
@@ -128,16 +120,7 @@ def step_global_nested(context: Context, dotted: str, value: str) -> None:
@given('a project config with nested data "{dotted}" = "{value}"')
def step_project_nested(context: Context, dotted: str, value: str) -> None:
path = context._three_scope_project_root / "config.toml"
data = _read_toml(path)
section, key = dotted.split(".", 1)
data.setdefault(section, {})[key] = value
_write_toml(path, data)
@given('a local config with nested data "{dotted}" = "{value}"')
def step_local_nested(context: Context, dotted: str, value: str) -> None:
path = context._three_scope_project_root / "config.local.toml"
path = context._two_scope_project_root / "config.toml"
data = _read_toml(path)
section, key = dotted.split(".", 1)
data.setdefault(section, {})[key] = value
@@ -194,30 +177,21 @@ def step_dir_tree_no_markers(context: Context) -> None:
)
# Given — local config file
@given('a local config file with "{key}" = "{value}"')
def step_local_file_with_key(context: Context, key: str, value: str) -> None:
path = context._three_scope_project_root / "config.local.toml"
data = _read_toml(path)
data[key] = value
_write_toml(path, data)
# ---------------------------------------------------------------------------
# When
# ---------------------------------------------------------------------------
@when('I resolve three-scope key "{key}"')
def step_resolve_three_scope(context: Context, key: str) -> None:
@when('I resolve two-scope key "{key}"')
def step_resolve_two_scope(context: Context, key: str) -> None:
svc = _svc(context)
context._three_scope_resolved = svc.resolve(key)
context._two_scope_resolved = svc.resolve(key)
@when("I read the three-scope merged config")
@when("I read the two-scope merged config")
def step_read_merged(context: Context) -> None:
svc = _svc(context)
context._three_scope_merged = svc.read_merged_config()
context._two_scope_merged = svc.read_merged_config()
@when("I discover the project root from a subdirectory")
@@ -230,13 +204,7 @@ def step_discover_project_root(context: Context) -> None:
context._discovered_root = discover_project_root(context._discovery_sub)
@when("I read the local config")
def step_read_local(context: Context) -> None:
svc = _svc(context)
context._local_config = svc.read_local_config()
@when('I set three-scope value "{key}" to "{value}" with scope "{scope}"')
@when('I set two-scope value "{key}" to "{value}" with scope "{scope}"')
def step_set_scoped(context: Context, key: str, value: str, scope: str) -> None:
svc = _svc(context)
config_scope = ConfigScope(scope)
@@ -248,15 +216,15 @@ def step_set_scoped(context: Context, key: str, value: str, scope: str) -> None:
# ---------------------------------------------------------------------------
@then('the three-scope resolved value should be "{value}"')
@then('the two-scope resolved value should be "{value}"')
def step_check_value(context: Context, value: str) -> None:
actual = str(context._three_scope_resolved.value)
actual = str(context._two_scope_resolved.value)
assert actual == value, f"Expected '{value}', got '{actual}'"
@then('the three-scope resolved source should be "{source}"')
@then('the two-scope resolved source should be "{source}"')
def step_check_source(context: Context, source: str) -> None:
actual = context._three_scope_resolved.source.value
actual = context._two_scope_resolved.source.value
assert actual == source, f"Expected source '{source}', got '{actual}'"
@@ -264,7 +232,7 @@ def step_check_source(context: Context, source: str) -> None:
def step_merged_section_key(
context: Context, section: str, key: str, value: str
) -> None:
merged = context._three_scope_merged
merged = context._two_scope_merged
assert section in merged, f"Section '{section}' not in merged config: {merged}"
sec = merged[section]
assert key in sec, f"Key '{key}' not in section '{section}': {sec}"
@@ -288,24 +256,9 @@ def step_root_none(context: Context) -> None:
)
@then("the local config should be an empty dict")
def step_local_empty(context: Context) -> None:
assert context._local_config == {}, (
f"Expected empty dict, got {context._local_config}"
)
@then('the local config file should contain "{key}" = "{value}"')
def step_local_file_contains(context: Context, key: str, value: str) -> None:
path = context._three_scope_project_root / "config.local.toml"
data = _read_toml(path)
assert key in data, f"Key '{key}' not in local config: {data}"
assert str(data[key]) == value, f"Expected '{value}', got '{data[key]}'"
@then('the project config file should contain "{key}" = "{value}"')
def step_project_file_contains(context: Context, key: str, value: str) -> None:
path = context._three_scope_project_root / "config.toml"
path = context._two_scope_project_root / "config.toml"
data = _read_toml(path)
assert key in data, f"Key '{key}' not in project config: {data}"
assert str(data[key]) == value, f"Expected '{value}', got '{data[key]}'"
@@ -325,29 +278,23 @@ def step_scope_enum_value(context: Context, value: str) -> None:
assert scope.value == value, f"Expected ConfigScope.{value}, got {scope}"
@then('ConfigScope should not have value "{value}"')
def step_scope_enum_not_have_value(context: Context, value: str) -> None:
values = [s.value for s in ConfigScope]
assert value not in values, (
f"ConfigScope should NOT have value '{value}', but found it in {values}"
)
@then('ConfigLevel should have value "{value}"')
def step_level_enum_value(context: Context, value: str) -> None:
level = ConfigLevel(value)
assert level.value == value, f"Expected ConfigLevel.{value}, got {level}"
@then('ConfigLevel "{value}" should exist between "{before}" and "{after}"')
def step_level_ordering(context: Context, value: str, before: str, after: str) -> None:
members = [m.value for m in ConfigLevel]
idx_before = members.index(before)
idx_value = members.index(value)
idx_after = members.index(after)
assert idx_before < idx_value < idx_after, (
f"Expected {before} < {value} < {after} in {members}"
)
@then('the default ignore patterns should include "{pattern}"')
def step_ignore_patterns(context: Context, pattern: str) -> None:
from cleveragents.application.services.context_service import (
DEFAULT_IGNORE_PATTERNS,
)
assert pattern in DEFAULT_IGNORE_PATTERNS, (
f"'{pattern}' not in DEFAULT_IGNORE_PATTERNS: {DEFAULT_IGNORE_PATTERNS}"
@then('ConfigLevel should not have value "{value}"')
def step_level_enum_not_have_value(context: Context, value: str) -> None:
values = [m.value for m in ConfigLevel]
assert value not in values, (
f"ConfigLevel should NOT have value '{value}', but found it in {values}"
)
+1 -1
View File
@@ -59,7 +59,7 @@ Config Resolution Project Scope
Should Contain ${result.stdout} config-resolution-project-ok
Config Resolution Verbose Chain
[Documentation] Verify that verbose=True returns all 6 chain entries
[Documentation] Verify that verbose=True returns all 5 chain entries
${result}= Run Process ${PYTHON} ${HELPER} resolve-verbose-chain cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
+3 -4
View File
@@ -182,17 +182,16 @@ def resolve_project() -> None:
def resolve_verbose_chain() -> None:
"""Verify verbose=True populates all 6 chain entries."""
"""Verify verbose=True populates all 5 chain entries."""
svc, tmpdir = _make_service()
try:
result = svc.resolve("core.log.level", verbose=True)
chain_len = len(result.chain)
if chain_len == 6:
if chain_len == 5:
# Verify the sources in order
expected_sources = [
ConfigLevel.CLI_FLAG.value,
ConfigLevel.ENV_VAR.value,
ConfigLevel.LOCAL.value,
ConfigLevel.PROJECT.value,
ConfigLevel.GLOBAL.value,
ConfigLevel.DEFAULT.value,
@@ -208,7 +207,7 @@ def resolve_verbose_chain() -> None:
sys.exit(1)
else:
print(
f"FAIL: expected 6 chain entries, got {chain_len}",
f"FAIL: expected 5 chain entries, got {chain_len}",
file=sys.stderr,
)
sys.exit(1)
@@ -1,7 +1,8 @@
"""Config service with multi-level resolution chain and typed key registry.
Provides a resolution layer on top of ``Settings`` (pydantic-settings) that
manages TOML file persistence and implements a five-level precedence chain:
manages TOML file persistence and implements the spec-defined five-level
precedence chain:
CLI flag > environment variable > project-scoped > global config > default
@@ -36,22 +37,27 @@ _logger = structlog.get_logger(__name__)
class ConfigLevel(Enum):
"""Precedence levels from highest to lowest priority."""
"""Precedence levels from highest to lowest priority.
Matches the five-level chain defined in the specification:
CLI flag > env var > project-scoped > global config > default.
"""
CLI_FLAG = "cli_flag"
ENV_VAR = "env_var"
LOCAL = "local"
PROJECT = "project"
GLOBAL = "global"
DEFAULT = "default"
class ConfigScope(Enum):
"""File-based configuration scopes for ``--scope`` CLI flag."""
"""File-based configuration scopes for ``--scope`` CLI flag.
Only ``global`` and ``project`` scopes are defined in the specification.
"""
GLOBAL = "global"
PROJECT = "project"
LOCAL = "local"
@dataclass(frozen=True, slots=True)
@@ -1230,13 +1236,6 @@ class ConfigService:
return None
return self._project_root / "config.toml"
@property
def local_config_path(self) -> Path | None:
"""Return the path to the local-scoped ``config.local.toml``."""
if self._project_root is None:
return None
return self._project_root / "config.local.toml"
# -- TOML file management -------------------------------------------------
def read_config(self) -> dict[str, Any]:
@@ -1254,23 +1253,14 @@ class ConfigService:
with open(path, "rb") as fh:
return tomllib.load(fh)
def read_local_config(self) -> dict[str, Any]:
"""Read the local-scoped ``config.local.toml`` from the project root."""
path = self.local_config_path
if path is None or not path.exists():
return {}
with open(path, "rb") as fh:
return tomllib.load(fh)
def read_merged_config(self) -> dict[str, Any]:
"""Return the three-scope deep-merged configuration.
"""Return the two-scope deep-merged configuration.
Merge order (lowest to highest priority):
``global < project < local``
``global < project``
"""
merged = self.read_config()
merged = _deep_merge(merged, self.read_project_config())
merged = _deep_merge(merged, self.read_local_config())
return merged
def write_config(self, data: dict[str, Any]) -> None:
@@ -1302,8 +1292,7 @@ class ConfigService:
Raises
------
ValueError
If the project root is not discovered and scope is
``PROJECT`` or ``LOCAL``.
If the project root is not discovered and scope is ``PROJECT``.
"""
if scope == ConfigScope.GLOBAL:
self.write_config(data)
@@ -1317,10 +1306,7 @@ class ConfigService:
)
raise ValueError(msg)
if scope == ConfigScope.PROJECT:
target = self._project_root / "config.toml"
else:
target = self._project_root / "config.local.toml"
target = self._project_root / "config.toml"
if target.exists():
with open(target) as fh:
@@ -1397,10 +1383,8 @@ class ConfigService:
if effective_scope == ConfigScope.GLOBAL:
data = self.read_config()
elif effective_scope == ConfigScope.PROJECT:
data = self.read_project_config()
else:
data = self.read_local_config()
data = self.read_project_config()
old_value = data.get(key)
data[key] = value
@@ -1490,7 +1474,7 @@ class ConfigService:
project_name: str | None = None,
verbose: bool = False,
) -> ResolvedValue:
"""Resolve *key* through the six-level precedence chain.
"""Resolve *key* through the five-level precedence chain.
Parameters
----------
@@ -1511,8 +1495,7 @@ class ConfigService:
Precedence (highest lowest)::
CLI flag > env var > local (config.local.toml)
> project (config.toml) > global > default
CLI flag > env var > project (config.toml) > global > default
"""
entry = self.validate_key(key)
chain: list[dict[str, Any]] = []
@@ -1553,33 +1536,7 @@ class ConfigService:
}
)
# Level 3: Local config (config.local.toml in project root)
local_val: Any | None = None
if winner_source is None:
local_data = self.read_local_config()
local_val = local_data.get(key)
if local_val is not None and winner_source is None:
coerced = self.validate_type(key, local_val)
if verbose:
chain.append(
{
"source": ConfigLevel.LOCAL.value,
"value": coerced,
"path": str(self.local_config_path or ""),
}
)
winner_value = coerced
winner_source = ConfigLevel.LOCAL
elif verbose:
chain.append(
{
"source": ConfigLevel.LOCAL.value,
"value": None,
"path": str(self.local_config_path or ""),
}
)
# Level 4: Project-scoped config (config.toml in project root,
# Level 3: Project-scoped config (config.toml in project root,
# or legacy per-project section in global config)
project_val: Any | None = None
if winner_source is None:
@@ -1617,7 +1574,7 @@ class ConfigService:
}
)
# Level 5: Global config file
# Level 4: Global config file
global_val: Any | None = None
if winner_source is None:
config_data = self.read_config()
@@ -1643,7 +1600,7 @@ class ConfigService:
}
)
# Level 6: Default
# Level 5: Default
if verbose:
chain.append({"source": ConfigLevel.DEFAULT.value, "value": entry.default})
if winner_source is None:
@@ -41,7 +41,6 @@ DEFAULT_IGNORE_PATTERNS = [
"venv",
".env",
".agentsignore",
"config.local.toml",
"*.pyc",
"*.pyo",
"*.pyd",
+6 -9
View File
@@ -189,7 +189,7 @@ def config_set(
typer.Option(
"--scope",
"-s",
help="Config scope: global, project, or local (default: global)",
help="Config scope: global or project (default: global)",
),
] = None,
project: Annotated[
@@ -202,13 +202,12 @@ def config_set(
Writes the value to the config file for the specified scope.
Scopes: ``global`` (~/.cleveragents/config.toml),
``project`` (<project-root>/config.toml),
``local`` (<project-root>/config.local.toml).
``project`` (<project-root>/config.toml).
Examples::
agents config set core.log.level DEBUG
agents config set plan.concurrency 8 --scope local
agents config set plan.concurrency 8 --scope project
agents config set core.automation-profile manual --project local/prod
"""
normalized = _validate_key(key)
@@ -225,7 +224,7 @@ def config_set(
config_scope = ConfigScope(scope.lower())
except ValueError as exc:
raise typer.BadParameter(
f"Invalid scope: '{scope}'. Use 'global', 'project', or 'local'."
f"Invalid scope: '{scope}'. Use 'global' or 'project'."
) from exc
# Read previous value
@@ -248,13 +247,11 @@ def config_set(
svc.write_config(config_data)
scope_display = f"project:{project}"
elif config_scope is not None:
# New three-scope set (--scope flag)
# Two-scope set (--scope flag)
if config_scope == ConfigScope.GLOBAL:
previous = config_data.get(normalized)
elif config_scope == ConfigScope.PROJECT:
previous = svc.read_project_config().get(normalized)
else:
previous = svc.read_local_config().get(normalized)
previous = svc.read_project_config().get(normalized)
svc.set_value(normalized, coerced, scope=config_scope)
scope_display = config_scope.value
else: