Skip to content

feat: add specify artifact command exposing composition stacks as JSON - #4267

Closed
nicolehaugen wants to merge 49 commits into
nicolehaugen-contribution-idsfrom
nicolehaugen-shiny-garbanzo
Closed

feat: add specify artifact command exposing composition stacks as JSON#4267
nicolehaugen wants to merge 49 commits into
nicolehaugen-contribution-idsfrom
nicolehaugen-shiny-garbanzo

Conversation

@nicolehaugen

Copy link
Copy Markdown

Description

Testing

  • Tested locally with uv run specify --help
  • Ran existing tests with uv sync && uv run pytest
  • Tested with a sample project (if applicable)

AI Disclosure

  • I did not use AI assistance for this contribution
  • I did use AI assistance (describe below)

nicolehaugen and others added 2 commits August 21, 2026 13:58
…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
Comment thread tests/test_artifact_command_parity.py Fixed
Comment thread tests/test_artifact_command.py Fixed
Comment thread tests/test_artifact_command.py Fixed
…rt' and 'import from''

Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
Copilot AI balanced review requested due to automatic review settings August 24, 2026 15:14
…rt' and 'import from''

Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds JSON-based artifact inventory and composition-stack introspection to the Specify CLI.

Changes:

  • Adds artifact list and artifact info commands.
  • 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 list can advertise artifacts that artifact info immediately 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

Comment thread src/specify_cli/artifacts/__init__.py Outdated
Comment thread src/specify_cli/artifacts/__init__.py
Comment thread src/specify_cli/artifacts/__init__.py Outdated
Comment thread src/specify_cli/artifacts/_commands.py Outdated
Comment thread tests/test_artifact_command.py Outdated
Comment thread src/specify_cli/artifacts/__init__.py Outdated
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 single provides.templates[] list whose entries carry type and file. PresetManifest therefore 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/metadata at the root and sectioned provides entries). Since PresetManifest rejects it, the parity test reaches the preset file only through convention fallback and never verifies manifest/resolver parity. Generate the canonical schema_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, and setup_plan.py). More importantly, get_artifact_info() passes that filename to PresetResolver.collect_all_layers(), which appends .sh and looks outside the runtime subdirectory, so these advertised list entries all resolve as unknown 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 raises typer.Exit for an invalid SPECIFY_INIT_DIR, so this call bypasses the artifact JSON error handler. In --json mode stderr is then plain Rich text rather than the promised {"error": ...} envelope. Add a quiet/project-resolution API that raises an ArtifactError, 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

  • StackLayer is unused, and the repository's Python lint job runs Ruff over tests, 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/scripts shape: all preset contributions live in provides.templates[], with type identifying 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 a project: lookup ID, but this catch-all converts every non-core/non-extension layer into a preset. A real .specify/templates/overrides/<name>.md therefore appears as layer: "preset", presetId: "_", which is false metadata. Handle the project: 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

