feat: add deterministic contribution IDs and specify artifact command - #4305
feat: add deterministic contribution IDs and specify artifact command#4305nicolehaugen wants to merge 86 commits into
specify artifact command#4305Conversation
…artifacts
Every command, template, script, and hook contribution returned by
preset and extension manifest surfaces now carries a computed opaque
identifier of the form {layer}:{sourceId}:{kind}:{name}, and every
resolved artifact-stack layer carries a matching lookupId derived from
the same recipe.
Identifiers are computed at read time from author-declared manifest
content only. No paths, timestamps, or file-content hashes contribute
to derivation, so identifiers are stable across machines, reinstalls,
and directory moves. Nothing is persisted to .specify/ or any cache.
Hooks that collide within a source on (eventName, command) get a
12-hex SHA-256 discriminator computed from the canonical JSON of the
entry's declared fields minus eventName/command. Two hook entries
with byte-identical remaining fields are rejected at manifest load
because there is no meaningful way to distinguish them.
The change is purely additive: all existing name-based resolution
behaviour is preserved, and no consumer keys off the new id or
lookupId fields.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Assisted-by: GitHub Copilot (model: claude-opus-4.7, autonomous)
Adds a new `specify artifact` command group with two subcommands:
* `specify artifact list --json` — flat inventory of every command,
template, and script SpecKit exposes for the current project. Each row
carries a stable `id` (`{kind}:{name}`), an author-declared
`name`, its `kind`, and a `description` string that is never
omitted (empty string when the author declared none).
* `specify artifact info <name> --json` — the same row plus its full
ordered composition `stack`: highest-priority contributor first, with
`active` marking the winner `PresetResolver.resolve_content` would
return and `hidden` marking rows shadowed by a higher-priority
`replace`. Each stack entry carries a portable POSIX `manifestPath`
(or `null` for the core baseline) and a `lookupId` from the
contribution-id grammar so the output round-trips against
`specify preset info` and `specify extension info`.
The two commands share one strict JSON error envelope on stderr
(`{ "error": "..." }`) with exit code 1 for the three logical errors
(unknown artifact, ambiguous artifact, not a Spec Kit project) and exit
code 2 for the "`--json` is required" usage error. stdout is always
empty on error, so the two streams stay independently parseable.
Implementation lives in a new `src/specify_cli/artifacts/` subpackage
that mirrors the existing `presets/` and `extensions/` layout — pure
logic in `__init__.py` and thin Typer wiring in `_commands.py`. The
subpackage reuses `PresetResolver.collect_all_layers` for the actual
composition math and only reshapes each layer into a `StackLayer` JSON
row, so `active` and `hidden` stay in lockstep with the resolver's
winner-selection logic.
Skills (`.github/skills/**/SKILL.md`) are intentionally excluded from
the inventory — they are integration-specific installation output, not a
shipped asset family. The command still surfaces the underlying command
that a skill was generated from.
Tests:
* `tests/test_artifact_command.py` — 32 tests: contract shape, sort
order, empty-inventory behavior, kind-hint parsing, ambiguous-name
error, unknown-artifact error, not-a-project error, skills exclusion,
CLI wiring end-to-end (`--json` required, JSON envelope shape,
stderr-only errors, empty stdout on error, UTF-8 with no BOM), and
preset-replace hiding the core layer.
* `tests/test_artifact_command_parity.py` — 6 tests: `manifestPath`
uses forward slashes on every OS and is never absolute, the `active`
row corresponds to the resolver's actual winner, and the pretty-printed
JSON has no trailing whitespace and ends in exactly one newline.
All 38 new tests pass. Full presets + extensions regression suite is
green modulo pre-existing Windows-symlink-privilege failures that
predate this branch.
Assisted-by: GitHub Copilot (model: claude-opus-4.7, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 4a40fb96-1bbe-4fb2-99d8-411170046cb0
…rt' and 'import from'' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
…rt' and 'import from'' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous) Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>
Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous) Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>
Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous) Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>
Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous) Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous) Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>
Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous) Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>
Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous) Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>
Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous) Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>
Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous) Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>
Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous) Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>
Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous) Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>
Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>
Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>
Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous) Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>
Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous) Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>
Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous) Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>
Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous) Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>
Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>
Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>
Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>
…g from parent dir Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>
Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous) Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>
Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous) Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>
Assisted-by: GitHub Copilot (model: Claude Sonnet 4.6, autonomous) Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>
Assisted-by: GitHub Copilot (model: GPT-5.4, autonomous) Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>
Assisted-by: GitHub Copilot (model: GPT-5.4, autonomous) Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>
There was a problem hiding this comment.
Review details
Suppressed comments (4)
src/specify_cli/artifacts/init.py:587
- A replace layer does not guarantee resolvable content. For example, the existing non-UTF-8 legacy-layer case makes
resolve_content()returnNone, but this condition still adds the artifact tolist; its advertised ID then fails withunknown artifactininfo. Gate inventory membership on successful composition so every listed row is retrievable.
bare, resolved_kind = _resolve_kind_hint(name, kind)
from ..presets import PresetError, PresetResolver # lazy: avoids circular import
src/specify_cli/artifacts/init.py:634
get_artifact_info()rebuilds the complete inventory before resolving one artifact. Sincelist_artifacts()scans every candidate across every preset/extension, the documented consumer pattern of callinginfofor each listed artifact becomes quadratic in the artifact count. Resolve the requested kind/name directly (checking at most the three kinds only for an unqualified name) and build just that stack.
"stack": [layer.to_json_dict() for layer in stack],
src/specify_cli/_identifier.py:149
- This parser accepts IDs forbidden by the declared grammar, including
core:x:command:y,project:x:template:y, andcore:_:hook:e:c. Downstream callers treat a returned layer as authoritative. Enforce the_source sentinel for core/project and restrict hooks to preset/extension; apply the same cross-component rules in the derivation helpers so construction and parsing remain consistent.
if parts[2] not in _CONTRIBUTION_KINDS:
return None
expected_len = 5 if parts[2] == "hook" else 4
if len(parts) != expected_len:
return None
src/specify_cli/presets/init.py:5735
- The current PR description says this
sourceIdis the on-disk extension directory name, but this branch deliberately substitutes the validated manifest ID and the tests assert that behavior. The implementation matches the round-trip requirement; update the PR description to state that directory identity is only the convention-only fallback.
ext_manifest = ExtensionManifest(ext_manifest_path)
if isinstance(ext_manifest.id, str) and ext_manifest.id:
source_id_for_lookup = ext_manifest.id
- Files reviewed: 22/22 changed files
- Comments generated: 3
- Review effort level: Balanced
There was a problem hiding this comment.
Review details
Suppressed comments (3)
src/specify_cli/artifacts/init.py:250
- The public adapter discards a core layer’s identity even though the linked #4212 contract requires baseline rows to expose
layer: "core"andlookupId: "core:_:{kind}:{name}". The resolver already supplies that exact ID, so converting it to threenullvalues prevents consumers from joining or diagnosing the core contribution. Preserve the core identity here and update the public layer type, tests, and artifact reference accordingly.
if (
resolver_layer.get("source") == "core"
and isinstance(lookup_id, str)
and lookup_id.startswith("core:_:")
):
return None, None, None
docs/reference/presets.md:223
- This states that built-in resolver layers omit
lookupId, butcollect_all_layers()now explicitly emitscore:_:{kind}:{name}for both project-local and bundled core layers (seesrc/specify_cli/presets/__init__.py:5782-5803), and the new resolver tests assert it. Document that core lookup ID instead; only the artifact-output adapter currently removes it.
`PresetResolver.collect_all_layers()` returns layer dicts that include a `lookupId` field for preset, extension, and project-override layers. 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. That join is guaranteed by the implementation, so consumers can key off `lookupId` directly rather than re-deriving the contribution id. 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; those layers have no manifest contribution to join to. Built-in fallback layers omit `lookupId`. Use `layer_kind_from_lookup_id` to classify lookup IDs rather than parsing the string yourself. 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.
extensions/EXTENSION-API-REFERENCE.md:903
- The built-in-layer sentence contradicts the API it documents:
PresetResolver.collect_all_layers()now returnscore:_:{kind}:{name}on every core layer, with regression assertions intests/test_presets.py. Clarify that resolver behavior rather than saying the field is omitted.
`PresetResolver.collect_all_layers()` returns layer dicts that include a `lookupId` field for project overrides, preset contributions, and extension contributions. 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. Built-in fallback layers omit `lookupId`.
- Files reviewed: 22/22 changed files
- Comments generated: 1
- Review effort level: Balanced
Assisted-by: GitHub Copilot (model: GPT-5.4, autonomous) Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>
There was a problem hiding this comment.
Review details
Suppressed comments (5)
Previously missed (2) — in code that hasn't changed since the last review.
src/specify_cli/artifacts/init.py:1004
- A stale registry entry whose preset/extension directory was deleted reaches this unconditional
iterdir()and turns the entire artifact inventory intoArtifactResolutionError, whilePresetResolversimply skips the missing pack. Return without yielding whenpack_diris not a directory so artifact enumeration remains aligned with resolution.
for entry in sorted(pack_dir.iterdir(), key=lambda p: p.name):
src/specify_cli/_identifier.py:104
- This constructor validates components independently but not their relationship, so it can emit IDs outside the documented grammar, such as
project:foo:template:xorpreset:_:template:x. Enforce that_is used exactly for the project layer so every constructed ID remains parseable under the advertised contract.
if layer not in _LAYER_KINDS:
raise IdentifierComponentError(f"Invalid layer '{layer}'")
if kind not in _NAMED_CONTRIBUTION_KINDS:
raise IdentifierComponentError(f"Invalid named contribution kind '{kind}'")
src/specify_cli/_identifier.py:145
- The complete-shape parser still accepts combinations that the identifier grammar and constructors forbid:
project:_:hook:event:command,project:foo:template:x, andpreset:_:template:xare all classified as valid layers. Reject project hooks and enforce the project/_source relationship before callers treat malformed IDs as authoritative.
layer = parts[0]
if layer not in _LAYER_KINDS:
return None
if parts[2] not in _CONTRIBUTION_KINDS:
return None
expected_len = 5 if parts[2] == "hook" else 4
src/specify_cli/presets/init.py:5797
- The PR description says
collect_all_layers()gives each layer alookupId, but the intended built-in contract implemented here and in the reference docs deliberately omits it. Update the PR description to state that built-in layers are recognized by the absence oflookupId,layer, andsourceId.
layers.append({
"path": bundled,
"source": "core",
"strategy": "replace",
})
src/specify_cli/presets/init.py:5735
- The current PR description says extension
sourceIdcomes from the on-disk directory, but this branch intentionally emits the manifest-declared ID and reserves the directory name for convention-only contributions. Update the PR description so it does not publish the opposite lookup contract.
ext_manifest = ExtensionManifest(ext_manifest_path)
if isinstance(ext_manifest.id, str) and ext_manifest.id:
source_id_for_lookup = ext_manifest.id
- Files reviewed: 22/22 changed files
- Comments generated: 0 new
- Review effort level: Balanced
…accepts `id`; docs and issue #4212 updated. Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>
There was a problem hiding this comment.
Review details
Suppressed comments (3)
Previously missed (2) — in code that hasn't changed since the last review.
src/specify_cli/artifacts/init.py:304
- Convention-only preset/extension layers can still carry
pack_dir/extension_dirwhile a valid pack manifest exists but does not declare this artifact. This function then publishes that file asmanifestPath, even though the lookup ID cannot be found in it, contradicting the field's “declaring manifest” contract. Carry whether the resolver match was manifest-declared and returnNonehere for convention-only layers.
if isinstance(pack_dir, Path):
manifest_path = pack_dir / manifest_name
elif pack_id:
manifest_path = project_root / ".specify" / tier_dir / pack_id / manifest_name
else:
return None
if not manifest_path.is_file():
return None
src/specify_cli/_identifier.py:49
- This “every layer carries a
lookupId” statement contradicts the module contract above and the public artifact schema: built-in layers intentionally omitlookupId. Describe this as a synthetic identifier specific to project-override layers so consumers do not infer that built-in layers also have one.
Project overrides are a resolver feature — they are not backed by any manifest
contribution. When a resolved artifact stack contains a project-override layer,
its ``lookupId`` uses this label so the round-trip invariant (every layer
carries a ``lookupId``) still holds. No manifest ``iter_contributions()`` will
ever emit a matching ``id``, so consumers see "not found" for the lookup, which
is the correct outcome for a layer with no originating manifest entry.
src/specify_cli/presets/init.py:5735
- The current PR description still says extension
sourceIdcomes from the on-disk directory, but manifest-declared layers here deliberately useExtensionManifest.id; the tests and reference docs also enforce that direct join. Update the PR description to reserve directory identity for convention-only contributions.
ext_manifest = ExtensionManifest(ext_manifest_path)
if isinstance(ext_manifest.id, str) and ext_manifest.id:
source_id_for_lookup = ext_manifest.id
- Files reviewed: 22/22 changed files
- Comments generated: 0 new
- Review effort level: Balanced
…n hook layers Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>
There was a problem hiding this comment.
Review details
Suppressed comments (3)
Previously missed (1) — in code that hasn't changed since the last review.
src/specify_cli/_identifier.py:106
derive_named_id()still accepts a project layer with any non-empty source ID (for example,project:custom:template:x), even though this module’s grammar fixes project overrides toproject:_:{kind}:{name}. The parser likewise classifies that impossible value as valid, so the shared constructor/parser can publish IDs outside the documented contract. Enforce_for project source IDs in both construction and parsing.
if layer not in _LAYER_KINDS:
raise IdentifierComponentError(f"Invalid layer '{layer}'")
if kind not in _NAMED_CONTRIBUTION_KINDS:
raise IdentifierComponentError(f"Invalid named contribution kind '{kind}'")
validate_component(name, "name")
src/specify_cli/artifacts/init.py:626
- At this point the artifact was already found in the inventory, so
resolve_content() is Nonemeans its content could not be composed (for example, an existing replace-layer file is not valid UTF-8), not that the artifact is unknown. ReturningArtifactNotFoundErrormakes a listed ID fail with the wrong error and contradicts the documentedartifact resolution failedcase for composition failures.
if PresetResolver(self.project_root).resolve_content(
validated_name, resolved_kind
) is None:
raise ArtifactNotFoundError(name)
src/specify_cli/presets/init.py:5735
- The current PR description says
collect_all_layers()uses the on-disk extension directory assourceId, but this code intentionally uses the validated manifest ID for declared entries and reserves the directory name for convention-only fallback. Update the PR description to state the implemented contract; otherwise consumers are told the opposite lookup-ID identity rule.
ext_manifest = ExtensionManifest(ext_manifest_path)
if isinstance(ext_manifest.id, str) and ext_manifest.id:
source_id_for_lookup = ext_manifest.id
- Files reviewed: 22/22 changed files
- Comments generated: 0 new
- Review effort level: Balanced
|
@copilot Align the new
Do not change the JSON field naming (camelCase in stack rows comes directly from |
…dation Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>
Addressed in
85 artifact tests + 762 tests across artifacts/presets/contribution-ids pass, and ruff is clean. |
There was a problem hiding this comment.
Review details
Suppressed comments (3)
docs/reference/artifacts.md:131
- This cause does not match the implementation: composition returning
Noneis currently emitted asunknown artifact, notartifact resolution failed, while invalid manifests are intentionally ignored in favor of convention discovery. Document the actual resolution-error cases instead.
| `artifact resolution failed` | The preset/extension registries could not be read, or artifact content could not be composed |
src/specify_cli/presets/init.py:5735
- The PR description says extension
sourceIduses the on-disk directory name, but this branch deliberately usesExtensionManifest.idfor manifest-declared contributions (and the tests/docs assert that contract). The implementation supports the requiredlookupIdround-trip, so update the PR description to reserve directory identity for convention-only contributions.
ext_manifest = ExtensionManifest(ext_manifest_path)
if isinstance(ext_manifest.id, str) and ext_manifest.id:
source_id_for_lookup = ext_manifest.id
src/specify_cli/_identifier.py:49
- “Every layer carries a
lookupId” contradicts this module's built-in-tier contract: core layers intentionally omitlookupId. Narrow this statement to project-override layers so consumers are not told that the invariant applies to core.
Project overrides are a resolver feature — they are not backed by any manifest
contribution. When a resolved artifact stack contains a project-override layer,
its ``lookupId`` uses this label so the round-trip invariant (every layer
carries a ``lookupId``) still holds. No manifest ``iter_contributions()`` will
ever emit a matching ``id``, so consumers see "not found" for the lookup, which
is the correct outcome for a layer with no originating manifest entry.
- Files reviewed: 22/22 changed files
- Comments generated: 1
- Review effort level: Balanced
|
Rename `PresetResolver.core_name_candidates` to be source-agnostic and update its docstring, since the method's behavior is not core-specific — it applies the Spec Kit-wide `speckit.` filename convention (see the existing `_core_stem` helper and its uses at `src/specify_cli/presets/__init__.py:194`, `:2334`, `:2590`, `:5411-5419`, `:5765-5774`). Scope (limited to PR-added code — do NOT modify the pre-existing resolve sites or `_core_stem`):
From: `core_name_candidates`To: `name_candidates` Rewrite the docstring to describe the convention without naming a tier. Suggested wording: Return exact-first filename candidates for a Given a logical name like so callers can try the fully-qualified filename first and fall back to the bare stem. Names that do not follow the a single-element list containing the original name. |
Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>
Addressed in Posted on behalf of @nicolehaugen by GitHub Copilot (model: GPT-5.4, autonomous). |
|
@copilot Two small changes in this PR's helper cluster around `_iter_core_candidates`. Keep it tight — do not modify pre-existing Spec Kit code.
File: Rename the classmethod and rewrite the docstring: Python Update the single caller in
The functions Above Python No comment needed on the other two — one anchor at
|
There was a problem hiding this comment.
Review details
Suppressed comments (4)
Previously missed (1) — in code that hasn't changed since the last review.
src/specify_cli/_identifier.py:106
- The documented project sentinel is not enforced:
derive_named_id("project", "foo", ...)emitsproject:foo:..., andlayer_kind_from_lookup_id()accepts it even though project IDs requiresourceId == "_". Enforce the sentinel in both construction and full-shape parsing so malformed project IDs cannot be treated as authoritative.
if layer not in _LAYER_KINDS:
raise IdentifierComponentError(f"Invalid layer '{layer}'")
if kind not in _NAMED_CONTRIBUTION_KINDS:
raise IdentifierComponentError(f"Invalid named contribution kind '{kind}'")
validate_component(name, "name")
src/specify_cli/_identifier.py:47
- This parenthetical contradicts the intentional built-in contract stated above: built-in layers omit
lookupId, so not every layer carries one. Narrow the statement to non-built-in layers.
its ``lookupId`` uses this label so the round-trip invariant (every layer
carries a ``lookupId``) still holds. No manifest ``iter_contributions()`` will
src/specify_cli/presets/init.py:5743
- The PR description still says
collect_all_layers()uses the on-disk extension directory assourceId, but this branch deliberately replaces it with the validated manifest ID for declared contributions. Update the PR description to match the implemented round-trip contract; the current text tells consumers the opposite identity to expect.
ext_manifest = ExtensionManifest(ext_manifest_path)
if isinstance(ext_manifest.id, str) and ext_manifest.id:
source_id_for_lookup = ext_manifest.id
src/specify_cli/_identifier.py:55
- The PR description still promises contribution IDs for every core contribution and a
lookupIdon each resolver layer, while this allow-list intentionally excludes core and the resolver omits built-in lookup IDs. Update the PR body to describe the source-agnostic built-in contract already implemented and documented here.
_LAYER_KINDS = frozenset({PROJECT_OVERRIDE_LAYER, "preset", "extension"})
_CONTRIBUTION_KINDS = frozenset({"command", "template", "script", "hook"})
_NAMED_CONTRIBUTION_KINDS = _CONTRIBUTION_KINDS - {"hook"}
_HOOK_LAYERS = frozenset({"preset", "extension"})
- Files reviewed: 22/22 changed files
- Comments generated: 1
- Review effort level: Balanced
| matching the resolver's ``templates/``-then-root lookup order. README files | ||
| are packaging metadata rather than artifacts and are excluded consistently. | ||
| """ | ||
| for subdir, kind, suffix in _CONVENTION_SUBDIRS: |
There was a problem hiding this comment.
@copilot Fix a stale-registry crash in _iter_convention_contributions in src/specify_cli/artifacts/__init__.py by modeling it after the pre-existing "skip missing pack dir" pattern already established elsewhere in Spec Kit.
Problem. If a preset/extension registry entry points to a directory that has been deleted from disk, the unguarded sorted(pack_dir.iterdir(), ...) call at the end of _iter_convention_contributions raises FileNotFoundError and takes down the entire specify artifact list / specify artifact info command. The subdirectory probes above it are already guarded with if not candidate_dir.is_dir(): continue, but the root-level walk is not.
Pre-existing precedent to model after. Spec Kit's own preset/extension resolution already handles this case gracefully — a stale registry entry is silently skipped, never fatal. See:
PresetResolver._get_manifest(pack_dir)returnsNonefor missing dirs; callers doif manifest is None: continue(used throughoutsrc/specify_cli/presets/__init__.pyin resolve paths and layer collectors).PresetManager.list_installed(): usesif not manifest_path.exists(): continue.PresetResolver.collect_all_layers(): silently drops missing packs, which is whypreset list/preset resolvekeep working over stale entries.
_iter_convention_contributions should follow the same rule.
Fix. Guard the pack root before the root-level walk, matching the style of the subdir guards immediately above:
if not pack_dir.is_dir():
return
for entry in sorted(pack_dir.iterdir(), key=lambda p: p.name):
...Place the guard right before the root-level for entry in sorted(pack_dir.iterdir(), ...) loop. Keep the existing subdirectory-loop guards as-is. Do not add error logging — the pre-existing pattern is silent skip.
Test. Add a test in tests/test_artifacts.py that:
-
Registers a preset (or extension) in the registry with an on-disk
pack_dir. -
Deletes the directory from disk without touching the registry entry.
-
Asserts
specify artifact listandspecify artifact info <a-real-artifact>succeed and simply omit the stale pack — do not raiseFileNotFoundErrorand do not fail the whole inventory. -
This mirrors the behavior of
preset listin the same scenario.
Run:
Plain text
.venv/bin/python -m pytest tests/test_artifacts.py -q
There was a problem hiding this comment.
Addressed in e7bdc62: guarded the root-level pack_dir.iterdir() walk in _iter_convention_contributions with if not pack_dir.is_dir(): return, matching the subdir guards above. Added test_stale_registry_entry_with_missing_pack_dir_is_skipped covering a registered preset whose pack_dir is deleted after registration. 86 artifact tests pass, ruff clean.
Assisted-by: GitHub Copilot (model: claude-opus-4.7, autonomous) Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>
Addressed in |
There was a problem hiding this comment.
Review details
Suppressed comments (7)
Previously missed (2) — in code that hasn't changed since the last review.
src/specify_cli/_identifier.py:49
- “Every layer carries a lookupId” contradicts the intentional built-in contract in this module and the artifact docs: built-in layers omit
lookupId. Scope the statement to project overrides so the identifier documentation remains internally consistent.
This issue also appears in the following locations of the same file:
- line 99
- line 145
Project overrides are a resolver feature — they are not backed by any manifest
contribution. When a resolved artifact stack contains a project-override layer,
its ``lookupId`` uses this label so the round-trip invariant (every layer
carries a ``lookupId``) still holds. No manifest ``iter_contributions()`` will
ever emit a matching ``id``, so consumers see "not found" for the lookup, which
is the correct outcome for a layer with no originating manifest entry.
src/specify_cli/artifacts/init.py:488
- An unreadable extension registry can escape this public catalog boundary as raw
OSError, becauseExtensionRegistry(...)reads it beforeis_corrupt()is reached. Wrap construction just as the preset validator must, so all registry read failures consistently becomeArtifactResolutionError.
This issue also appears on line 505 of the same file.
from ..extensions import ExtensionRegistry
if ExtensionRegistry(extensions_dir).is_corrupt():
raise ArtifactResolutionError()
src/specify_cli/_identifier.py:147
- The parser's “complete shape” validation still accepts impossible source/layer pairs such as
project:foo:template:xandpreset:_:template:x. Mirror the constructor's source-ID invariant here so malformed external lookup IDs returnNone.
layer = parts[0]
if layer not in _LAYER_KINDS:
return None
src/specify_cli/_identifier.py:106
- The constructor does not enforce the documented source-ID rule: it accepts
project:anything:...and also_for preset/extension layers. Those IDs do not represent valid sources, yetlayer_kind_from_lookup_id()can treat them as authoritative. Enforce_iff the layer isproject.
validate_component(layer, "layer")
validate_component(source_id, "sourceId")
validate_component(kind, "kind")
if layer not in _LAYER_KINDS:
raise IdentifierComponentError(f"Invalid layer '{layer}'")
if kind not in _NAMED_CONTRIBUTION_KINDS:
raise IdentifierComponentError(f"Invalid named contribution kind '{kind}'")
validate_component(name, "name")
src/specify_cli/artifacts/init.py:508
- This validator can leak
OSErrorinstead of the catalog'sArtifactResolutionError:PresetRegistry(...)calls_load()beforeis_corrupt(), and a directory at.registrydeterministically raisesIsADirectoryErrorthere. Wrap construction so directArtifactCatalogcallers receive the same logical error as malformed registries and the CLI envelope.
from ..presets import PresetRegistry
if PresetRegistry(presets_dir).is_corrupt():
raise ArtifactResolutionError()
docs/reference/artifacts.md:131
- This cause is stale: artifact content is no longer composed by
listorinfo, so composition failure does not produce this error. Document the implemented cases—corrupt registries, filesystem failures, and resolver layer-collection errors.
| `artifact resolution failed` | The preset/extension registries could not be read, or artifact content could not be composed |
src/specify_cli/presets/init.py:5743
- The current PR description says extension
sourceIduses the on-disk directory name, but this branch intentionally usesExtensionManifest.idfor declared entries and only falls back to the directory for convention-only entries. Update the PR description because it currently publishes the opposite lookup-ID contract.
ext_manifest = ExtensionManifest(ext_manifest_path)
if isinstance(ext_manifest.id, str) and ext_manifest.id:
source_id_for_lookup = ext_manifest.id
- Files reviewed: 22/22 changed files
- Comments generated: 0 new
- Review effort level: Balanced
Assisted-by: GitHub Copilot (model: claude-opus-4.7, autonomous) Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>
There was a problem hiding this comment.
Review details
Suppressed comments (5)
Previously missed (2) — in code that hasn't changed since the last review.
src/specify_cli/_identifier.py:105
- The constructor does not enforce the documented project-layer source invariant:
derive_named_id("project", "anything", "command", "x")returnsproject:anything:command:x, andlayer_kind_from_lookup_id()accepts it even though project IDs requiresourceId == "_". Enforce this relation in both construction and parsing so every accepted ID conforms to the declared grammar.
if layer not in _LAYER_KINDS:
raise IdentifierComponentError(f"Invalid layer '{layer}'")
if kind not in _NAMED_CONTRIBUTION_KINDS:
raise IdentifierComponentError(f"Invalid named contribution kind '{kind}'")
tests/test_contribution_ids.py:6
- This module summary says every resolved layer has a
lookupId, but the new built-in-layer tests explicitly require core rows to omit it. Describe only project/preset/extension provenance layers as carrying lookup IDs so the test documentation matches the contract it asserts.
Every command / template / script / hook contribution surfaced by a preset or
extension manifest exposes a computed ``id`` derived from author-declared data
only, and every layer of a resolved artifact stack exposes a matching
``lookupId``. The scenarios below cover: the identifier grammar across every
docs/reference/artifacts.md:131
- This cause is not implemented: artifact inspection never composes content, and layer-backed artifacts remain visible even when content cannot be decoded or composed.
ArtifactResolutionErroris used for corrupt registries, filesystem failures, or resolver errors while collecting layers, so document those actual causes instead.
| `artifact resolution failed` | The preset/extension registries could not be read, or artifact content could not be composed |
src/specify_cli/presets/init.py:5743
- The current PR description says extension
sourceIduses the on-disk directory name, but this code deliberately replaces it withExtensionManifest.id; the renamed-directory tests and reference docs assert that manifest identity is the contract. Update the PR description so it does not publish the opposite lookup-ID behavior.
ext_manifest = ExtensionManifest(ext_manifest_path)
if isinstance(ext_manifest.id, str) and ext_manifest.id:
source_id_for_lookup = ext_manifest.id
src/specify_cli/_identifier.py:47
- The parenthetical contradicts this PR's intentional built-in contract: built-in stack layers omit
lookupId, so it is not true that every layer carries one. Limit the invariant to provenance-backed/project-override layers.
contribution. When a resolved artifact stack contains a project-override layer,
its ``lookupId`` uses this label so the round-trip invariant (every layer
carries a ``lookupId``) still holds. No manifest ``iter_contributions()`` will
- Files reviewed: 22/22 changed files
- Comments generated: 0 new
- Review effort level: Balanced
|
@copilot Change `specify artifact list --json` to always include the `stack` array per artifact — matching the shape `bundle list --json` uses (full record per row, no summary/detail split, no opt-in flag). Why**Precedent — `bundle list --json` returns full records.** See `bundler/models/records.py:44-52` (`InstalledBundleRecord.to_dict()`) — one call, full record shape including `contributed_components`. There is no `bundle info` fallback needed for consumers that want provenance. **Real usage — wizard boot regression.** The Spec Kit Wizard canvas boot currently calls `list --json` then loops `info --json` per artifact — 68 shell-outs in a project with a few presets + an extension. Each shell-out pays Python cold-start (~400–900ms on Windows). Total boot cost: 27–60s. This regression is a direct consequence of `list`'s summary shape, and it disappears the moment `stack` is inlined. **No back-compat risk.** The `artifact` command is being introduced by this same PR. There are no pre-existing external callers to break. `list --json`'s response shape is being expanded, not renamed or narrowed. Change**File:** `src/specify_cli/artifacts/_commands.py` `artifact_list` (currently around lines 95-118): when `--json` is passed, each row must include `stack` — the same array shape `get_artifact_info` returns today, unchanged. Result per row: ```json That's exactly what Implementation — reuse, don't re-doDo not re-resolve layers per artifact after listing. Share the layer-serialization function between Keep
|
Assisted-by: GitHub Copilot (model: GPT-5 Codex, autonomous) Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>
Implemented in
Posted on behalf of @nicolehaugen by GitHub Copilot (model: GPT-5 Codex). |
There was a problem hiding this comment.
Review details
Suppressed comments (4)
Previously missed (3) — in code that hasn't changed since the last review.
src/specify_cli/_identifier.py:105
- The documented grammar requires
sourceId == "_"for project layers, but this constructor only checks that it is non-empty and colon-free. As a result,derive_named_id("project", "other", "template", "x")succeeds andlayer_kind_from_lookup_id()also accepts the invalid ID. Enforce the project/source relationship in both construction and parsing so the full-shape validation contract remains symmetric.
if layer not in _LAYER_KINDS:
raise IdentifierComponentError(f"Invalid layer '{layer}'")
if kind not in _NAMED_CONTRIBUTION_KINDS:
raise IdentifierComponentError(f"Invalid named contribution kind '{kind}'")
docs/reference/artifacts.md:131
- This cause is not implemented: the artifact catalog no longer composes content when serving
listorinfo, so composition failure does not produce this error. Document the actual paths mapped toArtifactResolutionError: corrupt registries, filesystem access failures, or resolver errors while collecting layers.
| `artifact resolution failed` | The preset/extension registries could not be read, or artifact content could not be composed |
src/specify_cli/_identifier.py:49
- The “every layer carries a
lookupId” invariant is false under this module's intentional built-in contract: lines 25-26 and both resolver core branches omitlookupIdfor built-in layers. Reword this to describe the synthetic project-override identity without claiming it applies to every stack layer.
its ``lookupId`` uses this label so the round-trip invariant (every layer
carries a ``lookupId``) still holds. No manifest ``iter_contributions()`` will
ever emit a matching ``id``, so consumers see "not found" for the lookup, which
is the correct outcome for a layer with no originating manifest entry.
src/specify_cli/presets/init.py:5729
- The current PR description says the on-disk extension directory is used as
sourceIdfor manifest-declared entries, but this implementation deliberately uses the validated manifest ID and reserves directory identity for convention-only contributions. Update the PR description so it does not publish the opposite opaque-ID contract.
# Manifest-declared entries use the manifest's validated ``id:``
# for the lookupId's sourceId, so ``lookupId`` joins directly to
# ``ExtensionManifest.iter_contributions()``'s ``id`` even when
# the installed directory (``ext_id``) was renamed. Convention-
# only contributions have no manifest to consult and fall back
# to the directory identity. The directory identity is retained
- Files reviewed: 22/22 changed files
- Comments generated: 1
- Review effort level: Balanced
| try: | ||
| root = _resolve_project_root() | ||
| catalog = ArtifactCatalog(root) | ||
| rows = catalog.list_artifacts_with_stack() |
There was a problem hiding this comment.
Updated in d9d1ef7: the artifact reference now documents artifact list --json as returning full records with stack per row, and aligns the field table / artifact info cross-reference with that shape. Verified with .venv/bin/python -m pytest tests/test_artifact_command.py -q and uvx ruff@0.15.0 check docs/reference/artifacts.md.
Posted on behalf of @nicolehaugen by GitHub Copilot (model: GPT-5.6, autonomous).
Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous) Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>
There was a problem hiding this comment.
Review details
Suppressed comments (4)
Previously missed (2) — in code that hasn't changed since the last review.
src/specify_cli/_identifier.py:105
- The documented grammar requires project IDs to use the
_source sentinel, but this constructor accepts values such asproject:other:template:x, andlayer_kind_from_lookup_id()also classifies them as valid. Enforcesource_id == "_"for the project layer in both construction and parsing so the shared API cannot create or accept IDs outside its own grammar.
validate_component(layer, "layer")
validate_component(source_id, "sourceId")
validate_component(kind, "kind")
if layer not in _LAYER_KINDS:
raise IdentifierComponentError(f"Invalid layer '{layer}'")
if kind not in _NAMED_CONTRIBUTION_KINDS:
raise IdentifierComponentError(f"Invalid named contribution kind '{kind}'")
src/specify_cli/artifacts/init.py:303
- This returns a
manifestPathwhenever the pack happens to contain a manifest, even for a convention-only artifact that the manifest does not declare. That makes the JSON contradict its documented “declaring manifest” meaning and points consumers to a file with no matching contribution. Preserve whether the resolver match was manifest-declared (or verify the lookup ID againstiter_contributions) and returnnullfor undeclared convention rows.
if isinstance(pack_dir, Path):
manifest_path = pack_dir / manifest_name
elif pack_id:
manifest_path = project_root / ".specify" / tier_dir / pack_id / manifest_name
else:
return None
if not manifest_path.is_file():
src/specify_cli/presets/init.py:5743
- The current PR description still says extension
sourceIdcomes from the on-disk directory, but this branch deliberately emits the validated manifest ID for declared entries (and the renamed-extension tests assert that contract). Please update the description to reserve directory identity for convention-only contributions; otherwise it publishes the opposite lookup-ID behavior.
ext_manifest = ExtensionManifest(ext_manifest_path)
if isinstance(ext_manifest.id, str) and ext_manifest.id:
source_id_for_lookup = ext_manifest.id
src/specify_cli/artifacts/init.py:675
artifact listcallscollect_all_layers()once per candidate, while each call walks every extension and reparses eachextension.yml(and reparses the matching manifest again for its source ID). With A artifacts and E extensions this produces O(A×E) YAML parses. Cache extension manifests on the resolver, as presets already are, or build the stack index in one pass before listing.
if key not in layers_cache:
try:
layers_cache[key] = resolver.collect_all_layers(name, kind)
except (OSError, PresetError) as exc:
- Files reviewed: 22/22 changed files
- Comments generated: 0 new
- Review effort level: Balanced
Consolidates the work from #4261 and #4267 into a single PR against
main.Why consolidated
#4261 (contribution IDs) and #4267 (
specify artifactcommand) were originally opened as a stack, but the underlying identifier grammar and resolver changes evolved together through review — splitting them cleanly would leave both PRs with incomplete stories. This PR ships the full, cohesive story in one place.What's in it
Contribution IDs (from #4261):
{layer}:{source_id}:{kind}:{name}identifier grammar for every core / preset / extension / project-override contribution.PresetResolver.collect_all_layers()returns each layer with a stablelookupIdso the same ID round-trips through the resolver._identifier.pymodule owns the grammar; consumers never re-derive it via string prefixes.Artifact command (from #4267):
specify artifactcommand that surfaces composition stacks as JSON, keyed by the same lookup IDs above._script_variants.pymodule: single place that knows thebash/,powershell/,python/runtime subdir layout for core scripts._locate_core_asset_dirin_assets.py— one source of truth for locating coretemplates/,commands/, andscripts/across wheel-installed and source-checkout layouts.Fixes surfaced during review:
collect_all_layers()uses the on-disk directory name as the extensionsourceId, matching howiter_extensions_by_priority()and the artifact catalog enumerate extensions — closes a gap where a manifest with a declarediddifferent from its directory produced alookupIdthe resolver would not itself hand back.provides.templates, so an installed override likespeckit.constitutionno longer becomes an ambiguoustemplate:row.project:layer prefix instead of being labeled as a preset with sourceId_.Fixes
Fixes #4210
Fixes #4212
Supersedes
Supersedes #4261 (closed)
Supersedes #4267 (closed)
Testing
1132 tests passing on Windows (11 skipped, 127 deselected are pre-existing Windows symlink-privilege failures unrelated to these changes, confirmed by running the same tests against unmodified
origin/main).Posted on behalf of @nicolehaugen by GitHub Copilot (model: claude-opus-4.7, supervised).