Skip to content

Commit 64f6a65

Browse files
nicolehaugenCopilot
andcommitted
fix(artifacts): enforce identifier grammar and gate manifestPath on declared contributions
Three related correctness fixes for the artifact-stack pipeline surfaced during PR #4305 review: 1. _identifier.py: add source_id_from_lookup_id() helper mirroring layer_kind_from_lookup_id, and enforce the project/'_' sentinel in derive_named_id (project layer requires source_id == '_'; preset/extension layers reject '_'). Swap the split(':', 2)[1] call in artifacts/__init__.py to use the new helper so consumers no longer parse identifier grammar directly. 2. presets/__init__.py: thread a manifest_declared flag through collect_all_layers so downstream consumers can distinguish manifest-declared contributions from convention-only fallbacks. 3. artifacts/__init__.py: _derive_manifest_path returns None when the layer is not manifest-declared, so a stack row for a convention-only contribution no longer falsely reports a manifestPath pointing at a manifest that does not declare it. Tests: compact param-based coverage for source_id_from_lookup_id and derive_named_id sentinel rules; one preset + one extension test proving lookupId uses the manifest's validated id when it differs from the on-disk directory name; one end-to-end extension test proving a convention-only contribution reports manifestPath: null. Existing TestManifestPathPortability fixtures updated to set manifest_declared: True. Assisted-by: GitHub Copilot (model: claude-opus-4.7, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 26f7fb0 commit 64f6a65

5 files changed

Lines changed: 192 additions & 1 deletion

File tree

‎src/specify_cli/_identifier.py‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,15 @@ def derive_named_id(layer: str, source_id: str, kind: str, name: str) -> str:
9595
derivation boundary that must enforce the grammar. Callers passing raw
9696
strings should either pre-validate or handle
9797
:class:`IdentifierComponentError`.
98+
99+
The project-layer sentinel is also enforced here: the project layer uses
100+
``sourceId == "_"`` (see :data:`PROJECT_OVERRIDE_LAYER`) and no other
101+
value; the preset and extension layers never use ``"_"``, which is
102+
reserved for the project layer. Callers that mix these up would produce a
103+
lookupId :func:`layer_kind_from_lookup_id` still parses but that no
104+
manifest ever emits — a silent join-key mismatch. Rejecting the mix here
105+
keeps the grammar's sentinel contract enforced at the single derivation
106+
boundary rather than in each caller.
98107
"""
99108
validate_component(layer, "layer")
100109
validate_component(source_id, "sourceId")
@@ -104,6 +113,14 @@ def derive_named_id(layer: str, source_id: str, kind: str, name: str) -> str:
104113
if kind not in _NAMED_CONTRIBUTION_KINDS:
105114
raise IdentifierComponentError(f"Invalid named contribution kind '{kind}'")
106115
validate_component(name, "name")
116+
if layer == PROJECT_OVERRIDE_LAYER and source_id != "_":
117+
raise IdentifierComponentError(
118+
f"Invalid sourceId '{source_id}': project layer requires '_'"
119+
)
120+
if layer in {"preset", "extension"} and source_id == "_":
121+
raise IdentifierComponentError(
122+
"Invalid sourceId '_': reserved for project layer"
123+
)
107124
return f"{layer}:{source_id}:{kind}:{name}"
108125

109126

@@ -171,6 +188,20 @@ def is_dotted_command_name(value: str) -> bool:
171188
)
172189

173190

191+
def source_id_from_lookup_id(lookup_id: str) -> str | None:
192+
"""Return the sourceId segment of a resolved-stack ``lookupId``, or ``None``.
193+
194+
Returns ``None`` for any value that :func:`layer_kind_from_lookup_id`
195+
would reject — same validation, same grammar, single source of truth.
196+
Consumers must not ``.split(":")`` a ``lookupId`` themselves: the
197+
grammar's segmentation lives in this module, and any caller doing its
198+
own split leaks the layout across the codebase.
199+
"""
200+
if layer_kind_from_lookup_id(lookup_id) is None:
201+
return None
202+
return lookup_id.split(":", 2)[1]
203+
204+
174205
def derive_hook_id(
175206
layer: str,
176207
source_id: str,

‎src/specify_cli/artifacts/__init__.py‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
derive_public_id,
2626
is_dotted_command_name,
2727
layer_kind_from_lookup_id,
28+
source_id_from_lookup_id,
2829
validate_component,
2930
)
3031
from .._script_variants import canonical_script_name
@@ -261,7 +262,10 @@ def _public_layer_shape(
261262
layer_kind = layer_kind_from_lookup_id(lookup_id)
262263
if layer_kind not in ("project", "preset", "extension"):
263264
raise ArtifactResolutionError()
264-
return layer_kind, lookup_id.split(":", 2)[1], lookup_id
265+
source_id = source_id_from_lookup_id(lookup_id)
266+
if source_id is None:
267+
raise ArtifactResolutionError()
268+
return layer_kind, source_id, lookup_id
265269

266270

267271
def _derive_manifest_path(layer: dict[str, Any], project_root: Path) -> str | None:
@@ -280,10 +284,22 @@ def _derive_manifest_path(layer: dict[str, Any], project_root: Path) -> str | No
280284
layers — which ``collect_all_layers()`` always sets alongside
281285
``lookupId``. Missing provenance keys mean no manifest path is available.
282286
287+
Convention-only contributions are surfaced by the resolver even when the
288+
pack's manifest does not declare them — the manifest file exists on disk
289+
but does not list the artifact in ``provides``. Reporting the manifest
290+
path in that case would be a false positive: consumers joining on the
291+
reported path would find no matching contribution. ``collect_all_layers``
292+
sets ``manifest_declared=True`` on layers that came from a manifest
293+
``provides`` entry, so those layers alone report a manifest path; a layer
294+
without that flag falls through to ``None`` even when the manifest file
295+
exists on disk.
296+
283297
Uses ``as_posix()`` so the string is stable across Windows and POSIX — a
284298
caller comparing snapshots between operating systems gets the same value
285299
on both.
286300
"""
301+
if not layer.get("manifest_declared"):
302+
return None
287303
lookup_id = layer.get("lookupId", "")
288304
layer_kind = layer_kind_from_lookup_id(lookup_id)
289305
if layer_kind == "preset":