Comment thread src/specify_cli/artifacts/__init__.py Outdated
Copilot AI review requested due to automatic review settings August 24, 2026 15:22

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 mixed provides.templates list with a type per entry. PresetManifest therefore 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 provides shape instead of a valid preset (preset/requires plus typed entries under provides.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, while artifact info returns unknown artifact for the same ID. Build the list from the same enabled registry/resolver sources used by collect_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's type (see presets/lean/preset.yml:15-18). Consequently valid preset contributions are omitted or classified as templates. Parse presets via PresetManifest.iter_contributions() and extensions via ExtensionManifest.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 .py rows (including underscore-vs-hyphen variants), whereas script contribution IDs use logical names such as setup-plan (tests/test_contribution_ids.py:124-127). These listed names cannot be resolved: collect_all_layers() appends .sh, so script:setup-plan.sh searches for setup-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 explicit project layer 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-level name (or metadata.name). As written, every normal preset stack reports the pack ID as presetName instead 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 raises typer.Exit when SPECIFY_INIT_DIR is invalid (src/specify_cli/_project.py:43-52). That bypasses the JSON error handler, so a --json invocation can emit non-JSON stderr despite this module's strict envelope contract. Add a non-emitting resolution path that converts these failures to NotASpecKitProjectError before 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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 to PresetResolver.collect_all_layers(..., "script"), which appends .sh and does not search the runtime subdirectory, so script rows returned by artifact list cannot be retrieved by artifact 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 (see presets/lean/preset.yml:3-6), but this reads metadata.name or a top-level name. Consequently real preset stack rows report the preset ID as presetName instead 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

  • PresetResolver emits project overrides with a project:_... 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 PresetResolver only 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 list can both advertise artifacts that artifact info rejects and omit artifacts that artifact info --kind resolves. 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 raises typer.Exit for an invalid SPECIFY_INIT_DIR. Because that exception bypasses the ArtifactError handlers, 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

Comment thread src/specify_cli/artifacts/_commands.py
Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous)

Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>
Copilot AI review requested due to automatic review settings August 24, 2026 15:40
Copilot AI and others added 2 commits August 24, 2026 20:32
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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 emits template:foo:bar, although : is reserved and get_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_id and derive_hook_id construct 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 mention layer_kind_from_lookup_id only 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, while collect_all_layers() builds lookupId from the directory name; this gate then drops a manifest-declared artifact even though the resolver can load its file (especially when file is 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 name but 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_pack makes the helper return None immediately, 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.md and speckit.foo.md present, it reads foo.md, while PresetResolver.collect_all_layers("speckit.foo", "command") selects speckit.foo.md first (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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.md produces template:foo:bar, violating the documented ID grammar, and the emitted row cannot round-trip through get_artifact_info() because _validate_artifact_name rejects 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_id and derive_hook_id construct 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 mention layer_kind_from_lookup_id only 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 info lookup rebuilds the complete catalog up to three times: once here, again in the post-validation _find_matches call, 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. Compute list_artifacts() once in get_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.md and speckit.foo.md exist, this records foo.md's description for speckit.foo, while PresetResolver.collect_all_layers("speckit.foo", "command") selects the exact speckit.foo.md first (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 as overrides/foo:bar.md (or overrides/scripts/foo:bar.sh) is therefore listed with a malformed ID that artifact info immediately 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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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() calls list_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 matching Artifact for 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

  • eventName is a derived value determined by the enclosing hook mapping key, but setdefault preserves an author-supplied field with that name. A hook under before_plan containing eventName: after_plan therefore emits eventName=after_plan while its name and id use before_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 Layout heading 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_id and derive_hook_id construct 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 mention layer_kind_from_lookup_id only 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.md gets a malformed template:foo:bar list row that artifact info subsequently rejects. Apply validate_component to 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.sh is 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

Comment on lines +681 to +684
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)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot fix

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

@nicolehaugen

Copy link
Copy Markdown
Author

@copilot In tests/test_artifact_command_parity.py, the _install_preset helper at lines 21–38 writes a legacy flat manifest ({id, version, metadata, provides}) that fails PresetManifest.validate(). The pack loads as ⚠️ Corrupted preset with manifest=None, so _iter_pack_contributions falls into the convention (on-disk scan) branch and never exercises Manifest.iter_contributions(). TestResolverParity.test_active_layer_matches_resolver still passes only because both the artifact code and PresetResolver.resolve_content converge on the same .md file via disk fallback — the manifest-declared parity contract the test claims to guard is not actually exercised.

Fix: replace this helper with the validated modern-manifest fixture already in tests/test_artifact_command.py (schema_version + nested preset:/requires: + normalized provides.templates). Ideally extract it to tests/conftest.py so both files share one implementation. Also switch the .registry write to PresetRegistry(...).add(pack_id, {...}) instead of hand-rolling JSON (currently writes schema_version: "1.0.0" while the real constant is "1.0").

Add a regression assertion or new test in TestResolverParity that fails when manifest.iter_contributions() is not the source of the emitted contribution — e.g. install a pack whose preset.yml declares one command name but whose on-disk commands/ directory contains a different filename; the resolver should pick the manifest-declared one and so should the artifact code.

Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous)

Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>

Copilot AI commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

@copilot In tests/test_artifact_command_parity.py, the _install_preset helper at lines 21–38 writes a legacy flat manifest ({id, version, metadata, provides}) tha...

Implemented in 28214a2. Artifact parity tests now use a shared validated modern-manifest helper registered through PresetRegistry.add(), and a regression uses a manifest-declared command with a differently named file to confirm the catalog follows iter_contributions(). Posted on behalf of @nicolehaugen by GitHub Copilot (model: GPT-5.6, autonomous).

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_id and derive_hook_id construct 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 use layer_kind_from_lookup_id only 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

  • eventName is synthesized from the enclosing hook mapping key and is also the value used in name and id, but setdefault preserves an author-supplied conflicting field. Since hook validation currently permits extra fields, before_plan: {eventName: after_tasks, ...} produces an entry whose eventName contradicts 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 as template:foo:bar, violating the colon-delimited ID contract; get_artifact_info() then rejects the ID returned by list_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

Comment thread src/specify_cli/artifacts/__init__.py Outdated
Copilot AI and others added 3 commits August 24, 2026 21:16
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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_load can produce non-JSON values in otherwise accepted hook fields (for example, an unquoted date becomes datetime.date). When two hooks share an event/command and need discriminators, json.dumps raises a raw TypeError, so manifest loading no longer returns the documented ValidationError. 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 Layout heading, 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 :, while get_artifact_info() rejects that same row. Skip baseline rows whose names fail validate_component so 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_pack exists but lacks the requested family, this returns None immediately 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

