feat: add specify artifact command exposing composition stacks as JSON - #4267
feat: add specify artifact command exposing composition stacks as JSON#4267nicolehaugen wants to merge 49 commits into
specify artifact command exposing composition stacks as JSON#4267Conversation
…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>
There was a problem hiding this comment.
Pull request overview
Adds JSON-based artifact inventory and composition-stack introspection to the Specify CLI.
Changes:
- Adds
artifact listandartifact infocommands. - Implements artifact discovery and stack serialization.
- Adds CLI, resolver-parity, and cross-platform tests.
Show a summary per file
| File | Description |
|---|---|
src/specify_cli/__init__.py |
Registers the artifact command group. |
src/specify_cli/artifacts/__init__.py |
Implements inventory and stack logic. |
src/specify_cli/artifacts/_commands.py |
Provides Typer CLI and JSON output. |
tests/test_artifact_command.py |
Tests contracts and CLI behavior. |
tests/test_artifact_command_parity.py |
Tests resolver and path parity. |
Review details
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Suppressed comments (1)
src/specify_cli/artifacts/init.py:669
- This directory scan includes disabled presets and unregistered preset directories, while
PresetResolver._get_all_presets_by_priority()only composes enabled registry entries (src/specify_cli/presets/__init__.py:5067-5073). As a result,artifact listcan advertise artifacts thatartifact infoimmediately reports as unknown. Enumerate the same enabled source set as the resolver; retain the resolver's intentional unregistered-extension behavior separately.
for tier in ("presets", "extensions"):
tier_dir = specify_dir / tier
if not tier_dir.is_dir():
continue
for pack_dir in sorted(tier_dir.iterdir(), key=lambda p: p.name):
if not pack_dir.is_dir():
- Files reviewed: 5/5 changed files
- Comments generated: 6
- Review effort level: Balanced
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
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.
tests/test_artifact_command.py:60
- This helper does not create a valid preset manifest: production presets require
schema_version,preset,requires, and a singleprovides.templates[]list whose entries carrytypeandfile.PresetManifesttherefore rejects every fixture created here, and the tests accidentally exercise convention fallback instead of manifest-backed preset composition. Build fixtures in the real schema so these tests can catch the production parsing bug.
"id": pack_id,
tests/test_artifact_command_parity.py:23
- This duplicated helper writes the same invalid preset schema (
id/metadataat the root and sectionedprovidesentries). SincePresetManifestrejects it, the parity test reaches the preset file only through convention fallback and never verifies manifest/resolver parity. Generate the canonicalschema_version/preset/requires/provides.templates[type,file]shape instead.
manifest = {
src/specify_cli/artifacts/init.py:282
- Core scripts are listed by physical filename, so each Bash/PowerShell/Python variant becomes a different artifact (for example,
setup-plan.sh,setup-plan.ps1, andsetup_plan.py). More importantly,get_artifact_info()passes that filename toPresetResolver.collect_all_layers(), which appends.shand looks outside the runtime subdirectory, so these advertised list entries all resolve asunknown artifact. Normalize variants to a logical script name and make stack lookup use the actual runtime paths before exposing them.
name = entry.name
src/specify_cli/artifacts/_commands.py:53
_resolve_init_dir_override()itself prints Rich errors and raisestyper.Exitfor an invalidSPECIFY_INIT_DIR, so this call bypasses the artifact JSON error handler. In--jsonmode stderr is then plain Rich text rather than the promised{"error": ...}envelope. Add a quiet/project-resolution API that raises anArtifactError, or translate validation without letting the shared helper emit first.
from .._project import _resolve_init_dir_override
override = _resolve_init_dir_override()
cwd = override if override is not None else Path.cwd()
if not (cwd / ".specify").is_dir():
raise NotASpecKitProjectError()
tests/test_artifact_command.py:24
StackLayeris unused, and the repository's Python lint job runs Ruff overtests, so this import triggers F401 and blocks CI. Remove it from the import list.
)
src/specify_cli/artifacts/init.py:689
- Preset manifests do not share the extension
provides.commands/templates/scriptsshape: all preset contributions live inprovides.templates[], withtypeidentifying command/template/script (PresetManifest.iter_contributions()at src/specify_cli/presets/init.py:546-568). Consequently real preset commands and scripts are omitted, and preset scripts are misclassified as templates. Parse each tier through its manifest model instead of applying the extension shape to both.
Both preset and extension manifests use the same ``provides`` shape:
src/specify_cli/artifacts/init.py:486
collect_all_layers()emits project overrides with aproject:lookup ID, but this catch-all converts every non-core/non-extension layer into a preset. A real.specify/templates/overrides/<name>.mdtherefore appears aslayer: "preset",presetId: "_", which is false metadata. Handle theproject:layer explicitly (and update the public layer contract accordingly) rather than falling through to preset handling.
layer="preset",
- Files reviewed: 5/5 changed files
- Comments generated: 1
- Review effort level: Balanced
There was a problem hiding this comment.
Review details
Suppressed comments (8)
Previously missed (2) — in code that hasn't changed since the last review.
tests/test_artifact_command.py:63
- This helper writes an invalid preset manifest: the real schema requires
preset,requires, and a mixedprovides.templateslist with atypeper entry.PresetManifesttherefore rejects this fixture, and the stack tests pass through convention fallback instead of exercising manifest-declared presets, masking the production parser mismatch.
manifest = {
"id": pack_id,
"version": "1.0.0",
"metadata": {"name": f"Test preset {pack_id}"},
"provides": provides,
tests/test_artifact_command_parity.py:27
- This duplicated helper also writes the extension-style
providesshape instead of a valid preset (preset/requiresplus typed entries underprovides.templates). The resolver rejects it and the parity test exercises convention fallback, so it does not verify parity for a real manifest-declared preset.
manifest = {
"id": pack_id,
"version": "1.0.0",
"metadata": {"name": f"Test preset {pack_id}"},
"provides": provides,
src/specify_cli/artifacts/init.py:672
- This enumerates raw on-disk declarations rather than the resolver-visible inventory. Disabled extensions, disabled/unregistered presets, and entries whose declared file is missing can therefore appear in
artifact list, whileartifact inforeturnsunknown artifactfor the same ID. Build the list from the same enabled registry/resolver sources used bycollect_all_layers()(and only retain names with a non-empty stack).
for pack_dir in sorted(tier_dir.iterdir(), key=lambda p: p.name):
if not pack_dir.is_dir():
continue
manifest_name = "preset.yml" if tier == "presets" else "extension.yml"
manifest = pack_dir / manifest_name
src/specify_cli/artifacts/init.py:707
- Preset manifests do not use these three sibling lists: valid presets put commands, templates, and scripts together under
provides.templates, distinguished by each entry'stype(seepresets/lean/preset.yml:15-18). Consequently valid preset contributions are omitted or classified as templates. Parse presets viaPresetManifest.iter_contributions()and extensions viaExtensionManifest.iter_contributions()instead of applying the extension shape to both.
for kind_key, kind_value in (
("commands", "command"),
("templates", "template"),
("scripts", "script"),
src/specify_cli/artifacts/init.py:284
- Using the physical filename as the artifact name exposes separate
.sh,.ps1, and.pyrows (including underscore-vs-hyphen variants), whereas script contribution IDs use logical names such assetup-plan(tests/test_contribution_ids.py:124-127). These listed names cannot be resolved:collect_all_layers()appends.sh, soscript:setup-plan.shsearches forsetup-plan.sh.sh, and bundled scripts live in runtime subdirectories. Normalize runtime variants to one logical script artifact and make stack lookup resolve that logical name to the actual runtime files.
name = entry.name
if name in seen:
continue
src/specify_cli/artifacts/init.py:484
- A project override has a
project:_:{kind}:{name}lookup ID, so it falls through both branches and is emitted as a preset with ID/name_. Project overrides are the documented highest-precedence resolver tier (docs/reference/presets.md:148-153) and need an explicitprojectlayer representation with null preset/manifest fields rather than being mislabeled.
pack_id = _extract_lookup_pack_id(lookup_id) or ""
pack_dir = project_root / ".specify" / "presets" / pack_id
display = _preset_display_name(pack_dir, pack_id) if pack_id else pack_id
manifest_path = _derive_manifest_path(layer, project_root)
rows.append(
src/specify_cli/artifacts/init.py:395
- Valid preset manifests store the human-readable name at
preset.name, not at top-levelname(ormetadata.name). As written, every normal preset stack reports the pack ID aspresetNameinstead of its display name.
display = data.get("name")
if isinstance(display, str) and display:
return display
src/specify_cli/artifacts/_commands.py:50
_resolve_init_dir_override()prints a Rich error and raisestyper.ExitwhenSPECIFY_INIT_DIRis invalid (src/specify_cli/_project.py:43-52). That bypasses the JSON error handler, so a--jsoninvocation can emit non-JSON stderr despite this module's strict envelope contract. Add a non-emitting resolution path that converts these failures toNotASpecKitProjectErrorbefore serialization.
from .._project import _resolve_init_dir_override
override = _resolve_init_dir_override()
- Files reviewed: 5/5 changed files
- Comments generated: 0 new
- Review effort level: Balanced
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 (5)
src/specify_cli/artifacts/init.py:293
- Core scripts are surfaced with their physical filename (for example,
setup-plan.sh) rather than the extension-free logical name accepted by preset/extension manifests.get_artifact_info()then passes that name toPresetResolver.collect_all_layers(..., "script"), which appends.shand does not search the runtime subdirectory, so script rows returned byartifact listcannot be retrieved byartifact info. Normalize scripts to logical names and make core script lookup use the runtime directories consistently.
name = entry.name
if name in seen:
continue
try:
text = entry.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
text = ""
seen[name] = _CoreBaselineRow(
name=name,
kind="script",
path=entry,
description=_extract_script_description(text),
src/specify_cli/artifacts/init.py:395
- Valid preset manifests store their display name at
preset.name(seepresets/lean/preset.yml:3-6), but this readsmetadata.nameor a top-levelname. Consequently real preset stack rows report the preset ID aspresetNameinstead of the human-friendly name.
metadata = data.get("metadata")
if isinstance(metadata, dict):
display = metadata.get("name")
if isinstance(display, str) and display:
return display
display = data.get("name")
if isinstance(display, str) and display:
return display
src/specify_cli/artifacts/init.py:449
PresetResolveremits project overrides with aproject:_...lookup ID, but this classifier only recognizes core and extension prefixes, so an override falls through and is falsely serialized as a preset with no ID/name. This also makes the emitted lookup ID violate this module's declared layer/lookup grammar. Represent project overrides explicitly (and update the JSON contract), or exclude them without mislabeling them.
# Layer classification: prefer lookupId prefix (authoritative) with a
# source-string fallback for defensive parsing.
if lookup_id.startswith("core:") or source.startswith("core"):
src/specify_cli/artifacts/init.py:674
- This directory/manifest scan does not use the resolver's eligibility rules. It includes every preset directory even though
PresetResolveronly composes presets registered in.registry, while omitting registered preset and extension artifacts that resolve through the supported convention fallback when no manifest entry exists. As a result,artifact listcan both advertise artifacts thatartifact inforejects and omit artifacts thatartifact info --kindresolves. Build the inventory from the same registry, enabled-state, and fallback semantics as the resolver.
for pack_dir in sorted(tier_dir.iterdir(), key=lambda p: p.name):
if not pack_dir.is_dir():
continue
manifest_name = "preset.yml" if tier == "presets" else "extension.yml"
manifest = pack_dir / manifest_name
if not manifest.is_file():
continue
src/specify_cli/artifacts/_commands.py:53
_resolve_init_dir_override()itself prints Rich errors and raisestyper.Exitfor an invalidSPECIFY_INIT_DIR. Because that exception bypasses theArtifactErrorhandlers, these JSON-only commands emit non-JSON stderr despite this function's stated strict-envelope purpose. Use a non-emitting project resolver or convert override validation failures into the artifact error envelope.
from .._project import _resolve_init_dir_override
override = _resolve_init_dir_override()
cwd = override if override is not None else Path.cwd()
if not (cwd / ".specify").is_dir():
raise NotASpecKitProjectError()
- Files reviewed: 5/5 changed files
- Comments generated: 1
- Review effort level: Balanced
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>
There was a problem hiding this comment.
Review details
Suppressed comments (6)
Previously missed (4) — in code that hasn't changed since the last review.
src/specify_cli/artifacts/init.py:689
- Filter invalid identifier components from the core baseline too. Project-local core filenames are user-controlled, and POSIX permits names such as
foo:bar.md; this loop currently emitstemplate:foo:bar, although:is reserved andget_artifact_info()rejects that exact row. Convention contributions already apply this filter at line 969, so the baseline needs the same guard to preserve list/info round trips.
for row in (*baseline.commands, *baseline.templates, *baseline.scripts):
key = (row.kind, row.name)
names.add(key)
core_lookup_id = derive_named_id("core", "_", row.kind, row.name)
descriptions_by_layer.setdefault(key, {}).setdefault(
core_lookup_id, row.description
)
extensions/EXTENSION-API-REFERENCE.md:914
derive_named_idandderive_hook_idconstruct identifiers; neither parses an existing identifier. Recommending them as parsing helpers leaves consumers without a workable implementation and conflicts with the opacity guidance. Tell consumers to compare/store IDs whole, and mentionlayer_kind_from_lookup_idonly for the limited supported layer-prefix inspection.
Identifiers are stable, but treat them as **opaque strings** in stored data (registries, cache files, external tooling). Parse them with the helpers in `specify_cli._identifier` (`derive_named_id`, `derive_hook_id`) rather than by string-splitting on `:` — the discriminator suffix and future grammar extensions may otherwise catch you out.
src/specify_cli/artifacts/init.py:897
- Manifest and resolver IDs can diverge for an unregistered extension whose directory name differs from its manifest ID.
contribution["id"]uses the manifest ID, whilecollect_all_layers()buildslookupIdfrom the directory name; this gate then drops a manifest-declared artifact even though the resolver can load its file (especially whenfileis non-conventional). Make the resolver propagate the manifest source ID or reject mismatched layouts so every resolvable manifest contribution has the promised matching ID.
lookup_id = contribution["id"]
if lookup_id in lookup_ids(kind, name):
yield kind, name, description, lookup_id
src/specify_cli/extensions/init.py:885
- This lookup is ambiguous for colliding hooks. Two hooks may share the synthesized
namebut have different discriminator IDs; this method returns the first, while hook registration keeps the last command entry (extensions/__init__.py:5192-5227). The convenience API can therefore return the ID of the declaration that is discarded and cannot retrieve the effective one. Require a discriminator/declared fields or report ambiguity instead of silently returning the first match.
``name`` is the declared name for command/template/script kinds, or the
``"{eventName}:{command}"`` compound for hook kinds.
"""
src/specify_cli/_assets.py:53
- Fall back to the source-checkout directory when the wheel bundle exists but lacks this asset family. As written, a partial/stale
core_packmakes the helper returnNoneimmediately, contradicting its documented two-tier lookup and the behavior of sibling resolvers such as_locate_bundled_extension()(_assets.py:70-79). This can hide source commands/templates/scripts in editable development environments.
core = _locate_core_pack()
if core is not None:
candidate = core / subdir
return candidate if candidate.is_dir() else None
src/specify_cli/artifacts/init.py:263
- The inventory still tries the stripped command filename before the exact logical name. With both
foo.mdandspeckit.foo.mdpresent, it readsfoo.md, whilePresetResolver.collect_all_layers("speckit.foo", "command")selectsspeckit.foo.mdfirst (presets/__init__.py:5677-5687), so the reported description can come from the wrong file. Extract the resolver's exact-name-first candidate ordering into the shared helper requested in the prior review and use it in both paths.
project_commands_dir / f"{stem}.md",
project_commands_dir / f"{logical_name}.md",
- Files reviewed: 16/16 changed files
- Comments generated: 0 new
- Review effort level: Balanced
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 (5)
Previously missed (3) — in code that hasn't changed since the last review.
src/specify_cli/artifacts/init.py:684
- Project-local core filenames are added without enforcing the colon-free identifier constraint. On POSIX, a file such as
.specify/templates/foo:bar.mdproducestemplate:foo:bar, violating the documented ID grammar, and the emitted row cannot round-trip throughget_artifact_info()because_validate_artifact_namerejects it. Structurally validate baseline names before publishing them.
This issue also appears on line 691 of the same file.
names.add(key)
core_lookup_id = derive_named_id("core", "_", row.kind, row.name)
descriptions_by_layer.setdefault(key, {}).setdefault(
extensions/EXTENSION-API-REFERENCE.md:914
derive_named_idandderive_hook_idconstruct identifiers; they cannot parse an existing identifier as this guidance claims. Since identifiers are opaque and there is no general parser, tell consumers not to parse them, note that the derive helpers are builders, and mentionlayer_kind_from_lookup_idonly for layer classification.
Identifiers are stable, but treat them as **opaque strings** in stored data (registries, cache files, external tooling). Parse them with the helpers in `specify_cli._identifier` (`derive_named_id`, `derive_hook_id`) rather than by string-splitting on `:` — the discriminator suffix and future grammar extensions may otherwise catch you out.
src/specify_cli/artifacts/init.py:740
- A single
infolookup rebuilds the complete catalog up to three times: once here, again in the post-validation_find_matchescall, and again in_describe. Each rebuild reparses pack manifests and resolves every artifact across every installed pack, so cost grows with the whole catalog rather than the requested item. Computelist_artifacts()once inget_artifact_info()and reuse that snapshot for matching and the description.
raise ArtifactNotFoundError(name)
src/specify_cli/artifacts/init.py:264
- Core command enumeration still prefers the stripped filename over the exact logical filename. If both
foo.mdandspeckit.foo.mdexist, this recordsfoo.md's description forspeckit.foo, whilePresetResolver.collect_all_layers("speckit.foo", "command")selects the exactspeckit.foo.mdfirst (presets/__init__.py:5677-5687). Build candidates exact-name-first using the resolver's shared name-candidate logic so inventory metadata describes the resolved file.
)
rows_by_name: dict[str, _CoreBaselineRow] = {}
for logical_name in sorted(logical_names):
name_candidates = PresetResolver.core_name_candidates(logical_name)
project_candidates = (
src/specify_cli/artifacts/init.py:695
- Project-override filenames reach this path without the colon guard used by
_iter_convention_contributions. A POSIX override such asoverrides/foo:bar.md(oroverrides/scripts/foo:bar.sh) is therefore listed with a malformed ID thatartifact infoimmediately rejects. Apply the same structural component validation before adding yielded names.
):
key = (kind, name)
names.add(key)
layer_descriptions = descriptions_by_layer.setdefault(key, {})
if lookup_id not in layer_descriptions or (
- Files reviewed: 16/16 changed files
- Comments generated: 0 new
- Review effort level: Balanced
There was a problem hiding this comment.
Review details
Suppressed comments (6)
Previously missed (4) — in code that hasn't changed since the last review.
src/specify_cli/artifacts/init.py:752
get_artifact_info()currently rebuilds the full inventory up to three times:_find_matches()callslist_artifacts()here (and again below), then_describe()calls it once more. Since each inventory build scans all packs and resolves every artifact stack, a one-artifact query scales as several whole-catalog traversals. Build the inventory once and retain the matchingArtifactfor validation and description.
if resolved_kind is None:
matches = self._find_matches(bare)
if not matches:
raise ArtifactNotFoundError(name)
if len(matches) > 1:
src/specify_cli/extensions/init.py:845
eventNameis a derived value determined by the enclosing hook mapping key, butsetdefaultpreserves an author-supplied field with that name. A hook underbefore_plancontainingeventName: after_plantherefore emitseventName=after_planwhile itsnameandidusebefore_plan. Overwrite the reserved derived field (or reject it during validation) so the contribution is internally consistent.
normalized.setdefault("eventName", event_name)
extensions/EXTENSION-API-REFERENCE.md:918
- The former
File System Layoutheading was replaced by the new section and never restored, so the.specify/tree now appears as an unexplained code block inside the contribution-identifier section. Reintroduce the heading before the tree.
```text
extensions/EXTENSION-API-REFERENCE.md:914
derive_named_idandderive_hook_idconstruct identifiers; they do not parse an existing identifier, so this guidance sends consumers to APIs that cannot perform the stated operation. Keep IDs opaque, and mentionlayer_kind_from_lookup_idonly for the limited supported layer classification.
Identifiers are stable, but treat them as **opaque strings** in stored data (registries, cache files, external tooling). Parse them with the helpers in `specify_cli._identifier` (`derive_named_id`, `derive_hook_id`) rather than by string-splitting on `:` — the discriminator suffix and future grammar extensions may otherwise catch you out.
src/specify_cli/artifacts/init.py:928
- Override filenames are also emitted without enforcing the colon-free ID component contract. A POSIX project containing
overrides/foo:bar.mdgets a malformedtemplate:foo:barlist row thatartifact infosubsequently rejects. Applyvalidate_componentto the stem before deriving the lookup ID.
for entry in sorted(overrides_dir.iterdir(), key=lambda p: p.name):
if not entry.is_file() or entry.suffix != _TEMPLATE_SUFFIX:
continue
name = entry.stem
command_layers = resolver.collect_all_layers(name, "command")
src/specify_cli/artifacts/init.py:945
- The script-override scan has the same non-round-trippable-ID problem:
overrides/scripts/foo:bar.shis published even though:is reserved and lookup validation rejects it. Validate the stem before constructing the project lookup ID.
for entry in sorted(scripts_dir.iterdir(), key=lambda p: p.name):
if entry.is_file() and entry.suffix == _SCRIPT_SUFFIX:
lookup_id = derive_named_id(PROJECT_OVERRIDE_LAYER, "_", "script", entry.stem)
yield "script", entry.stem, "", lookup_id
- Files reviewed: 16/16 changed files
- Comments generated: 1
- Review effort level: Balanced
| for row in (*baseline.commands, *baseline.templates, *baseline.scripts): | ||
| key = (row.kind, row.name) | ||
| names.add(key) | ||
| core_lookup_id = derive_named_id("core", "_", row.kind, row.name) |
There was a problem hiding this comment.
Fixed in 17dc23e: artifact inventory now skips project-local baseline/override entries whose names fail identifier-component validation (including :), so list rows always round-trip through artifact info. Added regression coverage in tests/test_artifact_command.py.
Posted on behalf of @nicolehaugen by GitHub Copilot (model: GPT-5.6, autonomous).
|
@copilot In Fix: replace this helper with the validated modern-manifest fixture already in Add a regression assertion or new test in |
Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous) Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>
Implemented in 28214a2. Artifact parity tests now use a shared validated modern-manifest helper registered through |
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.
extensions/EXTENSION-API-REFERENCE.md:914
derive_named_idandderive_hook_idconstruct identifiers; they do not parse them. Directing consumers to use them for parsing is therefore unusable API guidance. Tell callers to construct IDs with these helpers and uselayer_kind_from_lookup_idonly for supported lookup-ID classification.
Identifiers are stable, but treat them as **opaque strings** in stored data (registries, cache files, external tooling). Parse them with the helpers in `specify_cli._identifier` (`derive_named_id`, `derive_hook_id`) rather than by string-splitting on `:` — the discriminator suffix and future grammar extensions may otherwise catch you out.
src/specify_cli/extensions/init.py:846
eventNameis synthesized from the enclosing hook mapping key and is also the value used innameandid, butsetdefaultpreserves an author-supplied conflicting field. Since hook validation currently permits extra fields,before_plan: {eventName: after_tasks, ...}produces an entry whoseeventNamecontradicts its ID. Overwrite it with the authoritative mapping key (or reject the field during validation).
normalized = dict(entry)
normalized.setdefault("eventName", event_name)
flattened.append((event_name, normalized))
src/specify_cli/artifacts/init.py:701
- Project-local core and override scanners are not manifest-validated, and POSIX permits filenames such as
foo:bar.md. Such a name reaches this loop and is emitted astemplate:foo:bar, violating the colon-delimited ID contract;get_artifact_info()then rejects the ID returned bylist_artifacts(). Filter reserved-delimiter names before creating rows (and add a regression using a project-local file).
for kind, name in names:
- Files reviewed: 17/17 changed files
- Comments generated: 1
- Review effort level: Balanced
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>
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:150
yaml.safe_loadcan produce non-JSON values in otherwise accepted hook fields (for example, an unquoted date becomesdatetime.date). When two hooks share an event/command and need discriminators,json.dumpsraises a rawTypeError, so manifest loading no longer returns the documentedValidationError. Either reject non-JSON hook values during validation or define a deterministic normalization for YAML-native scalars before hashing.
normalized = _normalize_for_canonical_json(value)
return json.dumps(
normalized,
sort_keys=True,
ensure_ascii=False,
extensions/EXTENSION-API-REFERENCE.md:918
- The new section replaced the existing
## File System Layoutheading, leaving the following.specify/tree incorrectly nested under “Opacity guidance.” Restore the heading before the tree.
```text
src/specify_cli/artifacts/init.py:684
- Project-local core filenames are added without applying the identifier-component guard. On POSIX,
.specify/templates/foo:bar.md(and equivalent command/script files) therefore emits an ID with an extra:, whileget_artifact_info()rejects that same row. Skip baseline rows whose names failvalidate_componentso every listed ID round-trips.
def _layers_for(kind: ArtifactKind, name: str) -> list[dict[str, Any]]:
key = (kind, name)
if key not in layers_cache:
layers_cache[key] = resolver.collect_all_layers(name, kind)
src/specify_cli/_assets.py:53
- When
core_packexists but lacks the requested family, this returnsNoneimmediately instead of trying the source-checkout path. That regresses the previous two-candidate behavior and contradicts this helper's fallback contract; an incomplete/stale bundle can make an entire core asset family disappear despite valid repo assets.
core = _locate_core_pack()
if core is not None:
candidate = core / subdir
return candidate if candidate.is_dir() else None
- Files reviewed: 17/17 changed files
- Comments generated: 4
- Review effort level: Balanced
|
@copilot In Fix: in While at it, replace the hand-rolled union at lines 844–863 with a call to Add two regression tests:
|
|
@copilot fix all 4 code review feedback points from your most recent review |
Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous) Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>
Implemented in |
There was a problem hiding this comment.
Review details
Suppressed comments (6)
Previously missed (6) — in code that hasn't changed since the last review.
src/specify_cli/artifacts/init.py:704
- A contribution-only artifact whose preset uses
append,prepend, orwrapwithout any lowerreplacelayer is added to the inventory here, even thoughPresetResolver.resolve_content()returnsNonewhen no replace base exists (presets/init.py:5858-5865). This makesartifact listadvertise an artifact that cannot be resolved andartifact infomark its first layer active. Skip names whose collected stack has no replace layer, and add an append-only regression case.
key = (kind, name)
names.add(key)
src/specify_cli/artifacts/init.py:904
- This re-derives the ID from the installation directory instead of using the manifest contribution's
id. For an unregistered extension whose directory isrenamedbut whose manifest ID isoriginal(already covered in this PR's test),ExtensionManifest.iter_contributions()emitsextension:original:...while the stack emitsextension:renamed:...; thereforelookupIdcannot serve as the documented join key. Enforce matching identities or make the contribution API, stack contract, tests, and documentation consistently use one source identity.
if lookup_id in lookup_ids(kind, name):
yield kind, name, description, lookup_id
src/specify_cli/extensions/init.py:846
- Because hook mappings accept extra fields, a caller can provide an
eventNamethat differs from the enclosing event key.setdefaultpreserves that value, so the returned contribution reports oneeventNamewhile itsnameandidare derived from another. Always synthesizeeventNamefrom the authoritative mapping key (or reject the reserved field during validation).
normalized = dict(entry)
normalized.setdefault("eventName", event_name)
flattened.append((event_name, normalized))
src/specify_cli/extensions/init.py:603
- Hook entries currently allow arbitrary YAML values outside
command/priority, butcanonical_json()cannot serialize values such as YAML dates, sets, or binary data. With two hooks sharing an event/command, such a previously accepted manifest now raises a rawTypeErrorhere instead ofValidationError, and the artifact CLI can traceback. Validate that discriminator fields are JSON-serializable or define deterministic normalization for all accepted YAML types and translate failures toValidationError.
key = canonical_json(stripped)
extensions/EXTENSION-API-REFERENCE.md:914
- These helpers derive new IDs; they do not parse an existing identifier. Directing consumers to parse with them is unusable guidance, especially since no full parser is exposed. Tell consumers to store/compare IDs opaquely and mention
layer_kind_from_lookup_idonly for the supported layer-prefix classification.
Identifiers are stable, but treat them as **opaque strings** in stored data (registries, cache files, external tooling). Do not parse them by string-splitting on `:` — the discriminator suffix and future grammar extensions may otherwise catch you out. If you only need to classify a stack entry's layer, use `layer_kind_from_lookup_id`; `derive_named_id` and `derive_hook_id` construct new identifiers rather than parsing existing ones.
extensions/EXTENSION-API-REFERENCE.md:863
- Replacing the former
## File System Layoutheading here leaves the existing.specify/tree at lines 918-944 orphaned under the new “Opacity guidance” subsection. Restore a## File System Layoutheading immediately before that code block (and retain/add its table-of-contents entry if desired).
## Contribution Identifiers
- Files reviewed: 17/17 changed files
- Comments generated: 0 new
- Review effort level: Balanced
|
@copilot Test-quality audit of this PR turned up several tests that don't prove what they claim. Please address: Blocker — false-positive parity test
Waste — delete these, they cost CI time and prove nothing extra
Nit — strengthen
|
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.
docs/reference/presets.md:223
- This says every resolver
lookupIdpoints to the originating manifest contribution ID, but the resolver deliberately uses the registry key/directory name whilePresetManifest.iter_contributions()uses the manifest-declared ID. The mismatch cases covered intests/test_artifact_command_parity.py:129-170therefore do not round-trip. Document thatlookupIdidentifies the resolver layer and only equals the manifest ID when those source identities match.
`PresetResolver.collect_all_layers()` returns layer dicts that each include a `lookupId` field pointing back to the originating contribution's `id`. 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.
src/specify_cli/artifacts/init.py:763
get_artifact_info()rebuilds the complete disk-backed inventory up to three times: once here, again at line 757, and again through_describe()at line 763. Sincelist_artifacts()parses installed manifests and resolves every artifact stack,artifact infoscales at roughly triple the necessary work. Build the inventory once and reuse its rows for kind matching and the description.
if resolved_kind is None:
matches = self._find_matches(bare)
src/specify_cli/extensions/init.py:851
- This collision set causes
iter_contributions()to emit distinct IDs for every repeated(event, command)entry, but hook registration deduplicates those entries by command with last-write-wins semantics at lines 5195-5217. Consequently, IDs are exposed for earlier hooks that will never be installed or executed. Apply the same last-write-wins collapse before deriving IDs, or change registration so every represented hook remains effective.
siblings_for_id = [
{"eventName": event, "command": entry.get("command", "")}
for event, entry in flattened
]
extensions/EXTENSION-API-REFERENCE.md:865
- The claim that
lookupIdpoints back to the originating contribution conflicts with the documented behavior at lines 906 and 910: resolver IDs use the registry key/directory name and can differ from manifest contribution IDs. Clarify this qualification here so consumers do not treatlookupIdas a guaranteed key intoiter_contributions().
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 points back to the contribution the layer came from. Identifiers are **computed on demand from author-declared manifest content** and are **never persisted** to `.specify/` or to any cache file.
- Files reviewed: 17/17 changed files
- Comments generated: 1
- Review effort level: Balanced
Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous) Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>
Done in
Posted on behalf of @nicolehaugen by GitHub Copilot (model: GPT-5.6, autonomous). |
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.
extensions/EXTENSION-API-REFERENCE.md:916
- Replacing the former
## File System Layoutheading here leaves the existing.specify/tree below line 918 without a heading, so it is now rendered as part of the contribution-identifier section (immediately after “Opacity guidance”). Restore the file-system-layout heading before that tree.
Identifiers are stable, but treat them as **opaque strings** in stored data (registries, cache files, external tooling). Do not parse them by string-splitting on `:` — the discriminator suffix and future grammar extensions may otherwise catch you out. If you only need to classify a stack entry's layer, use `layer_kind_from_lookup_id`; `derive_named_id` and `derive_hook_id` construct new identifiers rather than parsing existing ones.
src/specify_cli/_assets.py:53
- If
core_packexists but does not contain this particular asset family, this returnsNoneimmediately instead of falling back to the source-checkout directory promised by this helper. That differs from the previous candidate-directory loop and from the other_assetsresolvers, so a partial wheel bundle (or a test/dev checkout containing only some bundled families) makes core assets disappear even though the repo copy exists. Only return from this branch when the candidate directory exists, then continue to the repo fallback otherwise.
core = _locate_core_pack()
if core is not None:
candidate = core / subdir
return candidate if candidate.is_dir() else None
src/specify_cli/extensions/init.py:596
- This deliberately permits multiple entries with the same
(event, command)when their metadata differs, but the runtime does not preserve them:HookExecutor.register_hookskeys each event's entries bycommandand keeps only the last one (src/specify_cli/extensions/__init__.py:5195-5206, asserted bytests/test_extensions.py:10185-10207). Consequentlyiter_contributions()advertises multiple stable hook IDs while only one hook can actually be installed or executed. Either reject every duplicate command per event to retain current semantics, or update hook registration/storage/execution to preserve the newly addressable entries.
for command_value, group in by_command.items():
if len(group) < 2:
continue
seen_canonical: Dict[bytes, int] = {}
- Files reviewed: 17/17 changed files
- Comments generated: 0 new
- Review effort level: Balanced
Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>
Done in |
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:763
get_artifact_info()rebuilds the complete inventory up to three times: once here, again for the kind check, and again in_describe(). Since each inventory build reparses pack manifests and resolves every artifact stack,artifact infoscales at roughly triple the already-expensive full-catalog scan; keep one inventory snapshot and use it for matching and the description.
if resolved_kind is None:
matches = self._find_matches(bare)
src/specify_cli/init.py:567
- This registers a new user-facing top-level command group, but the CLI reference has no artifact section or usage/output documentation. Every existing group is linked from
docs/reference/overview.md:5-39; please add the corresponding artifact reference (including the required--jsonbehavior, schemas, errors, and stack fields) and link it from the overview so this command is discoverable outside--help.
from .artifacts._commands import register as _register_artifact_cmds # noqa: E402
_register_artifact_cmds(app)
src/specify_cli/_assets.py:53
- When
core_packexists but does not contain the requested asset directory, this returnsNoneinstead of trying the source-checkout path promised by this resolver. A partial/stalecore_packtherefore disables command discovery and bundled-core fallback even when the repo assets are available; only return early when the wheel candidate is actually a directory.
if core is not None:
candidate = core / subdir
return candidate if candidate.is_dir() else None
- Files reviewed: 17/17 changed files
- Comments generated: 1
- Review effort level: Balanced
|
|
||
| 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 pointing back to the originating contribution's `id`. 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. |
Description
Testing
uv run specify --helpuv sync && uv run pytestAI Disclosure