Skip to content

feat(extensions): accept provides.templates and provides.scripts in manifest - #4012

Merged
mnriem merged 4 commits into
github:mainfrom
chelsealong:feat/4010-extension-manifest-templates-scripts
Aug 7, 2026
Merged

feat(extensions): accept provides.templates and provides.scripts in manifest#4012
mnriem merged 4 commits into
github:mainfrom
chelsealong:feat/4010-extension-manifest-templates-scripts

Conversation

@chelsealong

@chelsealong chelsealong commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Summary

Extensions could only formally declare commands under provides (plus config/hooks/events) — see ExtensionManifest (src/specify_cli/extensions/__init__.py). Templates and scripts an extension shipped were picked up purely by filename convention during preset/template resolution, with no id, name, or description, and forced to replace with no way to make that intent explicit.

This PR implements #4010 end to end — manifest schema, validation, and resolver wiring:

  • provides.templates and provides.scripts are now optional, validated sections of extension.yml, mirroring the shape PresetManifest already uses for its own templates (name/file/description), minus an authorable strategy — extension-provided artifacts always resolve as replace, and a manifest that includes a strategy key on one of these entries now raises a clear ValidationError instead of silently accepting (and ignoring) it.
  • provides.scripts[].runtimes accepts an optional list restricted to bash/powershell/python — declared metadata, not inferred from the file extension.
  • ExtensionManifest gains templates and scripts properties (parity with the existing commands/config properties) so tooling (e.g. a setup wizard, extension info) can enumerate an extension's declared artifacts directly from the manifest.
  • The "must provide at least one command, hook, or event" rule is relaxed so an extension may satisfy it with only a declared template or script — otherwise the new sections would be useless for an extension whose only contribution is a template/script.
  • PresetResolver.collect_all_layers, resolve(), and resolve_with_source() (src/specify_cli/presets/__init__.py) now consult ExtensionManifest.templates/.scripts for template_type in {"template", "script"} the same way they already did for .commands, via the shared _extension_manifest_declared_template() helper. The manifest is authoritative and is checked before convention-based lookup: a declared entry whose file doesn't sit at the conventional path (templates/<name>.md / scripts/<name>.sh directly under the extension root) still resolves, a declared entry wins over a stale conventional file at the same name, and a declared-but-missing file does not fall back to convention. Undeclared on-disk files keep resolving via convention exactly as before (no regression).
  • provides.templates/provides.scripts entries must have unique names within their section — a duplicate is rejected with a ValidationError, since the resolver returns the first entry matching a name and a later duplicate would otherwise be silently unreachable.
  • All new fields are additive under schema_version: "1.0"; no version bump needed.
  • Docs: extensions/EXTENSION-API-REFERENCE.md manifest schema section and Python API property list updated. The doc's "Always resolve as 'replace'" claim now matches actual resolver behavior for templates/scripts, not just commands. extensions/EXTENSION-DEVELOPMENT-GUIDE.md's provides section now distinguishes its own sub-fields (commands/templates/scripts) from hooks/events, which are top-level manifest fields, not nested under provides.

Testing

New test class TestExtensionManifestTemplatesAndScripts in tests/test_extensions.py covers: valid declarations, templates/scripts-only extensions (no commands/hooks/events), section-type validation, per-entry shape/path-safety/name-format validation, rejected strategy key, rejected duplicate names within a section, runtimes validation (type + allowed values), and description type-checking.

Three new tests in tests/test_presets.py::TestWrapStrategy cover resolver precedence per the issue's acceptance criteria: test_extension_template_resolves_via_manifest_when_filename_differs and test_extension_script_resolves_via_manifest_when_filename_differs assert a manifest-declared template/script resolves when its file lives away from the naming convention; test_extension_template_convention_lookup_unaffected_when_undeclared asserts an undeclared on-disk template still resolves via the pre-existing convention (no regression).

Confirmed all new tests fail against the pre-fix source (git checkout HEAD~1 -- src/specify_cli/extensions/__init__.py src/specify_cli/presets/__init__.py) — 19/20 manifest tests with AttributeError/DID NOT RAISE, and the two new resolver-precedence tests with an empty layer list — then pass again after restoring the fix. The duplicate-name rejection added after review (git checkout HEAD -- src/specify_cli/extensions/__init__.py against the pre-fix version of that file) was confirmed to fail with DID NOT RAISE ValidationError on both parametrized cases before the fix, and pass after.

$ .venv/bin/python -m pytest tests/test_extensions.py -k "TestExtensionManifestTemplatesAndScripts" -q
====================== 22 passed, 499 deselected in 0.35s ======================

$ .venv/bin/python -m pytest tests/test_extensions.py tests/test_presets.py -q
====================== 1109 passed, 2 warnings in 9.49s ======================

$ .venv/bin/python -m pytest tests -q
=========== 6623 passed, 9 skipped, 45 warnings in 370.28s (0:06:10) ===========

