Skip to content

Commit d848fb4

Browse files
feat: add artifact-owned contribution lookup (#4550)
* feat: expose resolver lookup IDs Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * refactor: keep contribution lookup artifact-owned Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix: preserve canonical hook event in lookup Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix: continue duplicate hook provider lookup Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * test: cover encoded hook contribution lookup Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix: identify artifact lookups by installation Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix: reject non-json artifact contributions Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix: enforce strict artifact json Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix: align lookup with manifest semantics Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix: preserve lexical artifact source paths Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix: validate artifact lookup output encoding Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 623b099 commit d848fb4

9 files changed

Lines changed: 902 additions & 42 deletions

File tree

docs/reference/artifacts.md

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ An **artifact** is any command, template, script, or hook Spec Kit exposes in a
44

55
The `specify artifact` command group is the read-only introspection surface for that inventory. `specify preset resolve <name>` answers "which file wins for this preset-managed name?"; `specify artifact` answers "what exists at all, and what is the full composition stack behind it?" — including built-in artifacts that no preset touches.
66

7-
Both subcommands currently require `--json`. Omitting it exits with code `2` and prints a usage message on stderr; no stdout is produced. Text rendering is deliberately deferred so the JSON shapes below are the only contract, and adding a default text renderer later stays a non-breaking, additive change.
7+
All subcommands currently require `--json`. Omitting it exits with code `2` and prints a usage message on stderr; no stdout is produced. Text rendering is deliberately deferred so the JSON shapes below are the only contract, and adding a default text renderer later stays a non-breaking, additive change.
88

99
## List Artifacts
1010

@@ -135,7 +135,7 @@ For command, template, and script artifacts, `stack` is ordered by resolution pr
135135
| -------------- | -------------------------------------------------------------------------------- |
136136
| `id` | `{kind}:{name}` — the source-agnostic round-trip key, identical on every row of the same artifact's stack |
137137
| `layer` | `project`, `preset`, or `extension`; `null` for built-in layers |
138-
| `sourceId` | Source component of `lookupId`, or `null` when the layer has no provenance |
138+
| `sourceId` | Installed preset or extension ID used by the resolver, or `null` when the layer has no provenance |
139139
| `presetId` | Preset pack directory id; `null` on built-in, `project`, and `extension` rows |
140140
| `presetName` | Preset display name when its manifest declares one, else the pack id; `null` when `presetId` is `null` |
141141
| `strategy` | `replace`, `wrap`, `prepend`, `append`, or `additive` |
@@ -147,7 +147,44 @@ For command, template, and script artifacts, `stack` is ordered by resolution pr
147147

148148
`active` and `hidden` are independent labels, not opposites. For command, template, and script artifacts, `active` identifies the highest-precedence layer selected by the existing Spec Kit layer-resolution order; it does not validate that the layer content can be read or composed. This preserves the diagnostic behavior of `specify preset resolve`, which reports the discovered layer chain even when content composition later produces a warning. Composing strategies (`wrap`, `prepend`, `append`) keep lower layers in the composed output, so an inactive layer is not necessarily hidden: only layers below the first `replace` layer are marked `hidden`. Built-in rows have no provenance: `layer`, `sourceId`, and `lookupId` are `null` — but `id` is always populated, even on built-in rows. `id` is the round-trip key: `specify artifact info` accepts it as input (for example, `specify artifact info command:speckit.specify --json`), and it resolves the same artifact whether the caller passes the bare name or the `id`.
149149

150-
Lookup IDs are derived by the artifact command from the resolved layer and its existing preset or extension manifest. Manifest-declared layers use the manifest's `id`; convention-only layers use the installed preset or extension directory id. Project-local overrides carry a synthetic `project:_:{kind}:{name}` ID, while built-in layers have no `lookupId`. These values are artifact-stack provenance, not the round-trip key — use `id` for that. `sourcePath` is populated only when the layer maps to a concrete installed preset/extension file or a tracked agent materialization; core, project-override, and other synthetic rows report `null`.
150+
Here, **provenance** means the origin of one artifact layer: the installed
151+
preset or extension that supplied it, the manifest that declared it, and the
152+
concrete file that backs it. Lookup IDs use the installed preset or extension
153+
ID, matching the identity and ordering used by the existing resolver. The
154+
installed ID is the registry/directory name and may differ from the logical
155+
`id` declared inside the manifest. Using the installed ID keeps separate
156+
installed directories distinct even when their manifests declare the same
157+
logical ID.
158+
159+
Project-local overrides carry a synthetic `project:_:{kind}:{name}` ID, while
160+
built-in layers have no `lookupId`. These values identify artifact-stack
161+
provenance; they are not the artifact round-trip key — use `id` with
162+
`artifact info` for that. `manifestPath` identifies the declaring manifest,
163+
and `sourcePath` identifies the concrete file backing the layer when one
164+
exists. Core, project-override, and other synthetic rows may report
165+
`sourcePath: null`.
166+
167+
## Contribution Lookup
168+
169+
```bash
170+
specify artifact lookup <lookupId> --json
171+
```
172+
173+
Resolves a manifest-backed stack `lookupId` to the validated preset or extension
174+
declaration that Spec Kit uses. The returned declaration reflects normal
175+
manifest processing, including canonical extension command names, injected
176+
defaults such as empty alias lists, and lowercase preset strategies. It is not
177+
a verbatim representation of the authored YAML.
178+
179+
This keeps cross-reference behavior inside the artifact command: preset and
180+
extension commands, manifests, and resolver return shapes are unchanged.
181+
182+
The response includes the stable lookup ID, provider coordinates, manifest and
183+
source paths, and the effective declaration under `contribution`.
184+
Convention-only contributions and project overrides have no originating
185+
manifest declaration, so lookup returns
186+
`{"error": "unknown contribution <lookupId>"}` with exit code `1`. Built-in
187+
rows never have a `lookupId`.
151188

152189
### Hook artifacts
153190

@@ -184,7 +221,7 @@ For hooks, `active` reports registration state from `.specify/extensions.yml`, n
184221

185222
Declared-but-unregistered hooks remain visible when their extension is included by the normal resolver. Registry-disabled extensions are excluded entirely, consistently with their other contributions. Invalid individual extension manifests are also omitted by the existing resolver and remain diagnosable through extension inspection and validation commands.
186223

187-
Hook lookup IDs use the artifact-private `{layer}:{sourceId}:hook:{encodedEventName}:{encodedTargetCommand}` grammar. Hook provenance is restricted to `preset` and `extension` layers; hooks never receive a built-in/core layer. The current manifest API exposes extension hook declarations, so current rows use the `extension` layer. The `preset` layer remains reserved by the hook identifier grammar for preset-provided hooks without requiring artifact IDs to be added to preset or extension manifest APIs.
224+
Hook lookup IDs use the artifact-private `{layer}:{sourceId}:hook:{encodedEventName}:{encodedTargetCommand}` grammar, where `sourceId` is the installed provider ID. Hook provenance is restricted to `preset` and `extension` layers; hooks never receive a built-in/core layer. The current manifest API exposes extension hook declarations, so current rows use the `extension` layer. The `preset` layer remains reserved by the hook identifier grammar for preset-provided hooks without requiring artifact IDs to be added to preset or extension manifest APIs.
188225

189226
## JSON Errors
190227

src/specify_cli/artifacts/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
ArtifactKind,
99
ArtifactNotFoundError,
1010
ArtifactResolutionError,
11+
ContributionNotFoundError,
1112
HookArtifact,
1213
HookLayerName,
1314
HookStackEntry,
@@ -25,6 +26,7 @@
2526
"ArtifactKind",
2627
"ArtifactNotFoundError",
2728
"ArtifactResolutionError",
29+
"ContributionNotFoundError",
2830
"HookArtifact",
2931
"HookLayerName",
3032
"HookStackEntry",

src/specify_cli/artifacts/_commands.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,44 @@ def artifact_info(
158158
sys.stdout.write("\n")
159159

160160

161+
@artifact_app.command("lookup")
162+
def artifact_lookup(
163+
lookup_id: str = typer.Argument(..., help="Contribution lookupId from an artifact stack."),
164+
json_flag: bool = typer.Option(
165+
False,
166+
"--json",
167+
help="Emit the validated manifest contribution used by Spec Kit as JSON.",
168+
),
169+
) -> None:
170+
"""Resolve a stack lookupId to its effective preset or extension contribution."""
171+
_require_json_flag(json_flag)
172+
try:
173+
root = _resolve_project_root()
174+
payload = ArtifactCatalog(root).get_contribution_info(lookup_id)
175+
except ArtifactError as exc:
176+
_emit_error_and_exit(exc)
177+
return # pragma: no cover
178+
except (OSError, PresetError):
179+
_emit_error_and_exit(ArtifactResolutionError())
180+
return # pragma: no cover
181+
182+
try:
183+
rendered = json.dumps(
184+
payload,
185+
indent=2,
186+
sort_keys=True,
187+
ensure_ascii=False,
188+
allow_nan=False,
189+
)
190+
rendered.encode("utf-8")
191+
except (TypeError, ValueError, UnicodeEncodeError):
192+
_emit_error_and_exit(ArtifactResolutionError())
193+
return # pragma: no cover
194+
195+
sys.stdout.write(rendered)
196+
sys.stdout.write("\n")
197+
198+
161199
def register(app: typer.Typer) -> None:
162200
"""Attach the artifact command group to the root Typer app."""
163201
app.add_typer(artifact_app, name="artifact")

src/specify_cli/artifacts/_identifiers.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,31 @@ def parse_hook_artifact_name(name: str) -> tuple[str, str]:
9999
)
100100

101101

102+
def parse_lookup_id(value: str) -> tuple[str, str, str, str]:
103+
"""Parse a contribution lookup ID into layer, source, kind, and name."""
104+
if not isinstance(value, str):
105+
raise IdentifierComponentError("Invalid lookupId")
106+
parts = value.split(":")
107+
if len(parts) == 4:
108+
layer, source_id, kind, name = parts
109+
derive_lookup_id(layer, source_id, kind, name)
110+
return layer, source_id, kind, name
111+
if len(parts) == 5 and parts[2] == "hook":
112+
layer, source_id, kind, encoded_event, encoded_command = parts
113+
if layer not in _HOOK_LAYERS or source_id == "_":
114+
raise IdentifierComponentError("Invalid hook lookupId")
115+
event_name, command = parse_hook_artifact_name(
116+
f"{encoded_event}:{encoded_command}"
117+
)
118+
if (
119+
derive_hook_lookup_id(layer, source_id, event_name, command)
120+
!= value
121+
):
122+
raise IdentifierComponentError("Invalid hook lookupId")
123+
return layer, source_id, kind, f"{encoded_event}:{encoded_command}"
124+
raise IdentifierComponentError("Invalid lookupId")
125+
126+
102127
def _encode_hook_component(value: Any, field_label: str) -> str:
103128
"""Encode one hook ID component without narrowing manifest syntax."""
104129
if not isinstance(value, str):

src/specify_cli/artifacts/catalog.py

Lines changed: 155 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
derive_hook_public_id,
2626
derive_public_id,
2727
parse_hook_artifact_name,
28+
parse_lookup_id,
2829
validate_component,
2930
)
3031
from .models import (
@@ -33,6 +34,7 @@
3334
ArtifactKind,
3435
ArtifactNotFoundError,
3536
ArtifactResolutionError,
37+
ContributionNotFoundError,
3638
HookArtifact,
3739
HookStackEntry,
3840
NotASpecKitProjectError,
@@ -405,6 +407,155 @@ def get_artifact_info(
405407
"stack": [layer.to_json_dict() for layer in stack],
406408
}
407409

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+
408559
def _get_hook_info(
409560
self,
410561
bare_name: str,
@@ -480,7 +631,7 @@ def _collect_hook_inventory(
480631
)
481632
if manifest_path is None:
482633
raise ArtifactResolutionError()
483-
source_id = manifest.id
634+
source_id = extension_id
484635

485636
for event_name, hook_config in (manifest.hooks or {}).items():
486637
entries_by_command: dict[
@@ -511,6 +662,7 @@ def _collect_hook_inventory(
511662
"id": public_id,
512663
"layer": "extension",
513664
"sourceId": source_id,
665+
"runtimeExtensionId": manifest.id,
514666
"presetId": None,
515667
"presetName": None,
516668
"manifestPath": manifest_path,
@@ -553,7 +705,8 @@ def _collect_hook_inventory(
553705
presetName=None,
554706
strategy="additive",
555707
active=any(
556-
binding.get("extension") == declaration["sourceId"]
708+
binding.get("extension")
709+
== declaration["runtimeExtensionId"]
557710
and binding.get("command") == command
558711
for binding in enabled_bindings
559712
),

src/specify_cli/artifacts/models.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,12 @@ def __init__(self, name: str) -> None:
139139
super().__init__(self.message)
140140

141141

142+
class ContributionNotFoundError(ArtifactError):
143+
def __init__(self, lookup_id: str) -> None:
144+
self.message = f"unknown contribution {lookup_id}"
145+
super().__init__(self.message)
146+
147+
142148
class AmbiguousArtifactError(ArtifactError):
143149
def __init__(self, name: str, kinds: Iterable[str]) -> None:
144150
kinds_list = sorted(kinds)
@@ -165,6 +171,7 @@ def __init__(self) -> None:
165171
"ArtifactKind",
166172
"ArtifactNotFoundError",
167173
"ArtifactResolutionError",
174+
"ContributionNotFoundError",
168175
"HookArtifact",
169176
"HookLayerName",
170177
"HookStackEntry",

0 commit comments

Comments
 (0)