Skip to content

Commit 8678014

Browse files
source-agnostic artifact IDs; built-in tier recognized by exclusion, never by name.
Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>
1 parent 8c29bfe commit 8678014

8 files changed

Lines changed: 135 additions & 119 deletions

File tree

docs/reference/artifacts.md

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ specify artifact info <name> --json
6666
"stack": [
6767
{
6868
"layer": "preset",
69+
"sourceId": "compliance",
6970
"presetId": "compliance",
7071
"presetName": "Compliance Preset",
7172
"strategy": "replace",
@@ -75,14 +76,15 @@ specify artifact info <name> --json
7576
"lookupId": "preset:compliance:command:speckit.specify"
7677
},
7778
{
78-
"layer": "core",
79+
"layer": null,
80+
"sourceId": null,
7981
"presetId": null,
8082
"presetName": null,
8183
"strategy": "replace",
8284
"active": false,
8385
"hidden": true,
8486
"manifestPath": null,
85-
"lookupId": "core:_:command:speckit.specify"
87+
"lookupId": null
8688
}
8789
]
8890
}
@@ -96,16 +98,17 @@ The top-level `id`, `name`, `kind`, and `description` fields match the correspon
9698

9799
| Field | Description |
98100
| -------------- | -------------------------------------------------------------------------------- |
99-
| `layer` | `project`, `preset`, `extension`, or `core` |
100-
| `presetId` | Preset pack directory id; `null` on `core`, `project`, and `extension` rows |
101+
| `layer` | `project`, `preset`, or `extension`; `null` for built-in layers |
102+
| `sourceId` | Source component of `lookupId`, or `null` when the layer has no provenance |
103+
| `presetId` | Preset pack directory id; `null` on built-in, `project`, and `extension` rows |
101104
| `presetName` | Preset display name when its manifest declares one, else the pack id; `null` when `presetId` is `null` |
102105
| `strategy` | `replace`, `wrap`, `prepend`, or `append` |
103106
| `active` | `true` only for index `0` — the layer whose content is served |
104107
| `hidden` | `true` when a lower-index `replace` layer cuts this layer out of the composition |
105108
| `manifestPath` | Project-relative path to the declaring manifest, or `null` when none applies |
106-
| `lookupId` | Deterministic `{layer}:{sourceId}:{kind}:{name}` identifier for the layer |
109+
| `lookupId` | Deterministic `{layer}:{sourceId}:{kind}:{name}` identifier, or `null` for built-in layers |
107110

108-
`active` and `hidden` are independent labels, not opposites. 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`. Core rows appear as the base of the stack with `presetId`/`presetName`/`manifestPath` set to `null` and a `core:_:{kind}:{name}` lookup ID.
111+
`active` and `hidden` are independent labels, not opposites. 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`.
109112

