|
25 | 25 | derive_hook_public_id, |
26 | 26 | derive_public_id, |
27 | 27 | parse_hook_artifact_name, |
| 28 | + parse_lookup_id, |
28 | 29 | validate_component, |
29 | 30 | ) |
30 | 31 | from .models import ( |
|
33 | 34 | ArtifactKind, |
34 | 35 | ArtifactNotFoundError, |
35 | 36 | ArtifactResolutionError, |
| 37 | + ContributionNotFoundError, |
36 | 38 | HookArtifact, |
37 | 39 | HookStackEntry, |
38 | 40 | NotASpecKitProjectError, |
@@ -405,6 +407,155 @@ def get_artifact_info( |
405 | 407 | "stack": [layer.to_json_dict() for layer in stack], |
406 | 408 | } |
407 | 409 |
|
| 410 | + def get_contribution_info(self, lookup_id: str) -> dict[str, Any]: |
| 411 | + """Resolve a stack ``lookupId`` to its validated manifest entry.""" |
| 412 | + _validate_project(self.project_root) |
| 413 | + _validate_extension_registry(self.project_root) |
| 414 | + try: |
| 415 | + layer, source_id, kind, name = parse_lookup_id(lookup_id) |
| 416 | + except IdentifierComponentError as exc: |
| 417 | + raise ContributionNotFoundError(lookup_id) from exc |
| 418 | + if layer == "project": |
| 419 | + raise ContributionNotFoundError(lookup_id) |
| 420 | + |
| 421 | + from ..presets import PresetError, PresetResolver |
| 422 | + |
| 423 | + resolver = PresetResolver(self.project_root) |
| 424 | + try: |
| 425 | + if layer == "preset": |
| 426 | + resolved = self._find_preset_contribution( |
| 427 | + resolver, source_id, kind, name |
| 428 | + ) |
| 429 | + else: |
| 430 | + resolved = self._find_extension_contribution( |
| 431 | + resolver, source_id, kind, name |
| 432 | + ) |
| 433 | + except (OSError, PresetError) as exc: |
| 434 | + raise ArtifactResolutionError() from exc |
| 435 | + if resolved is None: |
| 436 | + raise ContributionNotFoundError(lookup_id) |
| 437 | + |
| 438 | + contribution, manifest_path, source_path = resolved |
| 439 | + return { |
| 440 | + "id": lookup_id, |
| 441 | + "layer": layer, |
| 442 | + "sourceId": source_id, |
| 443 | + "kind": kind, |
| 444 | + "name": name, |
| 445 | + "manifestPath": manifest_path, |
| 446 | + "sourcePath": source_path, |
| 447 | + "contribution": contribution, |
| 448 | + } |
| 449 | + |
| 450 | + def _find_preset_contribution( |
| 451 | + self, |
| 452 | + resolver: Any, |
| 453 | + source_id: str, |
| 454 | + kind: str, |
| 455 | + name: str, |
| 456 | + ) -> tuple[dict[str, Any], str, str | None] | None: |
| 457 | + for pack_id, _metadata in resolver._get_all_presets_by_priority(): |
| 458 | + if pack_id != source_id: |
| 459 | + continue |
| 460 | + pack_dir = resolver.presets_dir / pack_id |
| 461 | + manifest = resolver._get_manifest(pack_dir) |
| 462 | + if manifest is None: |
| 463 | + return None |
| 464 | + for entry in manifest.templates: |
| 465 | + if ( |
| 466 | + isinstance(entry, dict) |
| 467 | + and entry.get("type") == kind |
| 468 | + and entry.get("name") == name |
| 469 | + ): |
| 470 | + return self._contribution_result( |
| 471 | + entry, pack_dir, manifest.path |
| 472 | + ) |
| 473 | + return None |
| 474 | + |
| 475 | + def _find_extension_contribution( |
| 476 | + self, |
| 477 | + resolver: Any, |
| 478 | + source_id: str, |
| 479 | + kind: str, |
| 480 | + name: str, |
| 481 | + ) -> tuple[dict[str, Any], str, str | None] | None: |
| 482 | + from ..extensions import ( |
| 483 | + ExtensionManifest, |
| 484 | + ValidationError, |
| 485 | + coerce_hook_entries, |
| 486 | + ) |
| 487 | + |
| 488 | + for _priority, extension_id, _metadata in resolver._get_all_extensions_by_priority(): |
| 489 | + if extension_id != source_id: |
| 490 | + continue |
| 491 | + extension_dir = resolver.extensions_dir / extension_id |
| 492 | + manifest_path = extension_dir / "extension.yml" |
| 493 | + try: |
| 494 | + manifest = ExtensionManifest(manifest_path) |
| 495 | + except (ValidationError, OSError, TypeError, AttributeError): |
| 496 | + return None |
| 497 | + if kind == "hook": |
| 498 | + event_name, command = parse_hook_artifact_name(name) |
| 499 | + matching: dict[str, Any] | None = None |
| 500 | + hook_config = (manifest.hooks or {}).get(event_name) |
| 501 | + for entry in coerce_hook_entries(hook_config): |
| 502 | + if isinstance(entry, dict) and entry.get("command") == command: |
| 503 | + matching = entry |
| 504 | + if matching is None: |
| 505 | + return None |
| 506 | + relative_manifest = _repo_relative_existing_file( |
| 507 | + self.project_root, manifest.path |
| 508 | + ) |
| 509 | + if relative_manifest is None: |
| 510 | + raise ArtifactResolutionError() |
| 511 | + return ( |
| 512 | + {**matching, "eventName": event_name}, |
| 513 | + relative_manifest, |
| 514 | + None, |
| 515 | + ) |
| 516 | + |
| 517 | + entries = { |
| 518 | + "command": manifest.commands, |
| 519 | + "template": manifest.templates, |
| 520 | + "script": manifest.scripts, |
| 521 | + }[kind] |
| 522 | + for entry in entries: |
| 523 | + if isinstance(entry, dict) and entry.get("name") == name: |
| 524 | + return self._contribution_result( |
| 525 | + entry, extension_dir, manifest.path |
| 526 | + ) |
| 527 | + return None |
| 528 | + |
| 529 | + def _contribution_result( |
| 530 | + self, |
| 531 | + entry: dict[str, Any], |
| 532 | + pack_dir: Path, |
| 533 | + manifest_path: Path, |
| 534 | + ) -> tuple[dict[str, Any], str, str | None]: |
| 535 | + relative_manifest = _repo_relative_existing_file( |
| 536 | + self.project_root, manifest_path |
| 537 | + ) |
| 538 | + if relative_manifest is None: |
| 539 | + raise ArtifactResolutionError() |
| 540 | + relative_file = entry.get("file") |
| 541 | + source_path = None |
| 542 | + if isinstance(relative_file, str): |
| 543 | + try: |
| 544 | + resolved_pack = pack_dir.resolve() |
| 545 | + candidate = pack_dir / relative_file |
| 546 | + candidate.resolve().relative_to(resolved_pack) |
| 547 | + except (OSError, ValueError): |
| 548 | + pass |
| 549 | + else: |
| 550 | + source_path = _repo_relative_existing_file( |
| 551 | + self.project_root, candidate |
| 552 | + ) |
| 553 | + return ( |
| 554 | + dict(entry), |
| 555 | + relative_manifest, |
| 556 | + source_path, |
| 557 | + ) |
| 558 | + |
408 | 559 | def _get_hook_info( |
409 | 560 | self, |
410 | 561 | bare_name: str, |
@@ -480,7 +631,7 @@ def _collect_hook_inventory( |
480 | 631 | ) |
481 | 632 | if manifest_path is None: |
482 | 633 | raise ArtifactResolutionError() |
483 | | - source_id = manifest.id |
| 634 | + source_id = extension_id |
484 | 635 |
|
485 | 636 | for event_name, hook_config in (manifest.hooks or {}).items(): |
486 | 637 | entries_by_command: dict[ |
@@ -511,6 +662,7 @@ def _collect_hook_inventory( |
511 | 662 | "id": public_id, |
512 | 663 | "layer": "extension", |
513 | 664 | "sourceId": source_id, |
| 665 | + "runtimeExtensionId": manifest.id, |
514 | 666 | "presetId": None, |
515 | 667 | "presetName": None, |
516 | 668 | "manifestPath": manifest_path, |
@@ -553,7 +705,8 @@ def _collect_hook_inventory( |
553 | 705 | presetName=None, |
554 | 706 | strategy="additive", |
555 | 707 | active=any( |
556 | | - binding.get("extension") == declaration["sourceId"] |
| 708 | + binding.get("extension") |
| 709 | + == declaration["runtimeExtensionId"] |
557 | 710 | and binding.get("command") == command |
558 | 711 | for binding in enabled_bindings |
559 | 712 | ), |
|
0 commit comments