fix(resources): support multiple named devcontainer configurations in auto-discovery
CI / lint (pull_request) Failing after 19s
CI / typecheck (pull_request) Successful in 46s
CI / quality (pull_request) Successful in 34s
CI / security (pull_request) Successful in 1m0s
CI / coverage (pull_request) Has been skipped
CI / build (pull_request) Successful in 17s
CI / helm (pull_request) Successful in 22s
CI / unit_tests (pull_request) Successful in 7m9s
CI / docker (pull_request) Has been skipped
CI / e2e_tests (pull_request) Successful in 17m9s
CI / integration_tests (pull_request) Failing after 23m2s
CI / status-check (pull_request) Failing after 2s
CI / benchmark-publish (pull_request) Has been skipped
CI / benchmark-regression (pull_request) Has been skipped

Extend DevcontainerDiscoveryResult and discover_devcontainers() to scan
.devcontainer/<name>/devcontainer.json patterns (one subdirectory level)
in addition to the existing fixed paths. Each named configuration produces
a distinct result with config_name set to the subdirectory name (e.g.
'api', 'frontend'). Root-level configs retain config_name=None.

- Replace _SCAN_PATHS with _FIXED_SCAN_PATHS for root-level configs
- Add glob-based scan of .devcontainer/<name>/devcontainer.json
- Add config_name: str | None attribute to DevcontainerDiscoveryResult
- Validate config_name (must be non-empty str or None)
- Add 8 new Behave scenarios covering named, multiple, mixed, empty cases
- Add 4 new Robot Framework integration tests for named config discovery
- All existing tests continue to pass (no regression)

ISSUES CLOSED: #2615
This commit is contained in:
2026-04-05 09:02:05 +00:00
parent b82a9b6962
commit a05f4c69a7
6 changed files with 281 additions and 7 deletions
+7
View File
@@ -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/<name>/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
+51
View File
@@ -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
@@ -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}"
)
+32
View File
@@ -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
+82
View File
@@ -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,
}
@@ -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/<name>/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/<name>/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/<name>/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