Comment thread src/specify_cli/artifacts/__init__.py
Comment thread src/specify_cli/artifacts/__init__.py
Comment thread extensions/EXTENSION-API-REFERENCE.md Outdated
Comment thread extensions/EXTENSION-API-REFERENCE.md Outdated
@nicolehaugen

Copy link
Copy Markdown
Author

@copilot In src/specify_cli/artifacts/__init__.py::_iter_pack_contributions (around line 883–895), the manifest-produced contribution["id"] is compared for equality against PresetResolver-derived lookupIds — but the two sides derive the sourceId segment differently. The resolver uses the directory name / registry key (via derive_named_id(layer, pack_id, kind, name) at presets/__init__.py:5644 for presets and 5675 for extensions). PresetManifest.iter_contributions() and ExtensionManifest.iter_contributions() use the manifest-declared id (presets/__init__.py:557, extensions equivalent). When these differ — an unregistered extension in a renamed directory, or any preset whose preset.yml id field was edited to something other than the directory name — the equality gate at line 894 silently drops the contribution while preset resolve continues to return content for it.

Fix: in _iter_pack_contributions, discard contribution["id"] and rebuild the lookupId with derive_named_id(layer, pack_id, kind, name) — where layer is "preset" or "extension" and pack_id is the enumeration-time key (directory name / registry key already passed in via the outer loop). This uses the same helper the resolver uses, so both sides derive the sourceId from one source of truth. One change handles both preset and extension cases.

While at it, replace the hand-rolled union at lines 844–863 with a call to PresetResolver._get_all_extensions_by_priority() (presets/__init__.py:5167) so the enumeration also stops reinventing what the resolver already provides — that helper handles registered + unregistered + corrupt-registry fail-closed + safe-id filtering. Promote it to a public name (e.g. iter_extensions_by_priority) if you'd rather not call an underscore method. Do the analogous consolidation for presets against _get_all_presets_by_priority if it exists.

Add two regression tests:

  1. An unregistered extension in .specify/extensions/renamed/ whose extension.yml declares extension.id: original, containing a manifest-declared command file. Assert artifact list includes it with lookupId extension:renamed:command:... (matching collect_all_layers).
  2. A registered preset whose preset.yml preset.id field has been hand-edited to disagree with the directory name. Assert the manifest-declared command still appears in artifact list with lookupId preset:<dir-name>:command:....

@nicolehaugen

Copy link
Copy Markdown
Author

@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>

Copilot AI commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

@copilot In src/specify_cli/artifacts/__init__.py::_iter_pack_contributions (around line 883–895), the manifest-produced contribution["id"] is compared for equality...

