From 462346f0ce20491a8d36b2d5fc2a6ef944c7172f Mon Sep 17 00:00:00 2001 From: Ramesh Padmanabhaiah <22363102+codeforester@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:35:46 +0530 Subject: [PATCH 1/5] ci: include uv lock freshness in full validation --- CHANGELOG.md | 1 + tests/full_validate.sh | 2 ++ 2 files changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bb80c4..c29c333 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ and versions are tracked in the repo-root `VERSION` file. ### Changed +- Include `uv.lock` freshness in the authoritative local validation aggregate (#424). - Bound convenience-profile discovery and validate implicit project configuration trust; cap YAML input size (#385). - Skip contended retention passes instead of blocking CLI invocations on housekeeping locks (#386). - Reuse secure log lock descriptors and cache source paths per invocation; logging I/O failures stay inside logging (#381). diff --git a/tests/full_validate.sh b/tests/full_validate.sh index 6138c9f..9dec3d8 100755 --- a/tests/full_validate.sh +++ b/tests/full_validate.sh @@ -25,7 +25,9 @@ require_commands() { } run_baseline() { + require_commands uv bash ./tests/validate.sh + uv lock --check } run_runtime() { From 66c9e0f5bfd63c34542724b0a145f66e1453b0ed Mon Sep 17 00:00:00 2001 From: Ramesh Padmanabhaiah <22363102+codeforester@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:43:17 +0530 Subject: [PATCH 2/5] ci: set up uv for quality baseline --- .github/workflows/tests.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index f59e494..98f4b37 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -72,6 +72,10 @@ jobs: uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.13" + - name: Set up uv + uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 + with: + version: "0.12.8" - name: Install quality dependencies run: python -m pip install ".[dev,typer,quality]" - name: Validate repository baseline From 972b635842b2fdc32428e372067bef8f84447eb2 Mon Sep 17 00:00:00 2001 From: Ramesh Padmanabhaiah <22363102+codeforester@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:13:23 +0530 Subject: [PATCH 3/5] docs: keep uv lock entry unreleased --- CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c29c333..c3716ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ and versions are tracked in the repo-root `VERSION` file. - Continue compatibility hardening and adoption work for the next release. +### Changed + +- Include `uv.lock` freshness in the authoritative local validation aggregate (#424). + ## [0.5.0] - 2026-10-03 ### Added @@ -20,7 +24,6 @@ and versions are tracked in the repo-root `VERSION` file. ### Changed -- Include `uv.lock` freshness in the authoritative local validation aggregate (#424). - Bound convenience-profile discovery and validate implicit project configuration trust; cap YAML input size (#385). - Skip contended retention passes instead of blocking CLI invocations on housekeeping locks (#386). - Reuse secure log lock descriptors and cache source paths per invocation; logging I/O failures stay inside logging (#381). From 0b410b341a592cdd59c4a456711928bcb8670fe0 Mon Sep 17 00:00:00 2001 From: Ramesh Padmanabhaiah <22363102+codeforester@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:35:46 +0530 Subject: [PATCH 4/5] fix: preserve distinct default configuration identities --- CHANGELOG.md | 2 ++ docs/api-stability.md | 8 +++++--- docs/local-config.md | 11 ++++++++--- lib/python/base_cli/config.py | 5 +++-- lib/python/base_cli/paths.py | 11 +++++++++++ lib/python/base_cli/profile.py | 8 ++++++-- tests/test_batteries_included_config.py | 22 +++++++++++++++++++++- tests/test_paths.py | 7 +++++++ 8 files changed, 63 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c3716ff..6b4e752 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,8 @@ and versions are tracked in the repo-root `VERSION` file. ### Changed +- Derive default configuration directories from collision-resistant application + identity namespaces so distinct names cannot share a path (#425). - Bound convenience-profile discovery and validate implicit project configuration trust; cap YAML input size (#385). - Skip contended retention passes instead of blocking CLI invocations on housekeeping locks (#386). - Reuse secure log lock descriptors and cache source paths per invocation; logging I/O failures stay inside logging (#381). diff --git a/docs/api-stability.md b/docs/api-stability.md index 8b4927b..2c3eb07 100644 --- a/docs/api-stability.md +++ b/docs/api-stability.md @@ -61,9 +61,11 @@ A future minor release may drop an end-of-life Python or dependency window with a migration note. `BatteriesIncludedConfigLoader.cli_name` is optional. When supplied without an -explicit `user_config_dir`, it determines the normalized application namespace -under the platform-default configuration root. An explicit directory always -takes precedence, so applications that own path policy can omit the identity. +explicit `user_config_dir`, it determines a readable, filesystem-safe, +collision-resistant application namespace under the platform-default +configuration root. An explicit directory always takes precedence, so +applications that own path policy can omit the identity. Existing directories +from older normalization rules are not migrated automatically. Platform tier details and the operating-system support test matrix are kept in [`platform-support.md`](platform-support.md). diff --git a/docs/local-config.md b/docs/local-config.md index bcc2abb..dbe9794 100644 --- a/docs/local-config.md +++ b/docs/local-config.md @@ -26,9 +26,14 @@ the convention-free default. For direct use, `BatteriesIncludedConfigLoader` accepts an optional `cli_name`. When no `user_config_dir` is supplied, that identity selects an isolated directory below the platform's default config root (for example, -`~/.config/tool` on Linux). Consumers with an existing configuration-root -policy should pass `user_config_dir` explicitly; the identity is then metadata -only. +`~/.config/tool` on Linux). The directory name uses the same policy as runtime +namespaces: a readable, filesystem-safe slug plus a stable identity digest when +normalization would change the name. Dotted identities remain distinct, and +names such as `Alpha Tool` and `Alpha-Tool` cannot share a directory. Consumers +with an existing configuration-root policy should pass `user_config_dir` +explicitly; that path remains authoritative. Existing directories created by an +older normalized policy are not merged, moved, or deleted automatically; a +consumer that needs migration must copy them under its chosen policy. ## Trust of discovered project configuration diff --git a/lib/python/base_cli/config.py b/lib/python/base_cli/config.py index 1808540..26db7a6 100644 --- a/lib/python/base_cli/config.py +++ b/lib/python/base_cli/config.py @@ -11,7 +11,7 @@ from ._dependencies import require_yaml from .errors import ConfigurationError -from .paths import default_config_root, normalize_cli_name +from .paths import config_namespace_component, default_config_root, normalize_cli_name __all__ = [ "BatteriesIncludedConfigLoader", @@ -213,7 +213,8 @@ def __init__( if user_config_dir is None: if normalized_name is None: raise ValueError("either cli_name or user_config_dir must be provided") - user_config_dir = default_config_root() / normalized_name + assert cli_name is not None + user_config_dir = default_config_root() / config_namespace_component(cli_name) self.cli_name = normalized_name self.user_config_dir = user_config_dir.expanduser() self.user_config_name = user_config_name diff --git a/lib/python/base_cli/paths.py b/lib/python/base_cli/paths.py index 99299fd..942d420 100644 --- a/lib/python/base_cli/paths.py +++ b/lib/python/base_cli/paths.py @@ -125,6 +125,17 @@ def runtime_namespace_component(value: str, fallback: str = "application") -> st return f"{readable}--{digest}" +def config_namespace_component(value: str, fallback: str = "application") -> str: + """Return the isolated default-config namespace for an application identity. + + Configuration uses the same readable slug and stable digest policy as + runtime state. This preserves dotted identities and distinguishes names + that would otherwise collapse through filename normalization. + """ + + return runtime_namespace_component(value, fallback=fallback) + + def runtime_slug(value: str, fallback: str = "unnamed") -> str: normalized = re.sub(r"[^a-zA-Z0-9._-]+", "-", value.strip()).strip(".-_").lower() return normalized or fallback diff --git a/lib/python/base_cli/profile.py b/lib/python/base_cli/profile.py index 5211929..1ef17f5 100644 --- a/lib/python/base_cli/profile.py +++ b/lib/python/base_cli/profile.py @@ -14,7 +14,7 @@ ) from .context import Context from .history import display_command as _generic_history_display_command -from .paths import default_cache_root, default_config_root, make_run_id, normalize_cli_name +from .paths import config_namespace_component, default_cache_root, default_config_root, make_run_id, normalize_cli_name from .runtime import RuntimeLayout __all__ = [ @@ -231,7 +231,11 @@ def batteries_included( if not normalized_name: raise ValueError("cli_name must contain a non-empty command name") root = (config_root or default_config_root()).expanduser() - selected_user_dir = user_config_dir.expanduser() if user_config_dir is not None else root / normalized_name + selected_user_dir = ( + user_config_dir.expanduser() + if user_config_dir is not None + else root / config_namespace_component(cli_name) + ) loader = BatteriesIncludedConfigLoader( user_config_dir=selected_user_dir, user_config_name=user_config_name, diff --git a/tests/test_batteries_included_config.py b/tests/test_batteries_included_config.py index 17bc4d7..db8cb6a 100644 --- a/tests/test_batteries_included_config.py +++ b/tests/test_batteries_included_config.py @@ -259,11 +259,31 @@ def test_cli_identity_namespaces_default_config_directory(self) -> None: with patch.dict("os.environ", {"BASE_CLI_CONFIG_DIR": str(root)}, clear=False): alpha = BatteriesIncludedConfigLoader("Alpha Tool") beta = BatteriesIncludedConfigLoader("beta") + dotted = BatteriesIncludedConfigLoader("acme.tools") + deploy = BatteriesIncludedConfigLoader("acme.deploy") self.assertEqual(alpha.cli_name, "Alpha-Tool") - self.assertEqual(alpha.user_config_dir, root / "Alpha-Tool") + self.assertNotEqual(alpha.user_config_dir, root / "Alpha-Tool") self.assertEqual(beta.user_config_dir, root / "beta") self.assertNotEqual(alpha.user_config_dir, beta.user_config_dir) + self.assertEqual(dotted.user_config_dir, root / "acme.tools") + self.assertEqual(deploy.user_config_dir, root / "acme.deploy") + self.assertNotEqual(dotted.user_config_dir, deploy.user_config_dir) + + def test_explicit_user_config_directory_remains_authoritative(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + explicit = Path(tmpdir) / "shared" + loader = BatteriesIncludedConfigLoader("acme.tools", user_config_dir=explicit) + + self.assertEqual(loader.user_config_dir, explicit) + + def test_profile_uses_the_same_identity_namespace_policy(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + _write_yaml(root / "acme.tools" / "config.yaml", "answer: 42\n") + with patch.dict("os.environ", {"BASE_CLI_CONFIG_DIR": str(root)}, clear=False): + profile = base_cli.CliProfile.batteries_included("acme.tools") + self.assertEqual(profile.load_user_config(), {"answer": 42}) def test_loader_requires_identity_or_explicit_config_directory(self) -> None: with self.assertRaisesRegex(ValueError, "either cli_name or user_config_dir"): diff --git a/tests/test_paths.py b/tests/test_paths.py index 46982a5..28adf2a 100644 --- a/tests/test_paths.py +++ b/tests/test_paths.py @@ -5,6 +5,7 @@ from base_cli.history import compact_home_text from base_cli.paths import ( + config_namespace_component, default_cache_root, default_config_root, normalize_explicit_cli_name, @@ -154,3 +155,9 @@ def test_path_like_identity_cannot_escape_runtime_owner(self) -> None: component = runtime_namespace_component("../../outside") self.assertNotIn("/", component) self.assertTrue(component.startswith("outside--")) + + def test_config_namespace_preserves_dotted_identity_and_rejects_collisions(self) -> None: + self.assertEqual(config_namespace_component("acme.tools"), "acme.tools") + self.assertEqual(config_namespace_component("acme.deploy"), "acme.deploy") + self.assertNotEqual(config_namespace_component("Alpha Tool"), config_namespace_component("Alpha-Tool")) + self.assertNotIn("/", config_namespace_component("../../outside")) From 4862c5cc643ca12b04c203f5e557be7aadf24306 Mon Sep 17 00:00:00 2001 From: Ramesh Padmanabhaiah <22363102+codeforester@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:15:40 +0530 Subject: [PATCH 5/5] fix: preserve existing config identity paths --- docs/local-config.md | 18 ++++++++++-------- lib/python/base_cli/paths.py | 10 +++++++--- lib/python/base_cli/profile.py | 4 +--- tests/test_batteries_included_config.py | 4 ++++ tests/test_paths.py | 2 ++ 5 files changed, 24 insertions(+), 14 deletions(-) diff --git a/docs/local-config.md b/docs/local-config.md index dbe9794..bba6aee 100644 --- a/docs/local-config.md +++ b/docs/local-config.md @@ -26,14 +26,16 @@ the convention-free default. For direct use, `BatteriesIncludedConfigLoader` accepts an optional `cli_name`. When no `user_config_dir` is supplied, that identity selects an isolated directory below the platform's default config root (for example, -`~/.config/tool` on Linux). The directory name uses the same policy as runtime -namespaces: a readable, filesystem-safe slug plus a stable identity digest when -normalization would change the name. Dotted identities remain distinct, and -names such as `Alpha Tool` and `Alpha-Tool` cannot share a directory. Consumers -with an existing configuration-root policy should pass `user_config_dir` -explicitly; that path remains authoritative. Existing directories created by an -older normalized policy are not merged, moved, or deleted automatically; a -consumer that needs migration must copy them under its chosen policy. +`~/.config/tool` on Linux). An already-safe identity such as `MyTool`, +`Alpha-Tool`, or `acme.tools` keeps its spelling so existing configuration +directories continue to be read. Identities that need normalization, such as +`Alpha Tool` or a path-like value, use a readable filesystem-safe slug plus a +stable identity digest; this keeps them distinct from already-safe names. +Consumers with an existing configuration-root policy should pass +`user_config_dir` explicitly; that path remains authoritative. Existing +directories created by an older normalized policy are not merged, moved, or +deleted automatically; a consumer that needs migration must copy them under +its chosen policy. ## Trust of discovered project configuration diff --git a/lib/python/base_cli/paths.py b/lib/python/base_cli/paths.py index 942d420..ae10a31 100644 --- a/lib/python/base_cli/paths.py +++ b/lib/python/base_cli/paths.py @@ -15,6 +15,7 @@ "base_cli_working_directory_override", default=None, ) +_SAFE_CONFIG_NAMESPACE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]*\Z") def default_cache_root( @@ -128,11 +129,14 @@ def runtime_namespace_component(value: str, fallback: str = "application") -> st def config_namespace_component(value: str, fallback: str = "application") -> str: """Return the isolated default-config namespace for an application identity. - Configuration uses the same readable slug and stable digest policy as - runtime state. This preserves dotted identities and distinguishes names - that would otherwise collapse through filename normalization. + Preserve an already-safe identity verbatim so existing user-config + directories remain readable after upgrade. Identities that need path + normalization use the runtime slug and stable digest policy, which keeps + transformed names distinct from their normalized counterparts. """ + if _SAFE_CONFIG_NAMESPACE.fullmatch(value): + return value return runtime_namespace_component(value, fallback=fallback) diff --git a/lib/python/base_cli/profile.py b/lib/python/base_cli/profile.py index 1ef17f5..ffd2fe3 100644 --- a/lib/python/base_cli/profile.py +++ b/lib/python/base_cli/profile.py @@ -232,9 +232,7 @@ def batteries_included( raise ValueError("cli_name must contain a non-empty command name") root = (config_root or default_config_root()).expanduser() selected_user_dir = ( - user_config_dir.expanduser() - if user_config_dir is not None - else root / config_namespace_component(cli_name) + user_config_dir.expanduser() if user_config_dir is not None else root / config_namespace_component(cli_name) ) loader = BatteriesIncludedConfigLoader( user_config_dir=selected_user_dir, diff --git a/tests/test_batteries_included_config.py b/tests/test_batteries_included_config.py index db8cb6a..8854fd6 100644 --- a/tests/test_batteries_included_config.py +++ b/tests/test_batteries_included_config.py @@ -261,6 +261,8 @@ def test_cli_identity_namespaces_default_config_directory(self) -> None: beta = BatteriesIncludedConfigLoader("beta") dotted = BatteriesIncludedConfigLoader("acme.tools") deploy = BatteriesIncludedConfigLoader("acme.deploy") + mixed_case = BatteriesIncludedConfigLoader("MyTool") + hyphenated = BatteriesIncludedConfigLoader("Alpha-Tool") self.assertEqual(alpha.cli_name, "Alpha-Tool") self.assertNotEqual(alpha.user_config_dir, root / "Alpha-Tool") @@ -268,6 +270,8 @@ def test_cli_identity_namespaces_default_config_directory(self) -> None: self.assertNotEqual(alpha.user_config_dir, beta.user_config_dir) self.assertEqual(dotted.user_config_dir, root / "acme.tools") self.assertEqual(deploy.user_config_dir, root / "acme.deploy") + self.assertEqual(mixed_case.user_config_dir, root / "MyTool") + self.assertEqual(hyphenated.user_config_dir, root / "Alpha-Tool") self.assertNotEqual(dotted.user_config_dir, deploy.user_config_dir) def test_explicit_user_config_directory_remains_authoritative(self) -> None: diff --git a/tests/test_paths.py b/tests/test_paths.py index 28adf2a..c25df7c 100644 --- a/tests/test_paths.py +++ b/tests/test_paths.py @@ -159,5 +159,7 @@ def test_path_like_identity_cannot_escape_runtime_owner(self) -> None: def test_config_namespace_preserves_dotted_identity_and_rejects_collisions(self) -> None: self.assertEqual(config_namespace_component("acme.tools"), "acme.tools") self.assertEqual(config_namespace_component("acme.deploy"), "acme.deploy") + self.assertEqual(config_namespace_component("MyTool"), "MyTool") + self.assertEqual(config_namespace_component("Alpha-Tool"), "Alpha-Tool") self.assertNotEqual(config_namespace_component("Alpha Tool"), config_namespace_component("Alpha-Tool")) self.assertNotIn("/", config_namespace_component("../../outside"))