‎src/specify_cli/presets/__init__.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5997,6 +5997,7 @@ def _find_in_subdirs(base_dir: Path) -> Optional[Path]:
59975997
"strategy": strategy,
59985998
"preset_id": pack_id,
59995999
"pack_dir": pack_dir,
6000+
"manifest_declared": entry is not None,
60006001
"lookupId": derive_named_id(
60016002
"preset", source_id_for_lookup, template_type, template_name
60026003
),
@@ -6059,6 +6060,7 @@ def _find_in_subdirs(base_dir: Path) -> Optional[Path]:
60596060
"strategy": "replace",
60606061
"extension_id": ext_id,
60616062
"extension_dir": ext_dir,
6063+
"manifest_declared": entry is not None,
60626064
"lookupId": derive_named_id(
60636065
"extension", source_id_for_lookup, template_type, template_name
60646066
),

‎tests/test_artifact_command.py‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1344,6 +1344,7 @@ def test_preset_manifest_path_is_repo_relative(self, tmp_path: Path):
13441344
"path": pack_dir / "spec-template.md",
13451345
"preset_id": "my-pack",
13461346
"pack_dir": pack_dir,
1347+
"manifest_declared": True,
13471348
}
13481349
assert (
13491350
_derive_manifest_path(layer, project_root)
@@ -1361,6 +1362,7 @@ def test_extension_manifest_path_is_repo_relative(self, tmp_path: Path):
13611362
"path": ext_dir / "commands" / "speckit.my-ext.go.md",
13621363
"extension_id": "my-ext",
13631364
"extension_dir": ext_dir,
1365+
"manifest_declared": True,
13641366
}
13651367
assert (
13661368
_derive_manifest_path(layer, project_root)
@@ -1384,6 +1386,7 @@ def test_renamed_pack_directory_wins_over_lookup_id_source(self, tmp_path: Path)
13841386
"path": pack_dir / "spec-template.md",
13851387
"preset_id": "renamed-on-disk",
13861388
"pack_dir": pack_dir,
1389+
"manifest_declared": True,
13871390
}
13881391
assert (
13891392
_derive_manifest_path(layer, project_root)
@@ -1400,6 +1403,7 @@ def test_missing_manifest_file_is_none(self, tmp_path: Path):
14001403
"path": pack_dir / "spec-template.md",
14011404
"preset_id": "my-pack",
14021405
"pack_dir": pack_dir,
1406+
"manifest_declared": True,
14031407
}
14041408
assert _derive_manifest_path(layer, project_root) is None
14051409