110113
Lookup IDs use the same grammar as [preset contribution identifiers](presets.md#contribution-identifiers), so a `lookupId` from this command joins directly to `PresetManifest.iter_contributions()` / `ExtensionManifest.iter_contributions()` for manifest-declared layers. Project-local overrides carry a synthetic `project:_:{kind}:{name}` ID that intentionally matches no manifest contribution.
111114

extensions/EXTENSION-API-REFERENCE.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -863,7 +863,7 @@ satisfied = version_satisfies("1.2.3", ">=1.0.0,<2.0.0") # bool
863863

864864
## Contribution Identifiers
865865

866-
Every command, template, script, and hook contributed by an extension (or a preset, or the core layer) is addressable at read time by a deterministic opaque identifier. Resolved artifact-stack layers carry a matching `lookupId` field that shares this grammar and identifies the layer's stack position. Manifest-declared preset and extension layers use the manifest's validated `id:` for `lookupId`'s `sourceId` component, so their `lookupId` joins directly to the matching `iter_contributions()` entry even after the installed directory is renamed; convention-only contributions have no manifest `id:` to consult and fall back to the on-disk directory / registry key instead (see [Determinism guarantees](#determinism-guarantees) below). Identifiers are **computed on demand from author-declared manifest content** and are **never persisted** to `.specify/` or to any cache file.
866+
Every command, template, script, and hook contributed by an extension or preset is addressable at read time by a deterministic opaque identifier. Resolved artifact-stack layers carry a matching `lookupId` field when they have provenance. Manifest-declared preset and extension layers use the manifest's validated `id:` for `lookupId`'s `sourceId` component, so their `lookupId` joins directly to the matching `iter_contributions()` entry even after the installed directory is renamed; convention-only contributions have no manifest `id:` to consult and fall back to the on-disk directory / registry key instead (see [Determinism guarantees](#determinism-guarantees) below). Identifiers are **computed on demand from author-declared manifest content** and are **never persisted** to `.specify/` or to any cache file.
867867

868868
### Grammar
869869

@@ -873,9 +873,9 @@ Named contributions (commands, templates, scripts) follow:
873873
{layer}:{sourceId}:{kind}:{name}
874874
```
875875

876-
- `layer` is one of `core`, `preset`, or `extension`.
877-
- `sourceId` is `_` for `core`, the preset pack id for `preset`, or the extension id for `extension`.
878-
- `kind` is one of `command`, `template`, `script`, or `hook`.
876+
- `layer` is one of `preset` or `extension`.
877+
- `sourceId` is the preset pack id for `preset`, or the extension id for `extension`.
878+
- `kind` is one of `command`, `template`, or `script`.
879879
- `name` is the contribution's declared `name` field.
880880

881881
Hook contributions use a compound name-component built from the event and command:

src/specify_cli/_identifier.py

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,19 +9,22 @@
99
across machines, project locations, and reinstalls, and what lets consumers use
1010
them as stable join keys.
1111
12-
Grammar for named contributions (commands, templates, scripts)::
12+
Grammar for manifest-backed named contributions (commands, templates, scripts)::
1313
1414
id = "{layer}:{sourceId}:{kind}:{name}"
1515
16-
layer ∈ {"core", "preset", "extension"}
17-
sourceId = "_" when layer == "core"; the preset id or extension id otherwise
18-
kind ∈ {"command", "template", "script", "hook"}
16+
layer ∈ {"project", "preset", "extension"}
17+
sourceId = "_" when layer == "project"; the preset or extension id otherwise
18+
kind ∈ {"command", "template", "script"}
1919
name = the contribution's declared ``name``
2020
2121
Hook identifiers use ``{eventName}:{command}`` as the name component::
2222
2323
id = "{layer}:{sourceId}:hook:{eventName}:{command}"
2424
25+
Built-in artifacts have no layer or lookup identifier. Their public identifier
26+
is source-agnostic: ``"{kind}:{name}"``.
27+
2528
The functions in this module are pure — inputs are strings or in-memory
2629
mappings parsed from a manifest, outputs are strings. None of them read from
2730
disk, look at ``os.environ``, call ``datetime``, or hash file contents. That
@@ -88,24 +91,34 @@ def derive_named_id(layer: str, source_id: str, kind: str, name: str) -> str:
8891
strings should either pre-validate or handle
8992
:class:`IdentifierComponentError`.
9093
"""
94+
if layer not in _LAYER_KINDS:
95+
raise IdentifierComponentError(f"Invalid layer '{layer}'")
96+
if kind not in _NAMED_CONTRIBUTION_KINDS:
97+
raise IdentifierComponentError(f"Invalid named contribution kind '{kind}'")
9198
validate_component(layer, "layer")
9299
validate_component(source_id, "sourceId")
93100
validate_component(kind, "kind")
94101
validate_component(name, "name")
95102
return f"{layer}:{source_id}:{kind}:{name}"
96103

97104

98-
_LAYER_KINDS = frozenset({"core", PROJECT_OVERRIDE_LAYER, "preset", "extension"})
105+
_LAYER_KINDS = frozenset({PROJECT_OVERRIDE_LAYER, "preset", "extension"})
99106
_CONTRIBUTION_KINDS = frozenset({"command", "template", "script", "hook"})
107+
_NAMED_CONTRIBUTION_KINDS = _CONTRIBUTION_KINDS - {"hook"}
108+
109+
110+
def derive_public_id(kind: str, name: str) -> str:
111+
"""Build the source-agnostic public identifier for an artifact."""
112+
return f"{kind}:{name}"
100113

101114

102115
def layer_kind_from_lookup_id(lookup_id: str) -> str | None:
103116
"""Return the layer segment of a resolved-stack ``lookupId``, or ``None``.
104117
105118
``lookupId`` values on resolved stack layers follow the same
106119
``"{layer}:..."`` grammar as manifest-contribution ``id`` values (see
107-
module docstring), with ``layer`` additionally taking on
108-
:data:`PROJECT_OVERRIDE_LAYER` for resolver-only project-override layers.
120+
module docstring), including :data:`PROJECT_OVERRIDE_LAYER` for
121+
project-local override layers.
109122
This is the single place that knows the set of valid layer prefixes, so
110123
consumers can classify a lookupId without re-deriving the grammar via
111124
string-prefix checks of their own.
@@ -114,9 +127,9 @@ def layer_kind_from_lookup_id(lookup_id: str) -> str | None:
114127
named contributions require exactly the four ``{layer}:{sourceId}:{kind}:
115128
{name}`` components, and hook contributions require exactly the five
116129
``{layer}:{sourceId}:hook:{eventName}:{command}`` components, with every
117-
component non-empty. A value such as ``"core:not-an-id"`` or ``"preset:x"``
118-
has a recognized layer prefix but the wrong number of components, so it is
119-
malformed and returns ``None`` rather than being treated as authoritative.
130+
component non-empty. A value such as ``"preset:x"`` has a recognized layer
131+
prefix but the wrong number of components, so it is malformed and returns
132+
``None`` rather than being treated as authoritative.
120133
"""
121134
parts = lookup_id.split(":")
122135
if len(parts) < 4 or any(not part for part in parts):
@@ -159,6 +172,8 @@ def derive_hook_id(
159172
Each component is revalidated with :func:`validate_component` — same
160173
contract as :func:`derive_named_id`.
161174
"""
175+
if layer not in _LAYER_KINDS:
176+
raise IdentifierComponentError(f"Invalid layer '{layer}'")
162177
validate_component(layer, "layer")
163178
validate_component(source_id, "sourceId")
164179
validate_component(event_name, "eventName")

src/specify_cli/artifacts/__init__.py

Lines changed: 45 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from .._identifier import (
2323
PROJECT_OVERRIDE_LAYER,
2424
IdentifierComponentError,
25+
derive_public_id,
2526
is_dotted_command_name,
2627
layer_kind_from_lookup_id,
2728
validate_component,
@@ -33,7 +34,7 @@
3334
# ---------------------------------------------------------------------------
3435

3536
ArtifactKind = Literal["command", "template", "script"]
36-
LayerName = Literal["project", "preset", "extension", "core"]
37+
LayerName = Literal["project", "preset", "extension"]
3738
Strategy = Literal["replace", "wrap", "prepend", "append"]
3839

3940

@@ -59,18 +60,20 @@ def to_json_dict(self) -> dict[str, Any]:
5960
class StackLayer:
6061
"""One row inside the ``stack`` array returned by ``get_artifact_info()``."""
6162

62-
layer: LayerName
63+
layer: LayerName | None
64+
sourceId: str | None
6365
presetId: str | None
6466
presetName: str | None
6567
strategy: Strategy
6668
active: bool
6769
hidden: bool
6870
manifestPath: str | None
69-
lookupId: str
71+
lookupId: str | None
7072

7173
def to_json_dict(self) -> dict[str, Any]:
7274
return {
7375
"layer": self.layer,
76+
"sourceId": self.sourceId,
7477
"presetId": self.presetId,
7578
"presetName": self.presetName,
7679
"strategy": self.strategy,
@@ -335,8 +338,7 @@ def _build_stack(
335338

336339
rows: list[StackLayer] = []
337340
for idx, layer in enumerate(raw):
338-
lookup_id = layer.get("lookupId", "")
339-
source = str(layer.get("source", ""))
341+
lookup_id = layer.get("lookupId")
340342
strategy = layer["strategy"]
341343
active = idx == 0
342344

@@ -345,15 +347,14 @@ def _build_stack(
345347
else:
346348
hidden = idx > first_replace_idx
347349

348-
# Layer classification: the lookupId prefix is the resolver's own
349-
# grammar (see layer_kind_from_lookup_id) and is authoritative; the
350-
# source-string check only guards against a malformed lookupId.
351-
layer_kind = layer_kind_from_lookup_id(lookup_id)
350+
layer_kind = layer_kind_from_lookup_id(lookup_id) if isinstance(lookup_id, str) else None
351+
source_id = lookup_id.split(":", 2)[1] if layer_kind else None
352352

353-
if layer_kind == "core" or (layer_kind is None and source.startswith("core")):
353+
if layer_kind == PROJECT_OVERRIDE_LAYER:
354354
rows.append(
355355
StackLayer(
356-
layer="core",
356+
layer="project",
357+
sourceId=source_id,
357358
presetId=None,
358359
presetName=None,
359360
strategy=strategy,
@@ -365,37 +366,35 @@ def _build_stack(
365366
)
366367
continue
367368

368-
if layer_kind == PROJECT_OVERRIDE_LAYER or (
369-
layer_kind is None and source == "project override"
370-
):
369+
if layer_kind == "extension":
370+
manifest_path = _derive_manifest_path(layer, project_root)
371371
rows.append(
372372
StackLayer(
373-
layer="project",
373+
layer="extension",
374+
sourceId=source_id,
374375
presetId=None,
375376
presetName=None,
376377
strategy=strategy,
377378
active=active,
378379
hidden=hidden,
379-
manifestPath=None,
380+
manifestPath=manifest_path,
380381
lookupId=lookup_id,
381382
)
382383
)
383384
continue
384385

385-
if layer_kind == "extension" or (
386-
layer_kind is None and source.startswith("extension:")
387-
):
388-
manifest_path = _derive_manifest_path(layer, project_root)
386+
if layer_kind != "preset":
389387
rows.append(
390388
StackLayer(
391-
layer="extension",
389+
layer=None,
390+
sourceId=None,
392391
presetId=None,
393392
presetName=None,
394393
strategy=strategy,
395394
active=active,
396395
hidden=hidden,
397-
manifestPath=manifest_path,
398-
lookupId=lookup_id,
396+
manifestPath=None,
397+
lookupId=None,
399398
)
400399
)
401400
continue
@@ -417,6 +416,7 @@ def _build_stack(
417416
rows.append(
418417
StackLayer(
419418
layer="preset",
419+
sourceId=source_id,
420420
presetId=pack_id or None,
421421
presetName=display or None,
422422
strategy=strategy,
@@ -546,19 +546,26 @@ def list_artifacts(self) -> list[Artifact]:
546546

547547
resolver = PresetResolver(self.project_root)
548548
layers_cache: dict[tuple[ArtifactKind, str], list[dict[str, Any]]] = {}
549+
resolved_cache: dict[tuple[ArtifactKind, str], bool] = {}
549550

550551
def _layers_for(kind: ArtifactKind, name: str) -> list[dict[str, Any]]:
551552
key = (kind, name)
552553
if key not in layers_cache:
553554
layers_cache[key] = resolver.collect_all_layers(name, kind)
554555
return layers_cache[key]
555556

557+
def _is_resolved(kind: ArtifactKind, name: str) -> bool:
558+
key = (kind, name)
559+
if key not in resolved_cache:
560+
resolved_cache[key] = resolver.resolve_content(name, kind) is not None
561+
return resolved_cache[key]
562+
556563
names: set[tuple[ArtifactKind, str]] = set()
557564
for kind, name in self._iter_candidate_artifacts(resolver):
558565
key = (kind, name)
559566
if not _is_valid_artifact_name_component(name, kind):
560567
continue
561-
if _layers_for(kind, name):
568+
if _layers_for(kind, name) and _is_resolved(kind, name):
562569
names.add(key)
563570

564571
artifacts: list[Artifact] = []
@@ -574,7 +581,7 @@ def _layers_for(kind: ArtifactKind, name: str) -> list[dict[str, Any]]:
574581
description = candidate
575582
break
576583
artifacts.append(
577-
Artifact(id=f"{kind}:{name}", name=name, kind=kind, description=description)
584+
Artifact(id=derive_public_id(kind, name), name=name, kind=kind, description=description)
578585
)
579586

580587
kind_order = {"command": 0, "template": 1, "script": 2}
@@ -602,48 +609,39 @@ def get_artifact_info(
602609
_validate_preset_registry(self.project_root)
603610
bare, resolved_kind = _resolve_kind_hint(name, kind)
604611

612+
inventory = self.list_artifacts()
605613
if resolved_kind is None:
606-
matches = self._find_matches(bare)
614+
matches = [(artifact.kind, artifact.name) for artifact in inventory if artifact.name == bare]
607615
if not matches:
608616
raise ArtifactNotFoundError(name)
609617
if len(matches) > 1:
610618
raise AmbiguousArtifactError(bare, [k for k, _ in matches])
611619
resolved_kind = matches[0][0]
612620

613621
validated_name = _validate_artifact_name(bare, resolved_kind)
614-
if not any(kind_name == resolved_kind for kind_name, _ in self._find_matches(validated_name)):
622+
artifact = next(
623+
(
624+
item
625+
for item in inventory
626+
if item.kind == resolved_kind and item.name == validated_name
627+
),
628+
None,
629+
)
630+
if artifact is None:
615631
raise ArtifactNotFoundError(name)
616632
stack = _build_stack(self.project_root, resolved_kind, validated_name)
617633
if not stack:
618634
raise ArtifactNotFoundError(name)
619635

620-
description = self._describe(resolved_kind, validated_name)
621636
return {
622-
"id": f"{resolved_kind}:{validated_name}",
637+
"id": derive_public_id(resolved_kind, validated_name),
623638
"name": validated_name,
624639
"kind": resolved_kind,
625-
"description": description,
640+
"description": artifact.description,
626641
"stack": [layer.to_json_dict() for layer in stack],
627642
}
628643

629644
# -------------------------------------------------------------- internals
630-
def _find_matches(self, name: str) -> list[tuple[ArtifactKind, str]]:
631-
"""Return every (kind, name) pair whose name matches exactly."""
632-
artifacts = self.list_artifacts()
633-
return [(a.kind, a.name) for a in artifacts if a.name == name]
634-
635-
def _describe(self, kind: ArtifactKind, name: str) -> str:
636-
"""Return the description that would appear on the flat-list row.
637-
638-
Sources the value from :meth:`list_artifacts` so the two commands
639-
agree on the same string for the same artifact — the ``info`` output
640-
promises "matching the same field on 'artifact list --json'".
641-
"""
642-
for artifact in self.list_artifacts():
643-
if artifact.kind == kind and artifact.name == name:
644-
return artifact.description
645-
return ""
646-
647645
def _iter_candidate_artifacts(
648646
self,
649647
resolver: Any,

0 commit comments

Comments
 (0)