Skip to content

Commit fd2b268

Browse files
fix: remove stale lookupId parsing fallback and tighten malformed lookupId validation
Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>
1 parent e1a63ce commit fd2b268

6 files changed

Lines changed: 100 additions & 51 deletions

File tree

docs/reference/presets.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,7 @@ Every command, template, and script contributed by a preset (or an extension, or
220220

221221
Identifiers are computed on demand from author-declared manifest content and are never persisted to `.specify/` or any cache. Copying a preset to another machine (or touching its files) does not change the identifiers it produces.
222222

223-
`PresetResolver.collect_all_layers()` returns layer dicts that each include a `lookupId` field that identifies the layer. For manifest-declared preset and extension layers, the `lookupId`'s `sourceId` component is the resolver's registry key or directory name, which can differ from the `id` used by `PresetManifest.iter_contributions()` / `ExtensionManifest.iter_contributions()` (the manifest-declared `id:` field). Both values follow the same grammar, but they only join directly when the installed directory name matches the manifest-declared id — so consumers should treat `lookupId` as the resolver's stack identity and use `layer_kind_from_lookup_id` / the manifest APIs when they need to reason about the originating contribution. Project-local overrides in `.specify/templates/overrides/` are a resolver-only concept — they carry a synthetic `project:_:{kind}:{name}` `lookupId` that intentionally does not match any manifest contribution.
223+
`PresetResolver.collect_all_layers()` returns layer dicts that each include a `lookupId` field that identifies the layer. For manifest-declared preset and extension layers, the `lookupId`'s `sourceId` component is the manifest's validated `id:` field, so it joins directly to the `id` used by `PresetManifest.iter_contributions()` / `ExtensionManifest.iter_contributions()` even when the installed directory was renamed. Convention-only layers (undeclared in any manifest) have no manifest id to consult, so their `lookupId`'s `sourceId` falls back to the resolver's registry key or on-disk directory name instead — consumers should treat `lookupId` as the resolver's stack identity and use `layer_kind_from_lookup_id` / the manifest APIs when they need to reason about the originating contribution. Project-local overrides in `.specify/templates/overrides/` are a resolver-only concept — they carry a synthetic `project:_:{kind}:{name}` `lookupId` that intentionally does not match any manifest contribution.
224224

225225
For the full grammar, including the hook name-component convention and last-write-wins deduplication used by extensions, see the [Extension API Reference — Contribution Identifiers](../../extensions/EXTENSION-API-REFERENCE.md#contribution-identifiers) section.
226226

extensions/EXTENSION-API-REFERENCE.md

Lines changed: 3 additions & 3 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 contribution `id` values and resolver `lookupId` values are **not always equal** — they only join directly when the installed directory name matches the manifest-declared `id:` (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 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.
867867

868868
### Grammar
869869

@@ -900,11 +900,11 @@ Project-local overrides in `.specify/templates/overrides/` are a resolver-only c
900900

901901
`ExtensionManifest.iter_contributions()` yields dicts of the form `{layer, sourceId, kind, name, id, ...author-declared fields}`; each entry's `id` is the computed identifier. `ExtensionManifest.contribution_id(kind, name)` returns the id for a single lookup, or `None` if no contribution matches. `PresetManifest` exposes the same two methods.
902902

903-
`PresetResolver.collect_all_layers()` returns layer dicts that include a `lookupId` field for every layer type (`project override`, preset, extension, core, and bundled core). Resolver `lookupId` values identify the layer by the resolver's registry key or directory name, which can differ from the manifest-declared source id used by `iter_contributions()`.
903+
`PresetResolver.collect_all_layers()` returns layer dicts that include a `lookupId` field for every layer type (`project override`, preset, extension, core, and bundled core). Manifest-declared preset and extension layers use the manifest's validated `id:` as the `lookupId` source id, so it matches the id `iter_contributions()` yields for that same contribution. Convention-only layers (no manifest entry declares the contribution) have no manifest id to consult, so their `lookupId` falls back to the resolver's registry key or on-disk directory name.
904904

905905
### Determinism guarantees
906906

907-
Manifest contribution identifier derivation reads only the in-memory declared manifest content. No filesystem paths, no `os.environ`, no timestamps, and no file-content hashes contribute to those manifest ids. Copying an extension or preset to a different machine (or touching its files) does not change the identifiers it produces. Resolver `lookupId` values are stack identifiers, not manifest contribution ids: for example, an unregistered extension's directory name is the resolver source id, so renaming that directory changes its `lookupId`.
907+
Manifest contribution identifier derivation reads only the in-memory declared manifest content. No filesystem paths, no `os.environ`, no timestamps, and no file-content hashes contribute to those manifest ids. Copying an extension or preset to a different machine (or touching its files) does not change the identifiers it produces. Manifest-declared resolver `lookupId` values share this stability — renaming the installed directory of a preset or extension that declares an `id:` does not change its `lookupId`. Only convention-only contributions (undeclared in any manifest) derive their `lookupId` from the on-disk directory name or registry key, so renaming that directory does change their `lookupId`.
908908

909909
### Opacity guidance
910910

src/specify_cli/_identifier.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,9 +108,23 @@ def layer_kind_from_lookup_id(lookup_id: str) -> str | None:
108108
This is the single place that knows the set of valid layer prefixes, so
109109
consumers can classify a lookupId without re-deriving the grammar via
110110
string-prefix checks of their own.
111+
112+
Validates the complete shape, not just the presence of a layer prefix:
113+
named contributions require exactly the four ``{layer}:{sourceId}:{kind}:
114+
{name}`` components, and hook contributions require exactly the five
115+
``{layer}:{sourceId}:hook:{eventName}:{command}`` components, with every
116+
component non-empty. A value such as ``"core:not-an-id"`` or ``"preset:x"``
117+
has a recognized layer prefix but the wrong number of components, so it is
118+
malformed and returns ``None`` rather than being treated as authoritative.
111119
"""
112-
layer, _, rest = lookup_id.partition(":")
113-
if not rest or layer not in _LAYER_KINDS:
120+
parts = lookup_id.split(":")
121+
if len(parts) < 4 or any(not part for part in parts):
122+
return None
123+
layer = parts[0]
124+
if layer not in _LAYER_KINDS:
125+
return None
126+
expected_len = 5 if parts[2] == "hook" else 4
127+
if len(parts) != expected_len:
114128
return None
115129
return layer
116130

src/specify_cli/artifacts/__init__.py

Lines changed: 28 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -407,13 +407,14 @@ def _derive_manifest_path(layer: dict[str, Any], project_root: Path) -> str | No
407407
Only ``preset`` and ``extension`` layers have an on-disk manifest — core
408408
and project-override layers return ``None``.
409409
410-
Since the resolver may set the ``lookupId``'s ``sourceId`` component to
411-
the manifest-declared ``id:`` (which can differ from the on-disk directory
412-
name for renamed packs), the on-disk directory is read from the layer's
413-
explicit ``preset_id`` / ``pack_dir`` (preset layers) or
414-
``extension_id`` / ``extension_dir`` (extension layers) keys, falling
415-
back to the ``lookupId`` ``sourceId`` only when those explicit keys are
416-
absent.
410+
The resolver may set the ``lookupId``'s ``sourceId`` component to the
411+
manifest-declared ``id:`` (which can differ from the on-disk directory
412+
name for renamed packs), so ``lookupId`` is never parsed for the on-disk
413+
directory here. The on-disk directory identity is read exclusively from
414+
the layer's explicit provenance keys — ``preset_id`` / ``pack_dir`` for
415+
preset layers, ``extension_id`` / ``extension_dir`` for extension
416+
layers — which ``collect_all_layers()`` always sets alongside
417+
``lookupId``. Missing provenance keys mean no manifest path is available.
417418
418419
Uses ``as_posix()`` so the string is stable across Windows and POSIX — a
419420
caller comparing snapshots between operating systems gets the same value
@@ -423,36 +424,26 @@ def _derive_manifest_path(layer: dict[str, Any], project_root: Path) -> str | No
423424
layer_kind = layer_kind_from_lookup_id(lookup_id)
424425
if layer_kind == "preset":
425426
pack_dir = layer.get("pack_dir")
426-
if isinstance(pack_dir, Path):
427-
manifest_path = pack_dir / "preset.yml"
428-
if not manifest_path.is_file():
429-
return None
430-
try:
431-
return manifest_path.relative_to(project_root).as_posix()
432-
except ValueError:
433-
return None
434-
pack_id = layer.get("preset_id") or _extract_lookup_pack_id(lookup_id)
427+
pack_id = layer.get("preset_id")
435428
tier_dir, manifest_name = "presets", "preset.yml"
436429
elif layer_kind == "extension":
437-
ext_dir = layer.get("extension_dir")
438-
if isinstance(ext_dir, Path):
439-
manifest_path = ext_dir / "extension.yml"
440-
if not manifest_path.is_file():
441-
return None
442-
try:
443-
return manifest_path.relative_to(project_root).as_posix()
444-
except ValueError:
445-
return None
446-
pack_id = layer.get("extension_id") or _extract_lookup_pack_id(lookup_id)
430+
pack_dir = layer.get("extension_dir")
431+
pack_id = layer.get("extension_id")
447432
tier_dir, manifest_name = "extensions", "extension.yml"
448433
else:
449434
return None
450-
if not pack_id:
435+
if isinstance(pack_dir, Path):
436+
manifest_path = pack_dir / manifest_name
437+
elif pack_id:
438+
manifest_path = project_root / ".specify" / tier_dir / pack_id / manifest_name
439+
else:
451440
return None
452-
manifest_path = project_root / ".specify" / tier_dir / pack_id / manifest_name
453441
if not manifest_path.is_file():
454442
return None
455-
return manifest_path.relative_to(project_root).as_posix()
443+
try:
444+
return manifest_path.relative_to(project_root).as_posix()
445+
except ValueError:
446+
return None
456447

457448

458449
def _preset_display_name(pack_dir: Path, pack_id: str) -> str:
@@ -475,14 +466,6 @@ class ``PresetManager.list_installed()`` and ``specify preset list`` use —
475466
return pack_id
476467

477468

478-
def _extract_lookup_pack_id(lookup_id: str) -> str | None:
479-
"""Return the ``sourceId`` segment of a lookupId, or ``None`` if malformed."""
480-
parts = lookup_id.split(":")
481-
if len(parts) < 4:
482-
return None
483-
return parts[1]
484-
485-
486469
def _build_stack(
487470
project_root: Path,
488471
kind: ArtifactKind,
@@ -579,11 +562,12 @@ def _build_stack(
579562
continue
580563

581564
# Preset layers carry the on-disk directory identity separately from
582-
# ``lookupId`` (which may use the manifest-declared ``id:``): prefer
583-
# the explicit ``preset_id`` / ``pack_dir`` keys before falling back
584-
# to parsing ``lookupId``, so a renamed pack still resolves to the
585-
# right on-disk directory for display-name and manifest-path lookup.
586-
pack_id = layer.get("preset_id") or _extract_lookup_pack_id(lookup_id) or ""
565+
# ``lookupId`` (which may use the manifest-declared ``id:``): use the
566+
# explicit ``preset_id`` / ``pack_dir`` keys ``collect_all_layers()``
567+
# always sets, never ``lookupId`` parsing, so a renamed pack still
568+
# resolves to the right on-disk directory for display-name and
569+
# manifest-path lookup.
570+
pack_id = layer.get("preset_id") or ""
587571
pack_dir_layer = layer.get("pack_dir")
588572
if isinstance(pack_dir_layer, Path):
589573
pack_dir = pack_dir_layer
@@ -994,9 +978,8 @@ def _iter_project_override_artifacts(
994978
continue
995979
command_layers = resolver.collect_all_layers(name, "command")
996980
backed_by_command = any(
997-
not str(layer.get("lookupId", "")).startswith(
998-
f"{PROJECT_OVERRIDE_LAYER}:"
999-
)
981+
layer_kind_from_lookup_id(str(layer.get("lookupId", "")))
982+
!= PROJECT_OVERRIDE_LAYER
1000983
for layer in command_layers
1001984
)
1002985
is_command = backed_by_command or is_dotted_command_name(name)

tests/test_artifact_command.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -934,6 +934,8 @@ def test_preset_manifest_path_is_repo_relative(self, tmp_path: Path):
934934
layer = {
935935
"lookupId": "preset:my-pack:template:spec-template",
936936
"path": pack_dir / "spec-template.md",
937+
"preset_id": "my-pack",
938+
"pack_dir": pack_dir,
937939
}
938940
assert (
939941
_derive_manifest_path(layer, project_root)
@@ -949,17 +951,58 @@ def test_extension_manifest_path_is_repo_relative(self, tmp_path: Path):
949951
layer = {
950952
"lookupId": "extension:my-ext:command:speckit.my-ext.go",
951953
"path": ext_dir / "commands" / "speckit.my-ext.go.md",
954+
"extension_id": "my-ext",
955+
"extension_dir": ext_dir,
952956
}
953957
assert (
954958
_derive_manifest_path(layer, project_root)
955959
== ".specify/extensions/my-ext/extension.yml"
956960
)
957961

962+
def test_renamed_pack_directory_wins_over_lookup_id_source(self, tmp_path: Path):
963+
"""The manifest path must track the on-disk directory, never a stale
964+
directory guessed from ``lookupId``'s manifest-declared ``sourceId``."""
965+
project_root = tmp_path / "proj"
966+
pack_dir = project_root / ".specify" / "presets" / "renamed-on-disk"
967+
pack_dir.mkdir(parents=True)
968+
(pack_dir / "preset.yml").write_text("id: original-manifest-id\n", encoding="utf-8")
969+
# A stale directory matching the manifest id must not exist, so a
970+
# lookupId-based guess would resolve to a nonexistent manifest.
971+
stale_dir = project_root / ".specify" / "presets" / "original-manifest-id"
972+
assert not stale_dir.exists()
973+
974+
layer = {
975+
"lookupId": "preset:original-manifest-id:template:spec-template",
976+
"path": pack_dir / "spec-template.md",
977+
"preset_id": "renamed-on-disk",
978+
"pack_dir": pack_dir,
979+
}
980+
assert (
981+
_derive_manifest_path(layer, project_root)
982+
== ".specify/presets/renamed-on-disk/preset.yml"
983+
)
984+
958985
def test_missing_manifest_file_is_none(self, tmp_path: Path):
959986
project_root = tmp_path / "proj"
960987
pack_dir = project_root / ".specify" / "presets" / "my-pack"
961988
pack_dir.mkdir(parents=True)
962989

990+
layer = {
991+
"lookupId": "preset:my-pack:template:spec-template",
992+
"path": pack_dir / "spec-template.md",
993+
"preset_id": "my-pack",
994+
"pack_dir": pack_dir,
995+
}
996+
assert _derive_manifest_path(layer, project_root) is None
997+
998+
def test_missing_provenance_keys_is_none(self, tmp_path: Path):
999+
"""Without explicit ``preset_id``/``pack_dir``, no path is guessed from
1000+
``lookupId`` — the caller gets ``None`` instead of a wrong path."""
1001+
project_root = tmp_path / "proj"
1002+
pack_dir = project_root / ".specify" / "presets" / "my-pack"
1003+
pack_dir.mkdir(parents=True)
1004+
(pack_dir / "preset.yml").write_text("id: my-pack\n", encoding="utf-8")
1005+
9631006
layer = {
9641007
"lookupId": "preset:my-pack:template:spec-template",
9651008
"path": pack_dir / "spec-template.md",

tests/test_contribution_ids.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,10 @@ class TestLayerKindFromLookupId:
159159
("preset:speckit-core:template:spec-template", "preset"),
160160
("extension:speckit-git:script:post-commit", "extension"),
161161
(f"{PROJECT_OVERRIDE_LAYER}:_:template:spec-template", PROJECT_OVERRIDE_LAYER),
162+
(
163+
"extension:speckit-git:hook:before_specify:speckit.git.branch",
164+
"extension",
165+
),
162166
],
163167
)
164168
def test_recognized_layer_prefixes(self, lookup_id, expected):
@@ -171,6 +175,11 @@ def test_recognized_layer_prefixes(self, lookup_id, expected):
171175
"bogus:_:command:speckit.plan",
172176
"core",
173177
":_:command:speckit.plan",
178+
"core:not-an-id",
179+
"preset:x",
180+
"core:_:command",
181+
"extension:speckit-git:hook:before_specify",
182+
"core::command:speckit.plan",
174183
],
175184
)
176185
def test_unrecognized_or_malformed_returns_none(self, lookup_id):

0 commit comments

Comments
 (0)