feat: add deterministic contribution IDs and specify artifact command - #4305
feat: add deterministic contribution IDs and specify artifact command#4305nicolehaugen wants to merge 62 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>
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.
extensions/EXTENSION-API-REFERENCE.md:866
- This identity distinction is now stale.
collect_all_layers()uses the validated manifest ID for manifest-declared preset/extension layers even when the installation directory differs, so thoselookupIdvalues do join directly toiter_contributions(); only convention-only contributions use the directory identity. Update this paragraph and the repeated statements in the Python API/determinism subsections.
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.
src/specify_cli/artifacts/init.py:635
ExtensionRegistry(...)can raiseOSErrorwhile reading an unreadable.registrybeforeis_corrupt()is reached, bypassing the artifact command's JSON error envelope. Translate constructor I/O failures toArtifactResolutionError, as with malformed registry content.
This issue also appears on line 654 of the same file.
if ExtensionRegistry(extensions_dir).is_corrupt():
raise ArtifactResolutionError()
src/specify_cli/artifacts/init.py:655
PresetRegistry(...)reads.registryin its constructor beforeis_corrupt()runs. A directory at.registry(or an unreadable file) therefore raisesOSErrorhere instead of producing the promisedArtifactResolutionErrorJSON envelope. Catch constructor I/O failures and translate them before callingis_corrupt().
if PresetRegistry(presets_dir).is_corrupt():
raise ArtifactResolutionError()
src/specify_cli/presets/init.py:5269
- The return-value documentation is stale:
ext_idis the on-disk directory identity, but manifest-declared layers now useExtensionManifest.idinlookupId. Clarify that the directory name is used only for lookup/provenance and convention-only contribution IDs.
Each entry is ``(priority, ext_id, metadata_or_none)`` where ``ext_id``
is always the on-disk directory name used in lookup identifiers.
- Files reviewed: 17/17 changed files
- Comments generated: 6
- Review effort level: Balanced
| candidate = next( | ||
| (path for path in script_variant_paths(_core_pack / "scripts", template_name) if path.exists()), | ||
| None, |
There was a problem hiding this comment.
Fixed in 1c4319c. The tier-5 branch now delegates to the existing _find_bundled_core helper, which already routes all three asset families through _locate_core_asset_dir — so a core_pack without scripts/ falls back to the source checkout in resolve() exactly as it does in collect_all_layers(). Added test_core_fallback_uses_shared_asset_resolver covering that case and asserting both APIs return the same path.
Posted on behalf of @nicolehaugen by GitHub Copilot (model: claude-opus-4.7, autonomous).
| ) -> Iterable[tuple[ArtifactKind, str, str, str]]: | ||
| """Yield ``(kind, name, "", lookup_id)`` for project-local overrides. | ||
|
|
There was a problem hiding this comment.
Fixed in 1c4319c. Added _describe_artifact_file, which routes to the existing _extract_frontmatter_description / _extract_script_description extractors, and _iter_project_override_artifacts now yields that value instead of "". Since descriptions are picked from the highest-priority layer that has one, an active override with metadata now reports its own description; a metadata-free override still falls back to the hidden layer. Both cases are covered by new tests.
Posted on behalf of @nicolehaugen by GitHub Copilot (model: claude-opus-4.7, autonomous).
|
|
||
| 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. | ||
|
|
||
| `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. |
There was a problem hiding this comment.
Updated in 1c4319c. The paragraph now states the manifest-declared join is guaranteed by the implementation so consumers can key off lookupId directly, and scopes the registry/directory fallback to convention-only layers that have no manifest contribution to join to.
Posted on behalf of @nicolehaugen by GitHub Copilot (model: claude-opus-4.7, autonomous).
| raise typer.Exit(code=2) | ||
|
|
||
|
|
||
| @artifact_app.command("list") |
There was a problem hiding this comment.
Added in 1c4319c: docs/reference/artifacts.md documents both JSON shapes (list rows and the info object), stack semantics including active/hidden being independent labels, core rows, lookup IDs and their join to the preset contribution grammar, and the JSON error envelope with its pinned messages and exit codes. Linked from docs/toc.yml and docs/reference/overview.md, and referenced from the _commands.py module docstring.
Posted on behalf of @nicolehaugen by GitHub Copilot (model: claude-opus-4.7, autonomous).
| ext_manifest = ExtensionManifest(ext_manifest_path) | ||
| if isinstance(ext_manifest.id, str) and ext_manifest.id: | ||
| source_id_for_lookup = ext_manifest.id |
There was a problem hiding this comment.
PR description updated in 1c4319c. The bullet now states that manifest-declared preset/extension layers derive their sourceId from the manifest's validated id:, with the registry key / on-disk directory name used only as the convention-only fallback.
Posted on behalf of @nicolehaugen by GitHub Copilot (model: claude-opus-4.7, autonomous).
| Each entry is ``(pack_id, metadata)`` where ``pack_id`` is the registry | ||
| key/directory name used in lookup identifiers. |
There was a problem hiding this comment.
Fixed in 1c4319c. Both iter_presets_by_priority and iter_extensions_by_priority docstrings now say the registry key / on-disk directory name identifies where the pack lives — used for lookup, provenance, and as the sourceId of convention-only contribution IDs — while manifest-declared layers take their lookupId sourceId from PresetManifest.id / ExtensionManifest.id.
Posted on behalf of @nicolehaugen by GitHub Copilot (model: claude-opus-4.7, autonomous).
…kupId validation 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:618
- This guard does not fully fail closed:
ExtensionRegistry.is_corrupt()accepts a present JSON object with noextensionskey, after which_load()normalizes it to empty and the resolver admits every on-disk directory as an unregistered extension. The preset check has the analogous false-inventory behavior. Require the collection key itself to be present and mapping-valued whenever a registry file exists.
if ExtensionRegistry(extensions_dir).is_corrupt():
src/specify_cli/presets/init.py:5270
- This says the directory identity is used in lookup identifiers, but manifest-declared extension layers now use
extension.id; only convention-only layers useext_id. Update the docstring so callers do not rely on the wrong identity.
"""Return extension directories in resolver lookup order.
Each entry is ``(priority, ext_id, metadata_or_none)`` where ``ext_id``
is always the on-disk directory name used in lookup identifiers.
"""
src/specify_cli/presets/init.py:5435
- This tier still bypasses
_locate_core_asset_dir. Ifcore_packexists but lacksscripts/,candidatestaysNoneandresolve()never tries the source checkout, whilecollect_all_layers()does fall back through the shared helper. Route tier-5 resolution through_locate_core_asset_dirfor all asset families so path resolution and stack reporting cannot disagree.
candidate = next(
(path for path in script_variant_paths(_core_pack / "scripts", template_name) if path.exists()),
None,
src/specify_cli/artifacts/_commands.py:35
- The linked #4212 acceptance criteria requires a CLI reference section documenting the full JSON shapes and stack semantics, but the documentation tree contains no
specify artifactreference. The Typer help strings do not document fields such asactive,hidden,manifestPath, orlookupId; add the required reference documentation before shipping the new public command.
artifact_app = typer.Typer(
name="artifact",
help="Introspect commands, templates, and scripts SpecKit exposes.",
no_args_is_help=True,
)
src/specify_cli/presets/init.py:5117
- This new docstring is inaccurate for manifest-declared contributions:
pack_idis the registry/on-disk identity, but their lookup IDs use the validated manifest ID. Reserve the statement aboutpack_idin lookup identifiers for convention-only contributions.
"""Return preset directories in resolver lookup order.
Each entry is ``(pack_id, metadata)`` where ``pack_id`` is the registry
key/directory name used in lookup identifiers.
"""
- Files reviewed: 17/17 changed files
- Comments generated: 2
- Review effort level: Balanced
…ribe project overrides, document specify artifact Assisted-by: GitHub Copilot (model: claude-opus-4.7, autonomous) Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>
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 (4)
Previously missed (1) — in code that hasn't changed since the last review.
src/specify_cli/presets/init.py:675
- A registry JSON object that omits
presetsis accepted as healthy, even though_load()silently normalizes it to an empty map and drops every installed preset from the artifact inventory. Treat a missing key the same as any other non-mappingpresetsvalue so this fail-closed probe fulfills its contract.
if "presets" in data and not isinstance(data["presets"], dict):
return True
src/specify_cli/artifacts/_commands.py:150
- The
infopath also lets filesystemOSErrors escape the JSON error contract—for example, an unreadable registry raises during construction before the corruption probe runs. Convert these read failures toArtifactResolutionError, as the command promises for registry/manifest resolution failures.
except PresetError:
_emit_error_and_exit(ArtifactResolutionError())
return # pragma: no cover
src/specify_cli/_identifier.py:128
- The “complete shape” check accepts invalid kinds:
core:_:bogus:namecurrently returnscore, and callers then treat that malformed ID as authoritative. Validate the kind enum before selecting the expected component count so only named artifact kinds and hooks classify successfully.
expected_len = 5 if parts[2] == "hook" else 4
if len(parts) != expected_len:
return None
src/specify_cli/presets/init.py:5731
- The PR description says extension
sourceIdnow uses the on-disk directory name, but this implementation intentionally usesExtensionManifest.idfor manifest-declared layers and only falls back to the directory for convention-only entries. The code, tests, and lookup round-trip contract agree here; update the PR description so consumers are not given the opposite 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: 20/20 changed files
- Comments generated: 2
- Review effort level: Balanced
Address the latest review feedback for root-level legacy templates and unsupported lookup ID kinds. Assisted-by: GitHub Copilot (model: GPT-5.6 Luna, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 33a4ecee-211d-4be0-9842-7a433c5ada8f
Treat missing registry collection keys as corruption and map filesystem read failures to the artifact JSON error envelope. Assisted-by: GitHub Copilot (model: GPT-5.6 Luna, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 33a4ecee-211d-4be0-9842-7a433c5ada8f
…d-artifacts' into pr/4305/nicolehaugen-contribution-ids-and-artifacts
There was a problem hiding this comment.
Review details
Suppressed comments (7)
Previously missed (4) — in code that hasn't changed since the last review.
docs/reference/presets.md:223
layer_kind_from_lookup_id()cannot distinguish manifest-declared contributions from convention-only contributions; both use the samepresetorextensionlayer prefix. It only classifies the source layer, so this guidance would lead consumers to make a distinction the API cannot provide.
`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. 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. Use `layer_kind_from_lookup_id` to tell the two cases apart 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.
docs/reference/artifacts.md:125
- The command does not fail when a manifest cannot be read: both managers treat invalid manifests as unavailable, and the resolver may continue with convention-based files. Only registry/read-resolution failures are converted to this message, so listing unreadable manifests as a guaranteed cause documents behavior the implementation does not provide.
| `artifact resolution failed` | The preset/extension registries or a manifest could not be read |
src/specify_cli/artifacts/init.py:812
get_artifact_info()rebuilds the complete inventory repeatedly:_find_matches()callslist_artifacts()here (and once above), then_describe()calls it a third time. Each rebuild walks every pack and resolves every artifact stack, so a singleinforequest scales poorly for the wizard use case. Build the inventory once and reuse the matchingArtifactfor validation and description.
if not any(kind_name == resolved_kind for kind_name, _ in self._find_matches(validated_name)):
src/specify_cli/artifacts/init.py:458
- A convention-only layer can belong to a pack that has a valid manifest which does not declare this artifact. This code still returns that manifest's path, so
manifestPathfalsely identifies it as the declaring manifest even though the layer'slookupIdintentionally has no manifest contribution to join. Carry whether the resolver match was manifest-declared and returnNonehere for convention fallback layers.
if not manifest_path.is_file():
return None
src/specify_cli/artifacts/init.py:654
PresetRegistry(...)executes_load()before this corruption probe. Because_load()neither guards a non-regular.registrypath nor catchesOSError, a registry directory or unreadable file raises beforeis_corrupt()runs and bypasses the artifact command's JSON error envelope. Convert that construction failure toArtifactResolutionError.
if PresetRegistry(presets_dir).is_corrupt():
src/specify_cli/_identifier.py:128
- This classifier accepts unknown contribution kinds such as
core:_:bogus:nameas validcorelookup IDs, even though its contract says malformed IDs returnNone. Callers then treat a value outside the identifier grammar as authoritative; validate the kind before selecting the expected component count.
return None
if parts[2] not in _CONTRIBUTION_KINDS:
return None
src/specify_cli/presets/init.py:5731
- The current PR description still says
collect_all_layers()uses the on-disk extension directory assourceId, but this implementation deliberately substitutes the validated manifest ID for manifest-declared layers. The code, tests, and round-trip contract agree on the manifest ID, so update the PR description to avoid publishing the opposite identifier contract.
ValidationError as ExtValidationError,
)
ext_manifest = ExtensionManifest(ext_manifest_path)
- Files reviewed: 20/20 changed files
- Comments generated: 1
- Review effort level: Balanced
Use the existing artifact description extractors for convention-based preset and extension files. Assisted-by: GitHub Copilot (model: GPT-5.6 Luna, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 33a4ecee-211d-4be0-9842-7a433c5ada8f
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:812
get_artifact_info()currently rebuilds the complete inventory up to three times: here, again during kind validation, and again in_describe(). Each build scans every contribution and callscollect_all_layers()for every artifact, so oneinfolookup multiplies the most expensive filesystem work—especially costly for the wizard's intended repeated lookups. Build the inventory once and reuse its matching row and description.
if resolved_kind is None:
matches = self._find_matches(bare)
docs/reference/artifacts.md:125
- This cause is not implemented: malformed or unreadable manifests are caught and skipped by the resolver/catalog, which may then use convention fallback, so they do not produce
artifact resolution failed. Narrow this row to the failures that actually reach the error envelope (corrupt registries or uncaught inventory filesystem access) to avoid promising fail-closed manifest validation.
| `artifact resolution failed` | The preset/extension registries or a manifest could not be read |
src/specify_cli/presets/init.py:5733
- The PR description says extension stack IDs use the on-disk directory name as
sourceId, but this code deliberately substitutesExtensionManifest.id; the tests and API docs also require the manifest ID solookupIdjoins toiter_contributions(). 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: 20/20 changed files
- Comments generated: 2
- Review effort level: Balanced
| if template_name.lower() == "readme": | ||
| continue |
| command_layers = resolver.collect_all_layers(name, "command") | ||
| backed_by_command = any( | ||
| layer_kind_from_lookup_id(str(layer.get("lookupId", ""))) | ||
| != PROJECT_OVERRIDE_LAYER | ||
| for layer in command_layers | ||
| ) | ||
| is_command = backed_by_command or is_dotted_command_name(name) | ||
| kind: ArtifactKind = "command" if is_command else "template" | ||
| lookup_id = derive_named_id(PROJECT_OVERRIDE_LAYER, "_", kind, name) | ||
| yield kind, name, _describe_artifact_file(entry, kind), lookup_id |
There was a problem hiding this comment.
Review details
Suppressed comments (2)
src/specify_cli/presets/init.py:5598
- This exclusion is only applied in
collect_all_layers(). The earlierresolve()convention loops still return<preset>/README.mdor<extension>/README.mdas a root-level template, sopreset resolve READMEcan succeed whileartifact list/infoomit the same artifact. Apply the same case-insensitive root-level README check in bothresolve()loops to keep resolution and inventory consistent.
if template_name.lower() == "readme":
continue
src/specify_cli/presets/init.py:5733
- The current PR description says extension
sourceIduses the on-disk directory name, but this implementation deliberately usesExtensionManifest.idfor manifest-declared layers (with the directory only for convention fallback). The code matches the round-trip requirement and tests, so update that PR-description bullet to publish the actual 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: 20/20 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).