$ uvx ruff@0.15.0 check src tests
All checks passed!

AI Disclosure

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

Implemented autonomously by Claude Code (model: Claude Sonnet 5) under human direction: read the issue and related open PRs first to confirm no overlap, implemented the manifest schema/validation/properties and resolver wiring, wrote failing-first regression tests, and verified the full test suite and ruff locally before pushing.

…anifest

Extensions could only formally declare commands under `provides`
(plus config/hooks/events); templates and scripts shipped by an
extension were picked up purely by filename convention, with no id,
description, or metadata. Add optional `provides.templates` and
`provides.scripts` sections to the extension manifest schema, mirroring
the preset template shape minus an authorable `strategy` (extension
artifacts always resolve as replace, so a present `strategy` key is
now a validation error rather than a silently accepted no-op).

ExtensionManifest gains `templates`/`scripts` properties so tooling can
enumerate an extension's declared artifacts directly from the
manifest. An extension may now satisfy the "must provide something"
rule with only a template or script, not just a command/hook/event.

Addresses the manifest-schema portion of github#4010; resolver
authoritative-vs-convention precedence for these new sections is left
for a follow-up.
collect_all_layers only consulted ExtensionManifest for command
resolution, leaving provides.templates/.scripts purely decorative --
a declared entry whose file didn't sit at the conventional path was
validated but never resolved. Extend the existing manifest-fallback
branch to cover template_type "template" and "script" the same way
it already does "command": convention lookup first, manifest lookup
as fallback so undeclared on-disk files keep resolving unchanged.
@chelsealong
chelsealong requested a review from mnriem as a code owner August 7, 2026 16:10
@mnriem
mnriem requested a balanced review from Copilot August 7, 2026 16:47
@mnriem mnriem self-assigned this Aug 7, 2026

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 declarative extension template/script metadata and resolver support.

Changes:

  • Validates provides.templates and provides.scripts.
  • Exposes artifacts through ExtensionManifest.
  • Adds resolver tests and API documentation.
Show a summary per file
File Description
src/specify_cli/extensions/__init__.py Adds schema validation and properties.
src/specify_cli/presets/__init__.py Resolves declared extension artifacts.
tests/test_extensions.py Tests manifest validation.
tests/test_presets.py Tests manifest-based resolution.
extensions/EXTENSION-API-REFERENCE.md Documents the new schema.

Review details

Tip

Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

  • Files reviewed: 5/5 changed files
  • Comments generated: 3
  • Review effort level: Balanced

Comment thread src/specify_cli/presets/__init__.py Outdated
Comment thread src/specify_cli/presets/__init__.py Outdated
Comment thread extensions/EXTENSION-API-REFERENCE.md

@mnriem mnriem left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Please address Copilot feedback

…ntion

Copilot review on github#4012 found the manifest-declared template/script lookup
was gated on convention lookup missing first, so a stale conventional file
could shadow a declared entry at a non-conventional path, and resolve()
never consulted the manifest at all (only collect_all_layers() did). Add a
shared _extension_manifest_declared_template() helper and check it before
convention-based lookup in both resolve() and collect_all_layers(), mirroring
the preset manifest precedence. Also update EXTENSION-DEVELOPMENT-GUIDE.md,
which still claimed provides only supports commands and required a command
or hook.
@chelsealong

Copy link
Copy Markdown
Contributor Author

Addressed the Copilot feedback in 0070d25:

  • Manifest lookup is now checked before convention-based lookup for extension templates/scripts (both in resolve() and collect_all_layers(), via a new shared _extension_manifest_declared_template() helper), so a declared entry at a non-conventional path wins over a stale conventional file, and a declared-but-missing file is no longer silently masked by convention.
  • resolve()/resolve_with_source() are now manifest-aware for templates/scripts, matching collect_all_layers()/resolve_content().
  • Added regression tests covering: manifest wins when both a stale conventional file and a declared file exist, a declared-but-missing file doesn't fall back, and resolve()/resolve_with_source() parity for scripts.
  • Updated extensions/EXTENSION-DEVELOPMENT-GUIDE.md's provides section, which still said only commands were supported and that a command or hook was required.

Full suite (6621 passed, 9 skipped) and ruff check pass.

@mnriem
mnriem requested a balanced review from Copilot August 7, 2026 17:17

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 (1)

src/specify_cli/presets/init.py:5176

  • The implementation now makes manifest declarations authoritative, but the PR description still says convention lookup runs first and the manifest is consulted only on a miss. That describes the pre-fix behavior and contradicts both these lines and the new precedence tests; please update the PR description so reviewers and release-note consumers see the actual manifest-first contract.
            # The extension manifest is authoritative, same as preset manifests
            # above: check it before convention-based lookup so a declared entry
            # at a non-conventional path wins over a stale conventional file.
            entry, manifest_candidate = self._extension_manifest_declared_template(
                ext_dir, template_name, template_type
  • Files reviewed: 6/6 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

@mnriem

mnriem commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Please address test & lint errors

… path

_extension_manifest_declared_template() resolved ext_dir/rel_path before
returning it, which follows symlinks in ext_dir's ancestors (e.g. macOS's
symlinked tmp dir) and diverges from the unresolved paths convention-based
lookup returns for the same directory. Resolve only for the traversal
containment check; return the unresolved candidate.