@@ -1414,6 +1418,7 @@ def test_missing_provenance_keys_is_none(self, tmp_path: Path):
14141418
layer = {
14151419
"lookupId": "preset:my-pack:template:spec-template",
14161420
"path": pack_dir / "spec-template.md",
1421+
"manifest_declared": True,
14171422
}
14181423
assert _derive_manifest_path(layer, project_root) is None
14191424

@@ -1426,6 +1431,30 @@ def test_builtin_and_project_layers_have_no_manifest(self, tmp_path: Path):
14261431
assert _derive_manifest_path(builtin_layer, project_root) is None
14271432
assert _derive_manifest_path(project_layer, project_root) is None
14281433

1434+
def test_convention_only_extension_layer_reports_no_manifest_path(
1435+
self, tmp_path: Path
1436+
):
1437+
"""A contribution the manifest does not declare in ``provides`` — a
1438+
"convention-only" contribution — must NOT report the manifest as its
1439+
source, even when the manifest file exists on disk. Joining on the
1440+
reported path would find no matching contribution. One extension test
1441+
covers both the preset and extension branches: ``_derive_manifest_path``
1442+
gates on the layer's ``manifest_declared`` flag before dispatching by
1443+
layer kind."""
1444+
project_root = tmp_path / "proj"
1445+
ext_dir = project_root / ".specify" / "extensions" / "foo"
1446+
ext_dir.mkdir(parents=True)
1447+
(ext_dir / "extension.yml").write_text("id: foo\n", encoding="utf-8")
1448+
1449+
layer = {
1450+
"lookupId": "extension:foo:command:speckit.baz",
1451+
"path": ext_dir / "commands" / "baz.md",
1452+
"extension_id": "foo",
1453+
"extension_dir": ext_dir,
1454+
"manifest_declared": False,
1455+
}
1456+
assert _derive_manifest_path(layer, project_root) is None
1457+
14291458

14301459
class TestPresetDisplayName:
14311460
"""`_preset_display_name` delegates to the validated `PresetManifest.name`."""

