diff --git a/CHANGELOG.md b/CHANGELOG.md index b8d8162ae..5cc321ad7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -167,6 +167,13 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - `ThoughtBlockWidget` background corrected from `$primary 20%` to `$primary-muted 20%`, making thought blocks visually lighter and more subtle per spec §29811. (#1448) +- **Resource — Devcontainer named-config auto-discovery**: `discover_devcontainers()` now + scans `.devcontainer//devcontainer.json` (one subdirectory level) in addition to the + two fixed root-level paths, enabling correct auto-discovery of all devcontainer instances in + monorepo projects. Each named configuration produces a distinct `DevcontainerDiscoveryResult` + with `config_name` set to the subdirectory name (e.g. `"api"`, `"frontend"`). Root-level + configs retain `config_name=None` for full backward compatibility. (#2615) + ## [3.7.0] — 2026-04-02 ### Added diff --git a/features/devcontainer_handler.feature b/features/devcontainer_handler.feature index e2f6e1014..20d78b6c4 100644 --- a/features/devcontainer_handler.feature +++ b/features/devcontainer_handler.feature @@ -101,6 +101,57 @@ Feature: Devcontainer Resource Handler When I create a discovery result with empty parent_location Then it should raise a ValueError + # ── Named configuration discovery (monorepo support) ────── + + Scenario: Auto-discover named devcontainer configuration + Given a temporary directory simulating a git checkout + And the directory has a named devcontainer configuration "api" + When I run devcontainer discovery on the directory as "git-checkout" + Then discovery should return 1 result + And the first result should have config name "api" + + Scenario: Auto-discover multiple named devcontainer configurations + Given a temporary directory simulating a git checkout + And the directory has a named devcontainer configuration "api" + And the directory has a named devcontainer configuration "frontend" + When I run devcontainer discovery on the directory as "git-checkout" + Then discovery should return 2 results + And the result config names should include "api" + And the result config names should include "frontend" + + Scenario: Auto-discover mixed root and named devcontainer configurations + Given a temporary directory simulating a git checkout + And the directory has a .devcontainer/devcontainer.json file + And the directory has a named devcontainer configuration "api" + When I run devcontainer discovery on the directory as "git-checkout" + Then discovery should return 2 results + + Scenario: Root devcontainer.json has no config name + Given a temporary directory simulating a git checkout + And the directory has a .devcontainer/devcontainer.json file + When I run devcontainer discovery on the directory as "git-checkout" + Then discovery should return 1 result + And the first result should have no config name + + Scenario: Root .devcontainer.json has no config name + Given a temporary directory simulating a git checkout + And the directory has a root .devcontainer.json file + When I run devcontainer discovery on the directory as "git-checkout" + Then discovery should return 1 result + And the first result should have no config name + + Scenario: Named config with invalid JSON is skipped + Given a temporary directory simulating a git checkout + And the directory has a named devcontainer configuration "broken" with invalid JSON + When I run devcontainer discovery on the directory as "git-checkout" + Then discovery should return 0 results + + Scenario: Empty .devcontainer directory returns no results + Given a temporary directory simulating a git checkout + And the directory has an empty .devcontainer directory + When I run devcontainer discovery on the directory as "git-checkout" + Then discovery should return 0 results + # ── is_trigger_type checks ──────────────────────────────── Scenario: git-checkout is a trigger type diff --git a/features/steps/devcontainer_handler_steps.py b/features/steps/devcontainer_handler_steps.py index 718627fab..4e7b74b3f 100644 --- a/features/steps/devcontainer_handler_steps.py +++ b/features/steps/devcontainer_handler_steps.py @@ -256,3 +256,58 @@ def step_check_is_trigger(context: Context, rtype: str) -> None: @then('"{rtype}" should not be a trigger type') def step_check_not_trigger(context: Context, rtype: str) -> None: assert is_trigger_type(rtype) is False + + +# ── Given steps for named configurations ───────────────────── + + +@given('the directory has a named devcontainer configuration "{name}"') +def step_add_named_dc(context: Context, name: str) -> None: + tmp = Path(context.tmp_path) + named_dir = tmp / ".devcontainer" / name + named_dir.mkdir(parents=True, exist_ok=True) + (named_dir / "devcontainer.json").write_text(_VALID_DC_JSON, encoding="utf-8") + + +@given('the directory has a named devcontainer configuration "{name}" with invalid JSON') +def step_add_named_dc_invalid(context: Context, name: str) -> None: + tmp = Path(context.tmp_path) + named_dir = tmp / ".devcontainer" / name + named_dir.mkdir(parents=True, exist_ok=True) + (named_dir / "devcontainer.json").write_text("not valid json {{", encoding="utf-8") + + +@given("the directory has an empty .devcontainer directory") +def step_add_empty_dc_dir(context: Context) -> None: + tmp = Path(context.tmp_path) + dc_dir = tmp / ".devcontainer" + dc_dir.mkdir(exist_ok=True) + + +# ── Then steps for named configurations ────────────────────── + + +@then('the first result should have config name "{name}"') +def step_check_first_result_config_name(context: Context, name: str) -> None: + assert len(context.discovery_results) > 0 + result = context.discovery_results[0] + assert result.config_name == name, ( + f"Expected config_name={name!r}, got {result.config_name!r}" + ) + + +@then("the first result should have no config name") +def step_check_first_result_no_config_name(context: Context) -> None: + assert len(context.discovery_results) > 0 + result = context.discovery_results[0] + assert result.config_name is None, ( + f"Expected config_name=None, got {result.config_name!r}" + ) + + +@then('the result config names should include "{name}"') +def step_check_result_config_names_include(context: Context, name: str) -> None: + names = {r.config_name for r in context.discovery_results} + assert name in names, ( + f"Expected {name!r} in config names, got {names!r}" + ) diff --git a/robot/devcontainer_handler.robot b/robot/devcontainer_handler.robot index b45c47768..f6c5a8090 100644 --- a/robot/devcontainer_handler.robot +++ b/robot/devcontainer_handler.robot @@ -87,3 +87,35 @@ Discovery Result Validation Log ${result.stderr} Should Be Equal As Integers ${result.rc} 0 Should Contain ${result.stdout} result-validation-ok + +Devcontainer Discovery Named Single Config + [Documentation] Discover a single named devcontainer configuration + ${result}= Run Process ${PYTHON} ${HELPER} discovery-named-single cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} discovery-named-single-ok + +Devcontainer Discovery Named Multiple Configs + [Documentation] Discover multiple named devcontainer configurations + ${result}= Run Process ${PYTHON} ${HELPER} discovery-named-multiple cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} discovery-named-multiple-ok + +Devcontainer Discovery Named Mixed Root And Named + [Documentation] Discover mixed root and named devcontainer configurations + ${result}= Run Process ${PYTHON} ${HELPER} discovery-named-mixed cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} discovery-named-mixed-ok + +Devcontainer Discovery Named Config Name Attribute + [Documentation] Verify config_name attribute on DevcontainerDiscoveryResult + ${result}= Run Process ${PYTHON} ${HELPER} discovery-named-config-name-attr cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} discovery-named-config-name-attr-ok diff --git a/robot/helper_devcontainer_handler.py b/robot/helper_devcontainer_handler.py index f169dbc8c..9b872cf56 100644 --- a/robot/helper_devcontainer_handler.py +++ b/robot/helper_devcontainer_handler.py @@ -191,6 +191,84 @@ def cmd_result_validation() -> None: print("result-validation-ok") + +def cmd_discovery_named_single() -> None: + """Discover a single named devcontainer configuration.""" + with tempfile.TemporaryDirectory() as tmp: + named_dir = Path(tmp) / ".devcontainer" / "api" + named_dir.mkdir(parents=True) + (named_dir / "devcontainer.json").write_text(_VALID_DC_JSON, encoding="utf-8") + results = discover_devcontainers(tmp, "git-checkout") + assert len(results) == 1, f"Expected 1 result, got {len(results)}" + assert results[0].config_name == "api", ( + f"Expected config_name='api', got {results[0].config_name!r}" + ) + print("discovery-named-single-ok") + + +def cmd_discovery_named_multiple() -> None: + """Discover multiple named devcontainer configurations.""" + with tempfile.TemporaryDirectory() as tmp: + for name in ["api", "frontend"]: + d = Path(tmp) / ".devcontainer" / name + d.mkdir(parents=True) + (d / "devcontainer.json").write_text(_VALID_DC_JSON, encoding="utf-8") + results = discover_devcontainers(tmp, "git-checkout") + assert len(results) == 2, f"Expected 2 results, got {len(results)}" + names = {r.config_name for r in results} + assert names == {"api", "frontend"}, ( + f"Expected {{'api', 'frontend'}}, got {names!r}" + ) + print("discovery-named-multiple-ok") + + +def cmd_discovery_named_mixed() -> None: + """Discover mixed root and named devcontainer configurations.""" + with tempfile.TemporaryDirectory() as tmp: + dc_dir = Path(tmp) / ".devcontainer" + dc_dir.mkdir() + (dc_dir / "devcontainer.json").write_text(_VALID_DC_JSON, encoding="utf-8") + named_dir = dc_dir / "api" + named_dir.mkdir() + (named_dir / "devcontainer.json").write_text(_VALID_DC_JSON, encoding="utf-8") + results = discover_devcontainers(tmp, "git-checkout") + assert len(results) == 2, f"Expected 2 results, got {len(results)}" + root_results = [r for r in results if r.config_name is None] + named_results = [r for r in results if r.config_name == "api"] + assert len(root_results) == 1, "Expected 1 root result" + assert len(named_results) == 1, "Expected 1 named result" + print("discovery-named-mixed-ok") + + +def cmd_discovery_named_config_name_attr() -> None: + """Verify config_name attribute on DevcontainerDiscoveryResult.""" + with tempfile.TemporaryDirectory() as tmp: + # Named config has config_name set + named_dir = Path(tmp) / ".devcontainer" / "backend" + named_dir.mkdir(parents=True) + dc_file = named_dir / "devcontainer.json" + dc_file.write_text(_VALID_DC_JSON, encoding="utf-8") + result = DevcontainerDiscoveryResult( + config_path=dc_file, + config_data=json.loads(_VALID_DC_JSON), + parent_location=tmp, + config_name="backend", + ) + assert result.config_name == "backend", ( + f"Expected 'backend', got {result.config_name!r}" + ) + # Root config has config_name=None + root_result = DevcontainerDiscoveryResult( + config_path=dc_file, + config_data=json.loads(_VALID_DC_JSON), + parent_location=tmp, + config_name=None, + ) + assert root_result.config_name is None, ( + f"Expected None, got {root_result.config_name!r}" + ) + print("discovery-named-config-name-attr-ok") + _COMMANDS = { "protocol-check": cmd_protocol_check, "strategy-check": cmd_strategy_check, @@ -202,6 +280,10 @@ _COMMANDS = { "dag-hierarchy": cmd_dag_hierarchy, "resolver-import": cmd_resolver_import, "result-validation": cmd_result_validation, + "discovery-named-single": cmd_discovery_named_single, + "discovery-named-multiple": cmd_discovery_named_multiple, + "discovery-named-mixed": cmd_discovery_named_mixed, + "discovery-named-config-name-attr": cmd_discovery_named_config_name_attr, } diff --git a/src/cleveragents/resource/handlers/discovery.py b/src/cleveragents/resource/handlers/discovery.py index 58c22ba87..244adc0ac 100644 --- a/src/cleveragents/resource/handlers/discovery.py +++ b/src/cleveragents/resource/handlers/discovery.py @@ -10,6 +10,11 @@ resource is linked to a project. The scanner walks the top level of the resource location (no recursive descent) and validates each ``devcontainer.json`` before registration. +Named configurations (``devcontainer//devcontainer.json``) are +supported for monorepo projects. Each named configuration produces a +separate ``devcontainer-instance`` resource with the configuration name +preserved as the ``config_name`` attribute. + Based on: - docs/specification.md -- Devcontainer, Auto-Discovery - ADR-043: Devcontainer Integration @@ -28,8 +33,8 @@ _TRIGGER_TYPES: frozenset[str] = frozenset( {"git-checkout", "fs-directory"}, ) -#: Paths to scan relative to the resource location. -_SCAN_PATHS: tuple[str, ...] = ( +#: Fixed paths to scan relative to the resource location (no config name). +_FIXED_SCAN_PATHS: tuple[str, ...] = ( ".devcontainer/devcontainer.json", ".devcontainer.json", ) @@ -42,9 +47,12 @@ class DevcontainerDiscoveryResult: config_path: Absolute path to the ``devcontainer.json`` file. config_data: Parsed JSON content of the devcontainer config. parent_location: Location of the parent resource. + config_name: Name of the named configuration (e.g. ``"api"``, + ``"frontend"``), or ``None`` for root-level configurations + (``.devcontainer/devcontainer.json`` and ``.devcontainer.json``). """ - __slots__ = ("config_data", "config_path", "parent_location") + __slots__ = ("config_data", "config_name", "config_path", "parent_location") def __init__( self, @@ -52,6 +60,7 @@ class DevcontainerDiscoveryResult: config_path: Path, config_data: dict[str, object], parent_location: str, + config_name: str | None = None, ) -> None: if not isinstance(config_path, Path): raise TypeError( @@ -63,9 +72,18 @@ class DevcontainerDiscoveryResult: ) if not parent_location: raise ValueError("parent_location must not be empty") + if config_name is not None and not isinstance(config_name, str): + raise TypeError( + f"config_name must be a str or None, got {type(config_name).__name__}" + ) + if config_name is not None and not config_name: + raise ValueError( + "config_name must not be an empty string; use None instead" + ) self.config_path = config_path self.config_data = config_data self.parent_location = parent_location + self.config_name = config_name def discover_devcontainers( @@ -74,9 +92,12 @@ def discover_devcontainers( ) -> list[DevcontainerDiscoveryResult]: """Scan a resource location for devcontainer configurations. - Only scans the well-known paths (``.devcontainer/devcontainer.json`` - and root ``.devcontainer.json``). Each file is validated as JSON - before being included in the results. + Scans the well-known fixed paths (``.devcontainer/devcontainer.json`` + and root ``.devcontainer.json``) as well as named configurations at + ``.devcontainer//devcontainer.json`` (one subdirectory level). + + Each file is validated as JSON before being included in the results. + Named configurations carry the subdirectory name as ``config_name``. Args: resource_location: Filesystem path of the parent resource. @@ -107,7 +128,9 @@ def discover_devcontainers( return [] results: list[DevcontainerDiscoveryResult] = [] - for relative in _SCAN_PATHS: + + # ── Fixed paths (root-level configs, no config_name) ────────────────── + for relative in _FIXED_SCAN_PATHS: candidate = base / relative if not candidate.is_file(): continue @@ -121,9 +144,33 @@ def discover_devcontainers( config_path=candidate, config_data=config_data, parent_location=resource_location, + config_name=None, ) ) + # ── Named configurations: .devcontainer//devcontainer.json ────── + dc_dir = base / ".devcontainer" + if dc_dir.is_dir(): + for subdir in sorted(dc_dir.iterdir()): + if not subdir.is_dir(): + continue + candidate = subdir / "devcontainer.json" + if not candidate.is_file(): + continue + + config_data = _load_devcontainer_json(candidate) + if config_data is None: + continue + + results.append( + DevcontainerDiscoveryResult( + config_path=candidate, + config_data=config_data, + parent_location=resource_location, + config_name=subdir.name, + ) + ) + return results