Fixes the 4 CI test failures across all OS/Python matrix jobs on github#4012.
@chelsealong

Copy link
Copy Markdown
Contributor Author

Fixed in 467c0f7: `_extension_manifest_declared_template()` was calling `.resolve()` on the returned candidate path, which follows symlinks in `ext_dir`'s ancestors (e.g. the symlinked tmp dir on macOS CI runners) and diverges from the unresolved paths convention-based lookup returns for the same directory — that's what the 4 macOS/ubuntu/windows pytest failures were. Now resolution is only used for the path-traversal containment check; the returned path stays unresolved, matching convention lookup. Reproduced the exact failures locally by pointing TMPDIR at a symlink, confirmed they're gone after the fix, then ran the full suite (6621 passed, 9 skipped) and ruff check src tests (all checks passed) with the fix in place.

@mnriem
mnriem requested a balanced review from Copilot August 7, 2026 17:44

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)

extensions/EXTENSION-API-REFERENCE.md:141

  • Issue #4010 explicitly includes updating AGENTS.md with the new sections and replace-only rule in its acceptance criteria, but this PR only updates the extension guides. Add the corresponding contributor guidance to complete the documented scope.
#### `provides.templates[].strategy` / `provides.scripts[].strategy`

- Not an authorable field. Extension-contributed templates and scripts are
  always resolved as `replace`; a manifest that includes a `strategy` key on
  one of these entries is rejected with a `ValidationError`. Composable
  strategies (`wrap`/`prepend`/`append`) are preset-only.

extensions/EXTENSION-DEVELOPMENT-GUIDE.md:180

  • hooks and events are top-level manifest fields, not provides sub-fields (the validator reads them from self.data). This wording can lead authors to indent them under provides, where they will not satisfy validation. Distinguish the three provides fields from the top-level alternatives.
**Optional sub-fields** (at least one of `commands`, `templates`, `scripts`, `hooks`, or `events` is required):

src/specify_cli/presets/init.py:5184

  • The PR summary says convention lookup runs first and the manifest is consulted only on a miss, but this code intentionally makes the manifest authoritative before convention lookup. That matches issue #4010 and the new tests, so the implementation appears correct; update the PR description to state the actual manifest-first precedence.
            # The extension manifest is authoritative, same as preset manifests
            # above: check it before convention-based lookup so a declared entry
            # at a non-conventional path wins over a stale conventional file.
            entry, manifest_candidate = self._extension_manifest_declared_template(
                ext_dir, template_name, template_type
            )
            if manifest_candidate is not None:
                return manifest_candidate
            if entry is not None:

src/specify_cli/extensions/init.py:583

  • Duplicate names within provides.templates or provides.scripts are currently accepted. The resolver returns the first matching entry (presets/__init__.py:5018-5037), making later declarations unreachable while enumeration APIs still expose them. Reject duplicate names within each section and add regression coverage.
        for entry in entries:
            if not isinstance(entry, dict):
  • Files reviewed: 6/6 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

@mnriem
mnriem self-requested a review August 7, 2026 17:55
@mnriem
mnriem merged commit 684b3d8 into github:main Aug 7, 2026
14 checks passed
@mnriem

mnriem commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Closes #4010

@chelsealong

chelsealong commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

Edit: this PR merged (684b3d8) before 1ac3c3c was pushed, so that commit did not make it into main. Opened #4016 to carry it forward instead.


Addressed the remaining Copilot feedback in 1ac3c3c:

  • provides.templates/provides.scripts entries with a duplicate name within the same section are now rejected with a ValidationError (the resolver returns the first entry matching a name, so a later duplicate was silently unreachable). Added regression coverage for both sections.
  • EXTENSION-DEVELOPMENT-GUIDE.md's provides section now distinguishes its own sub-fields (commands/templates/scripts) from hooks/events, which are top-level manifest fields, not nested under provides.
  • Updated the PR description: the resolver bullet now correctly says the manifest is checked before convention-based lookup (it previously described the pre-0070d25 behavior).

Full suite (6623 passed, 9 skipped) and ruff check pass.

Re: the AGENTS.md acceptance-criterion item — I intentionally left that file alone. It documents only the AI-agent integration subsystem (adding Claude/Gemini/Copilot/etc. support); none of the other subsystems (presets, the extension system itself) are covered there either. The extension manifest schema is documented in extensions/EXTENSION-API-REFERENCE.md and EXTENSION-DEVELOPMENT-GUIDE.md, both already updated. Happy to add a pointer there if a maintainer prefers otherwise.

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