Skip to content

Commit e129201

Browse files
committed
fix: preserve colon-containing hook artifacts
Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 667230a1-e9fa-4500-a57f-c1c482be2507
1 parent 2398ab7 commit e129201

4 files changed

Lines changed: 117 additions & 23 deletions

File tree

docs/reference/artifacts.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ Lookup IDs are derived by the artifact command from the resolved layer and its e
154154
Hook rows project hook declarations from extensions included by the standard preset and extension resolver. The round-trip shorthand is:
155155

156156
```text
157-
hook:{eventName}:{targetCommand}
157+
hook:{encodedEventName}:{encodedTargetCommand}
158158
```
159159

160160
For example, both of these select the same hook artifact:
@@ -164,6 +164,12 @@ specify artifact info hook:before_specify:speckit.compliance.pre-check --json
164164
specify artifact info before_specify:speckit.compliance.pre-check --kind hook --json
165165
```
166166

167+
Hook ID components are percent-encoded only when required to keep the colon-delimited shorthand unambiguous. This encoding is limited to the artifact `id`, `name`, and `lookupId` representation; hook manifests, runtime bindings, `eventName`, and `targetCommand` are unchanged. For example, an event named `custom:after` targeting `/skill:speckit-test-ext-hello` has the artifact ID:
168+
169+
```text
170+
hook:custom%3Aafter:%2Fskill%3Aspeckit-test-ext-hello
171+
```
172+
167173
Hook rows add three top-level fields:
168174

169175
| Field | Description |
@@ -178,7 +184,7 @@ For hooks, `active` reports registration state from `.specify/extensions.yml`, n
178184

179185
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.
180186

181-
Hook lookup IDs use the artifact-private `{layer}:{sourceId}:hook:{eventName}:{targetCommand}` 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.
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.
182188

183189
## JSON Errors
184190

src/specify_cli/artifacts/_identifiers.py

Lines changed: 53 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,15 @@
22

33
from __future__ import annotations
44

5+
import re
56
from typing import Any
7+
from urllib.parse import quote, unquote_to_bytes
68

79
PROJECT_OVERRIDE_LAYER = "project"
810
_ARTIFACT_KINDS = frozenset({"command", "template", "script"})
911
_LAYER_KINDS = frozenset({PROJECT_OVERRIDE_LAYER, "preset", "extension"})
1012
_HOOK_LAYERS = frozenset({"preset", "extension"})
13+
_INVALID_PERCENT_ESCAPE = re.compile(r"%(?![0-9A-Fa-f]{2})")
1114

1215

1316
class IdentifierComponentError(ValueError):
@@ -63,9 +66,9 @@ def derive_lookup_id(layer: str, source_id: str, kind: str, name: str) -> str:
6366

6467
def derive_hook_public_id(event_name: str, command: str) -> str:
6568
"""Build the source-agnostic identifier for a hook artifact."""
66-
validate_component(event_name, "eventName")
67-
validate_component(command, "command")
68-
return f"hook:{event_name}:{command}"
69+
encoded_event = _encode_hook_component(event_name, "eventName")
70+
encoded_command = _encode_hook_component(command, "command")
71+
return f"hook:{encoded_event}:{encoded_command}"
6972

7073

7174
def derive_hook_lookup_id(
@@ -74,12 +77,56 @@ def derive_hook_lookup_id(
7477
"""Build the artifact-private lookup identifier for a hook declaration."""
7578
validate_component(layer, "layer")
7679
validate_component(source_id, "sourceId")
77-
validate_component(event_name, "eventName")
78-
validate_component(command, "command")
80+
encoded_event = _encode_hook_component(event_name, "eventName")
81+
encoded_command = _encode_hook_component(command, "command")
7982
if layer not in _HOOK_LAYERS:
8083
raise IdentifierComponentError(f"Invalid hook layer '{layer}'")
8184
if source_id == "_":
8285
raise IdentifierComponentError(
8386
"Invalid sourceId '_': hooks require a preset or extension source"
8487
)
85-
return f"{layer}:{source_id}:hook:{event_name}:{command}"
88+
return f"{layer}:{source_id}:hook:{encoded_event}:{encoded_command}"
89+
90+
91+
def parse_hook_artifact_name(name: str) -> tuple[str, str]:
92+
"""Decode the ``{eventName}:{targetCommand}`` portion of a hook artifact ID."""
93+
encoded_event, separator, encoded_command = name.partition(":")
94+
if not separator or ":" in encoded_command:
95+
raise IdentifierComponentError("Invalid hook artifact name")
96+
return (
97+
_decode_hook_component(encoded_event, "eventName"),
98+
_decode_hook_component(encoded_command, "command"),
99+
)
100+
101+
102+
def _encode_hook_component(value: Any, field_label: str) -> str:
103+
"""Encode one hook ID component without narrowing manifest syntax."""
104+
if not isinstance(value, str):
105+
raise IdentifierComponentError(
106+
f"Invalid {field_label}: expected a string, got {type(value).__name__}"
107+
)
108+
if not value:
109+
raise IdentifierComponentError(
110+
f"Invalid {field_label}: value must not be empty"
111+
)
112+
return quote(value, safe="")
113+
114+
115+
def _decode_hook_component(value: str, field_label: str) -> str:
116+
"""Decode one hook ID component, rejecting malformed percent escapes."""
117+
validate_component(value, field_label)
118+
if _INVALID_PERCENT_ESCAPE.search(value):
119+
raise IdentifierComponentError(
120+
f"Invalid {field_label}: malformed percent escape"
121+
)
122+
try:
123+
decoded = unquote_to_bytes(value).decode("utf-8")
124+
except UnicodeDecodeError as exc:
125+
raise IdentifierComponentError(
126+
f"Invalid {field_label}: value is not valid UTF-8"
127+
) from exc
128+
if not decoded:
129+
raise IdentifierComponentError(
130+
f"Invalid {field_label}: value must not be empty"
131+
)
132+
return decoded

src/specify_cli/artifacts/catalog.py

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
derive_hook_lookup_id,
2525
derive_hook_public_id,
2626
derive_public_id,
27+
parse_hook_artifact_name,
2728
validate_component,
2829
)
2930
from .models import (
@@ -240,11 +241,8 @@ def _resolve_kind_hint(name: str, kind: ArtifactKind | None) -> tuple[str, Artif
240241
def _validate_artifact_name(name: str, kind: ArtifactKind) -> str:
241242
"""Validate the structural identifier component constraints for ``name``."""
242243
if kind == "hook":
243-
event_name, separator, command = name.partition(":")
244-
if not separator:
245-
raise ArtifactNotFoundError(name)
246244
try:
247-
derive_hook_public_id(event_name, command)
245+
parse_hook_artifact_name(name)
248246
except IdentifierComponentError as exc:
249247
raise ArtifactNotFoundError(name) from exc
250248
return name
@@ -403,7 +401,7 @@ def _get_hook_info(
403401
) -> dict[str, Any]:
404402
"""Return one hook row and its additive declaration stack."""
405403
_validate_artifact_name(bare_name, "hook")
406-
event_name, _, command = bare_name.partition(":")
404+
event_name, command = parse_hook_artifact_name(bare_name)
407405
hook_rows, stack_cache = self._collect_hook_inventory(resolver)
408406
for row in hook_rows:
409407
if row.eventName != event_name or row.targetCommand != command:
@@ -473,23 +471,29 @@ def _collect_hook_inventory(
473471
source_id = manifest.id
474472

475473
for event_name, hook_config in (manifest.hooks or {}).items():
476-
entries_by_command: dict[str, dict[str, Any]] = {}
474+
entries_by_command: dict[
475+
str, tuple[dict[str, Any], str, str]
476+
] = {}
477477
for entry in coerce_hook_entries(hook_config):
478478
if not isinstance(entry, dict):
479479
continue
480480
command = entry.get("command")
481481
try:
482-
validate_component(command, "command")
482+
public_id = derive_hook_public_id(event_name, command)
483+
lookup_id = derive_hook_lookup_id(
484+
"extension", source_id, event_name, command
485+
)
483486
except IdentifierComponentError:
484487
continue
485488
entries_by_command.pop(command, None)
486-
entries_by_command[command] = entry
487-
488-
for command, entry in entries_by_command.items():
489-
public_id = derive_hook_public_id(event_name, command)
490-
lookup_id = derive_hook_lookup_id(
491-
"extension", source_id, event_name, command
489+
entries_by_command[command] = (
490+
entry,
491+
public_id,
492+
lookup_id,
492493
)
494+
495+
for command, entry_data in entries_by_command.items():
496+
entry, public_id, lookup_id = entry_data
493497
insertion_index += 1
494498
declaration = {
495499
"id": public_id,
@@ -560,10 +564,11 @@ def _collect_hook_inventory(
560564
),
561565
"",
562566
)
567+
public_id = derive_hook_public_id(event_name, command)
563568
rows.append(
564569
HookArtifact(
565-
id=derive_hook_public_id(event_name, command),
566-
name=f"{event_name}:{command}",
570+
id=public_id,
571+
name=public_id.removeprefix("hook:"),
567572
kind="hook",
568573
description=description,
569574
eventName=event_name,

tests/test_artifact_command.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1948,6 +1948,42 @@ def test_hook_shorthand_round_trips(self, spec_kit_project: Path):
19481948
assert payload["eventName"] == "before_specify"
19491949
assert payload["targetCommand"] == "speckit.compliance.pre-check"
19501950

1951+
def test_colon_containing_values_round_trip_through_encoded_id(
1952+
self, spec_kit_project: Path
1953+
):
1954+
_install_extension_with_hooks(
1955+
spec_kit_project,
1956+
"ext",
1957+
hooks={
1958+
"custom:after": [
1959+
{
1960+
"command": "/skill:speckit-test-ext-hello",
1961+
"description": "Colon-compatible hook",
1962+
}
1963+
]
1964+
},
1965+
)
1966+
1967+
rows = ArtifactCatalog(spec_kit_project).list_artifacts_with_stack()
1968+
row = next(item for item in rows if item["kind"] == "hook")
1969+
1970+
assert (
1971+
row["id"]
1972+
== "hook:custom%3Aafter:%2Fskill%3Aspeckit-test-ext-hello"
1973+
)
1974+
assert row["name"] == (
1975+
"custom%3Aafter:%2Fskill%3Aspeckit-test-ext-hello"
1976+
)
1977+
assert row["eventName"] == "custom:after"
1978+
assert row["targetCommand"] == "/skill:speckit-test-ext-hello"
1979+
assert row["stack"][0]["lookupId"] == (
1980+
"extension:ext:hook:custom%3Aafter:"
1981+
"%2Fskill%3Aspeckit-test-ext-hello"
1982+
)
1983+
1984+
info = ArtifactCatalog(spec_kit_project).get_artifact_info(row["id"])
1985+
assert info == row
1986+
19511987
def test_kind_hint_resolves_hook_name(self, spec_kit_project: Path):
19521988
_install_extension_with_hooks(
19531989
spec_kit_project,

0 commit comments

Comments
 (0)