‎tests/test_contribution_ids.py‎

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
derive_named_id,
3232
derive_public_id,
3333
layer_kind_from_lookup_id,
34+
source_id_from_lookup_id,
3435
validate_component,
3536
)
3637
from specify_cli.extensions import ExtensionManifest, ValidationError
@@ -529,3 +530,115 @@ def test_no_id_written_to_preset_manifest_files(self, tmp_path):
529530
assert ":command:" not in on_disk
530531
assert ":template:" not in on_disk
531532
assert ":script:" not in on_disk
533+
534+
535+
# ---------------------------------------------------------------------------
536+
# `_identifier.py` review-round nits — sourceId accessor + derive_named_id
537+
# sentinel enforcement. Consumers must not ``.split(":")`` a lookupId
538+
# themselves, and the ``project``/``_`` pairing is enforced at the single
539+
# derivation boundary rather than at each caller.
540+
# ---------------------------------------------------------------------------
541+
542+
543+
class TestSourceIdFromLookupId:
544+
@pytest.mark.parametrize(
545+
"lookup_id, expected",
546+
[
547+
("preset:speckit-core:command:speckit.plan", "speckit-core"),
548+
(
549+
"extension:speckit-git:hook:before_specify:speckit.git.branch",
550+
"speckit-git",
551+
),
552+
("", None),
553+
("preset:foo", None),
554+
("unknown:foo:command:bar", None),
555+
("project:_:hook:evt:cmd", None),
556+
],
557+
)
558+
def test_extracts_source_id_or_none(self, lookup_id, expected):
559+
assert source_id_from_lookup_id(lookup_id) == expected
560+
561+
562+
class TestDeriveNamedIdSentinel:
563+
@pytest.mark.parametrize(
564+
"layer, source_id",
565+
[
566+
(PROJECT_OVERRIDE_LAYER, "other"),
567+
("preset", "_"),
568+
("extension", "_"),
569+
],
570+
)
571+
def test_rejects_invalid_layer_source_pairs(self, layer, source_id):
572+
with pytest.raises(IdentifierComponentError):
573+
derive_named_id(layer, source_id, "command", "n")
574+
575+
def test_project_layer_accepts_underscore_source(self):
576+
assert (
577+
derive_named_id(PROJECT_OVERRIDE_LAYER, "_", "command", "n")
578+
== f"{PROJECT_OVERRIDE_LAYER}:_:command:n"
579+
)
580+
581+
582+
# ---------------------------------------------------------------------------
583+
# Manifest-declared id wins over installed-directory name — one preset test
584+
# and one extension test because the two branches of ``collect_all_layers``
585+
# could diverge independently. Each proves ``lookupId`` on the resolved layer
586+
# equals the manifest contribution ``id``.
587+
# ---------------------------------------------------------------------------
588+
589+
590+
def _write_registry(project: Path, tier: str, pack_id: str) -> None:
591+
registry = {
592+
"schema_version": "1.0",
593+
tier: {pack_id: {"version": "1.0.0", "priority": 10, "enabled": True}},
594+
}
595+
(project / ".specify" / tier / ".registry").write_text(
596+
json.dumps(registry), encoding="utf-8"
597+
)
598+
599+
600+
class TestManifestIdWinsOverDirectoryName:
601+
def test_preset_lookup_id_uses_manifest_id_when_directory_renamed(self, tmp_path):
602+
project = _make_project(tmp_path)
603+
dir_name, manifest_id = "renamed-preset", "original-preset"
604+
pack_dir = project / ".specify" / "presets" / dir_name
605+
(pack_dir / "templates").mkdir(parents=True)
606+
(pack_dir / "templates" / "spec-template.md").write_text("p", encoding="utf-8")
607+
data = _preset_data(manifest_id)
608+
data["provides"] = {
609+
"templates": [
610+
{"type": "template", "name": "spec-template", "file": "templates/spec-template.md"}
611+
]
612+
}
613+
_write_manifest(pack_dir, data, "preset.yml")
614+
_write_registry(project, "presets", dir_name)
615+
616+
layers = PresetResolver(project).collect_all_layers("spec-template", "template")
617+
layer = next(L for L in layers if L["source"].startswith(dir_name))
618+
manifest = PresetManifest(pack_dir / "preset.yml")
619+
assert layer["lookupId"] == manifest.contribution_id("template", "spec-template")
620+
assert layer["lookupId"] == f"preset:{manifest_id}:template:spec-template"
621+
622+
def test_extension_lookup_id_uses_manifest_id_when_directory_renamed(self, tmp_path):
623+
project = _make_project(tmp_path)
624+
dir_name, manifest_id = "renamed-ext", "original-ext"
625+
# Extension commands are auto-namespaced under speckit.<manifest_id>
626+
namespaced = f"speckit.{manifest_id}.branch"
627+
ext_dir = project / ".specify" / "extensions" / dir_name
628+
(ext_dir / "commands").mkdir(parents=True)
629+
(ext_dir / "commands" / "branch.md").write_text("e", encoding="utf-8")
630+
data = _extension_data(manifest_id, with_templates=False, with_scripts=False)
631+
data["provides"]["commands"] = [
632+
{"name": "speckit.branch", "file": "commands/branch.md", "description": "F"}
633+
]
634+
_write_manifest(ext_dir, data, "extension.yml")
635+
_write_registry(project, "extensions", dir_name)
636+
637+
layers = PresetResolver(project).collect_all_layers(namespaced, "command")
638+
layer = next(L for L in layers if L.get("extension_id") == dir_name)
639+
manifest = ExtensionManifest(ext_dir / "extension.yml")
640+
assert layer["lookupId"] == manifest.contribution_id("command", namespaced)
641+
assert layer["lookupId"] == f"extension:{manifest_id}:command:{namespaced}"
642+
643+
644+

0 commit comments

Comments
 (0)