|
31 | 31 | derive_named_id, |
32 | 32 | derive_public_id, |
33 | 33 | layer_kind_from_lookup_id, |
| 34 | + source_id_from_lookup_id, |
34 | 35 | validate_component, |
35 | 36 | ) |
36 | 37 | from specify_cli.extensions import ExtensionManifest, ValidationError |
@@ -529,3 +530,115 @@ def test_no_id_written_to_preset_manifest_files(self, tmp_path): |
529 | 530 | assert ":command:" not in on_disk |
530 | 531 | assert ":template:" not in on_disk |
531 | 532 | assert ":script:" not in on_disk |
| 533 | + |
| 534 | + |
| 535 | +# --------------------------------------------------------------------------- |
| 536 | +# `_identifier.py` review-round nits — sourceId accessor + derive_named_id |
| 537 | +# sentinel enforcement. Consumers must not ``.split(":")`` a lookupId |
| 538 | +# themselves, and the ``project``/``_`` pairing is enforced at the single |
| 539 | +# derivation boundary rather than at each caller. |
| 540 | +# --------------------------------------------------------------------------- |
| 541 | + |
| 542 | + |
| 543 | +class TestSourceIdFromLookupId: |
| 544 | + @pytest.mark.parametrize( |
| 545 | + "lookup_id, expected", |
| 546 | + [ |
| 547 | + ("preset:speckit-core:command:speckit.plan", "speckit-core"), |
| 548 | + ( |
| 549 | + "extension:speckit-git:hook:before_specify:speckit.git.branch", |
| 550 | + "speckit-git", |
| 551 | + ), |
| 552 | + ("", None), |
| 553 | + ("preset:foo", None), |
| 554 | + ("unknown:foo:command:bar", None), |
| 555 | + ("project:_:hook:evt:cmd", None), |
| 556 | + ], |
| 557 | + ) |
| 558 | + def test_extracts_source_id_or_none(self, lookup_id, expected): |
| 559 | + assert source_id_from_lookup_id(lookup_id) == expected |
| 560 | + |
| 561 | + |
| 562 | +class TestDeriveNamedIdSentinel: |
| 563 | + @pytest.mark.parametrize( |
| 564 | + "layer, source_id", |
| 565 | + [ |
| 566 | + (PROJECT_OVERRIDE_LAYER, "other"), |
| 567 | + ("preset", "_"), |
| 568 | + ("extension", "_"), |
| 569 | + ], |
| 570 | + ) |
| 571 | + def test_rejects_invalid_layer_source_pairs(self, layer, source_id): |
| 572 | + with pytest.raises(IdentifierComponentError): |
| 573 | + derive_named_id(layer, source_id, "command", "n") |
| 574 | + |
| 575 | + def test_project_layer_accepts_underscore_source(self): |
| 576 | + assert ( |
| 577 | + derive_named_id(PROJECT_OVERRIDE_LAYER, "_", "command", "n") |
| 578 | + == f"{PROJECT_OVERRIDE_LAYER}:_:command:n" |
| 579 | + ) |
| 580 | + |
| 581 | + |
| 582 | +# --------------------------------------------------------------------------- |
| 583 | +# Manifest-declared id wins over installed-directory name — one preset test |
| 584 | +# and one extension test because the two branches of ``collect_all_layers`` |
| 585 | +# could diverge independently. Each proves ``lookupId`` on the resolved layer |
| 586 | +# equals the manifest contribution ``id``. |
| 587 | +# --------------------------------------------------------------------------- |
| 588 | + |
| 589 | + |
| 590 | +def _write_registry(project: Path, tier: str, pack_id: str) -> None: |
| 591 | + registry = { |
| 592 | + "schema_version": "1.0", |
| 593 | + tier: {pack_id: {"version": "1.0.0", "priority": 10, "enabled": True}}, |
| 594 | + } |
| 595 | + (project / ".specify" / tier / ".registry").write_text( |
| 596 | + json.dumps(registry), encoding="utf-8" |
| 597 | + ) |
| 598 | + |
| 599 | + |
| 600 | +class TestManifestIdWinsOverDirectoryName: |
| 601 | + def test_preset_lookup_id_uses_manifest_id_when_directory_renamed(self, tmp_path): |
| 602 | + project = _make_project(tmp_path) |
| 603 | + dir_name, manifest_id = "renamed-preset", "original-preset" |
| 604 | + pack_dir = project / ".specify" / "presets" / dir_name |
| 605 | + (pack_dir / "templates").mkdir(parents=True) |
| 606 | + (pack_dir / "templates" / "spec-template.md").write_text("p", encoding="utf-8") |
| 607 | + data = _preset_data(manifest_id) |
| 608 | + data["provides"] = { |
| 609 | + "templates": [ |
| 610 | + {"type": "template", "name": "spec-template", "file": "templates/spec-template.md"} |
| 611 | + ] |
| 612 | + } |
| 613 | + _write_manifest(pack_dir, data, "preset.yml") |
| 614 | + _write_registry(project, "presets", dir_name) |
| 615 | + |
| 616 | + layers = PresetResolver(project).collect_all_layers("spec-template", "template") |
| 617 | + layer = next(L for L in layers if L["source"].startswith(dir_name)) |
| 618 | + manifest = PresetManifest(pack_dir / "preset.yml") |
| 619 | + assert layer["lookupId"] == manifest.contribution_id("template", "spec-template") |
| 620 | + assert layer["lookupId"] == f"preset:{manifest_id}:template:spec-template" |
| 621 | + |
| 622 | + def test_extension_lookup_id_uses_manifest_id_when_directory_renamed(self, tmp_path): |
| 623 | + project = _make_project(tmp_path) |
| 624 | + dir_name, manifest_id = "renamed-ext", "original-ext" |
| 625 | + # Extension commands are auto-namespaced under speckit.<manifest_id> |
| 626 | + namespaced = f"speckit.{manifest_id}.branch" |
| 627 | + ext_dir = project / ".specify" / "extensions" / dir_name |
| 628 | + (ext_dir / "commands").mkdir(parents=True) |
| 629 | + (ext_dir / "commands" / "branch.md").write_text("e", encoding="utf-8") |
| 630 | + data = _extension_data(manifest_id, with_templates=False, with_scripts=False) |
| 631 | + data["provides"]["commands"] = [ |
| 632 | + {"name": "speckit.branch", "file": "commands/branch.md", "description": "F"} |
| 633 | + ] |
| 634 | + _write_manifest(ext_dir, data, "extension.yml") |
| 635 | + _write_registry(project, "extensions", dir_name) |
| 636 | + |
| 637 | + layers = PresetResolver(project).collect_all_layers(namespaced, "command") |
| 638 | + layer = next(L for L in layers if L.get("extension_id") == dir_name) |
| 639 | + manifest = ExtensionManifest(ext_dir / "extension.yml") |
| 640 | + assert layer["lookupId"] == manifest.contribution_id("command", namespaced) |
| 641 | + assert layer["lookupId"] == f"extension:{manifest_id}:command:{namespaced}" |
| 642 | + |
| 643 | + |
| 644 | + |
0 commit comments