Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions skills/studio/scripts/studio/commands/kit.py
Original file line number Diff line number Diff line change
Expand Up @@ -1404,11 +1404,11 @@ def _local_path_provenance(kit_source: Path, install_mode: str, studio_dir: Path

def _load_manifest_install_adapter(kit_source: Path, kit_slug: str = "") -> Optional[Manifest]:
"""Load a manifest installer adapter only through a manifest-backed KitModel."""
if not ((kit_source / ".cf-studio-kit.toml").is_file() or (kit_source / "manifest.toml").is_file()):
return None

from ..utils.kit_model import load_kit_model
from ..utils.manifest import load_manifest
from ..utils.manifest import load_manifest, resolve_kit_manifest_path

if resolve_kit_manifest_path(kit_source) is None:
return None

model = load_kit_model(kit_source, source_hint="manifest", kit_slug=kit_slug)
if getattr(model, "manifest_source", "") not in {"canonical", "legacy_manifest"}:
Expand All @@ -1432,10 +1432,10 @@ def _validate_register_manifest_containment(
source_root = kit_source.resolve()
if not _path_is_within(source_root, root):
errors.append(f"Kit source '{kit_source}' must be inside project root '{root}' for register mode")
manifest_file = kit_source / "manifest.toml"
if not manifest_file.is_file():
manifest_file = kit_source / ".cf-studio-kit.toml"
if not manifest_file.is_file() or not _path_is_within(manifest_file, root):
from ..utils.manifest import resolve_kit_manifest_path

manifest_file = resolve_kit_manifest_path(kit_source)
if manifest_file is None or not manifest_file.is_file() or not _path_is_within(manifest_file, root):
errors.append("Kit manifest must be inside the project root for register mode")
manifest_root_value = manifest.root.replace(
"{cf-studio-path}", ".",
Expand Down
31 changes: 25 additions & 6 deletions skills/studio/scripts/studio/utils/manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -1026,16 +1026,35 @@
# Public API
# ---------------------------------------------------------------------------

def resolve_kit_manifest_path(kit_source: Path) -> Optional[Path]:
"""Return the effective manifest path for a kit source.

Canonical ``.cf-studio-kit.toml`` always wins when both formats exist.
Legacy ``manifest.toml`` is only used as a fallback.
"""
canonical_path = kit_source / ".cf-studio-kit.toml"

Check failure on line 1035 in skills/studio/scripts/studio/utils/manifest.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal ".cf-studio-kit.toml" 3 times.

See more on https://sonarcloud.io/project/issues?id=constructorfabric_studio&issues=AZ7al1DXDOBH4SAQmWdn&open=AZ7al1DXDOBH4SAQmWdn&pullRequest=48
if canonical_path.is_file():
return canonical_path
legacy_path = kit_source / "manifest.toml"
if legacy_path.is_file():
return legacy_path
return None


# @cpt-begin:cpt-studio-algo-kit-manifest-install:p1:inst-manifest-read
def load_manifest(kit_source: Path, kit_slug: str = "") -> Optional[Union[Manifest, ManifestV2]]:
"""Read and parse ``manifest.toml`` from *kit_source*.
"""Read and parse the effective kit manifest from *kit_source*.

Returns ``None`` if the file does not exist. V2 manifests are delegated
to ``parse_manifest_v2`` and return a ``ManifestV2`` instance.
Raises ``ValueError`` if the file exists but is invalid.
Returns ``None`` if neither canonical nor legacy manifest exists.
Canonical ``.cf-studio-kit.toml`` takes precedence over legacy
``manifest.toml`` when both are present. V2 manifests are delegated to
``parse_manifest_v2`` and return a ``ManifestV2`` instance. Raises
``ValueError`` if the selected manifest exists but is invalid.
"""
manifest_path = kit_source / "manifest.toml"
if not manifest_path.is_file():
manifest_path = resolve_kit_manifest_path(kit_source)
if manifest_path is None:
return None
if manifest_path.name == ".cf-studio-kit.toml":
return _load_canonical_kit_manifest(kit_source, kit_slug=kit_slug)

try:
Expand Down
47 changes: 47 additions & 0 deletions tests/test_kit_manifest_validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -1077,6 +1077,53 @@ def test_no_manifest_no_resource_check(self):
]
self.assertEqual(len(resource_errors), 0)

def test_canonical_manifest_ignores_malformed_legacy_manifest(self):
"""Standalone validation ignores manifest.toml when canonical manifest exists."""
from studio.commands.validate_kits import _validate_kit_by_path

with TemporaryDirectory() as td:
td_path = Path(td)
kit_dir = td_path / "mykit"
kit_dir.mkdir()
(kit_dir / "constraints.toml").write_text(
"[FEATURE.identifiers.flow]\nrequired = true\n",
encoding="utf-8",
)
(kit_dir / "feature-template.md").write_text("# Feature\n", encoding="utf-8")
(kit_dir / ".cf-studio-kit.toml").write_text(
"\n".join([
'manifest_version = "1.0"',
"",
"[[kits]]",
'slug = "mykit"',
'version = "1.0"',
"",
"[[kits.resources]]",
'id = "ruleset"',
'kind = "constraints"',
'source = "constraints.toml"',
'type = "file"',
"",
"[[kits.resources]]",
'id = "feature-template"',
'kind = "template"',
'source = "feature-template.md"',
'type = "file"',
]) + "\n",
encoding="utf-8",
)
(kit_dir / "manifest.toml").write_text("[broken\ninvalid", encoding="utf-8")

rc, result = _validate_kit_by_path(kit_dir, verbose=True)

self.assertEqual(rc, 0)
self.assertEqual(result["kits"][0]["manifest_source"], "canonical")
resource_errors = [
e for e in result.get("errors", [])
if e.get("type") == "resources"
]
self.assertEqual(resource_errors, [])

def test_canonical_constraints_kind_resources_with_arbitrary_names(self):
"""Standalone canonical kit loads constraints by kind, not filename or id."""
from studio.commands.validate_kits import _validate_kit_by_path
Expand Down
37 changes: 37 additions & 0 deletions tests/test_manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,43 @@ def test_defaults_applied(self, tmp_path: Path) -> None:
assert m.resources[0].user_modifiable is True
assert m.resources[0].description == ""

def test_canonical_manifest_wins_over_legacy_manifest(self, tmp_path: Path) -> None:
"""When both manifests exist, load_manifest ignores legacy manifest.toml."""
kit = tmp_path / "dual-manifest-kit"
kit.mkdir()
(kit / "constraints.toml").write_text("[artifacts]\n", encoding="utf-8")
(kit / "feature-template.md").write_text("# Feature\n", encoding="utf-8")
(kit / ".cf-studio-kit.toml").write_text(
textwrap.dedent(
"""\
manifest_version = "1.0"

[[kits]]
slug = "dual-manifest-kit"
version = "1.0"

[[kits.resources]]
id = "ruleset"
kind = "constraints"
source = "constraints.toml"
type = "file"

[[kits.resources]]
id = "feature-template"
kind = "template"
source = "feature-template.md"
type = "file"
"""
),
encoding="utf-8",
)
(kit / "manifest.toml").write_text("[broken\ninvalid", encoding="utf-8")

m = load_manifest(kit)

assert m is not None
assert [res.id for res in m.resources] == ["ruleset", "feature-template"]


# ---------------------------------------------------------------------------
# validate_manifest
Expand Down
Loading