Implemented in 27ef852: artifact preset enumeration now uses the resolver's priority/key API, docs distinguish manifest ids from resolver lookupIds, and regression coverage pins both renamed extension and renamed preset source identity. Posted on behalf of @nicolehaugen by GitHub Copilot (model: GPT-5.6, autonomous).

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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, or wrap without any lower replace layer is added to the inventory here, even though PresetResolver.resolve_content() returns None when no replace base exists (presets/init.py:5858-5865). This makes artifact list advertise an artifact that cannot be resolved and artifact info mark 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 is renamed but whose manifest ID is original (already covered in this PR's test), ExtensionManifest.iter_contributions() emits extension:original:... while the stack emits extension:renamed:...; therefore lookupId cannot 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 eventName that differs from the enclosing event key. setdefault preserves that value, so the returned contribution reports one eventName while its name and id are derived from another. Always synthesize eventName from 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, but canonical_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 raw TypeError here instead of ValidationError, 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 to ValidationError.
                    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_id only 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 Layout heading here leaves the existing .specify/ tree at lines 918-944 orphaned under the new “Opacity guidance” subsection. Restore a ## File System Layout heading 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

@nicolehaugen

Copy link
Copy Markdown
Author

@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

tests/test_artifact_command_parity.py::TestResolverParity::test_active_layer_matches_resolver. The fixture writes the file at the convention path commands/speckit.constitution.md. That means both _iter_pack_contributions' manifest branch and its convention-fallback branch produce the same lookupId (preset:test-parity:command:speckit.constitution), and _classify_layer returns "preset" either way. resolve_content reads the on-disk file directly. So the test passes even if PresetManifest.iter_contributions() is broken or never called — which is exactly the false-positive shape we already hit once in this PR. Fix by mirroring the sibling test_manifest_declared_artifact_matches_resolver fixture: put the file at a non-convention path (e.g. commands/differently-named.md) and declare its logical name only in the manifest. Then only the manifest branch can produce the row, and the assertion actually distinguishes the intended path. Or delete it, since the sibling test already covers the contract cleanly.

Waste — delete these, they cost CI time and prove nothing extra

  • tests/test_artifact_command_parity.py::TestManifestPathIsPosix::test_no_backslashes and test_never_absolute. test_artifact_command.py::TestManifestPathPortability::test_preset_manifest_path_is_repo_relative already asserts the exact string ".specify/presets/my-pack/preset.yml", which strictly dominates "no backslash / not absolute / no drive letter."
  • tests/test_artifact_command_parity.py::TestJSONShape::test_no_trailing_whitespace and test_terminated_by_single_newline. Both build json.dumps(rows, indent=2) + "\n" inside the test body and then assert properties of that literal string — never invoke the CLI. Tautological. The real CLI envelope is already covered by test_artifact_command.py::TestCLI::test_list_json_emits_array.
  • tests/test_assets.py::TestLocateCoreAssetDir::test_returns_none_for_unknown_subdir_with_wheel_bundle. _locate_core_asset_dir short-circuits on the subdir allow-list guard before _locate_core_pack() is ever called, so the mocked wheel bundle is never consulted. Identical path to the "without wheel bundle" version right above it.
  • tests/test_extensions.py::TestExtensionManifest::test_load_core_command_names_prefers_wheel_core_pack. Monkey-patches _locate_core_asset_dir wholesale, so the wheel-vs-repo precedence logic (which lives inside that function) is never executed. The assets-layer test test_prefers_wheel_core_pack_over_repo_checkout already covers the real contract.

Nit — strengthen

tests/test_contribution_ids.py::TestLookupIdRoundTrip::test_core_layer_carries_core_lookup_id. Hardcodes "core:_:template:spec-template" instead of comparing to derive_named_id("core", "_", "template", "spec-template"). Add the round-trip equality so a producer/consumer drift can't slip through in lockstep with a hand-updated string.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 lookupId points to the originating manifest contribution ID, but the resolver deliberately uses the registry key/directory name while PresetManifest.iter_contributions() uses the manifest-declared ID. The mismatch cases covered in tests/test_artifact_command_parity.py:129-170 therefore do not round-trip. Document that lookupId identifies 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. Since list_artifacts() parses installed manifests and resolves every artifact stack, artifact info scales 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 lookupId points 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 treat lookupId as a guaranteed key into iter_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

Comment thread src/specify_cli/extensions/__init__.py Outdated
Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous)

Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>

Copilot AI commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

@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...

Done in 228d686:

  • Blocker: deleted test_active_layer_matches_resolver and moved its unique active["layer"] == "preset" assertion onto the sibling test_manifest_declared_artifact_matches_resolver, whose fixture already uses the non-convention path commands/differently-named.md, so only the manifest branch can produce the row.
  • Deleted TestManifestPathIsPosix and TestJSONShape in tests/test_artifact_command_parity.py (dominated by test_preset_manifest_path_is_repo_relative and test_list_json_emits_array), plus the now-unused json import and the stale module docstring reference to OS path invariants.
  • Deleted tests/test_assets.py::TestLocateCoreAssetDir::test_returns_none_for_unknown_subdir_with_wheel_bundle and tests/test_extensions.py::TestExtensionManifest::test_load_core_command_names_prefers_wheel_core_pack.
  • test_core_layer_carries_core_lookup_id now compares against derive_named_id("core", "_", "template", "spec-template") instead of the hardcoded string.

pytest tests/test_artifact_command_parity.py tests/test_artifact_command.py tests/test_assets.py tests/test_contribution_ids.py tests/test_extensions.py passes and ruff@0.15.0 check src tests is clean.

Posted on behalf of @nicolehaugen by GitHub Copilot (model: GPT-5.6, autonomous).

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 Layout heading 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_pack exists but does not contain this particular asset family, this returns None immediately 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 _assets resolvers, 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_hooks keys each event's entries by command and keeps only the last one (src/specify_cli/extensions/__init__.py:5195-5206, asserted by tests/test_extensions.py:10185-10207). Consequently iter_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>

Copilot AI commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

@copilot fix all 4 code review feedback points from your most recent review

Done in 6d9f7b2 for the remaining open review item: duplicate-hook manifest rejection was removed and contract-aligned tests/docs were updated. The other four requested review points were already covered in prior commits on this branch. Posted on behalf of @nicolehaugen by GitHub Copilot (model: GPT-5.6, autonomous).

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 info scales 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 --json behavior, 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_pack exists but does not contain the requested asset directory, this returns None instead of trying the source-checkout path promised by this resolver. A partial/stale core_pack therefore 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

Comment thread docs/reference/presets.md

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.
@nicolehaugen

Copy link
Copy Markdown
Author

Superseded by #4305, which consolidates this work with #4261 into a single PR against main. The identifier grammar in #4261 and the specify artifact command here evolved together through review — splitting them cleanly would leave both PRs with incomplete stories, so ship as one.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants