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
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:
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user