feat(presets): resolve constitution templates at command time (#3984)

* feat(presets): resolve constitutions at command time

Gate install-time constitution materialization behind the constitution-sync preset while preserving one-time init seeding and authored-file safeguards.

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

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 7dbce70f-80c6-4e14-a30d-78cb358bcb84

* fix(presets): emit composed template content

Add a machine-readable preset resolve mode backed by PresetResolver.resolve_content and require the constitution command to consume it.

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

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 7dbce70f-80c6-4e14-a30d-78cb358bcb84

* fix(presets): unify runtime template composition

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

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 7dbce70f-80c6-4e14-a30d-78cb358bcb84

* fix(presets): secure runtime template resolution

Align runtime resolution across script variants, validate registry path components, and honor canonical extension ordering and convention paths.

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

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* fix(presets): align runtime priority semantics

Normalize and tie-break preset priorities consistently across script variants, and preserve template bytes when Python materializes generated files.

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

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* fix(presets): stop at effective template base

Avoid parsing irrelevant lower layers once resolution reaches a replace base, and decode raw bytes so Python preserves source line endings.

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

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* fix(presets): align extension template resolution

Support root-level extension templates across runtime resolvers, fail safely when Bash cannot parse an extension registry, and validate requested templates in every prerequisite output mode.

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

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* fix(presets): resolve dotted command identifiers

Route safe dotted names through command resolution, correct traversal coverage, and make Windows CI text decoding explicit.

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

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* Avoid orphan feature directories on template errors

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

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* Align malformed preset manifest handling

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

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 3158e06f-95df-4e3a-843f-f159a35aa30c

* Complete runtime resolver parity

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

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 3158e06f-95df-4e3a-843f-f159a35aa30c

* Fail closed on resolver input errors

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

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 3158e06f-95df-4e3a-843f-f159a35aa30c

* Force UTF-8 and full manifest validation

Force UTF-8 decoding for registry and manifest reads in the Bash and
PowerShell embedded-Python parsers so resolution no longer depends on the
process locale, and validate every manifest template entry's required
fields, type, and strategy consistent with the canonical PresetManifest.

Assisted-by: GitHub Copilot (model: claude-opus-4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 3158e06f-95df-4e3a-843f-f159a35aa30c

* Fail closed on empty manifests and corrupt registries

Reject manifests missing the provides/templates sections or declaring an
empty template list in all three runtime resolvers, matching the canonical
PresetManifest which treats those as invalid instead of silently degrading a
composing layer to a convention `replace` lookup.

Make a corrupt or unreadable extension registry fail closed in Bash,
PowerShell, and Python instead of swallowing the error and treating every
on-disk extension directory as unregistered-and-enabled, which could activate
a disabled extension.

Read the preset and extension registries as explicit UTF-8 in the PowerShell
resolver so priority/enabled-state decoding no longer depends on the process
code page under Windows PowerShell 5.1.

Assisted-by: GitHub Copilot (model: claude-opus-4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 3158e06f-95df-4e3a-843f-f159a35aa30c

* fix(presets): fail closed when extension registry is not a regular file

The Bash and Python resolvers used is_file()/`-f` to gate reading the
extension `.registry`, which returns false for a directory or a broken
symlink at that path. In those cases the resolvers treated the registry
as absent and scanned every on-disk extension directory as unregistered
and enabled — a fail-open path. Detect any filesystem entry at the
registry path (including broken symlinks) and reject unless it is a
readable regular file. PowerShell now rejects a non-leaf entry explicitly
for parity. Adds directory- and broken-symlink parity regressions.

Assisted-by: GitHub Copilot (model: claude-opus-4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 3158e06f-95df-4e3a-843f-f159a35aa30c

* fix(presets): fail closed on corrupt registry in canonical resolver and PowerShell

Two remaining fail-open paths for an invalid extension registry:

- The canonical PresetResolver enumerated extensions through
  ExtensionRegistry, whose _load() normalizes a corrupt or unreadable
  registry to an empty mapping. The directory scan then admitted every
  on-disk extension directory as unregistered-and-enabled, so a corrupt
  registry could still supply constitution content at init and through
  constitution-sync materialization. Add a non-invasive is_corrupt()
  probe (recovery behavior for install/enable/disable is unchanged) and
  raise from _get_all_extensions_by_priority() when the registry exists
  but is invalid. _load() now also recovers from OSError/UnicodeDecodeError
  so a directory or unreadable registry no longer crashes construction.

- The PowerShell resolver gated the registry read with Test-Path, which
  returns false for a dangling symlink on Windows, letting a broken
  .registry symlink bypass the guard and enable every on-disk extension.
  Detect the entry via directory enumeration (which observes a broken
  symlink) and reject it unless it is a readable regular file.

Adds canonical corrupt/directory-registry regressions and extends the
broken-symlink parity test to PowerShell.

Assisted-by: GitHub Copilot (model: claude-opus-4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 3158e06f-95df-4e3a-843f-f159a35aa30c

* fix(presets): detect dangling registry symlink in ExtensionRegistry.is_corrupt

is_corrupt() gated on Path.exists(), which follows symlinks and returns
False for a dangling .registry symlink — so the canonical PresetResolver
treated it as an absent registry and fell back to scanning every on-disk
extension directory as unregistered-and-enabled, reopening the fail-open
path this guard closes. Detect lexical existence with os.path.lexists and
require a regular file before parsing, so a broken symlink (or directory)
is reported corrupt and resolution fails closed. Adds a canonical
broken-symlink regression alongside the directory case.

Assisted-by: GitHub Copilot (model: claude-opus-4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 3158e06f-95df-4e3a-843f-f159a35aa30c

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7dbce70f-80c6-4e14-a30d-78cb358bcb84
Copilot-Session: 3158e06f-95df-4e3a-843f-f159a35aa30c
This commit is contained in:
Manfred Riem
2026-08-10 10:22:31 -05:00
committed by GitHub
parent 1a60d1b6b8
commit 16cfab7724
45 changed files with 2721 additions and 361 deletions
+13
View File
@@ -59,6 +59,19 @@ Content resolution functions for composition:
- **Bash**: `resolve_template_content()` in `scripts/bash/common.sh` (templates only; command/script composition is handled by the Python resolver)
- **PowerShell**: `Resolve-TemplateContent` in `scripts/powershell/common.ps1` (templates only; command/script composition is handled by the Python resolver)
### Constitution lifecycle
Initialization resolves `constitution-template` through the full stack and seeds
`.specify/memory/constitution.md` once. Existing files are preserved byte-for-byte. On subsequent
`/constitution` runs, the command resolves the current composed template at runtime and uses the live
constitution as the source of project-specific values and amendments.
Preset installation, removal, enablement, disablement, and priority changes do not materialize
`constitution-template` by default. When the enabled preset registry contains `constitution-sync`,
those operations may reconcile the live file, but only if its provenance hash proves it is still
generated content. Missing files may be seeded when the preset is installed; authored or edited
constitutions are never overwritten.
## Command Registration
When a preset is installed with `type: "command"` entries, the `PresetManager` registers them into all detected agent directories using the shared `CommandRegistrar` from `src/specify_cli/agents.py`.
+10
View File
@@ -15,6 +15,16 @@ If no preset is installed, core templates are used — exactly the same behavior
Template resolution happens **at runtime** — although preset files are copied into `.specify/presets/<id>/` during installation, Spec Kit walks the resolution stack on every template lookup rather than merging templates into a single location.
`constitution-template` follows the same runtime model. Project initialization seeds
`.specify/memory/constitution.md` once so downstream commands always have a constitution to read.
After that, installing, removing, enabling, disabling, or reprioritizing presets does not rewrite the
live constitution. Each `/constitution` run resolves the current composed `constitution-template`,
then applies existing project values and amendments to that scaffold.
Teams that intentionally want preset stack changes to refresh an unchanged generated constitution can
install the bundled `constitution-sync` preset. It restores guarded install-time materialization in
addition to its command-time propagation behavior; authored constitutions remain protected.
For detailed resolution and command registration flows, see [ARCHITECTURE.md](ARCHITECTURE.md).
## Command Overrides
+2 -2
View File
@@ -1,6 +1,6 @@
{
"schema_version": "1.0",
"updated_at": "2026-04-24T00:00:00Z",
"updated_at": "2026-08-04T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.json",
"presets": {
"lean": {
@@ -30,7 +30,7 @@
"name": "Constitution Template Sync",
"id": "constitution-sync",
"version": "1.0.0",
"description": "Opt-in: restores /constitution propagation of amended guidance into plan/spec/tasks templates and installed command files, for teams that treat materialized templates as reviewed artifacts.",
"description": "Opt-in: restores guarded install-time constitution seeding and /constitution propagation for teams that treat materialized templates as reviewed artifacts.",
"author": "github",
"repository": "https://github.com/github/spec-kit",
"license": "MIT",
+15 -6
View File
@@ -1,13 +1,15 @@
# Constitution Template Sync
An **opt-in** preset that restores `/constitution`'s ability to propagate amended guidance into your
project's own templates and command files. After you update the constitution, it aligns
`plan-template.md`, `spec-template.md`, `tasks-template.md`, project-local command files, and
An **opt-in** preset that restores materialized constitution workflows. It refreshes an unchanged
generated `.specify/memory/constitution.md` when constitution-providing presets are installed,
removed, enabled, disabled, or reprioritized. After `/constitution` updates the live file, it also
aligns `plan-template.md`, `spec-template.md`, `tasks-template.md`, project-local command files, and
guidance docs so they reflect the current principles.
This propagation used to be built into `/constitution`; it was dropped when the command moved to the
preset model. Installing this preset opts you back into it: you get the guidance materialized into
reviewed, committed artifacts instead of relying on runtime resolution alone.
preset model. Installing this preset opts you back into materialization: preset stack changes refresh
the generated constitution, and `/constitution` propagates its guidance into reviewed, committed
artifacts instead of relying on runtime resolution alone.
> **What you're opting into.** Propagation was removed deliberately — it duplicates the constitution
> as the source of truth and can fight the composition stack (materialized edits get shadowed or
@@ -28,7 +30,12 @@ versioned preset a core team maintains.
## What it does
Ships a single `wrap`-strategy override of `speckit.constitution`. It composes on top of the
Its presence enables core's guarded install-time constitution reconciliation. Installing the preset
materializes the currently resolved `constitution-template`; later stack changes re-materialize it
only while the live file still matches its recorded generated-content hash. Human edits disable
automatic replacement.
It also ships a single `wrap`-strategy override of `speckit.constitution`. It composes on top of the
current core command (via `{CORE_TEMPLATE}`), so it stays forward-compatible with core changes, and
appends a propagation pass that, after the constitution is written:
@@ -43,6 +50,8 @@ appends a propagation pass that, after the constitution is written:
- It does **not** disable runtime resolution. `plan`, `tasks`, and `analyze` still read the live
constitution every run; this preset adds materialized copies on top — it does not replace the
source of truth.
- It does **not** overwrite an authored or edited constitution. Install-time reconciliation only
replaces content whose provenance proves it is an unchanged generated file.
- It does **not** edit versioned, package-owned files — templates or command files provided or
wrapped by another preset or extension. Those are recomposed from the resolution stack, so it
only ever writes into your project's own `.specify/templates/` scaffolds and command files that
+1 -1
View File
@@ -4,7 +4,7 @@ preset:
id: "constitution-sync"
name: "Constitution Template Sync"
version: "1.0.0"
description: "Opt-in: restores /constitution propagation of amended guidance into plan/spec/tasks templates and installed command files, for teams that treat materialized templates as reviewed artifacts."
description: "Opt-in: restores guarded install-time constitution seeding and /constitution propagation for teams that treat materialized templates as reviewed artifacts."
author: "github"
repository: "https://github.com/github/spec-kit"
license: "MIT"
+43 -8
View File
@@ -12,6 +12,7 @@
# --require-tasks Require tasks.md to exist (for implementation phase)
# --include-tasks Include tasks.md in AVAILABLE_DOCS list
# --paths-only Only output path variables (no validation)
# --template NAME Include composed template content in JSON output
# --help, -h Show help message
#
# OUTPUTS:
@@ -26,9 +27,10 @@ JSON_MODE=false
REQUIRE_TASKS=false
INCLUDE_TASKS=false
PATHS_ONLY=false
TEMPLATE_NAME=""
for arg in "$@"; do
case "$arg" in
while [[ $# -gt 0 ]]; do
case "$1" in
--json)
JSON_MODE=true
;;
@@ -41,6 +43,14 @@ for arg in "$@"; do
--paths-only)
PATHS_ONLY=true
;;
--template)
shift
if [[ $# -eq 0 ]]; then
echo "ERROR: --template requires a template name" >&2
exit 1
fi
TEMPLATE_NAME="$1"
;;
--help|-h)
cat << 'EOF'
Usage: check-prerequisites.sh [OPTIONS]
@@ -52,6 +62,7 @@ OPTIONS:
--require-tasks Require tasks.md to exist (for implementation phase)
--include-tasks Include tasks.md in AVAILABLE_DOCS list
--paths-only Only output path variables (no prerequisite validation)
--template NAME Include composed template content in JSON output
--help, -h Show this help message
EXAMPLES:
@@ -68,10 +79,11 @@ EOF
exit 0
;;
*)
echo "ERROR: Unknown option '$arg'. Use --help for usage information." >&2
echo "ERROR: Unknown option '$1'. Use --help for usage information." >&2
exit 1
;;
esac
shift
done
# Source common functions
@@ -156,6 +168,16 @@ if $INCLUDE_TASKS && [[ -f "$TASKS" ]]; then
docs+=("tasks.md")
fi
TEMPLATE_CONTENT=""
if [[ -n "$TEMPLATE_NAME" ]]; then
if TEMPLATE_CONTENT=$(resolve_template_content "$TEMPLATE_NAME" "$REPO_ROOT"; status=$?; printf x; exit "$status"); then
TEMPLATE_CONTENT="${TEMPLATE_CONTENT%x}"
else
echo "ERROR: Could not resolve required $TEMPLATE_NAME from the template override stack for $REPO_ROOT" >&2
exit 1
fi
fi
# Output results
if $JSON_MODE; then
# Build JSON array of documents
@@ -165,10 +187,18 @@ if $JSON_MODE; then
else
json_docs=$(printf '%s\n' "${docs[@]}" | jq -R . | jq -s .)
fi
jq -cn \
--arg feature_dir "$FEATURE_DIR" \
--argjson docs "$json_docs" \
'{FEATURE_DIR:$feature_dir,AVAILABLE_DOCS:$docs}'
if [[ -n "$TEMPLATE_NAME" ]]; then
jq -cn \
--arg feature_dir "$FEATURE_DIR" \
--argjson docs "$json_docs" \
--arg template_content "$TEMPLATE_CONTENT" \
'{FEATURE_DIR:$feature_dir,AVAILABLE_DOCS:$docs,TEMPLATE_CONTENT:$template_content}'
else
jq -cn \
--arg feature_dir "$FEATURE_DIR" \
--argjson docs "$json_docs" \
'{FEATURE_DIR:$feature_dir,AVAILABLE_DOCS:$docs}'
fi
else
if [[ ${#docs[@]} -eq 0 ]]; then
json_docs="[]"
@@ -176,7 +206,12 @@ if $JSON_MODE; then
json_docs=$(for d in "${docs[@]}"; do printf '"%s",' "$(json_escape "$d")"; done)
json_docs="[${json_docs%,}]"
fi
printf '{"FEATURE_DIR":"%s","AVAILABLE_DOCS":%s}\n' "$(json_escape "$FEATURE_DIR")" "$json_docs"
if [[ -n "$TEMPLATE_NAME" ]]; then
printf '{"FEATURE_DIR":"%s","AVAILABLE_DOCS":%s,"TEMPLATE_CONTENT":"%s"}\n' \
"$(json_escape "$FEATURE_DIR")" "$json_docs" "$(json_escape "$TEMPLATE_CONTENT")"
else
printf '{"FEATURE_DIR":"%s","AVAILABLE_DOCS":%s}\n' "$(json_escape "$FEATURE_DIR")" "$json_docs"
fi
fi
else
# Text output
+322 -108
View File
@@ -398,6 +398,101 @@ json_escape() {
check_file() { [[ -f "$1" ]] && echo "$2" || echo "$2"; }
check_dir() { [[ -d "$1" && -n $(ls -A "$1" 2>/dev/null) ]] && echo "$2" || echo "$2"; }
_python3_command() {
if command -v python3 >/dev/null 2>&1 &&
python3 -c 'import sys; raise SystemExit(sys.version_info.major != 3)' >/dev/null 2>&1; then
printf '%s\n' "python3"
elif command -v python >/dev/null 2>&1 &&
python -c 'import sys; raise SystemExit(sys.version_info.major != 3)' >/dev/null 2>&1; then
printf '%s\n' "python"
elif command -v py >/dev/null 2>&1 &&
py -3 -c 'import sys' >/dev/null 2>&1; then
printf '%s\n' "py -3"
else
return 1
fi
}
_sorted_extension_ids() {
local ext_dir="$1"
local python_spec
if python_spec=$(_python3_command); then
local -a python_cmd
read -r -a python_cmd <<< "$python_spec"
local py_stderr sorted_ids
py_stderr=$(mktemp)
if sorted_ids=$(SPECKIT_EXTENSIONS="$ext_dir" "${python_cmd[@]}" -c "
import json, os, re, sys
from pathlib import Path
root = Path(os.environ['SPECKIT_EXTENSIONS'])
registered = {}
registry = root / '.registry'
if os.path.lexists(registry):
if not registry.is_file():
print('registry_invalid: not a regular file', file=sys.stderr)
sys.exit(1)
try:
data = json.loads(registry.read_text(encoding='utf-8'))
except Exception as exc:
print('registry_invalid: ' + str(exc), file=sys.stderr)
sys.exit(1)
if not isinstance(data, dict):
print('registry_invalid: root must be a mapping', file=sys.stderr)
sys.exit(1)
raw_extensions = data.get('extensions', {})
if not isinstance(raw_extensions, dict):
print('registry_invalid: extensions must be a mapping', file=sys.stderr)
sys.exit(1)
registered = raw_extensions
def priority(value):
if isinstance(value, bool):
return 10
try:
parsed = int(value)
return parsed if parsed >= 1 else 10
except (TypeError, ValueError, OverflowError):
return 10
ranked = []
for ext_id, meta in registered.items():
if isinstance(ext_id, str) and re.fullmatch(r'[a-z0-9-]+', ext_id) and isinstance(meta, dict) and bool(meta.get('enabled', True)):
ranked.append((priority(meta.get('priority')), ext_id))
for path in root.iterdir():
if path.is_dir() and re.fullmatch(r'[a-z0-9-]+', path.name) and path.name not in registered:
ranked.append((10, path.name))
for _, ext_id in sorted(ranked):
print(ext_id)
" 2>"$py_stderr"); then
rm -f "$py_stderr"
printf '%s\n' "$sorted_ids"
return 0
else
echo "Error: invalid extension registry $ext_dir/.registry" >&2
rm -f "$py_stderr"
return 1
fi
fi
if [ -e "$ext_dir/.registry" ] || [ -L "$ext_dir/.registry" ]; then
if [ ! -f "$ext_dir/.registry" ] || [ ! -r "$ext_dir/.registry" ]; then
echo "Error: invalid extension registry $ext_dir/.registry" >&2
return 1
fi
echo "Error: Python 3 is required to honor the extension registry" >&2
return 2
fi
local ext extension_id
for ext in "$ext_dir"/*/; do
[ -d "$ext" ] || continue
extension_id=$(basename "$ext")
case "$extension_id" in *[!a-z0-9-]*) continue ;; esac
printf '%s\n' "$extension_id"
done
}
# Resolve a template name to a file path using the priority stack:
# 1. .specify/templates/overrides/
# 2. .specify/presets/<preset-id>/templates/ (sorted by priority from .registry)
@@ -408,6 +503,8 @@ resolve_template() {
local repo_root="$2"
local base="$repo_root/.specify/templates"
case "$template_name" in ""|*[!a-z0-9-]*) return 1 ;; esac
# Priority 1: Project overrides
local override="$base/overrides/${template_name}.md"
[ -f "$override" ] && echo "$override" && return 0
@@ -416,19 +513,32 @@ resolve_template() {
local presets_dir="$repo_root/.specify/presets"
if [ -d "$presets_dir" ]; then
local registry_file="$presets_dir/.registry"
if [ -f "$registry_file" ] && command -v python3 >/dev/null 2>&1; then
local python_spec=""
local -a python_cmd=()
if python_spec=$(_python3_command); then
read -r -a python_cmd <<< "$python_spec"
fi
if [ -f "$registry_file" ] && [ "${#python_cmd[@]}" -gt 0 ]; then
# Read preset IDs sorted by priority (lower number = higher precedence).
# The python3 call is wrapped in an if-condition so that set -e does not
# abort the function when python3 exits non-zero (e.g. invalid JSON).
local sorted_presets=""
if sorted_presets=$(SPECKIT_REGISTRY="$registry_file" python3 -c "
import json, sys, os
if sorted_presets=$(SPECKIT_REGISTRY="$registry_file" "${python_cmd[@]}" -c "
import json, re, sys, os
try:
with open(os.environ['SPECKIT_REGISTRY']) as f:
with open(os.environ['SPECKIT_REGISTRY'], encoding='utf-8') as f:
data = json.load(f)
presets = data.get('presets', {})
for pid, meta in sorted(presets.items(), key=lambda x: x[1].get('priority', 10) if isinstance(x[1], dict) else 10):
if isinstance(meta, dict) and meta.get('enabled', True) is not False:
def priority(meta):
if not isinstance(meta, dict) or isinstance(meta.get('priority'), bool):
return 10
try:
value = int(meta.get('priority', 10))
return value if value >= 1 else 10
except (TypeError, ValueError, OverflowError):
return 10
for pid, meta in sorted(presets.items(), key=lambda x: (priority(x[1]), x[0])):
if isinstance(meta, dict) and bool(meta.get('enabled', True)) and re.fullmatch(r'[a-z0-9-]+', pid):
print(pid)
except Exception:
sys.exit(1)
@@ -438,6 +548,8 @@ except Exception:
while IFS= read -r preset_id; do
local candidate="$presets_dir/$preset_id/templates/${template_name}.md"
[ -f "$candidate" ] && echo "$candidate" && return 0
candidate="$presets_dir/$preset_id/${template_name}.md"
[ -f "$candidate" ] && echo "$candidate" && return 0
done <<< "$sorted_presets"
fi
# python3 succeeded but registry has no presets — nothing to search
@@ -447,6 +559,8 @@ except Exception:
[ -d "$preset" ] || continue
local candidate="$preset/templates/${template_name}.md"
[ -f "$candidate" ] && echo "$candidate" && return 0
candidate="$preset/${template_name}.md"
[ -f "$candidate" ] && echo "$candidate" && return 0
done
fi
else
@@ -455,6 +569,8 @@ except Exception:
[ -d "$preset" ] || continue
local candidate="$preset/templates/${template_name}.md"
[ -f "$candidate" ] && echo "$candidate" && return 0
candidate="$preset/${template_name}.md"
[ -f "$candidate" ] && echo "$candidate" && return 0
done
fi
fi
@@ -462,13 +578,17 @@ except Exception:
# Priority 3: Extension-provided templates
local ext_dir="$repo_root/.specify/extensions"
if [ -d "$ext_dir" ]; then
for ext in "$ext_dir"/*/; do
[ -d "$ext" ] || continue
# Skip hidden directories (e.g. .backup, .cache)
case "$(basename "$ext")" in .*) continue;; esac
local sorted_extensions=""
if ! sorted_extensions=$(_sorted_extension_ids "$ext_dir"); then
return 2
fi
while IFS= read -r extension_id; do
[ -n "$extension_id" ] || continue
local ext="$ext_dir/$extension_id"
local candidate="$ext/templates/${template_name}.md"
[ -f "$candidate" ] || candidate="$ext/${template_name}.md"
[ -f "$candidate" ] && echo "$candidate" && return 0
done
done <<< "$sorted_extensions"
fi
# Priority 4: Core templates
@@ -492,6 +612,8 @@ resolve_template_content() {
local repo_root="$2"
local base="$repo_root/.specify/templates"
case "$template_name" in ""|*[!a-z0-9-]*) return 1 ;; esac
# Collect all layers (highest priority first)
local -a layer_paths=()
local -a layer_strategies=()
@@ -499,133 +621,206 @@ resolve_template_content() {
# Priority 1: Project overrides (always "replace")
local override="$base/overrides/${template_name}.md"
if [ -f "$override" ]; then
layer_paths+=("$override")
layer_strategies+=("replace")
if ! cat "$override"; then
echo "Error: failed to read template layer $override" >&2
return 2
fi
return 0
fi
local effective_base_found=false
# Priority 2: Installed presets (sorted by priority from .registry)
local presets_dir="$repo_root/.specify/presets"
if [ -d "$presets_dir" ]; then
local registry_file="$presets_dir/.registry"
local sorted_presets=""
if [ -f "$registry_file" ] && command -v python3 >/dev/null 2>&1; then
if sorted_presets=$(SPECKIT_REGISTRY="$registry_file" python3 -c "
import json, sys, os
local registry_parsed=false
local python_spec=""
local -a python_cmd=()
if python_spec=$(_python3_command); then
read -r -a python_cmd <<< "$python_spec"
fi
if [ -f "$registry_file" ] && [ "${#python_cmd[@]}" -gt 0 ]; then
if sorted_presets=$(SPECKIT_REGISTRY="$registry_file" "${python_cmd[@]}" -c "
import json, re, sys, os
try:
with open(os.environ['SPECKIT_REGISTRY']) as f:
with open(os.environ['SPECKIT_REGISTRY'], encoding='utf-8') as f:
data = json.load(f)
presets = data.get('presets', {})
for pid, meta in sorted(presets.items(), key=lambda x: x[1].get('priority', 10) if isinstance(x[1], dict) else 10):
if isinstance(meta, dict) and meta.get('enabled', True) is not False:
def priority(meta):
if not isinstance(meta, dict) or isinstance(meta.get('priority'), bool):
return 10
try:
value = int(meta.get('priority', 10))
return value if value >= 1 else 10
except (TypeError, ValueError, OverflowError):
return 10
for pid, meta in sorted(presets.items(), key=lambda x: (priority(x[1]), x[0])):
if isinstance(meta, dict) and bool(meta.get('enabled', True)) and re.fullmatch(r'[a-z0-9-]+', pid):
print(pid)
except Exception:
sys.exit(1)
" 2>/dev/null); then
if [ -n "$sorted_presets" ]; then
local yaml_warned=false
while IFS= read -r preset_id; do
# Read strategy and file path from preset manifest
local strategy="replace"
local manifest_file=""
local manifest="$presets_dir/$preset_id/preset.yml"
if [ -f "$manifest" ] && command -v python3 >/dev/null 2>&1; then
# Requires PyYAML; falls back to replace/convention if unavailable
local result
local py_stderr
py_stderr=$(mktemp)
result=$(SPECKIT_MANIFEST="$manifest" SPECKIT_TMPL="$template_name" python3 -c "
registry_parsed=true
fi
fi
if [ "$registry_parsed" = false ]; then
for preset in "$presets_dir"/*/; do
[ -d "$preset" ] || continue
local fallback_id
fallback_id=$(basename "$preset")
case "$fallback_id" in *[!a-z0-9-]*) continue ;; esac
sorted_presets+="${sorted_presets:+$'\n'}$fallback_id"
done
fi
if [ -n "$sorted_presets" ]; then
while IFS= read -r preset_id; do
local strategy="replace"
local manifest_file=""
local manifest="$presets_dir/$preset_id/preset.yml"
local manifest_declared=false
if [ -f "$manifest" ]; then
if [ "${#python_cmd[@]}" -eq 0 ]; then
echo "Error: Python 3 and PyYAML are required to resolve preset template composition" >&2
return 2
fi
local result
local py_stderr
local parse_status
py_stderr=$(mktemp)
if result=$(SPECKIT_MANIFEST="$manifest" SPECKIT_TMPL="$template_name" "${python_cmd[@]}" -c "
import sys, os
try:
import yaml
except ImportError:
print('yaml_missing', file=sys.stderr)
print('replace\t')
sys.exit(0)
sys.exit(2)
try:
with open(os.environ['SPECKIT_MANIFEST']) as f:
with open(os.environ['SPECKIT_MANIFEST'], encoding='utf-8') as f:
data = yaml.safe_load(f)
for t in data.get('provides', {}).get('templates', []):
if not isinstance(data, dict):
raise ValueError('manifest root must be a mapping')
if 'provides' not in data:
raise ValueError('manifest missing provides section')
provides = data['provides']
if not isinstance(provides, dict):
raise ValueError('manifest provides must be a mapping')
if 'templates' not in provides:
raise ValueError('manifest provides missing templates')
templates = provides['templates']
if not isinstance(templates, list):
raise ValueError('manifest templates must be a list')
if not templates:
raise ValueError('manifest must provide at least one template')
valid_types = ('template', 'command', 'script')
valid_strategies = ('replace', 'prepend', 'append', 'wrap')
for t in templates:
if not isinstance(t, dict):
raise ValueError('manifest template entries must be mappings')
if 'type' not in t or 'name' not in t or 'file' not in t:
raise ValueError('manifest template entry missing type, name, or file')
for field in ('type', 'name', 'file'):
if not isinstance(t[field], str):
raise ValueError('manifest template ' + field + ' must be a string')
if t['type'] not in valid_types:
raise ValueError('invalid manifest template type')
strategy = t.get('strategy', 'replace')
if not isinstance(strategy, str):
raise ValueError('manifest template strategy must be a string')
strategy = strategy.lower()
if strategy not in valid_strategies:
raise ValueError('invalid manifest template strategy')
if t['type'] == 'script' and strategy not in ('replace', 'wrap'):
raise ValueError('invalid manifest script strategy')
for t in templates:
if t.get('name') == os.environ['SPECKIT_TMPL'] and t.get('type', 'template') == 'template':
print(t.get('strategy', 'replace') + '\t' + t.get('file', ''))
file_value = t.get('file', '')
strategy = t.get('strategy', 'replace')
print('found\t' + strategy + '\t' + file_value)
sys.exit(0)
print('replace\t')
except Exception:
print('replace\t')
" 2>"$py_stderr")
local parse_status=$?
if [ $parse_status -eq 0 ] && [ -n "$result" ]; then
IFS=$'\t' read -r strategy manifest_file <<< "$result"
strategy=$(printf '%s' "$strategy" | tr '[:upper:]' '[:lower:]')
fi
if [ "$yaml_warned" = false ] && grep -q 'yaml_missing' "$py_stderr" 2>/dev/null; then
echo "Warning: PyYAML not available; composition strategies may be ignored" >&2
yaml_warned=true
fi
rm -f "$py_stderr"
fi
# Try manifest file path first, then convention path
local candidate=""
if [ -n "$manifest_file" ]; then
# Reject absolute paths and parent traversal
case "$manifest_file" in
/*|*../*|../*) manifest_file="" ;;
esac
fi
if [ -n "$manifest_file" ]; then
local mf="$presets_dir/$preset_id/$manifest_file"
[ -f "$mf" ] && candidate="$mf"
fi
if [ -z "$candidate" ]; then
local cf="$presets_dir/$preset_id/templates/${template_name}.md"
[ -f "$cf" ] && candidate="$cf"
fi
if [ -n "$candidate" ]; then
layer_paths+=("$candidate")
layer_strategies+=("$strategy")
fi
done <<< "$sorted_presets"
fi
else
# python3 failed — fall back to unordered directory scan (replace only)
for preset in "$presets_dir"/*/; do
[ -d "$preset" ] || continue
local candidate="$preset/templates/${template_name}.md"
if [ -f "$candidate" ]; then
layer_paths+=("$candidate")
layer_strategies+=("replace")
print('absent\treplace\t')
except Exception as exc:
print(f'manifest_invalid: {exc}', file=sys.stderr)
sys.exit(3)
" 2>"$py_stderr"); then
parse_status=0
else
parse_status=$?
fi
done
fi
else
# No python3 or registry — fall back to unordered directory scan (replace only)
for preset in "$presets_dir"/*/; do
[ -d "$preset" ] || continue
local candidate="$preset/templates/${template_name}.md"
if [ -f "$candidate" ]; then
layer_paths+=("$candidate")
layer_strategies+=("replace")
if [ "$parse_status" -ne 0 ]; then
if [ "$parse_status" -eq 2 ]; then
echo "Error: PyYAML is required to resolve preset template composition" >&2
else
echo "Error: invalid preset manifest $manifest" >&2
fi
rm -f "$py_stderr"
return 2
fi
if [ -n "$result" ]; then
local declaration
IFS=$'\t' read -r declaration strategy manifest_file <<< "$result"
[ "$declaration" = "found" ] && manifest_declared=true
strategy=$(printf '%s' "$strategy" | tr '[:upper:]' '[:lower:]')
fi
rm -f "$py_stderr"
fi
done
local candidate=""
if [ -n "$manifest_file" ]; then
case "$manifest_file" in
/*|*../*|../*) manifest_file="" ;;
esac
fi
if [ -n "$manifest_file" ]; then
local mf="$presets_dir/$preset_id/$manifest_file"
[ -f "$mf" ] && candidate="$mf"
fi
if [ -z "$candidate" ] && [ "$manifest_declared" = false ]; then
local cf="$presets_dir/$preset_id/templates/${template_name}.md"
[ -f "$cf" ] && candidate="$cf"
if [ -z "$candidate" ]; then
cf="$presets_dir/$preset_id/${template_name}.md"
[ -f "$cf" ] && candidate="$cf"
fi
fi
if [ -n "$candidate" ]; then
layer_paths+=("$candidate")
layer_strategies+=("$strategy")
if [ "$strategy" = "replace" ]; then
effective_base_found=true
break
fi
fi
done <<< "$sorted_presets"
fi
fi
# Priority 3: Extension-provided templates (always "replace")
local ext_dir="$repo_root/.specify/extensions"
if [ -d "$ext_dir" ]; then
for ext in "$ext_dir"/*/; do
[ -d "$ext" ] || continue
case "$(basename "$ext")" in .*) continue;; esac
if [ "$effective_base_found" = false ] && [ -d "$ext_dir" ]; then
local sorted_extensions=""
if ! sorted_extensions=$(_sorted_extension_ids "$ext_dir"); then
return 2
fi
while IFS= read -r extension_id; do
[ -n "$extension_id" ] || continue
local ext="$ext_dir/$extension_id"
local candidate="$ext/templates/${template_name}.md"
[ -f "$candidate" ] || candidate="$ext/${template_name}.md"
if [ -f "$candidate" ]; then
layer_paths+=("$candidate")
layer_strategies+=("replace")
effective_base_found=true
break
fi
done
done <<< "$sorted_extensions"
fi
# Priority 4: Core templates (always "replace")
local core="$base/${template_name}.md"
if [ -f "$core" ]; then
if [ "$effective_base_found" = false ] && [ -f "$core" ]; then
layer_paths+=("$core")
layer_strategies+=("replace")
fi
@@ -642,12 +837,18 @@ except Exception:
# If the top (highest-priority) layer is replace, it wins entirely —
# lower layers are irrelevant regardless of their strategies.
if [ "${layer_strategies[0]}" = "replace" ]; then
cat "${layer_paths[0]}"
if ! cat "${layer_paths[0]}"; then
echo "Error: failed to read template layer ${layer_paths[0]}" >&2
return 2
fi
return 0
fi
if [ "$has_composition" = false ]; then
cat "${layer_paths[0]}"
if ! cat "${layer_paths[0]}"; then
echo "Error: failed to read template layer ${layer_paths[0]}" >&2
return 2
fi
return 0
fi
@@ -663,12 +864,16 @@ except Exception:
done
if [ $base_idx -lt 0 ]; then
return 1 # no base layer found
echo "Error: template '$template_name' has composing layers but no replace base" >&2
return 2
fi
# Read the base content; compose layers above the base (higher priority)
local content
content=$(cat "${layer_paths[$base_idx]}"; printf x)
if ! content=$(cat "${layer_paths[$base_idx]}"; status=$?; printf x; exit "$status"); then
echo "Error: failed to read template layer ${layer_paths[$base_idx]}" >&2
return 2
fi
content="${content%x}"
for (( i=base_idx-1; i>=0; i-- )); do
@@ -676,17 +881,26 @@ except Exception:
local strat="${layer_strategies[$i]}"
local layer_content
# Preserve trailing newlines
layer_content=$(cat "$path"; printf x)
if ! layer_content=$(cat "$path"; status=$?; printf x; exit "$status"); then
echo "Error: failed to read template layer $path" >&2
return 2
fi
layer_content="${layer_content%x}"
case "$strat" in
replace) content="$layer_content" ;;
prepend) content="$(printf '%s\n\n%s' "$layer_content" "$content")" ;;
append) content="$(printf '%s\n\n%s' "$content" "$layer_content")" ;;
prepend)
content=$(printf '%s\n\n%s' "$layer_content" "$content"; printf x)
content="${content%x}"
;;
append)
content=$(printf '%s\n\n%s' "$content" "$layer_content"; printf x)
content="${content%x}"
;;
wrap)
case "$layer_content" in
*'{CORE_TEMPLATE}'*) ;;
*) echo "Error: wrap strategy missing {CORE_TEMPLATE} placeholder" >&2; return 1 ;;
*) echo "Error: wrap strategy missing {CORE_TEMPLATE} placeholder" >&2; return 2 ;;
esac
while [[ "$layer_content" == *'{CORE_TEMPLATE}'* ]]; do
local before="${layer_content%%\{CORE_TEMPLATE\}*}"
@@ -695,7 +909,7 @@ except Exception:
done
content="$layer_content"
;;
*) echo "Error: unknown strategy '$strat'" >&2; return 1 ;;
*) echo "Error: unknown strategy '$strat'" >&2; return 2 ;;
esac
done
+19 -4
View File
@@ -339,12 +339,27 @@ if [ "$DRY_RUN" != true ]; then
exit 1
fi
NEEDS_SPEC=false
SPEC_TEMPLATE_FOUND=false
SPEC_TEMPLATE_CONTENT=""
if [ ! -f "$SPEC_FILE" ]; then
NEEDS_SPEC=true
if SPEC_TEMPLATE_CONTENT=$(resolve_template_content "spec-template" "$REPO_ROOT"; status=$?; printf x; exit "$status"); then
SPEC_TEMPLATE_CONTENT="${SPEC_TEMPLATE_CONTENT%x}"
SPEC_TEMPLATE_FOUND=true
else
resolve_status=$?
if [ "$resolve_status" -ne 1 ]; then
exit "$resolve_status"
fi
fi
fi
mkdir -p "$FEATURE_DIR"
if [ ! -f "$SPEC_FILE" ]; then
TEMPLATE=$(resolve_template "spec-template" "$REPO_ROOT") || true
if [ -n "$TEMPLATE" ] && [ -f "$TEMPLATE" ]; then
cp "$TEMPLATE" "$SPEC_FILE"
if [ "$NEEDS_SPEC" = true ]; then
if [ "$SPEC_TEMPLATE_FOUND" = true ]; then
printf '%s' "$SPEC_TEMPLATE_CONTENT" > "$SPEC_FILE"
else
echo "Warning: Spec template not found; created empty spec file" >&2
touch "$SPEC_FILE"
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
set -e
SCRIPT_DIR="$(CDPATH="" cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/common.sh"
JSON_MODE=false
TEMPLATE_NAME=""
for arg in "$@"; do
case "$arg" in
--json) JSON_MODE=true ;;
--help|-h)
echo "Usage: $0 <template-name> [--json]"
exit 0
;;
-*)
echo "ERROR: Unknown option '$arg'" >&2
exit 1
;;
*)
if [[ -n "$TEMPLATE_NAME" ]]; then
echo "ERROR: Unexpected argument '$arg'" >&2
exit 1
fi
TEMPLATE_NAME="$arg"
;;
esac
done
if [[ -z "$TEMPLATE_NAME" ]]; then
echo "ERROR: Template name is required" >&2
exit 1
fi
REPO_ROOT=$(get_repo_root)
if TEMPLATE_CONTENT=$(resolve_template_content "$TEMPLATE_NAME" "$REPO_ROOT"; status=$?; printf x; exit "$status"); then
TEMPLATE_CONTENT="${TEMPLATE_CONTENT%x}"
else
echo "ERROR: Could not resolve required $TEMPLATE_NAME from the template override stack for $REPO_ROOT" >&2
exit 1
fi
if $JSON_MODE; then
if has_jq; then
jq -cn \
--arg template_name "$TEMPLATE_NAME" \
--arg template_content "$TEMPLATE_CONTENT" \
'{TEMPLATE_NAME:$template_name,TEMPLATE_CONTENT:$template_content}'
else
printf '{"TEMPLATE_NAME":"%s","TEMPLATE_CONTENT":"%s"}\n' \
"$(json_escape "$TEMPLATE_NAME")" "$(json_escape "$TEMPLATE_CONTENT")"
fi
else
printf '%s' "$TEMPLATE_CONTENT"
fi
+6 -4
View File
@@ -43,21 +43,23 @@ if [[ -f "$IMPL_PLAN" ]]; then
echo "Plan already exists at $IMPL_PLAN, skipping template copy"
fi
else
TEMPLATE=$(resolve_template "plan-template" "$REPO_ROOT") || true
if [[ -n "$TEMPLATE" ]] && [[ -f "$TEMPLATE" ]]; then
cp "$TEMPLATE" "$IMPL_PLAN"
if resolve_template_content "plan-template" "$REPO_ROOT" > "$IMPL_PLAN"; then
if $JSON_MODE; then
echo "Copied plan template to $IMPL_PLAN" >&2
else
echo "Copied plan template to $IMPL_PLAN"
fi
else
resolve_status=$?
rm -f "$IMPL_PLAN"
if [ "$resolve_status" -ne 1 ]; then
exit "$resolve_status"
fi
if $JSON_MODE; then
echo "Warning: Plan template not found" >&2
else
echo "Warning: Plan template not found"
fi
# Create a basic plan file if template doesn't exist
touch "$IMPL_PLAN"
fi
fi
+7 -4
View File
@@ -51,7 +51,9 @@ fi
# Resolve tasks template through override stack
TASKS_TEMPLATE=$(resolve_template "tasks-template" "$REPO_ROOT") || true
if [[ -z "$TASKS_TEMPLATE" ]] || [[ ! -f "$TASKS_TEMPLATE" ]]; then
if TASKS_TEMPLATE_CONTENT=$(resolve_template_content "tasks-template" "$REPO_ROOT"; status=$?; printf x; exit "$status"); then
TASKS_TEMPLATE_CONTENT="${TASKS_TEMPLATE_CONTENT%x}"
else
echo "ERROR: Could not resolve required tasks-template from the template override stack for $REPO_ROOT" >&2
echo "Template 'tasks-template' was not found in any supported location (overrides, presets, extensions, or shared core). Add an override at .specify/templates/overrides/tasks-template.md, or run 'specify init' / reinstall shared infra to restore the core .specify/templates/tasks-template.md template." >&2
exit 1
@@ -69,7 +71,8 @@ if $JSON_MODE; then
--arg feature_dir "$FEATURE_DIR" \
--argjson docs "$json_docs" \
--arg tasks_template "${TASKS_TEMPLATE:-}" \
'{FEATURE_DIR:$feature_dir,AVAILABLE_DOCS:$docs,TASKS_TEMPLATE:$tasks_template}'
--arg tasks_template_content "$TASKS_TEMPLATE_CONTENT" \
'{FEATURE_DIR:$feature_dir,AVAILABLE_DOCS:$docs,TASKS_TEMPLATE:$tasks_template,TASKS_TEMPLATE_CONTENT:$tasks_template_content}'
else
if [[ ${#docs[@]} -eq 0 ]]; then
json_docs="[]"
@@ -77,8 +80,8 @@ if $JSON_MODE; then
json_docs=$(for d in "${docs[@]}"; do printf '"%s",' "$(json_escape "$d")"; done)
json_docs="[${json_docs%,}]"
fi
printf '{"FEATURE_DIR":"%s","AVAILABLE_DOCS":%s,"TASKS_TEMPLATE":"%s"}\n' \
"$(json_escape "$FEATURE_DIR")" "$json_docs" "$(json_escape "${TASKS_TEMPLATE:-}")"
printf '{"FEATURE_DIR":"%s","AVAILABLE_DOCS":%s,"TASKS_TEMPLATE":"%s","TASKS_TEMPLATE_CONTENT":"%s"}\n' \
"$(json_escape "$FEATURE_DIR")" "$json_docs" "$(json_escape "${TASKS_TEMPLATE:-}")" "$(json_escape "$TASKS_TEMPLATE_CONTENT")"
fi
else
echo "FEATURE_DIR: $FEATURE_DIR"
+18 -2
View File
@@ -12,6 +12,7 @@
# -RequireTasks Require tasks.md to exist (for implementation phase)
# -IncludeTasks Include tasks.md in AVAILABLE_DOCS list
# -PathsOnly Only output path variables (no validation)
# -Template NAME Include composed template content in JSON output
# -Help, -h Show help message
[CmdletBinding()]
@@ -20,6 +21,7 @@ param(
[switch]$RequireTasks,
[switch]$IncludeTasks,
[switch]$PathsOnly,
[string]$Template,
[switch]$Help
)
@@ -37,6 +39,7 @@ OPTIONS:
-RequireTasks Require tasks.md to exist (for implementation phase)
-IncludeTasks Include tasks.md in AVAILABLE_DOCS list
-PathsOnly Only output path variables (no prerequisite validation)
-Template NAME Include composed template content in JSON output
-Help, -h Show this help message
EXAMPLES:
@@ -129,13 +132,26 @@ if ($IncludeTasks -and (Test-Path $paths.TASKS)) {
$docs += 'tasks.md'
}
$templateContent = $null
if ($Template) {
$templateContent = Resolve-TemplateContent -TemplateName $Template -RepoRoot $paths.REPO_ROOT
if ($null -eq $templateContent) {
[Console]::Error.WriteLine("ERROR: Could not resolve required $Template from the template override stack for $($paths.REPO_ROOT)")
exit 1
}
}
# Output results
if ($Json) {
# JSON output
[PSCustomObject]@{
$result = [ordered]@{
FEATURE_DIR = $paths.FEATURE_DIR
AVAILABLE_DOCS = $docs
} | ConvertTo-Json -Compress
}
if ($Template) {
$result.TEMPLATE_CONTENT = $templateContent
}
[PSCustomObject]$result | ConvertTo-Json -Compress
} else {
# Text output
Write-Output "FEATURE_DIR:$($paths.FEATURE_DIR)"
+239 -82
View File
@@ -332,6 +332,82 @@ function Get-Python3Command {
return $null
}
function Get-NormalizedPriority {
param($Value)
if ($Value -is [bool]) { return 10 }
if ($Value -is [string]) {
$integerText = $Value.Trim()
if ($integerText -cnotmatch '^[+-]?[0-9]+(?:_[0-9]+)*$') { return 10 }
$Value = $integerText.Replace('_', '')
}
try {
$parsedPriority = [System.Numerics.BigInteger]$Value
} catch {
return 10
}
return $(if ($parsedPriority -ge 1) { $parsedPriority } else { 10 })
}
function Get-SortedExtensionIds {
param([Parameter(Mandatory=$true)][string]$ExtensionsDir)
$registeredNames = @()
$ranked = @()
$registryFile = Join-Path $ExtensionsDir '.registry'
# Detect any filesystem entry at the registry path without following symlinks.
# Test-Path follows links and reports $false for a dangling symlink, so a
# broken .registry symlink would otherwise bypass this guard and let the
# directory scan below enable every on-disk extension. Enumerating the parent
# directory still observes a broken symlink as an entry.
$registryEntry = Get-ChildItem -LiteralPath $ExtensionsDir -Force -ErrorAction SilentlyContinue |
Where-Object { $_.Name -eq '.registry' } |
Select-Object -First 1
if ($registryEntry) {
if (-not (Test-Path -LiteralPath $registryFile -PathType Leaf)) {
throw "Invalid extension registry ${registryFile}: not a regular file"
}
try {
$data = [System.IO.File]::ReadAllText($registryFile, [System.Text.Encoding]::UTF8) | ConvertFrom-Json
} catch {
throw "Invalid extension registry ${registryFile}: $($_.Exception.Message)"
}
if ($null -eq $data -or $data -isnot [PSCustomObject]) {
throw "Invalid extension registry ${registryFile}: root must be a mapping"
}
$extensionsProperty = $data.PSObject.Properties['extensions']
if ($extensionsProperty) {
if ($extensionsProperty.Value -isnot [PSCustomObject]) {
throw "Invalid extension registry ${registryFile}: 'extensions' must be a mapping"
}
$extensions = $extensionsProperty.Value
} else {
$extensions = [PSCustomObject]@{}
}
$registeredNames = @($extensions.PSObject.Properties | ForEach-Object { $_.Name })
foreach ($entry in $extensions.PSObject.Properties) {
if ($entry.Name -cnotmatch '^[a-z0-9-]+$' -or $entry.Value -isnot [PSCustomObject]) {
continue
}
$enabledProperty = $entry.Value.PSObject.Properties['enabled']
if ($enabledProperty -and -not [bool]$enabledProperty.Value) { continue }
$priority = 10
$priorityProperty = $entry.Value.PSObject.Properties['priority']
if ($priorityProperty) {
$priority = Get-NormalizedPriority -Value $priorityProperty.Value
}
$ranked += [PSCustomObject]@{ Priority = $priority; Id = $entry.Name }
}
}
foreach ($directory in Get-ChildItem -Path $ExtensionsDir -Directory -ErrorAction SilentlyContinue) {
if ($directory.Name -cmatch '^[a-z0-9-]+$' -and $directory.Name -cnotin $registeredNames) {
$ranked += [PSCustomObject]@{ Priority = 10; Id = $directory.Name }
}
}
return $ranked | Sort-Object Priority, Id | ForEach-Object { $_.Id }
}
# Resolve a template name to a file path using the priority stack:
# 1. .specify/templates/overrides/
# 2. .specify/presets/<preset-id>/templates/ (sorted by priority from .registry)
@@ -343,6 +419,8 @@ function Resolve-Template {
[Parameter(Mandatory=$true)][string]$RepoRoot
)
if ($TemplateName -cnotmatch '^[a-z0-9-]+$') { return $null }
$base = Join-Path $RepoRoot '.specify/templates'
# Priority 1: Project overrides
@@ -357,7 +435,7 @@ function Resolve-Template {
$registryParsed = $false
if (Test-Path $registryFile) {
try {
$registryData = Get-Content $registryFile -Raw | ConvertFrom-Json
$registryData = [System.IO.File]::ReadAllText($registryFile, [System.Text.Encoding]::UTF8) | ConvertFrom-Json
if ($null -eq $registryData -or $registryData -isnot [PSCustomObject]) {
throw 'Registry root must be an object'
}
@@ -372,30 +450,20 @@ function Resolve-Template {
param($Entry)
if ($Entry.Value -is [PSCustomObject]) {
$priorityProperty = $Entry.Value.PSObject.Properties['priority']
if ($priorityProperty) { return $priorityProperty.Value }
if ($priorityProperty) {
return Get-NormalizedPriority -Value $priorityProperty.Value
}
}
return 10
}
if ($presetEntries.Count -gt 1) {
$allNumeric = $true
$allStrings = $true
foreach ($entry in $presetEntries) {
$priority = & $priorityFor $entry
if ($null -eq $priority -or $priority -isnot [ValueType]) {
$allNumeric = $false
}
if ($null -eq $priority -or $priority -isnot [string]) {
$allStrings = $false
}
}
if (-not $allNumeric -and -not $allStrings) {
throw 'Registry priorities are not mutually orderable'
}
}
$sortedPresets = $presetEntries |
Where-Object { $_.Value -is [PSCustomObject] } |
Where-Object { $null -eq $_.Value.enabled -or $_.Value.enabled -ne $false } |
Sort-Object { & $priorityFor $_ } |
Where-Object {
$enabled = $_.Value.PSObject.Properties['enabled']
-not $enabled -or [bool]$enabled.Value
} |
Where-Object { $_.Name -cmatch '^[a-z0-9-]+$' } |
Sort-Object @{ Expression = { & $priorityFor $_ } }, @{ Expression = { $_.Name } } |
ForEach-Object { $_.Name }
}
$registryParsed = $true
@@ -408,12 +476,16 @@ function Resolve-Template {
foreach ($presetId in $sortedPresets) {
$candidate = Join-Path $presetsDir "$presetId/templates/$TemplateName.md"
if (Test-Path $candidate) { return $candidate }
$candidate = Join-Path $presetsDir "$presetId/$TemplateName.md"
if (Test-Path $candidate) { return $candidate }
}
} else {
# Fallback: alphabetical directory order
foreach ($preset in Get-ChildItem -Path $presetsDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -notlike '.*' } | Sort-Object Name) {
$candidate = Join-Path $preset.FullName "templates/$TemplateName.md"
if (Test-Path $candidate) { return $candidate }
$candidate = Join-Path $preset.FullName "$TemplateName.md"
if (Test-Path $candidate) { return $candidate }
}
}
}
@@ -421,8 +493,11 @@ function Resolve-Template {
# Priority 3: Extension-provided templates
$extDir = Join-Path $RepoRoot '.specify/extensions'
if (Test-Path $extDir) {
foreach ($ext in Get-ChildItem -Path $extDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -notlike '.*' } | Sort-Object Name) {
$candidate = Join-Path $ext.FullName "templates/$TemplateName.md"
foreach ($extensionId in Get-SortedExtensionIds -ExtensionsDir $extDir) {
$candidate = Join-Path $extDir "$extensionId/templates/$TemplateName.md"
if (-not (Test-Path $candidate)) {
$candidate = Join-Path $extDir "$extensionId/$TemplateName.md"
}
if (Test-Path $candidate) { return $candidate }
}
}
@@ -443,6 +518,10 @@ function Resolve-TemplateContent {
[Parameter(Mandatory=$true)][string]$RepoRoot
)
if ($TemplateName -cnotmatch '^[a-z0-9-]+$') {
return $null
}
$base = Join-Path $RepoRoot '.specify/templates'
# Collect all layers (highest priority first)
@@ -452,49 +531,77 @@ function Resolve-TemplateContent {
# Priority 1: Project overrides (always "replace")
$override = Join-Path $base "overrides/$TemplateName.md"
if (Test-Path $override) {
$layerPaths += $override
$layerStrategies += 'replace'
return [System.IO.File]::ReadAllText(
$override,
[System.Text.Encoding]::UTF8
)
}
$effectiveBaseFound = $false
# Priority 2: Installed presets (sorted by priority from .registry)
$presetsDir = Join-Path $RepoRoot '.specify/presets'
if (Test-Path $presetsDir) {
$registryFile = Join-Path $presetsDir '.registry'
$sortedPresets = @()
$registryParsed = $false
if (Test-Path $registryFile) {
try {
$registryData = Get-Content $registryFile -Raw | ConvertFrom-Json
$presets = $registryData.presets
if ($presets) {
$sortedPresets = $presets.PSObject.Properties |
Where-Object { $null -eq $_.Value.enabled -or $_.Value.enabled -ne $false } |
Sort-Object { if ($null -ne $_.Value.priority) { $_.Value.priority } else { 10 } } |
$registryData = [System.IO.File]::ReadAllText($registryFile, [System.Text.Encoding]::UTF8) | ConvertFrom-Json
if ($null -eq $registryData -or $registryData -isnot [PSCustomObject]) {
throw 'Registry root must be an object'
}
$presetsProperty = $registryData.PSObject.Properties['presets']
if ($presetsProperty) {
$presets = $presetsProperty.Value
if ($null -eq $presets -or $presets -isnot [PSCustomObject]) {
throw 'Registry presets must be an object'
}
$presetEntries = @($presets.PSObject.Properties)
$priorityFor = {
param($Entry)
if ($Entry.Value -is [PSCustomObject]) {
$priorityProperty = $Entry.Value.PSObject.Properties['priority']
if ($priorityProperty) {
return Get-NormalizedPriority -Value $priorityProperty.Value
}
}
return 10
}
$sortedPresets = $presetEntries |
Where-Object { $_.Value -is [PSCustomObject] } |
Where-Object {
$enabled = $_.Value.PSObject.Properties['enabled']
-not $enabled -or [bool]$enabled.Value
} |
Where-Object { $_.Name -cmatch '^[a-z0-9-]+$' } |
Sort-Object @{ Expression = { & $priorityFor $_ } }, @{ Expression = { $_.Name } } |
ForEach-Object { $_.Name }
}
$registryParsed = $true
} catch {
$sortedPresets = @()
$registryParsed = $false
}
}
if ($sortedPresets.Count -gt 0) {
$pyCmd = Get-Python3Command
if (-not $pyCmd) {
# Check if any preset has strategy fields that would be ignored
foreach ($pid in $sortedPresets) {
$mf = Join-Path $presetsDir "$pid/preset.yml"
if ((Test-Path $mf) -and (Select-String -Path $mf -Pattern 'strategy:' -Quiet -ErrorAction SilentlyContinue)) {
Write-Warning "No Python 3 found; preset composition strategies will be ignored"
break
}
}
}
$yamlWarned = $false
foreach ($presetId in $sortedPresets) {
if (-not $registryParsed) {
$sortedPresets = Get-ChildItem -Path $presetsDir -Directory -ErrorAction SilentlyContinue |
Where-Object { $_.Name -cmatch '^[a-z0-9-]+$' } |
Sort-Object Name |
ForEach-Object { $_.Name }
}
$pyCmd = @(Get-Python3Command)
foreach ($presetId in $sortedPresets) {
# Read strategy and file path from preset manifest
$strategy = 'replace'
$manifestFilePath = ''
$manifestDeclared = $false
$manifest = Join-Path $presetsDir "$presetId/preset.yml"
if ((Test-Path $manifest) -and $pyCmd) {
if ((Test-Path $manifest) -and -not $pyCmd) {
throw "Python 3 and PyYAML are required to resolve preset template composition"
}
if (Test-Path $manifest) {
try {
# Use Python to parse YAML manifest for strategy and file path
$pyArgs = if ($pyCmd.Count -gt 1) { $pyCmd[1..($pyCmd.Count-1)] } else { @() }
@@ -505,32 +612,71 @@ try:
import yaml
except ImportError:
print('yaml_missing', file=sys.stderr)
print('replace\t')
sys.exit(0)
sys.exit(2)
try:
with open(sys.argv[1]) as f:
with open(sys.argv[1], encoding='utf-8') as f:
data = yaml.safe_load(f)
for t in data.get('provides', {}).get('templates', []):
if not isinstance(data, dict):
raise ValueError('manifest root must be a mapping')
if 'provides' not in data:
raise ValueError('manifest missing provides section')
provides = data['provides']
if not isinstance(provides, dict):
raise ValueError('manifest provides must be a mapping')
if 'templates' not in provides:
raise ValueError('manifest provides missing templates')
templates = provides['templates']
if not isinstance(templates, list):
raise ValueError('manifest templates must be a list')
if not templates:
raise ValueError('manifest must provide at least one template')
valid_types = ('template', 'command', 'script')
valid_strategies = ('replace', 'prepend', 'append', 'wrap')
for t in templates:
if not isinstance(t, dict):
raise ValueError('manifest template entries must be mappings')
if 'type' not in t or 'name' not in t or 'file' not in t:
raise ValueError('manifest template entry missing type, name, or file')
for field in ('type', 'name', 'file'):
if not isinstance(t[field], str):
raise ValueError('manifest template ' + field + ' must be a string')
if t['type'] not in valid_types:
raise ValueError('invalid manifest template type')
strategy = t.get('strategy', 'replace')
if not isinstance(strategy, str):
raise ValueError('manifest template strategy must be a string')
strategy = strategy.lower()
if strategy not in valid_strategies:
raise ValueError('invalid manifest template strategy')
if t['type'] == 'script' and strategy not in ('replace', 'wrap'):
raise ValueError('invalid manifest script strategy')
for t in templates:
if t.get('name') == sys.argv[2] and t.get('type', 'template') == 'template':
print(t.get('strategy', 'replace') + '\t' + t.get('file', ''))
file_value = t.get('file', '')
strategy = t.get('strategy', 'replace')
print('found\t' + strategy + '\t' + file_value)
sys.exit(0)
print('replace\t')
except Exception:
print('replace\t')
print('absent\treplace\t')
except Exception as exc:
print(f'manifest_invalid: {exc}', file=sys.stderr)
sys.exit(3)
"@ $manifest $TemplateName 2>$pyStderrFile
if ($LASTEXITCODE -ne 0) {
if ($LASTEXITCODE -eq 2) {
throw "PyYAML is required to resolve preset template composition"
}
throw "Invalid preset manifest $manifest"
}
if ($stratResult) {
$parts = $stratResult.Trim() -split "`t", 2
$strategy = $parts[0].ToLowerInvariant()
if ($parts.Count -gt 1 -and $parts[1]) { $manifestFilePath = $parts[1] }
}
if (-not $yamlWarned -and (Test-Path $pyStderrFile) -and (Get-Content $pyStderrFile -Raw -ErrorAction SilentlyContinue) -match 'yaml_missing') {
Write-Warning "PyYAML not available; composition strategies may be ignored"
$yamlWarned = $true
$parts = $stratResult.Trim() -split "`t", 3
$manifestDeclared = $parts[0] -eq 'found'
$strategy = $parts[1].ToLowerInvariant()
if ($parts.Count -gt 2 -and $parts[2]) { $manifestFilePath = $parts[2] }
}
Remove-Item $pyStderrFile -Force -ErrorAction SilentlyContinue
} catch {
$strategy = 'replace'
if ($pyStderrFile) { Remove-Item $pyStderrFile -Force -ErrorAction SilentlyContinue }
throw
}
}
# Try manifest file path first, then convention path
@@ -545,42 +691,45 @@ except Exception:
$mf = Join-Path $presetsDir "$presetId/$manifestFilePath"
if (Test-Path $mf) { $candidate = $mf }
}
if (-not $candidate) {
if (-not $candidate -and -not $manifestDeclared) {
$cf = Join-Path $presetsDir "$presetId/templates/$TemplateName.md"
if (Test-Path $cf) { $candidate = $cf }
if (-not $candidate) {
$cf = Join-Path $presetsDir "$presetId/$TemplateName.md"
if (Test-Path $cf) { $candidate = $cf }
}
}
if ($candidate) {
$layerPaths += $candidate
$layerStrategies += $strategy
if ($strategy -eq 'replace') {
$effectiveBaseFound = $true
break
}
}
}
} else {
# Fallback: alphabetical directory order (no registry or parse failure)
foreach ($preset in Get-ChildItem -Path $presetsDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -notlike '.*' }) {
$candidate = Join-Path $preset.FullName "templates/$TemplateName.md"
if (Test-Path $candidate) {
$layerPaths += $candidate
$layerStrategies += 'replace'
}
}
}
}
# Priority 3: Extension-provided templates (always "replace")
$extDir = Join-Path $RepoRoot '.specify/extensions'
if (Test-Path $extDir) {
foreach ($ext in Get-ChildItem -Path $extDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -notlike '.*' } | Sort-Object Name) {
$candidate = Join-Path $ext.FullName "templates/$TemplateName.md"
if (-not $effectiveBaseFound -and (Test-Path $extDir)) {
foreach ($extensionId in Get-SortedExtensionIds -ExtensionsDir $extDir) {
$candidate = Join-Path $extDir "$extensionId/templates/$TemplateName.md"
if (-not (Test-Path $candidate)) {
$candidate = Join-Path $extDir "$extensionId/$TemplateName.md"
}
if (Test-Path $candidate) {
$layerPaths += $candidate
$layerStrategies += 'replace'
$effectiveBaseFound = $true
break
}
}
}
# Priority 4: Core templates (always "replace")
$core = Join-Path $base "$TemplateName.md"
if (Test-Path $core) {
if (-not $effectiveBaseFound -and (Test-Path $core)) {
$layerPaths += $core
$layerStrategies += 'replace'
}
@@ -590,7 +739,7 @@ except Exception:
# If the top (highest-priority) layer is replace, it wins entirely --
# lower layers are irrelevant regardless of their strategies.
if ($layerStrategies[0] -eq 'replace') {
return (Get-Content $layerPaths[0] -Raw)
return [System.IO.File]::ReadAllText($layerPaths[0], [System.Text.Encoding]::UTF8)
}
# Check if any layer uses a non-replace strategy
@@ -600,7 +749,7 @@ except Exception:
}
if (-not $hasComposition) {
return (Get-Content $layerPaths[0] -Raw)
return [System.IO.File]::ReadAllText($layerPaths[0], [System.Text.Encoding]::UTF8)
}
# Find the effective base: scan from highest priority (index 0) downward
@@ -612,14 +761,22 @@ except Exception:
break
}
}
if ($baseIdx -lt 0) { return $null }
if ($baseIdx -lt 0) {
throw "Template '$TemplateName' has composing layers but no replace base"
}
$content = Get-Content $layerPaths[$baseIdx] -Raw
$content = [System.IO.File]::ReadAllText(
$layerPaths[$baseIdx],
[System.Text.Encoding]::UTF8
)
for ($i = $baseIdx - 1; $i -ge 0; $i--) {
$path = $layerPaths[$i]
$strat = $layerStrategies[$i]
$layerContent = Get-Content $path -Raw
$layerContent = [System.IO.File]::ReadAllText(
$path,
[System.Text.Encoding]::UTF8
)
switch ($strat) {
'replace' { $content = $layerContent }
+8 -5
View File
@@ -262,13 +262,16 @@ if (-not $DryRun) {
exit 1
}
$needsSpec = -not (Test-Path -PathType Leaf $specFile)
$content = $null
if ($needsSpec) {
$content = Resolve-TemplateContent -TemplateName 'spec-template' -RepoRoot $repoRoot
}
New-Item -ItemType Directory -Path $featureDir -Force | Out-Null
if (-not (Test-Path -PathType Leaf $specFile)) {
$template = Resolve-Template -TemplateName 'spec-template' -RepoRoot $repoRoot
if ($template -and (Test-Path $template)) {
# Read the template content and write it to the spec file with UTF-8 encoding without BOM
$content = [System.IO.File]::ReadAllText($template)
if ($needsSpec) {
if ($null -ne $content) {
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($specFile, $content, $utf8NoBom)
} else {
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env pwsh
param(
[Parameter(Position=0)]
[string]$TemplateName,
[switch]$Json,
[switch]$Help
)
$ErrorActionPreference = 'Stop'
if ($Help) {
Write-Output "Usage: resolve-template.ps1 <template-name> [-Json]"
exit 0
}
if (-not $TemplateName) {
[Console]::Error.WriteLine("ERROR: Template name is required")
exit 1
}
. "$PSScriptRoot/common.ps1"
$repoRoot = Get-RepoRoot
$templateContent = Resolve-TemplateContent -TemplateName $TemplateName -RepoRoot $repoRoot
if ($null -eq $templateContent) {
[Console]::Error.WriteLine("ERROR: Could not resolve required $TemplateName from the template override stack for $repoRoot")
exit 1
}
if ($Json) {
[PSCustomObject]@{
TEMPLATE_NAME = $TemplateName
TEMPLATE_CONTENT = $templateContent
} | ConvertTo-Json -Compress
} else {
[Console]::Out.Write($templateContent)
}
+2 -4
View File
@@ -41,10 +41,8 @@ if (Test-Path $paths.IMPL_PLAN -PathType Leaf) {
Write-Output "Plan already exists at $($paths.IMPL_PLAN), skipping template copy"
}
} else {
$template = Resolve-Template -TemplateName 'plan-template' -RepoRoot $paths.REPO_ROOT
if ($template -and (Test-Path $template)) {
# Read the template content and write it to the implementation plan file with UTF-8 encoding without BOM
$content = [System.IO.File]::ReadAllText($template)
$content = Resolve-TemplateContent -TemplateName 'plan-template' -RepoRoot $paths.REPO_ROOT
if ($null -ne $content) {
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($paths.IMPL_PLAN, $content, $utf8NoBom)
# Emit the copy status like the bash twin (setup-plan.sh); route to stderr
+8 -2
View File
@@ -57,12 +57,17 @@ if (Test-Path $paths.QUICKSTART) { $docs += 'quickstart.md' }
# Resolve tasks template through override stack
$tasksTemplate = Resolve-Template -TemplateName 'tasks-template' -RepoRoot $paths.REPO_ROOT
if (-not $tasksTemplate -or -not (Test-Path -LiteralPath $tasksTemplate -PathType Leaf)) {
$tasksTemplateContent = Resolve-TemplateContent -TemplateName 'tasks-template' -RepoRoot $paths.REPO_ROOT
if ($null -eq $tasksTemplateContent) {
[Console]::Error.WriteLine("ERROR: Could not resolve required tasks-template from the template override stack for $($paths.REPO_ROOT)")
[Console]::Error.WriteLine("Template 'tasks-template' was not found in any supported location (overrides, presets, extensions, or shared core). Add an override at .specify/templates/overrides/tasks-template.md, or run 'specify init' / reinstall shared infra to restore the core .specify/templates/tasks-template.md template.")
exit 1
}
$tasksTemplate = (Resolve-Path -LiteralPath $tasksTemplate).Path
if ($tasksTemplate -and (Test-Path -LiteralPath $tasksTemplate -PathType Leaf)) {
$tasksTemplate = (Resolve-Path -LiteralPath $tasksTemplate).Path
} else {
$tasksTemplate = ''
}
# Output results
if ($Json) {
@@ -70,6 +75,7 @@ if ($Json) {
FEATURE_DIR = $paths.FEATURE_DIR
AVAILABLE_DOCS = $docs
TASKS_TEMPLATE = $tasksTemplate
TASKS_TEMPLATE_CONTENT = $tasksTemplateContent
} | ConvertTo-Json -Compress
} else {
Write-Output "FEATURE_DIR: $($paths.FEATURE_DIR)"
+55 -4
View File
@@ -9,10 +9,22 @@ from dataclasses import dataclass
from pathlib import Path
try:
from common import FeaturePaths, format_speckit_command, get_feature_paths
from common import (
FeaturePaths,
TemplateResolutionError,
format_speckit_command,
get_feature_paths,
resolve_template_content,
)
except ImportError: # pragma: no cover - direct execution from unusual cwd
sys.path.insert(0, str(Path(__file__).resolve().parent))
from common import FeaturePaths, format_speckit_command, get_feature_paths
from common import (
FeaturePaths,
TemplateResolutionError,
format_speckit_command,
get_feature_paths,
resolve_template_content,
)
def _json_line(payload: object) -> str:
@@ -28,6 +40,7 @@ OPTIONS:
--require-tasks Require tasks.md to exist (for implementation phase)
--include-tasks Include tasks.md in AVAILABLE_DOCS list
--paths-only Only output path variables (no prerequisite validation)
--template NAME Include composed template content in JSON output
--help, -h Show this help message
EXAMPLES:
@@ -49,6 +62,7 @@ class Args:
require_tasks: bool = False
include_tasks: bool = False
paths_only: bool = False
template_name: str | None = None
def _parse_args(argv: list[str]) -> Args:
@@ -56,8 +70,11 @@ def _parse_args(argv: list[str]) -> Args:
require_tasks = False
include_tasks = False
paths_only = False
template_name = None
for arg in argv:
index = 0
while index < len(argv):
arg = argv[index]
if arg == "--json":
json_mode = True
elif arg == "--require-tasks":
@@ -66,6 +83,15 @@ def _parse_args(argv: list[str]) -> Args:
include_tasks = True
elif arg == "--paths-only":
paths_only = True
elif arg == "--template":
index += 1
if index >= len(argv):
print(
"ERROR: --template requires a template name",
file=sys.stderr,
)
raise SystemExit(1)
template_name = argv[index]
elif arg in {"--help", "-h"}:
sys.stdout.write(HELP_TEXT)
raise SystemExit(0)
@@ -75,12 +101,14 @@ def _parse_args(argv: list[str]) -> Args:
file=sys.stderr,
)
raise SystemExit(1)
index += 1
return Args(
json_mode=json_mode,
require_tasks=require_tasks,
include_tasks=include_tasks,
paths_only=paths_only,
template_name=template_name,
)
@@ -211,9 +239,32 @@ def main(argv: list[str] | None = None) -> int:
return 1
docs = _available_docs(paths, args.include_tasks)
template_content = None
if args.template_name:
try:
template_content = resolve_template_content(
args.template_name, paths.repo_root
)
except TemplateResolutionError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 1
if template_content is None:
print(
f"ERROR: Could not resolve required {args.template_name} from "
f"the template override stack for {paths.repo_root}",
file=sys.stderr,
)
return 1
if args.json_mode:
payload: dict[str, object] = {
"FEATURE_DIR": str(paths.feature_dir),
"AVAILABLE_DOCS": docs,
}
if args.template_name:
payload["TEMPLATE_CONTENT"] = template_content
sys.stdout.write(
_json_line({"FEATURE_DIR": str(paths.feature_dir), "AVAILABLE_DOCS": docs})
_json_line(payload)
)
else:
_print_text_results(paths, args.include_tasks)
+280 -19
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import json
import os
import re
import sys
from dataclasses import dataclass
from pathlib import Path
@@ -182,12 +183,30 @@ def get_feature_paths(
)
_SAFE_COMPONENT_PATTERN = re.compile(r"[a-z0-9-]+")
def _is_safe_component(value: object) -> bool:
return (
isinstance(value, str)
and _SAFE_COMPONENT_PATTERN.fullmatch(value) is not None
)
def _normalize_priority(value: object) -> int:
if isinstance(value, bool):
return 10
try:
priority = int(value)
except (TypeError, ValueError, OverflowError):
return 10
return priority if priority >= 1 else 10
def _sorted_preset_ids(presets_dir: Path) -> list[str]:
registry = presets_dir / ".registry"
if registry.is_file():
# Mirrors bash: any failure while reading or sorting the registry
# (invalid JSON, non-dict shapes, unorderable priority values) falls
# back to the directory scan below.
# Invalid JSON or registry shapes fall back to the directory scan below.
try:
data = json.loads(registry.read_text(encoding="utf-8"))
presets = data.get("presets", {})
@@ -195,11 +214,18 @@ def _sorted_preset_ids(presets_dir: Path) -> list[str]:
pid
for pid, meta in sorted(
presets.items(),
key=lambda kv: kv[1].get("priority", 10)
if isinstance(kv[1], dict)
else 10,
key=lambda kv: (
_normalize_priority(kv[1].get("priority"))
if isinstance(kv[1], dict)
else 10,
kv[0],
),
)
if (
_is_safe_component(pid)
and isinstance(meta, dict)
and bool(meta.get("enabled", True))
)
if isinstance(meta, dict) and meta.get("enabled", True) is not False
]
except Exception:
pass
@@ -207,12 +233,78 @@ def _sorted_preset_ids(presets_dir: Path) -> list[str]:
return sorted(
p.name
for p in presets_dir.iterdir()
if p.is_dir() and not p.name.startswith(".")
if p.is_dir() and _is_safe_component(p.name)
)
except OSError:
return []
def _sorted_extension_ids(extensions_dir: Path) -> list[str]:
registry = extensions_dir / ".registry"
registered_ids: set[str] = set()
extensions: dict[object, object] = {}
if os.path.lexists(registry):
if not registry.is_file():
raise TemplateResolutionError(
f"Invalid extension registry {registry}: not a regular file"
)
try:
data = json.loads(registry.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
raise TemplateResolutionError(
f"Failed to parse extension registry {registry}: {exc}"
) from exc
if not isinstance(data, dict):
raise TemplateResolutionError(
f"Invalid extension registry {registry}: root must be a mapping"
)
raw_extensions = data.get("extensions", {})
if not isinstance(raw_extensions, dict):
raise TemplateResolutionError(
f"Invalid extension registry {registry}: "
"'extensions' must be a mapping"
)
extensions = raw_extensions
registered_ids = {
ext_id for ext_id in extensions if isinstance(ext_id, str)
}
ranked: list[tuple[int, str]] = []
for ext_id, metadata in extensions.items():
if (
_is_safe_component(ext_id)
and isinstance(metadata, dict)
and bool(metadata.get("enabled", True))
):
ranked.append((_normalize_priority(metadata.get("priority")), ext_id))
try:
ranked.extend(
(10, path.name)
for path in extensions_dir.iterdir()
if (
path.is_dir()
and _is_safe_component(path.name)
and path.name not in registered_ids
)
)
except OSError:
pass
return [ext_id for _, ext_id in sorted(ranked)]
def _conventional_template(
base_dir: Path, template_name: str
) -> Path | None:
for candidate in (
base_dir / "templates" / f"{template_name}.md",
base_dir / f"{template_name}.md",
):
if candidate.is_file():
return candidate
return None
def resolve_template(template_name: str, repo_root: Path) -> Path | None:
"""Resolve a template name to a file path using the priority stack.
@@ -222,6 +314,9 @@ def resolve_template(template_name: str, repo_root: Path) -> Path | None:
3. .specify/extensions/<ext-id>/templates/ (hidden directories skipped)
4. .specify/templates/ (core)
"""
if not _is_safe_component(template_name):
return None
base = repo_root / ".specify" / "templates"
override = base / "overrides" / f"{template_name}.md"
@@ -231,21 +326,18 @@ def resolve_template(template_name: str, repo_root: Path) -> Path | None:
presets_dir = repo_root / ".specify" / "presets"
if presets_dir.is_dir():
for preset_id in _sorted_preset_ids(presets_dir):
candidate = presets_dir / preset_id / "templates" / f"{template_name}.md"
if candidate.is_file():
candidate = _conventional_template(
presets_dir / preset_id, template_name
)
if candidate is not None:
return candidate
ext_dir = repo_root / ".specify" / "extensions"
if ext_dir.is_dir():
try:
extensions = sorted(p for p in ext_dir.iterdir() if p.is_dir())
except OSError:
extensions = []
for ext in extensions:
if ext.name.startswith("."):
continue
candidate = ext / "templates" / f"{template_name}.md"
if candidate.is_file():
for extension_id in _sorted_extension_ids(ext_dir):
ext = ext_dir / extension_id
candidate = _conventional_template(ext, template_name)
if candidate is not None:
return candidate
core = base / f"{template_name}.md"
@@ -254,6 +346,175 @@ def resolve_template(template_name: str, repo_root: Path) -> Path | None:
return None
class TemplateResolutionError(RuntimeError):
"""Raised when template layers exist but cannot be composed safely."""
# Mirror the canonical PresetManifest contract (see src/specify_cli/presets)
# so runtime resolution rejects the same structurally malformed manifests.
_VALID_TEMPLATE_TYPES = ("template", "command", "script")
_VALID_TEMPLATE_STRATEGIES = ("replace", "prepend", "append", "wrap")
_VALID_SCRIPT_STRATEGIES = ("replace", "wrap")
def _validate_manifest_template_entry(entry: object) -> None:
"""Validate a single manifest template entry against the canonical rules."""
if not isinstance(entry, dict):
raise ValueError("manifest template entries must be mappings")
if "type" not in entry or "name" not in entry or "file" not in entry:
raise ValueError("manifest template entry missing type, name, or file")
for field in ("type", "name", "file"):
if not isinstance(entry[field], str):
raise ValueError(f"manifest template {field} must be a string")
if entry["type"] not in _VALID_TEMPLATE_TYPES:
raise ValueError(f"invalid manifest template type '{entry['type']}'")
strategy = entry.get("strategy", "replace")
if not isinstance(strategy, str):
raise ValueError("manifest template strategy must be a string")
strategy = strategy.lower()
if strategy not in _VALID_TEMPLATE_STRATEGIES:
raise ValueError(f"invalid manifest template strategy '{strategy}'")
if entry["type"] == "script" and strategy not in _VALID_SCRIPT_STRATEGIES:
raise ValueError(
f"invalid manifest script strategy '{strategy}'"
)
def _preset_template_layer(
preset_dir: Path, template_name: str
) -> tuple[Path, str] | None:
"""Return the preset template path and composition strategy."""
manifest_path = preset_dir / "preset.yml"
conventional = _conventional_template(preset_dir, template_name)
try:
import yaml
except ImportError as exc:
if manifest_path.is_file():
raise TemplateResolutionError(
"PyYAML is required to resolve preset template composition"
) from exc
return (conventional, "replace") if conventional is not None else None
if manifest_path.is_file():
try:
manifest = yaml.safe_load(manifest_path.read_text(encoding="utf-8"))
if not isinstance(manifest, dict):
raise ValueError("manifest root must be a mapping")
if "provides" not in manifest:
raise ValueError("manifest missing provides section")
provides = manifest["provides"]
if not isinstance(provides, dict):
raise ValueError("manifest provides must be a mapping")
if "templates" not in provides:
raise ValueError("manifest provides missing templates")
templates = provides["templates"]
if not isinstance(templates, list):
raise ValueError("manifest templates must be a list")
if not templates:
raise ValueError("manifest must provide at least one template")
for entry in templates:
_validate_manifest_template_entry(entry)
for entry in templates:
if (
entry.get("name") != template_name
or entry.get("type", "template") != "template"
):
continue
file_value = entry.get("file", "")
strategy = entry.get("strategy", "replace")
relative = Path(file_value)
if (
not relative
or relative.is_absolute()
or ".." in relative.parts
):
return None
candidate = preset_dir / relative
if not candidate.is_file():
return None
return candidate, strategy.lower()
except (OSError, UnicodeError, ValueError, yaml.YAMLError) as exc:
raise TemplateResolutionError(
f"Failed to parse preset manifest {manifest_path}: {exc}"
) from exc
return (conventional, "replace") if conventional is not None else None
def resolve_template_content(template_name: str, repo_root: Path) -> str | None:
"""Resolve and compose template content through the project layer stack."""
if not _is_safe_component(template_name):
return None
layers: list[tuple[Path, str]] = []
def compose_from_base() -> str:
try:
content = layers[-1][0].read_bytes().decode("utf-8")
for path, strategy in reversed(layers[:-1]):
layer_content = path.read_bytes().decode("utf-8")
if strategy == "prepend":
content = f"{layer_content}\n\n{content}"
elif strategy == "append":
content = f"{content}\n\n{layer_content}"
elif strategy == "wrap":
placeholder = "{CORE_TEMPLATE}"
if placeholder not in layer_content:
raise TemplateResolutionError(
f"Wrap layer {path} is missing {placeholder}"
)
content = layer_content.replace(placeholder, content)
else:
raise TemplateResolutionError(
f"Unknown template composition strategy '{strategy}' in {path}"
)
except (OSError, UnicodeError) as exc:
raise TemplateResolutionError(
f"Failed to read template layer for '{template_name}': {exc}"
) from exc
return content
override = (
repo_root
/ ".specify"
/ "templates"
/ "overrides"
/ f"{template_name}.md"
)
if override.is_file():
layers.append((override, "replace"))
return compose_from_base()
presets_dir = repo_root / ".specify" / "presets"
for preset_id in _sorted_preset_ids(presets_dir):
layer = _preset_template_layer(presets_dir / preset_id, template_name)
if layer is not None:
layers.append(layer)
if layer[1] == "replace":
return compose_from_base()
extensions_dir = repo_root / ".specify" / "extensions"
for extension_id in _sorted_extension_ids(extensions_dir):
extension_dir = extensions_dir / extension_id
candidate = _conventional_template(extension_dir, template_name)
if candidate is not None:
layers.append((candidate, "replace"))
return compose_from_base()
core = repo_root / ".specify" / "templates" / f"{template_name}.md"
if core.is_file():
layers.append((core, "replace"))
return compose_from_base()
if not layers:
return None
raise TemplateResolutionError(
f"Template '{template_name}' has composing layers but no replace base"
)
def get_invoke_separator(repo_root: Path) -> str:
integration_json = repo_root / ".specify" / "integration.json"
if not integration_json.is_file():
+26 -7
View File
@@ -7,16 +7,25 @@ import datetime
import json
import re
import shlex
import shutil
import sys
from dataclasses import dataclass
from pathlib import Path
try:
from common import get_repo_root, persist_feature_json, resolve_template
from common import (
TemplateResolutionError,
get_repo_root,
persist_feature_json,
resolve_template_content,
)
except ImportError: # pragma: no cover - direct execution from unusual cwd
sys.path.insert(0, str(Path(__file__).resolve().parent))
from common import get_repo_root, persist_feature_json, resolve_template
from common import (
TemplateResolutionError,
get_repo_root,
persist_feature_json,
resolve_template_content,
)
def _json_line(payload: object) -> str:
@@ -374,12 +383,22 @@ def main(argv: list[str] | None = None) -> int:
)
return 1
template_content = None
needs_spec = not spec_file.is_file()
if needs_spec:
try:
template_content = resolve_template_content(
"spec-template", repo_root
)
except TemplateResolutionError as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
feature_dir.mkdir(parents=True, exist_ok=True)
if not spec_file.is_file():
template = resolve_template("spec-template", repo_root)
if template is not None and template.is_file():
shutil.copy(template, spec_file)
if needs_spec:
if template_content is not None:
spec_file.write_bytes(template_content.encode("utf-8"))
else:
print(
"Warning: Spec template not found; created empty spec file",
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env python3
"""Resolve composed template content from the project template stack."""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
try:
from common import (
TemplateResolutionError,
get_repo_root,
resolve_template_content,
)
except ImportError: # pragma: no cover - direct execution from unusual cwd
sys.path.insert(0, str(Path(__file__).resolve().parent))
from common import (
TemplateResolutionError,
get_repo_root,
resolve_template_content,
)
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser()
parser.add_argument("template_name")
parser.add_argument("--json", action="store_true")
args = parser.parse_args(argv)
repo_root = get_repo_root(Path(__file__))
try:
content = resolve_template_content(args.template_name, repo_root)
except TemplateResolutionError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 1
if content is None:
print(
f"ERROR: Could not resolve required {args.template_name} from the "
f"template override stack for {repo_root}",
file=sys.stderr,
)
return 1
if args.json:
print(
json.dumps(
{
"TEMPLATE_NAME": args.template_name,
"TEMPLATE_CONTENT": content,
},
ensure_ascii=False,
separators=(",", ":"),
)
)
else:
sys.stdout.write(content)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+17 -6
View File
@@ -4,15 +4,22 @@
from __future__ import annotations
import json
import shutil
import sys
from pathlib import Path
try:
from common import get_feature_paths, resolve_template
from common import (
TemplateResolutionError,
get_feature_paths,
resolve_template_content,
)
except ImportError: # pragma: no cover - direct execution from unusual cwd
sys.path.insert(0, str(Path(__file__).resolve().parent))
from common import get_feature_paths, resolve_template
from common import (
TemplateResolutionError,
get_feature_paths,
resolve_template_content,
)
def _json_line(payload: object) -> str:
@@ -55,9 +62,13 @@ def main(argv: list[str] | None = None) -> int:
file=status_stream,
)
else:
template = resolve_template("plan-template", paths.repo_root)
if template is not None and template.is_file():
shutil.copy(template, paths.impl_plan)
try:
template_content = resolve_template_content("plan-template", paths.repo_root)
except TemplateResolutionError as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
if template_content is not None:
paths.impl_plan.write_bytes(template_content.encode("utf-8"))
print(f"Copied plan template to {paths.impl_plan}", file=status_stream)
else:
print("Warning: Plan template not found", file=status_stream)
+17 -4
View File
@@ -10,17 +10,21 @@ from pathlib import Path
try:
from common import (
FeaturePaths,
TemplateResolutionError,
format_speckit_command,
get_feature_paths,
resolve_template,
resolve_template_content,
)
except ImportError: # pragma: no cover - direct execution from unusual cwd
sys.path.insert(0, str(Path(__file__).resolve().parent))
from common import (
FeaturePaths,
TemplateResolutionError,
format_speckit_command,
get_feature_paths,
resolve_template,
resolve_template_content,
)
@@ -120,8 +124,14 @@ def main(argv: list[str] | None = None) -> int:
docs = _available_docs(paths)
tasks_template = resolve_template("tasks-template", paths.repo_root)
if tasks_template is None or not tasks_template.is_file():
try:
tasks_template_content = resolve_template_content(
"tasks-template", paths.repo_root
)
except TemplateResolutionError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 1
if tasks_template_content is None:
print(
"ERROR: Could not resolve required tasks-template from the template "
f"override stack for {paths.repo_root}",
@@ -138,18 +148,21 @@ def main(argv: list[str] | None = None) -> int:
return 1
if json_mode:
tasks_template = resolve_template("tasks-template", paths.repo_root)
sys.stdout.write(
_json_line(
{
"FEATURE_DIR": str(paths.feature_dir),
"AVAILABLE_DOCS": docs,
"TASKS_TEMPLATE": str(tasks_template),
"TASKS_TEMPLATE": str(tasks_template) if tasks_template else "",
"TASKS_TEMPLATE_CONTENT": tasks_template_content,
}
)
)
else:
tasks_template = resolve_template("tasks-template", paths.repo_root)
print(f"FEATURE_DIR: {paths.feature_dir}")
print(f"TASKS_TEMPLATE: {tasks_template}")
print(f"TASKS_TEMPLATE: {tasks_template or 'not found'}")
print("AVAILABLE_DOCS:")
_check_file(paths.research, "research.md")
_check_file(paths.data_model, "data-model.md")
+39
View File
@@ -745,6 +745,13 @@ class ExtensionRegistry:
if not self.registry_path.exists():
return {"schema_version": self.SCHEMA_VERSION, "extensions": {}}
# A non-regular file (e.g. a directory at the registry path) is not a
# readable registry. Recover to empty so construction — used by the
# install/enable/disable flows — does not crash. Resolution paths that
# must fail closed consult is_corrupt() instead of relying on this.
if not self.registry_path.is_file():
return {"schema_version": self.SCHEMA_VERSION, "extensions": {}}
try:
with open(self.registry_path, "r", encoding="utf-8") as f:
data = json.load(f)
@@ -764,6 +771,38 @@ class ExtensionRegistry:
# starting fresh would let a later _save() wipe it.
return {"schema_version": self.SCHEMA_VERSION, "extensions": {}}
def is_corrupt(self) -> bool:
"""Report whether an existing registry file is present but unreadable.
``_load`` deliberately recovers from a corrupt registry by normalizing
it to an empty mapping so install/enable/disable flows keep working.
Resolution paths, however, must fail closed: a corrupt registry that
normalizes to ``{}`` would otherwise cause every on-disk extension
directory to be admitted as an unregistered, enabled extension. This
probe lets those callers distinguish "no registry" (safe) from
"registry exists but is invalid" (unsafe) without changing recovery
behavior. An absent registry returns ``False``; a directory, broken
or dangling symlink, non-regular file, unreadable file, non-mapping
root, or non-mapping ``extensions`` value returns ``True``.
"""
# os.path.lexists (not Path.exists) so a dangling symlink is detected
# rather than followed to a non-existent target and mistaken for an
# absent registry — which would reopen the fail-open directory scan.
if not os.path.lexists(self.registry_path):
return False
if not self.registry_path.is_file():
return True
try:
with open(self.registry_path, "r", encoding="utf-8") as f:
data = json.load(f)
except (json.JSONDecodeError, OSError, UnicodeDecodeError):
return True
if not isinstance(data, dict):
return True
if "extensions" in data and not isinstance(data["extensions"], dict):
return True
return False
def _save(self):
"""Save registry to disk."""
self.extensions_dir.mkdir(parents=True, exist_ok=True)
+45 -24
View File
@@ -56,6 +56,7 @@ from ..shared_infra import (
_CONSTITUTION_PROVENANCE_FILE = ".constitution-template.json"
_CONSTITUTION_SYNC_PRESET_ID = "constitution-sync"
def _content_sha256(content: bytes) -> str:
@@ -3602,13 +3603,10 @@ class PresetManager:
stacklevel=2,
)
# Seed/re-seed memory/constitution.md from a preset-provided
# constitution-template. The constitution is the only template that is
# materialized to a live file rather than resolved on demand, so a
# preset that ships one (e.g. strategy: replace with a ratified
# constitution) must be propagated here. Guard against clobbering an
# already-authored constitution by only replacing a file whose recorded
# hash (or exact legacy core-template content) proves it was generated.
# Materialize constitution-template changes only for projects that opt
# into the constitution-sync preset. The core /constitution command
# resolves this template on demand; constitution-sync preserves the
# previous install-time behavior for teams that want reviewed snapshots.
self._seed_constitution_from_preset(manifest, dest_dir)
return manifest
@@ -3616,14 +3614,13 @@ class PresetManager:
def _seed_constitution_from_preset(
self, manifest: PresetManifest, preset_dir: Path
) -> None:
"""Seed memory/constitution.md from a preset constitution-template.
"""Seed memory/constitution.md when constitution-sync opts into snapshots.
Only runs when the preset declares a ``type: template`` entry named
``constitution-template`` or provides one at a convention path, and the
live memory file is either missing or is an unchanged generated file.
Authored constitutions are never overwritten.
Installing constitution-sync itself materializes the currently resolved
stack. Later preset installs only reconcile when they provide a
``constitution-template``. Authored constitutions are never overwritten.
"""
provides_constitution = any(
provides_constitution = manifest.id == _CONSTITUTION_SYNC_PRESET_ID or any(
t.get("type") == "template" and t.get("name") == "constitution-template"
for t in manifest.templates
) or any(
@@ -3644,7 +3641,7 @@ class PresetManager:
def reconcile_constitution(
self, failure_context: str, *, create_if_missing: bool = False
) -> None:
"""Reconcile generated constitution content without failing a persisted change."""
"""Reconcile an opted-in generated constitution without failing a change."""
try:
self._reconcile_constitution(create_if_missing=create_if_missing)
except (OSError, UnicodeDecodeError, PresetValidationError, ValueError) as exc:
@@ -3656,7 +3653,11 @@ class PresetManager:
)
def _reconcile_constitution(self, *, create_if_missing: bool = False) -> None:
"""Materialize the winning constitution layer when the live file is generated."""
"""Materialize the winning layer when constitution-sync is enabled."""
sync_metadata = self.registry.get(_CONSTITUTION_SYNC_PRESET_ID)
if sync_metadata is None or not sync_metadata.get("enabled", True):
return
memory_constitution = (
self.project_root / ".specify" / "memory" / "constitution.md"
)
@@ -4962,6 +4963,18 @@ class PresetResolver:
self._manifest_cache[key] = None
return self._manifest_cache[key]
@staticmethod
def _is_safe_registry_id(value: object) -> bool:
return isinstance(value, str) and re.fullmatch(r"[a-z0-9-]+", value) is not None
def _get_all_presets_by_priority(self) -> List[tuple[str, dict]]:
registry = PresetRegistry(self.presets_dir)
return [
(pack_id, metadata)
for pack_id, metadata in registry.list_by_priority()
if self._is_safe_registry_id(pack_id)
]
def _manifest_declared_template(
self, pack_dir: Path, template_name: str, template_type: str
) -> tuple[dict | None, Path | None]:
@@ -5067,6 +5080,16 @@ class PresetResolver:
return []
registry = ExtensionRegistry(self.extensions_dir)
# Fail closed on a corrupt registry. ExtensionRegistry._load() recovers
# by normalizing an unreadable registry to an empty mapping, which would
# otherwise cause the directory scan below to admit every on-disk
# directory as an unregistered, enabled extension — a fail-open path
# that could supply constitution content from an invalid registry state.
if registry.is_corrupt():
raise PresetValidationError(
f"Invalid extension registry {registry.registry_path}: "
"refusing to enumerate extensions"
)
# Use keys() to track ALL extensions (including corrupted entries) without deep copy
# This prevents corrupted entries from being picked up as "unregistered" dirs
registered_extension_ids = registry.keys()
@@ -5078,6 +5101,8 @@ class PresetResolver:
# Only include enabled extensions in the result
for ext_id, metadata in all_registered:
if not self._is_safe_registry_id(ext_id):
continue
# Skip disabled extensions
if not metadata.get("enabled", True):
continue
@@ -5086,7 +5111,7 @@ class PresetResolver:
# Add unregistered directories with implicit priority=10
for ext_dir in self.extensions_dir.iterdir():
if not ext_dir.is_dir() or ext_dir.name.startswith("."):
if not ext_dir.is_dir() or not self._is_safe_registry_id(ext_dir.name):
continue
if ext_dir.name not in registered_extension_ids:
all_extensions.append((10, ext_dir.name, None))
@@ -5152,8 +5177,7 @@ class PresetResolver:
# Priority 2: Installed presets (sorted by priority — lower number wins)
if not skip_presets and self.presets_dir.exists():
registry = PresetRegistry(self.presets_dir)
for pack_id, _metadata in registry.list_by_priority():
for pack_id, _metadata in self._get_all_presets_by_priority():
pack_dir = self.presets_dir / pack_id
# The preset manifest is authoritative: if it declares this
# template with an explicit ``file:``, resolve to that path —
@@ -5356,13 +5380,11 @@ class PresetResolver:
return {"path": resolved_str, "source": "project override"}
if str(self.presets_dir) in resolved_str and self.presets_dir.exists():
registry = PresetRegistry(self.presets_dir)
for pack_id, _metadata in registry.list_by_priority():
for pack_id, metadata in self._get_all_presets_by_priority():
pack_dir = self.presets_dir / pack_id
try:
resolved.relative_to(pack_dir)
meta = registry.get(pack_id)
version = meta.get("version", "?") if meta else "?"
version = metadata.get("version", "?")
return {
"path": resolved_str,
"source": f"{pack_id} v{version}",
@@ -5448,8 +5470,7 @@ class PresetResolver:
# Priority 2: Installed presets (sorted by priority — lower number = higher precedence)
if self.presets_dir.exists():
registry = PresetRegistry(self.presets_dir)
for pack_id, metadata in registry.list_by_priority():
for pack_id, metadata in self._get_all_presets_by_priority():
pack_dir = self.presets_dir / pack_id
# Read strategy and manifest file path from preset manifest
strategy = "replace"
+21 -3
View File
@@ -9,6 +9,7 @@ keeps working.
from __future__ import annotations
import os
import re
from pathlib import Path
import typer
@@ -352,9 +353,26 @@ def preset_resolve(
from .. import _require_specify_project
from . import PresetResolver
is_command = "." in template_name
valid_name = (
re.fullmatch(r"[a-z0-9-]+(?:\.[a-z0-9-]+)+", template_name)
if is_command
else re.fullmatch(r"[a-z0-9-]+", template_name)
)
if valid_name is None:
typer.echo(
f"Error: invalid template name '{template_name}'; "
"use lowercase letters, digits, and hyphens, with non-empty "
"dot-separated segments for commands",
err=True,
)
raise typer.Exit(1)
project_root = _require_specify_project()
resolver = PresetResolver(project_root)
layers = resolver.collect_all_layers(template_name)
template_type = "command" if is_command else "template"
layers = resolver.collect_all_layers(template_name, template_type)
safe_template_name = _escape_markup(str(template_name))
if layers:
@@ -377,7 +395,7 @@ def preset_resolve(
if has_composition:
# Verify composition is actually possible
try:
composed = resolver.resolve_content(template_name)
composed = resolver.resolve_content(template_name, template_type)
except Exception as exc:
composed = None
console.print(
@@ -416,7 +434,7 @@ def preset_resolve(
)
else:
# No layers found — fall back to resolve_with_source for non-composition cases
result = resolver.resolve_with_source(template_name)
result = resolver.resolve_with_source(template_name, template_type)
if result:
console.print(
f" [bold]{safe_template_name}[/bold]: "
+5 -5
View File
@@ -1,9 +1,9 @@
---
description: Generate a custom checklist for the current feature based on user requirements.
scripts:
sh: scripts/bash/check-prerequisites.sh --json
ps: scripts/powershell/check-prerequisites.ps1 -Json
py: scripts/python/check_prerequisites.py --json
sh: scripts/bash/check-prerequisites.sh --json --template checklist-template
ps: scripts/powershell/check-prerequisites.ps1 -Json -Template checklist-template
py: scripts/python/check_prerequisites.py --json --template checklist-template
---
## Checklist Purpose: "Unit Tests for English"
@@ -72,7 +72,7 @@ You **MUST** consider the user input before proceeding (if not empty).
## Execution Steps
1. **Setup**: Run `{SCRIPT}` from repo root and parse JSON for FEATURE_DIR and AVAILABLE_DOCS list.
1. **Setup**: Run `{SCRIPT}` from repo root and parse JSON for FEATURE_DIR, AVAILABLE_DOCS list, and TEMPLATE_CONTENT.
- All file paths must be absolute.
- For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
@@ -127,7 +127,7 @@ You **MUST** consider the user input before proceeding (if not empty).
- Use progressive disclosure: add follow-on retrieval only if gaps detected
- If source docs are large, generate interim summary items instead of embedding raw text
6. **Generate checklist** - Create "Unit Tests for Requirements":
6. **Generate checklist** - Use TEMPLATE_CONTENT as the structural template and create "Unit Tests for Requirements":
- Create `FEATURE_DIR/checklists/` directory if it doesn't exist
- Generate unique checklist filename:
- Use short, descriptive name based on domain (e.g., `ux.md`, `api.md`, `security.md`)
+19 -6
View File
@@ -4,6 +4,10 @@ handoffs:
- label: Build Specification
agent: speckit.specify
prompt: Implement the feature specification based on the updated constitution. I want to build...
scripts:
sh: scripts/bash/resolve-template.sh constitution-template --json
ps: scripts/powershell/resolve-template.ps1 constitution-template -Json
py: scripts/python/resolve_template.py constitution-template --json
---
## User Input
@@ -70,13 +74,22 @@ and commands read the constitution at runtime and are not modified here.
## Outline
You are updating the project constitution at `.specify/memory/constitution.md`. This file is a TEMPLATE containing placeholder tokens in square brackets (e.g. `[PROJECT_NAME]`, `[PRINCIPLE_1_NAME]`). Your job is to (a) collect/derive concrete values and (b) fill the template precisely.
**Note**: If `.specify/memory/constitution.md` does not exist yet, it should have been initialized from `.specify/templates/constitution-template.md` during project setup. If it's missing, copy the template first.
You are updating the project constitution at `.specify/memory/constitution.md`. The active
constitution scaffold is resolved at command time from `constitution-template` through the Spec Kit
preset/template resolution stack.
Follow this execution flow:
1. Load the existing constitution at `.specify/memory/constitution.md`.
1. Run `{SCRIPT}` from the repository root and parse `TEMPLATE_CONTENT` as the active template.
- The shared resolver applies project overrides, composing preset layers, and extension layers
before the core template fallback. It MUST succeed before continuing.
- If it fails, stop and report the resolution error; do not continue with only one contributing
template layer.
- If `.specify/memory/constitution.md` exists, load it as the source of current project-specific
values and amendments. Preserve information that is still applicable when applying the newly
resolved scaffold.
- If it does not exist, use the resolved template as the initial document.
- Do not write back to any versioned template layer.
- Identify every placeholder token of the form `[ALL_CAPS_IDENTIFIER]`.
**IMPORTANT**: The user might require less or more principles than the ones used in the template. If a number is specified, respect that - follow the general template. You will update the doc accordingly.
@@ -90,7 +103,7 @@ Follow this execution flow:
- PATCH: Clarifications, wording, typo fixes, non-semantic refinements.
- If version bump type ambiguous, propose reasoning before finalizing.
3. Draft the updated constitution content:
3. Draft the updated constitution content using the resolved template as the required structure:
- Replace every placeholder with concrete text (no bracketed tokens left except intentionally retained template slots that the project has chosen not to define yet—explicitly justify any left).
- Preserve heading hierarchy and comments can be removed once replaced unless they still add clarifying guidance.
- Ensure each Principle section: succinct name line, paragraph (or bullet list) capturing nonnegotiable rules, explicit rationale if not obvious.
@@ -128,7 +141,7 @@ If the user supplies partial updates (e.g., only one principle revision), still
If critical info missing (e.g., ratification date truly unknown), insert `TODO(<FIELD_NAME>): explanation` and include in the Sync Impact Report under deferred items.
Do not create a new template; always operate on the existing `.specify/memory/constitution.md` file.
Write only `.specify/memory/constitution.md`; do not create or modify template source files.
## Post-Execution Checks
+2 -2
View File
@@ -60,7 +60,7 @@ You **MUST** consider the user input before proceeding (if not empty).
## Outline
1. **Setup**: Run `{SCRIPT}` from repo root and parse FEATURE_DIR, TASKS_TEMPLATE, and AVAILABLE_DOCS list. `FEATURE_DIR` and `TASKS_TEMPLATE` must be absolute paths when provided. `AVAILABLE_DOCS` is a list of document names/relative paths available under `FEATURE_DIR` (for example `research.md` or `contracts/`). For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
1. **Setup**: Run `{SCRIPT}` from repo root and parse FEATURE_DIR, TASKS_TEMPLATE_CONTENT, TASKS_TEMPLATE, and AVAILABLE_DOCS list. `FEATURE_DIR` and `TASKS_TEMPLATE` must be absolute paths when provided. `AVAILABLE_DOCS` is a list of document names/relative paths available under `FEATURE_DIR` (for example `research.md` or `contracts/`). For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
2. **Load design documents**: Read from FEATURE_DIR:
- **Required**: plan.md (tech stack, libraries, structure), spec.md (user stories with priorities)
@@ -79,7 +79,7 @@ You **MUST** consider the user input before proceeding (if not empty).
- Create parallel execution examples per user story
- Validate task completeness (each user story has all needed tasks, independently testable)
4. **Generate tasks.md**: Read the tasks template from TASKS_TEMPLATE (from the JSON output above) and use it as structure. If TASKS_TEMPLATE is empty, fall back to `.specify/templates/tasks-template.md`. Fill with:
4. **Generate tasks.md**: Use TASKS_TEMPLATE_CONTENT (from the JSON output above) as the structure. For compatibility with older setup scripts that omit TASKS_TEMPLATE_CONTENT, read TASKS_TEMPLATE instead. Fill with:
- Correct feature name from plan.md
- Phase 1: Setup tasks (project initialization)
- Phase 2: Foundational tasks (blocking prerequisites for all user stories)
@@ -242,11 +242,11 @@ class MarkdownIntegrationTests:
if script_variant == "sh":
for name in ["check-prerequisites.sh", "common.sh", "create-new-feature.sh",
"setup-plan.sh", "setup-tasks.sh"]:
"resolve-template.sh", "setup-plan.sh", "setup-tasks.sh"]:
files.append(f".specify/scripts/bash/{name}")
else:
for name in ["check-prerequisites.ps1", "common.ps1", "create-new-feature.ps1",
"setup-plan.ps1", "setup-tasks.ps1"]:
"resolve-template.ps1", "setup-plan.ps1", "setup-tasks.ps1"]:
files.append(f".specify/scripts/powershell/{name}")
for name in ["checklist-template.md",
@@ -494,6 +494,7 @@ class SkillsIntegrationTests:
".specify/scripts/bash/check-prerequisites.sh",
".specify/scripts/bash/common.sh",
".specify/scripts/bash/create-new-feature.sh",
".specify/scripts/bash/resolve-template.sh",
".specify/scripts/bash/setup-plan.sh",
".specify/scripts/bash/setup-tasks.sh",
]
@@ -502,6 +503,7 @@ class SkillsIntegrationTests:
".specify/scripts/powershell/check-prerequisites.ps1",
".specify/scripts/powershell/common.ps1",
".specify/scripts/powershell/create-new-feature.ps1",
".specify/scripts/powershell/resolve-template.ps1",
".specify/scripts/powershell/setup-plan.ps1",
".specify/scripts/powershell/setup-tasks.ps1",
]
@@ -495,6 +495,7 @@ class TomlIntegrationTests:
"check-prerequisites.sh",
"common.sh",
"create-new-feature.sh",
"resolve-template.sh",
"setup-plan.sh",
"setup-tasks.sh",
]:
@@ -504,6 +505,7 @@ class TomlIntegrationTests:
"check-prerequisites.ps1",
"common.ps1",
"create-new-feature.ps1",
"resolve-template.ps1",
"setup-plan.ps1",
"setup-tasks.ps1",
]:
@@ -409,6 +409,7 @@ class YamlIntegrationTests:
"check-prerequisites.sh",
"common.sh",
"create-new-feature.sh",
"resolve-template.sh",
"setup-plan.sh",
"setup-tasks.sh",
]:
@@ -418,6 +419,7 @@ class YamlIntegrationTests:
"check-prerequisites.ps1",
"common.ps1",
"create-new-feature.ps1",
"resolve-template.ps1",
"setup-plan.ps1",
"setup-tasks.ps1",
]:
@@ -192,6 +192,7 @@ class TestClineIntegration(MarkdownIntegrationTests):
"check-prerequisites.sh",
"common.sh",
"create-new-feature.sh",
"resolve-template.sh",
"setup-plan.sh",
"setup-tasks.sh",
]:
@@ -201,6 +202,7 @@ class TestClineIntegration(MarkdownIntegrationTests):
"check-prerequisites.ps1",
"common.ps1",
"create-new-feature.ps1",
"resolve-template.ps1",
"setup-plan.ps1",
"setup-tasks.ps1",
]:
@@ -278,6 +278,7 @@ class TestCopilotCommandsMode:
".specify/scripts/bash/check-prerequisites.sh",
".specify/scripts/bash/common.sh",
".specify/scripts/bash/create-new-feature.sh",
".specify/scripts/bash/resolve-template.sh",
".specify/scripts/bash/setup-plan.sh",
".specify/scripts/bash/setup-tasks.sh",
".specify/templates/checklist-template.md",
@@ -342,6 +343,7 @@ class TestCopilotCommandsMode:
".specify/scripts/powershell/check-prerequisites.ps1",
".specify/scripts/powershell/common.ps1",
".specify/scripts/powershell/create-new-feature.ps1",
".specify/scripts/powershell/resolve-template.ps1",
".specify/scripts/powershell/setup-plan.ps1",
".specify/scripts/powershell/setup-tasks.ps1",
".specify/templates/checklist-template.md",
@@ -854,6 +856,7 @@ class TestCopilotSkillsMode:
".specify/scripts/bash/check-prerequisites.sh",
".specify/scripts/bash/common.sh",
".specify/scripts/bash/create-new-feature.sh",
".specify/scripts/bash/resolve-template.sh",
".specify/scripts/bash/setup-plan.sh",
".specify/scripts/bash/setup-tasks.sh",
# Templates
@@ -348,6 +348,7 @@ class TestGenericIntegration:
".specify/scripts/bash/check-prerequisites.sh",
".specify/scripts/bash/common.sh",
".specify/scripts/bash/create-new-feature.sh",
".specify/scripts/bash/resolve-template.sh",
".specify/scripts/bash/setup-plan.sh",
".specify/scripts/bash/setup-tasks.sh",
".specify/templates/checklist-template.md",
@@ -406,6 +407,7 @@ class TestGenericIntegration:
".specify/scripts/powershell/check-prerequisites.ps1",
".specify/scripts/powershell/common.ps1",
".specify/scripts/powershell/create-new-feature.ps1",
".specify/scripts/powershell/resolve-template.ps1",
".specify/scripts/powershell/setup-plan.ps1",
".specify/scripts/powershell/setup-tasks.ps1",
".specify/templates/checklist-template.md",
+61
View File
@@ -109,6 +109,67 @@ def write_feature_json(
)
def install_composition_stack(
repo: Path, template_name: str, core_content: str
) -> str:
"""Install wrap/prepend/append presets over a core template."""
templates = repo / ".specify" / "templates"
templates.mkdir(parents=True, exist_ok=True)
(templates / f"{template_name}.md").write_text(core_content, encoding="utf-8")
layers = [
("wrap-pack", 1, "wrap", "## Wrapper\n{CORE_TEMPLATE}\n## End\n"),
("prepend-pack", 2, "prepend", "# Prepended\n"),
("append-pack", 3, "append", "# Appended\n"),
]
registry: dict[str, object] = {"presets": {}}
registry_presets = registry["presets"]
assert isinstance(registry_presets, dict)
for preset_id, priority, strategy, content in layers:
preset_dir = repo / ".specify" / "presets" / preset_id
template_dir = preset_dir / "templates"
template_dir.mkdir(parents=True)
(template_dir / f"{template_name}.md").write_text(content, encoding="utf-8")
(preset_dir / "preset.yml").write_text(
"provides:\n"
" templates:\n"
" - type: template\n"
f" name: {template_name}\n"
f" file: templates/{template_name}.md\n"
f" strategy: {strategy}\n",
encoding="utf-8",
)
registry_presets[preset_id] = {
"enabled": True,
"priority": priority,
}
(repo / ".specify" / "presets" / ".registry").write_text(
json.dumps(registry, separators=(",", ":")) + "\n",
encoding="utf-8",
)
appended = "# Appended\n"
prepended = "# Prepended\n"
wrapper = "## Wrapper\n{CORE_TEMPLATE}\n## End\n"
composed = f"{core_content}\n\n{appended}"
composed = f"{prepended}\n\n{composed}"
return wrapper.replace("{CORE_TEMPLATE}", composed)
def break_wrap_layer(repo: Path, template_name: str) -> None:
"""Replace the installed wrap layer with one missing its placeholder."""
(
repo
/ ".specify"
/ "presets"
/ "wrap-pack"
/ "templates"
/ f"{template_name}.md"
).write_text("# Broken wrapper\n", encoding="utf-8")
def normalize_repo_paths(text: str, repo: Path) -> str:
"""Replace the repo path with a placeholder so two-repo runs compare equal."""
repo_paths = sorted({str(repo), str(repo.resolve())}, key=len, reverse=True)
@@ -12,6 +12,7 @@ from pathlib import Path
import pytest
from tests.conftest import requires_bash
from tests.parity_helpers import install_composition_stack
PROJECT_ROOT = Path(__file__).resolve().parent.parent
COMMON_SH = PROJECT_ROOT / "scripts" / "bash" / "common.sh"
@@ -136,6 +137,87 @@ def _normalize_help_text(text: str) -> str:
return "\n".join("" if not line.strip() else line for line in normalized.split("\n"))
@requires_bash
@pytest.mark.parametrize("missing", [False, True], ids=["composed", "missing"])
def test_all_variants_resolve_requested_template(
prereq_repo: Path, missing: bool
) -> None:
_write_feature_json(prereq_repo)
feature = prereq_repo / "specs" / "001-my-feature"
feature.mkdir(parents=True)
(feature / "plan.md").write_text("# Plan\n", encoding="utf-8")
template_name = "missing-template" if missing else "checklist-template"
expected = install_composition_stack(
prereq_repo, "checklist-template", "# Checklist\n"
)
results = [
_run(
_bash_cmd(prereq_repo, "--json", "--template", template_name),
prereq_repo,
),
_run(
_py_cmd(prereq_repo, "--json", "--template", template_name),
prereq_repo,
),
]
if HAS_PWSH or _WINDOWS_POWERSHELL:
results.append(
_run(
_ps_cmd(prereq_repo, "-Json", "-Template", template_name),
prereq_repo,
)
)
expected_status = 1 if missing else 0
assert all(result.returncode == expected_status for result in results)
if missing:
assert all(result.stdout == "" for result in results)
else:
assert all(
_json_stdout(result)["TEMPLATE_CONTENT"] == expected
for result in results
)
@requires_bash
@pytest.mark.parametrize("missing", [False, True], ids=["composed", "missing"])
def test_all_variants_validate_requested_template_in_text_mode(
prereq_repo: Path, missing: bool
) -> None:
_write_feature_json(prereq_repo)
feature = prereq_repo / "specs" / "001-my-feature"
feature.mkdir(parents=True)
(feature / "plan.md").write_text("# Plan\n", encoding="utf-8")
template_name = "missing-template" if missing else "checklist-template"
install_composition_stack(
prereq_repo, "checklist-template", "# Checklist\n"
)
results = [
_run(
_bash_cmd(prereq_repo, "--template", template_name),
prereq_repo,
),
_run(
_py_cmd(prereq_repo, "--template", template_name),
prereq_repo,
),
]
if HAS_PWSH or _WINDOWS_POWERSHELL:
results.append(
_run(
_ps_cmd(prereq_repo, "-Template", template_name),
prereq_repo,
)
)
expected_status = 1 if missing else 0
assert all(result.returncode == expected_status for result in results)
if missing:
assert all(result.stdout == "" for result in results)
@requires_bash
@pytest.mark.parametrize(
"args",
+1 -1
View File
@@ -79,7 +79,7 @@ def test_template_renders_python_invocation(name: str):
result = IntegrationBase.process_template(content, "agent", "py")
assert "{SCRIPT}" not in result
assert re.search(
r"python3 \.specify/scripts/python/\w+\.py(?: --[\w-]+)*", result
r"python3 \.specify/scripts/python/\w+\.py(?: [\w-]+)*", result
), f"{name} did not render a Python invocation"
+99 -1
View File
@@ -13,6 +13,8 @@ from tests.conftest import requires_bash
from tests.parity_helpers import (
HAS_POWERSHELL,
bash_cmd,
break_wrap_layer,
install_composition_stack,
install_scripts,
json_stdout,
make_repo,
@@ -382,12 +384,108 @@ def test_python_full_run_matches_bash(repo_pair: tuple[Path, Path]) -> None:
branch = json_stdout(py)["BRANCH_NAME"]
for repo in repo_pair:
spec = repo / "specs" / branch / "spec.md"
assert spec.read_text(encoding="utf-8") == TEMPLATE_BODY
assert spec.read_bytes() == TEMPLATE_BODY.encode("utf-8")
assert (repo_b / ".specify" / "feature.json").read_bytes() == (
repo_a / ".specify" / "feature.json"
).read_bytes()
@requires_bash
def test_all_variants_materialize_composed_spec_template(tmp_path: Path) -> None:
repos = [
_setup_repo(tmp_path, "bash"),
_setup_repo(tmp_path, "powershell"),
_setup_repo(tmp_path, "python"),
]
expected = ""
for current in repos:
expected = install_composition_stack(
current, "spec-template", TEMPLATE_BODY
)
bash = run(
bash_cmd(
repos[0],
SCRIPT,
"--json",
"--number",
"1",
"--short-name",
"composed",
"x",
),
repos[0],
)
py = run(
py_cmd(
repos[2],
SCRIPT,
"--json",
"--number",
"1",
"--short-name",
"composed",
"x",
),
repos[2],
)
results = [bash, py]
checked_repos = [repos[0], repos[2]]
if HAS_POWERSHELL:
results.insert(
1,
run(
ps_cmd(
repos[1],
SCRIPT,
"-Json",
"-Number",
"1",
"-ShortName",
"composed",
"x",
),
repos[1],
),
)
checked_repos.insert(1, repos[1])
assert all(result.returncode == 0 for result in results)
for current in checked_repos:
assert (
current / "specs" / "001-composed" / "spec.md"
).read_text(encoding="utf-8") == expected
@requires_bash
def test_all_variants_fail_for_broken_spec_composition(tmp_path: Path) -> None:
repos = [
_setup_repo(tmp_path, "bash"),
_setup_repo(tmp_path, "powershell"),
_setup_repo(tmp_path, "python"),
]
for current in repos:
install_composition_stack(current, "spec-template", TEMPLATE_BODY)
break_wrap_layer(current, "spec-template")
bash = run(bash_cmd(repos[0], SCRIPT, "--json", "x"), repos[0])
py = run(py_cmd(repos[2], SCRIPT, "--json", "x"), repos[2])
results = [(bash, repos[0]), (py, repos[2])]
if HAS_POWERSHELL:
results.append(
(
run(ps_cmd(repos[1], SCRIPT, "-Json", "x"), repos[1]),
repos[1],
)
)
assert all(result.returncode != 0 for result, _ in results)
assert all(
not (current / "specs" / "001-x").exists()
for _, current in results
)
@requires_bash
def test_python_missing_template_warning_matches_bash(
repo_pair: tuple[Path, Path],
+205 -33
View File
@@ -1135,6 +1135,40 @@ class TestPresetResolver:
result = resolver.resolve("nonexistent-template")
assert result is None
def test_resolver_ignores_traversing_registry_ids(self, project_dir):
"""Registry IDs cannot escape preset or extension install roots."""
for registry_dir, registry_key, outside_name in (
("presets", "presets", "outside-preset"),
("extensions", "extensions", "outside-extension"),
):
outside = project_dir.parent / outside_name
(outside / "templates").mkdir(parents=True)
(outside / "templates" / "spec-template.md").write_text(
f"# Sensitive {registry_key}\n",
encoding="utf-8",
)
installed = project_dir / ".specify" / registry_dir
installed.mkdir(parents=True, exist_ok=True)
(installed / ".registry").write_text(
json.dumps(
{
registry_key: {
f"../../../{outside_name}": {
"enabled": True,
"priority": 1,
}
}
}
),
encoding="utf-8",
)
content = PresetResolver(project_dir).resolve_content("spec-template")
assert content is not None
assert "Core Spec Template" in content
assert "Sensitive" not in content
def test_resolve_higher_priority_pack_wins(self, project_dir, temp_dir, valid_pack_data):
"""Test that a pack with lower priority number wins over higher number."""
manager = PresetManager(project_dir)
@@ -1447,6 +1481,65 @@ class TestPresetResolver:
result = resolver.resolve("unique-disabled-template")
assert result is None, "Disabled extension should not be picked up as unregistered"
@pytest.mark.parametrize(
"registry_bytes",
[b"{ not valid json", b'{"extensions": []}', b"[]"],
ids=["invalid_json", "non_mapping_extensions", "non_mapping_root"],
)
def test_resolve_fails_closed_on_corrupt_extension_registry(
self, project_dir, registry_bytes
):
"""A corrupt extension registry must fail closed rather than let the
directory scan admit every on-disk extension as enabled."""
extensions_dir = project_dir / ".specify" / "extensions"
ext_templates_dir = extensions_dir / "sneaky-ext" / "templates"
ext_templates_dir.mkdir(parents=True)
(ext_templates_dir / "custom-template.md").write_text(
"# Should not be served\n"
)
(extensions_dir / ".registry").write_bytes(registry_bytes)
resolver = PresetResolver(project_dir)
with pytest.raises(PresetValidationError, match="Invalid extension registry"):
resolver._get_all_extensions_by_priority()
with pytest.raises(PresetValidationError, match="Invalid extension registry"):
resolver.resolve("custom-template")
def test_resolve_fails_closed_when_registry_is_directory(self, project_dir):
"""A directory at the registry path must fail closed, not be treated as
an absent registry that enables every on-disk extension."""
extensions_dir = project_dir / ".specify" / "extensions"
ext_templates_dir = extensions_dir / "sneaky-ext" / "templates"
ext_templates_dir.mkdir(parents=True)
(ext_templates_dir / "custom-template.md").write_text(
"# Should not be served\n"
)
(extensions_dir / ".registry").mkdir()
resolver = PresetResolver(project_dir)
with pytest.raises(PresetValidationError, match="Invalid extension registry"):
resolver.resolve("custom-template")
def test_resolve_fails_closed_when_registry_is_broken_symlink(self, project_dir):
"""A dangling ``.registry`` symlink must fail closed. ``Path.exists()``
follows symlinks and would mistake it for an absent registry, reopening
the fail-open directory scan."""
extensions_dir = project_dir / ".specify" / "extensions"
ext_templates_dir = extensions_dir / "sneaky-ext" / "templates"
ext_templates_dir.mkdir(parents=True)
(ext_templates_dir / "custom-template.md").write_text(
"# Should not be served\n"
)
(extensions_dir / ".registry").symlink_to(
extensions_dir / "does-not-exist"
)
registry = ExtensionRegistry(extensions_dir)
assert registry.is_corrupt()
resolver = PresetResolver(project_dir)
with pytest.raises(PresetValidationError, match="Invalid extension registry"):
resolver.resolve("custom-template")
def test_resolve_pack_over_extension(self, project_dir, pack_dir, temp_dir, valid_pack_data):
"""Test that pack templates take priority over extension templates."""
# Create extension with templates
@@ -3454,6 +3547,9 @@ class TestPresetCatalogMultiCatalog:
SELF_TEST_PRESET_DIR = Path(__file__).parent.parent / "presets" / "self-test"
CONSTITUTION_SYNC_PRESET_DIR = (
Path(__file__).parent.parent / "presets" / "constitution-sync"
)
SELF_TEST_WRAP_WARNING = (
r"Cannot compose command 'speckit\.wrap-test': no base layer\. "
r"Stale command files may remain\."
@@ -3480,6 +3576,11 @@ def install_self_test_preset(manager: PresetManager, speckit_version: str = "0.1
return manager.install_from_directory(SELF_TEST_PRESET_DIR, speckit_version)
def install_constitution_sync_preset(manager: PresetManager) -> PresetManifest:
"""Enable guarded install-time constitution materialization."""
return manager.install_from_directory(CONSTITUTION_SYNC_PRESET_DIR, "0.15.0")
def _make_convention_constitution_preset(temp_dir: Path) -> Path:
"""Create a preset whose constitution is found by convention, not its manifest."""
preset_dir = temp_dir / "convention-constitution"
@@ -3612,6 +3713,7 @@ class TestSelfTestPreset:
(templates_dir / f"{name}.md").write_text(f"# Core {name}\n")
manager = PresetManager(project_dir)
install_constitution_sync_preset(manager)
install_self_test_preset(manager)
manager.remove("self-test")
@@ -3630,6 +3732,7 @@ class TestSelfTestPreset:
(templates_dir / "constitution-template.md").write_text("# Core Constitution\n")
manager = PresetManager(project_dir)
install_constitution_sync_preset(manager)
install_self_test_preset(manager)
memory = project_dir / ".specify" / "memory" / "constitution.md"
edited = memory.read_text() + "\n## Authored amendment\n"
@@ -3713,19 +3816,16 @@ class TestSelfTestPreset:
metadata = manager.registry.get("self-test")
assert metadata["registered_commands"] == {}
def test_self_test_seeds_constitution_when_memory_absent(self, project_dir):
"""Installing a preset seeds memory/constitution.md from its template."""
def test_self_test_does_not_seed_constitution_without_sync(self, project_dir):
"""Installing a preset does not materialize its constitution by default."""
manager = PresetManager(project_dir)
install_self_test_preset(manager)
memory = project_dir / ".specify" / "memory" / "constitution.md"
assert memory.exists(), "constitution.md was not seeded from the preset"
assert "preset:self-test" in memory.read_text(), (
"constitution.md was not seeded from the self-test preset template"
)
assert not memory.exists()
def test_self_test_reseeds_exact_core_constitution(self, project_dir):
"""An unchanged core constitution is re-seeded from the preset template."""
def test_self_test_preserves_generated_constitution_without_sync(self, project_dir):
"""Preset install and removal preserve generated content without the opt-in."""
resolver = PresetResolver(project_dir)
bundled_core = resolver._find_bundled_core(
"constitution-template", "template", ".md"
@@ -3738,10 +3838,19 @@ class TestSelfTestPreset:
manager = PresetManager(project_dir)
install_self_test_preset(manager)
manager.remove("self-test")
content = memory.read_text()
assert "preset:self-test" in content, "placeholder constitution was not re-seeded"
assert "[PROJECT_NAME]" not in content
assert memory.read_bytes() == core
def test_self_test_seeds_constitution_with_sync(self, project_dir):
"""constitution-sync preserves the previous install-time seeding behavior."""
manager = PresetManager(project_dir)
install_constitution_sync_preset(manager)
install_self_test_preset(manager)
memory = project_dir / ".specify" / "memory" / "constitution.md"
assert "preset:self-test" in memory.read_text()
assert "[PROJECT_NAME]" not in memory.read_text()
@pytest.mark.parametrize(
"provenance_content",
@@ -3769,6 +3878,7 @@ class TestSelfTestPreset:
original = memory.read_bytes()
manager = PresetManager(project_dir)
install_constitution_sync_preset(manager)
install_self_test_preset(manager)
assert memory.read_bytes() == original
@@ -3785,6 +3895,7 @@ class TestSelfTestPreset:
memory.write_text(authored)
manager = PresetManager(project_dir)
install_constitution_sync_preset(manager)
install_self_test_preset(manager)
assert memory.read_text() == authored
@@ -3831,7 +3942,9 @@ class TestSelfTestPreset:
)
)
PresetManager(project_dir).install_from_directory(preset_dir, "0.1.5")
manager = PresetManager(project_dir)
install_constitution_sync_preset(manager)
manager.install_from_directory(preset_dir, "0.1.5")
assert memory.read_text() == authored
assert not (memory.parent / ".constitution-template.json").exists()
@@ -3846,6 +3959,7 @@ class TestSelfTestPreset:
memory.write_text(authored)
manager = PresetManager(project_dir)
install_constitution_sync_preset(manager)
install_self_test_preset(manager)
assert memory.read_text() == authored
@@ -3858,6 +3972,7 @@ class TestSelfTestPreset:
memory.write_text(authored)
manager = PresetManager(project_dir)
install_constitution_sync_preset(manager)
install_self_test_preset(manager)
assert memory.read_text() == authored, "authored constitution was overwritten"
@@ -3915,6 +4030,7 @@ class TestSelfTestPreset:
)
manager = PresetManager(project_dir)
install_constitution_sync_preset(manager)
manager.install_from_directory(preset_dir, "0.1.5")
memory = project_dir / ".specify" / "memory" / "constitution.md"
@@ -3928,6 +4044,7 @@ class TestSelfTestPreset:
):
"""An unchanged generated constitution follows priority and fallback layers."""
manager = PresetManager(project_dir)
install_constitution_sync_preset(manager)
install_self_test_preset(manager)
preset_dir = temp_dir / "higher-priority"
@@ -3975,6 +4092,7 @@ class TestSelfTestPreset:
):
"""Removing a convention layer rematerializes the remaining resolver layer."""
manager = PresetManager(project_dir)
install_constitution_sync_preset(manager)
install_self_test_preset(manager)
manager.install_from_directory(
_make_convention_constitution_preset(temp_dir), "0.1.5", priority=1
@@ -3996,6 +4114,7 @@ class TestSelfTestPreset:
templates_dir = project_dir / ".specify" / "templates"
(templates_dir / "constitution-template.md").write_text("# Core Constitution\n")
manager = PresetManager(project_dir)
install_constitution_sync_preset(manager)
manager.install_from_directory(
_make_convention_constitution_preset(temp_dir), "0.1.5"
)
@@ -4013,6 +4132,7 @@ class TestSelfTestPreset:
):
"""Provenance triggers fallback when a custom-path manifest is invalid."""
manager = PresetManager(project_dir)
install_constitution_sync_preset(manager)
install_self_test_preset(manager)
preset_dir = temp_dir / "custom-constitution"
@@ -4073,9 +4193,9 @@ class TestSelfTestPreset:
manager = PresetManager(project_dir)
with pytest.warns(UserWarning, match="symlinked"):
install_self_test_preset(manager)
install_constitution_sync_preset(manager)
assert manager.registry.is_installed("self-test")
assert manager.registry.is_installed("constitution-sync")
assert not (outside / "constitution.md").exists()
def test_constitution_seed_rejects_dangling_destination_symlink(
@@ -4092,9 +4212,9 @@ class TestSelfTestPreset:
manager = PresetManager(project_dir)
with pytest.warns(UserWarning, match="symlinked"):
install_self_test_preset(manager)
install_constitution_sync_preset(manager)
assert manager.registry.is_installed("self-test")
assert manager.registry.is_installed("constitution-sync")
assert not outside.exists()
def test_constitution_materialization_error_is_nonfatal(
@@ -4133,6 +4253,7 @@ class TestSelfTestPreset:
)
manager = PresetManager(project_dir)
install_constitution_sync_preset(manager)
with pytest.warns(UserWarning, match="Failed to seed constitution"):
manifest = manager.install_from_directory(preset_dir, "0.1.5")
@@ -9786,6 +9907,7 @@ class TestPresetSetPriority:
from specify_cli import app
manager = PresetManager(project_dir)
install_constitution_sync_preset(manager)
install_self_test_preset(manager)
manager.install_from_directory(
_make_convention_constitution_preset(temp_dir), "0.1.5", priority=20
@@ -10031,6 +10153,7 @@ class TestPresetEnableDisable:
from specify_cli import app
manager = PresetManager(project_dir)
install_constitution_sync_preset(manager)
install_self_test_preset(manager)
manager.install_from_directory(
_make_convention_constitution_preset(temp_dir), "0.1.5", priority=1
@@ -10251,6 +10374,29 @@ def test_constitution_commands_guard_against_non_governance_work(command_path):
assert "do not invoke it" in normalized_content or "without invoking it" in normalized_content
def test_core_constitution_command_resolves_template_at_runtime():
"""The core command must consume the composed scaffold on every invocation."""
content = CORE_CONSTITUTION_COMMAND.read_text()
assert "resolve-template.sh constitution-template --json" in content
assert "resolve-template.ps1 constitution-template -Json" in content
assert "resolve_template.py constitution-template --json" in content
assert "parse `TEMPLATE_CONTENT` as the active template" in content
assert "do not continue with only one contributing" in content
assert "Do not write back to any versioned template layer" in content
def test_core_checklist_command_resolves_template_at_runtime():
"""The checklist command must consume the composed scaffold."""
content = (CORE_CONSTITUTION_COMMAND.parent / "checklist.md").read_text(
encoding="utf-8"
)
assert "--template checklist-template" in content
assert "TEMPLATE_CONTENT" in content
assert "Use TEMPLATE_CONTENT as the structural template" in content
class TestLeanPreset:
"""Tests for the lean preset that ships with the repo."""
@@ -12779,10 +12925,10 @@ def test_preset_wrapper_resolves_ghes_asset_when_host_configured(tmp_path, monke
class TestEnsureConstitutionResolverAware:
"""`ensure_constitution_from_template` must resolve through PresetResolver.
The constitution is the only template materialized to a live file rather
than resolved on demand. These tests pin the regression from issue #3272:
a preset-provided ``constitution-template`` must seed memory, while the
core template is used when no preset overrides it.
Init materializes the live constitution once, while later /constitution
runs resolve on demand. These tests pin the regression from issue #3272:
a preset-provided ``constitution-template`` must win during the init seed,
while the core template is used when no preset overrides it.
"""
def _core_constitution(self, project_dir):
@@ -12843,10 +12989,8 @@ class TestEnsureConstitutionResolverAware:
manager = PresetManager(project_dir)
install_self_test_preset(manager)
# Remove the memory file seeded during install to test ensure() in
# isolation; it must re-seed from the preset, not the core template.
memory = project_dir / ".specify" / "memory" / "constitution.md"
memory.unlink()
assert not memory.exists()
ensure_constitution_from_template(project_dir)
@@ -12889,9 +13033,8 @@ class TestEnsureConstitutionResolverAware:
manager = PresetManager(project_dir)
manager.install_from_directory(self._wrap_constitution_preset(temp_dir), "0.1.5")
# Ensure we validate ensure() behavior directly.
memory = project_dir / ".specify" / "memory" / "constitution.md"
memory.unlink()
assert not memory.exists()
ensure_constitution_from_template(project_dir)
content = memory.read_text()
@@ -13260,11 +13403,41 @@ class TestInstalledPresetRichMarkup:
assert result.exit_code == 0, (args, result.output, result.exception)
assert "Broken [/red] tag" in strip_ansi(result.output)
def test_resolve_escapes_template_name(self, project_dir):
"""``preset resolve`` echoes its argument; an unbalanced tag must not crash."""
def test_resolve_rejects_invalid_template_name(self, project_dir):
"""``preset resolve`` rejects names before joining them into paths."""
result = self._invoke(project_dir, ["preset", "resolve", "no[/red]such"])
assert result.exit_code == 1, (result.output, result.exception)
assert "invalid template name" in strip_ansi(result.output)
def test_resolve_rejects_path_traversal(self, project_dir):
"""The resolver rejects traversal before joining names into paths."""
result = self._invoke(
project_dir,
["preset", "resolve", "../../../README"],
)
assert result.exit_code == 1
assert "invalid template name" in strip_ansi(result.output)
def test_resolve_accepts_dotted_command_name(self, project_dir):
"""Documented dotted command identifiers use command resolution."""
result = self._invoke(
project_dir,
["preset", "resolve", "speckit.constitution"],
)
assert result.exit_code == 0, (result.output, result.exception)
assert "no[/red]such" in strip_ansi(result.output)
assert "constitution.md" in strip_ansi(result.output)
def test_resolve_rejects_empty_command_segments(self, project_dir):
"""Dotted command identifiers cannot contain empty path-like segments."""
result = self._invoke(
project_dir,
["preset", "resolve", "speckit..constitution"],
)
assert result.exit_code == 1
assert "invalid template name" in strip_ansi(result.output)
def test_resolve_escapes_layer_path_and_source(self, project_dir):
"""The top-layer path/source lines must render markup literally.
@@ -13358,14 +13531,13 @@ class TestInstalledPresetRichMarkup:
assert "[base]" in output, output
assert "[append]" in output, output
class TestConstitutionSyncPreset:
"""The bundled opt-in ``constitution-sync`` preset re-adds propagation.
"""The bundled opt-in ``constitution-sync`` preset re-adds materialization.
Follow-up to #3790: core ``/constitution`` no longer propagates guidance
into templates. This preset restores that behavior for teams that treat
materialized templates as reviewed artifacts, delivered as a ``wrap`` of
the core command so it stays forward-compatible with core changes.
into templates. Issue #3950 also gates install-time constitution seeding on
this preset. Its command override remains a ``wrap`` of core so it stays
forward-compatible with core changes.
"""
PRESET_DIR = Path(__file__).parent.parent / "presets" / "constitution-sync"
@@ -0,0 +1,753 @@
"""Parity tests for composed runtime template resolution."""
from __future__ import annotations
import json
import os
from pathlib import Path
import pytest
from tests.conftest import requires_bash
from tests.parity_helpers import (
HAS_POWERSHELL,
bash_cmd,
clean_env,
install_composition_stack,
install_scripts,
json_stdout,
make_repo,
ps_cmd,
py_cmd,
run,
)
SCRIPT = "resolve-template"
TEMPLATE = "constitution-template"
def _setup_repo(tmp_path: Path) -> tuple[Path, str]:
repo = make_repo(tmp_path)
install_scripts(repo, SCRIPT)
expected = install_composition_stack(repo, TEMPLATE, "# Core\n")
return repo, expected
@requires_bash
def test_all_variants_emit_composed_template_content(tmp_path: Path) -> None:
repo, expected = _setup_repo(tmp_path)
results = [
run(bash_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo),
run(py_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo),
]
if HAS_POWERSHELL:
results.append(run(ps_cmd(repo, SCRIPT, TEMPLATE, "-Json"), repo))
assert all(result.returncode == 0 for result in results)
assert all(result.stderr == "" for result in results)
assert all(
json_stdout(result)
== {"TEMPLATE_NAME": TEMPLATE, "TEMPLATE_CONTENT": expected}
for result in results
)
@requires_bash
@pytest.mark.parametrize(
"without_registry,core_content",
[
(True, "# Core\n"),
(False, "# Café ✓\n"),
],
ids=["directory_fallback", "unicode"],
)
def test_all_variants_preserve_composition_parity(
tmp_path: Path, without_registry: bool, core_content: str
) -> None:
repo = make_repo(tmp_path)
install_scripts(repo, SCRIPT)
expected = install_composition_stack(repo, TEMPLATE, core_content)
if without_registry:
(repo / ".specify" / "presets" / ".registry").unlink()
expected = (
"# Prepended\n\n\n"
"## Wrapper\n"
f"{core_content}\n"
"## End\n\n\n"
"# Appended\n"
)
results = [
run(bash_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo),
run(py_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo),
]
if HAS_POWERSHELL:
results.append(run(ps_cmd(repo, SCRIPT, TEMPLATE, "-Json"), repo))
assert all(result.returncode == 0 for result in results)
assert all(
json_stdout(result)["TEMPLATE_CONTENT"] == expected
for result in results
)
@requires_bash
def test_all_variants_read_utf8_registry_under_ascii_locale(
tmp_path: Path,
) -> None:
"""Registry/manifest reads must force UTF-8, not the process locale.
With UTF-8 mode disabled and a C locale, the interpreter's default text
encoding is ASCII. Non-ASCII *metadata* in the registry or a manifest must
still resolve, because the resolvers open those files as UTF-8 explicitly.
Template content stays ASCII so the pure-Python variant can emit it on the
ASCII stdout this configuration forces.
"""
repo = make_repo(tmp_path)
install_scripts(repo, SCRIPT)
expected = install_composition_stack(repo, TEMPLATE, "# Core\n")
# Inject non-ASCII metadata into the preset registry and a manifest so a
# locale-dependent decode would raise instead of resolving cleanly.
registry = repo / ".specify" / "presets" / ".registry"
registry_data = json.loads(registry.read_text(encoding="utf-8"))
registry_data["presets"]["wrap-pack"]["description"] = "Café ✓ wrapper"
registry.write_text(
json.dumps(registry_data, separators=(",", ":")) + "\n",
encoding="utf-8",
)
manifest = repo / ".specify" / "presets" / "wrap-pack" / "preset.yml"
manifest.write_text(
manifest.read_text(encoding="utf-8") + ' description: "Café ✓"\n',
encoding="utf-8",
)
env = clean_env()
# Force the interpreter's default text encoding to ASCII so an unqualified
# open() would fail on the non-ASCII metadata above.
env["PYTHONUTF8"] = "0"
env["PYTHONCOERCECLOCALE"] = "0"
env["LC_ALL"] = "C"
env["LANG"] = "C"
results = [
run(bash_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo, env),
run(py_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo, env),
]
if HAS_POWERSHELL:
results.append(run(ps_cmd(repo, SCRIPT, TEMPLATE, "-Json"), repo, env))
assert all(result.returncode == 0 for result in results)
assert all(
json_stdout(result)["TEMPLATE_CONTENT"] == expected
for result in results
)
@requires_bash
@pytest.mark.parametrize(
"template_name",
["missing-template", "../../../outside"],
ids=["missing", "path_traversal"],
)
def test_all_variants_reject_unresolvable_template(
tmp_path: Path, template_name: str
) -> None:
repo = make_repo(tmp_path)
install_scripts(repo, SCRIPT)
(repo / "outside.md").write_text("sensitive content\n", encoding="utf-8")
results = [
run(bash_cmd(repo, SCRIPT, template_name, "--json"), repo),
run(py_cmd(repo, SCRIPT, template_name, "--json"), repo),
]
if HAS_POWERSHELL:
results.append(run(ps_cmd(repo, SCRIPT, template_name, "-Json"), repo))
assert all(result.returncode == 1 for result in results)
assert all(result.stdout == "" for result in results)
assert all("sensitive content" not in result.stderr for result in results)
@requires_bash
def test_all_variants_ignore_traversing_preset_registry_ids(tmp_path: Path) -> None:
repo = make_repo(tmp_path)
install_scripts(repo, SCRIPT)
outside = repo.parent / "outside"
outside.mkdir()
(outside / f"{TEMPLATE}.md").write_text("sensitive content\n", encoding="utf-8")
presets = repo / ".specify" / "presets"
presets.mkdir(parents=True)
(presets / ".registry").write_text(
'{"presets":{"../../../outside":{"enabled":true,"priority":1}}}\n',
encoding="utf-8",
)
results = [
run(bash_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo),
run(py_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo),
]
if HAS_POWERSHELL:
results.append(run(ps_cmd(repo, SCRIPT, TEMPLATE, "-Json"), repo))
assert all(result.returncode == 1 for result in results)
assert all("sensitive content" not in result.stdout for result in results)
@requires_bash
def test_all_variants_support_root_level_preset_convention(tmp_path: Path) -> None:
repo = make_repo(tmp_path)
install_scripts(repo, SCRIPT)
preset = repo / ".specify" / "presets" / "root-pack"
preset.mkdir(parents=True)
(preset / f"{TEMPLATE}.md").write_text("# Root convention\n", encoding="utf-8")
(repo / ".specify" / "presets" / ".registry").write_text(
'{"presets":{"root-pack":{"enabled":true,"priority":1}}}\n',
encoding="utf-8",
)
results = [
run(bash_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo),
run(py_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo),
]
if HAS_POWERSHELL:
results.append(run(ps_cmd(repo, SCRIPT, TEMPLATE, "-Json"), repo))
assert all(result.returncode == 0 for result in results)
assert all(
json_stdout(result)["TEMPLATE_CONTENT"] == "# Root convention\n"
for result in results
)
@requires_bash
def test_all_variants_honor_extension_registry_state_and_priority(
tmp_path: Path,
) -> None:
repo = make_repo(tmp_path)
install_scripts(repo, SCRIPT)
extensions = repo / ".specify" / "extensions"
for extension_id, content in (
("disabled-ext", "# Disabled\n"),
("low-priority", "# Low priority\n"),
("high-priority", "# High priority\n"),
):
template_dir = extensions / extension_id / "templates"
template_dir.mkdir(parents=True)
(template_dir / f"{TEMPLATE}.md").write_text(content, encoding="utf-8")
(extensions / ".registry").write_text(
'{"extensions":{'
'"disabled-ext":{"enabled":null,"priority":1},'
'"low-priority":{"enabled":true,"priority":20},'
'"high-priority":{"enabled":true,"priority":5}'
"}}\n",
encoding="utf-8",
)
results = [
run(bash_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo),
run(py_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo),
]
if HAS_POWERSHELL:
results.append(run(ps_cmd(repo, SCRIPT, TEMPLATE, "-Json"), repo))
assert all(result.returncode == 0 for result in results)
assert all(
json_stdout(result)["TEMPLATE_CONTENT"] == "# High priority\n"
for result in results
)
@requires_bash
def test_all_variants_support_root_level_extension_convention(
tmp_path: Path,
) -> None:
repo = make_repo(tmp_path)
install_scripts(repo, SCRIPT)
extension = repo / ".specify" / "extensions" / "root-extension"
extension.mkdir(parents=True)
(extension / f"{TEMPLATE}.md").write_text(
"# Root extension\n",
encoding="utf-8",
)
(repo / ".specify" / "extensions" / ".registry").write_text(
'{"extensions":{"root-extension":{"enabled":true,"priority":1}}}\n',
encoding="utf-8",
)
results = [
run(bash_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo),
run(py_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo),
]
if HAS_POWERSHELL:
results.append(run(ps_cmd(repo, SCRIPT, TEMPLATE, "-Json"), repo))
assert all(result.returncode == 0 for result in results)
assert all(
json_stdout(result)["TEMPLATE_CONTENT"] == "# Root extension\n"
for result in results
)
@requires_bash
def test_all_variants_treat_extension_registry_ids_case_sensitively(
tmp_path: Path,
) -> None:
repo = make_repo(tmp_path)
install_scripts(repo, SCRIPT)
extension = repo / ".specify" / "extensions" / "foo" / "templates"
extension.mkdir(parents=True)
(extension / f"{TEMPLATE}.md").write_text(
"# Lowercase extension\n",
encoding="utf-8",
)
(repo / ".specify" / "extensions" / ".registry").write_text(
'{"extensions":{"FOO":{"enabled":true,"priority":1}}}\n',
encoding="utf-8",
)
results = [
run(bash_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo),
run(py_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo),
]
if HAS_POWERSHELL:
results.append(run(ps_cmd(repo, SCRIPT, TEMPLATE, "-Json"), repo))
assert all(result.returncode == 0 for result in results)
assert all(
json_stdout(result)["TEMPLATE_CONTENT"] == "# Lowercase extension\n"
for result in results
)
@requires_bash
@pytest.mark.parametrize(
"registry_content",
["{ not valid json", '{"extensions":[]}\n', "[]\n"],
ids=["invalid_json", "non_mapping_extensions", "non_mapping_root"],
)
def test_all_variants_fail_for_malformed_extension_registry(
tmp_path: Path, registry_content: str
) -> None:
"""A corrupt extension registry must fail closed, not silently enable
every on-disk extension directory as unregistered."""
repo = make_repo(tmp_path)
install_scripts(repo, SCRIPT)
extensions = repo / ".specify" / "extensions"
template_dir = extensions / "sneaky-ext" / "templates"
template_dir.mkdir(parents=True)
(template_dir / f"{TEMPLATE}.md").write_text(
"# Should not be served\n", encoding="utf-8"
)
(extensions / ".registry").write_text(registry_content, encoding="utf-8")
results = [
run(bash_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo),
run(py_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo),
]
if HAS_POWERSHELL:
results.append(run(ps_cmd(repo, SCRIPT, TEMPLATE, "-Json"), repo))
assert all(result.returncode != 0 for result in results)
assert all(result.stdout == "" for result in results)
assert all(
"Should not be served" not in result.stdout for result in results
)
@requires_bash
def test_all_variants_fail_when_registry_is_a_directory(
tmp_path: Path,
) -> None:
"""A directory at the extension registry path must fail closed, not be
treated as an absent registry that enables every on-disk extension."""
repo = make_repo(tmp_path)
install_scripts(repo, SCRIPT)
extensions = repo / ".specify" / "extensions"
template_dir = extensions / "sneaky-ext" / "templates"
template_dir.mkdir(parents=True)
(template_dir / f"{TEMPLATE}.md").write_text(
"# Should not be served\n", encoding="utf-8"
)
# Create ``.registry`` as a directory rather than a regular file.
(extensions / ".registry").mkdir()
results = [
run(bash_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo),
run(py_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo),
]
if HAS_POWERSHELL:
results.append(run(ps_cmd(repo, SCRIPT, TEMPLATE, "-Json"), repo))
assert all(result.returncode != 0 for result in results)
assert all(result.stdout == "" for result in results)
@requires_bash
def test_all_variants_fail_when_registry_is_broken_symlink(
tmp_path: Path,
) -> None:
"""A broken symlink at the extension registry path must fail closed across
Bash, Python, and PowerShell resolvers rather than being treated as absent."""
repo = make_repo(tmp_path)
install_scripts(repo, SCRIPT)
extensions = repo / ".specify" / "extensions"
template_dir = extensions / "sneaky-ext" / "templates"
template_dir.mkdir(parents=True)
(template_dir / f"{TEMPLATE}.md").write_text(
"# Should not be served\n", encoding="utf-8"
)
(extensions / ".registry").symlink_to(extensions / "does-not-exist")
results = [
run(bash_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo),
run(py_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo),
]
if HAS_POWERSHELL:
results.append(run(ps_cmd(repo, SCRIPT, TEMPLATE, "-Json"), repo))
assert all(result.returncode != 0 for result in results)
assert all(result.stdout == "" for result in results)
@requires_bash
@pytest.mark.parametrize("base_kind", ["override", "preset"])
def test_all_variants_ignore_malformed_layers_below_replace_base(
tmp_path: Path,
base_kind: str,
) -> None:
repo = make_repo(tmp_path)
install_scripts(repo, SCRIPT)
expected = "# Winning base\r\nBody\r\n"
presets = repo / ".specify" / "presets"
if base_kind == "override":
override = repo / ".specify" / "templates" / "overrides"
override.mkdir(parents=True)
(override / f"{TEMPLATE}.md").write_bytes(expected.encode("utf-8"))
registry = {"presets": {"broken-pack": {"enabled": True, "priority": 1}}}
else:
winning = presets / "winning-pack" / "templates"
winning.mkdir(parents=True)
(winning / f"{TEMPLATE}.md").write_bytes(expected.encode("utf-8"))
registry = {
"presets": {
"winning-pack": {"enabled": True, "priority": 1},
"broken-pack": {"enabled": True, "priority": 2},
}
}
broken = presets / "broken-pack"
broken.mkdir(parents=True)
(broken / "preset.yml").write_text("provides: [\n", encoding="utf-8")
(presets / ".registry").write_text(
json.dumps(registry, separators=(",", ":")) + "\n",
encoding="utf-8",
)
results = [
run(bash_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo),
run(py_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo),
]
if HAS_POWERSHELL:
results.append(run(ps_cmd(repo, SCRIPT, TEMPLATE, "-Json"), repo))
assert all(result.returncode == 0 for result in results)
assert all(
json_stdout(result)["TEMPLATE_CONTENT"] == expected
for result in results
)
@requires_bash
@pytest.mark.parametrize(
("entries", "expected"),
[
(
[
("disabled-pack", {"enabled": False, "priority": 0}),
("numeric-pack", {"enabled": True, "priority": 2}),
("string-pack", {"enabled": True, "priority": "1"}),
],
"# string-pack\n",
),
(
[
("z-pack", {"enabled": True}),
("a-pack", {"enabled": True}),
],
"# a-pack\n",
),
(
[
("float-pack", {"enabled": True, "priority": 5.9}),
("six-pack", {"enabled": True, "priority": 6}),
],
"# float-pack\n",
),
(
[
("a-huge-pack", {"enabled": True, "priority": 2147483648}),
("z-default-pack", {"enabled": True, "priority": "invalid"}),
],
"# z-default-pack\n",
),
(
[
("decimal-string-pack", {"enabled": True, "priority": "5.9"}),
("exponent-string-pack", {"enabled": True, "priority": "1e3"}),
("hex-string-pack", {"enabled": True, "priority": "0x10"}),
("six-pack", {"enabled": True, "priority": 6}),
],
"# six-pack\n",
),
],
ids=[
"mixed_priorities",
"equal_priority_id_tiebreaker",
"float_priority",
"large_integer_priority",
"non_integer_numeric_strings",
],
)
def test_all_variants_normalize_and_tiebreak_preset_priorities(
tmp_path: Path,
entries: list[tuple[str, dict[str, object]]],
expected: str,
) -> None:
repo = make_repo(tmp_path)
install_scripts(repo, SCRIPT)
presets = repo / ".specify" / "presets"
registry: dict[str, object] = {"presets": {}}
registry_presets = registry["presets"]
assert isinstance(registry_presets, dict)
for preset_id, metadata in entries:
template_dir = presets / preset_id / "templates"
template_dir.mkdir(parents=True)
(template_dir / f"{TEMPLATE}.md").write_text(
f"# {preset_id}\n",
encoding="utf-8",
)
registry_presets[preset_id] = metadata
(presets / ".registry").write_text(
json.dumps(registry, separators=(",", ":")) + "\n",
encoding="utf-8",
)
results = [
run(bash_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo),
run(py_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo),
]
if HAS_POWERSHELL:
results.append(run(ps_cmd(repo, SCRIPT, TEMPLATE, "-Json"), repo))
assert all(result.returncode == 0 for result in results)
assert all(
json_stdout(result)["TEMPLATE_CONTENT"] == expected
for result in results
)
@requires_bash
def test_all_variants_fail_when_wrap_placeholder_is_missing(
tmp_path: Path,
) -> None:
repo = make_repo(tmp_path)
install_scripts(repo, SCRIPT)
templates = repo / ".specify" / "templates"
templates.mkdir(parents=True)
(templates / f"{TEMPLATE}.md").write_text("# Core\n", encoding="utf-8")
preset = repo / ".specify" / "presets" / "wrap-pack"
(preset / "templates").mkdir(parents=True)
(preset / "templates" / f"{TEMPLATE}.md").write_text(
"# Broken wrapper\n", encoding="utf-8"
)
(preset / "preset.yml").write_text(
"provides:\n"
" templates:\n"
" - type: template\n"
f" name: {TEMPLATE}\n"
f" file: templates/{TEMPLATE}.md\n"
" strategy: wrap\n",
encoding="utf-8",
)
(repo / ".specify" / "presets" / ".registry").write_text(
'{"presets":{"wrap-pack":{"enabled":true,"priority":1}}}\n',
encoding="utf-8",
)
results = [
run(bash_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo),
run(py_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo),
]
if HAS_POWERSHELL:
results.append(run(ps_cmd(repo, SCRIPT, TEMPLATE, "-Json"), repo))
assert all(result.returncode != 0 for result in results)
assert all(result.stdout == "" for result in results)
@requires_bash
def test_all_variants_fail_when_yaml_parser_is_unavailable(
tmp_path: Path,
) -> None:
repo, _ = _setup_repo(tmp_path)
blocker = tmp_path / "blocker"
blocker.mkdir()
(blocker / "yaml.py").write_text(
"raise ImportError('simulated missing PyYAML')\n",
encoding="utf-8",
)
env = clean_env()
env["PYTHONPATH"] = str(blocker)
results = [
run(bash_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo, env),
run(py_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo, env),
]
if HAS_POWERSHELL:
results.append(
run(ps_cmd(repo, SCRIPT, TEMPLATE, "-Json"), repo, env)
)
assert all(result.returncode != 0 for result in results)
assert all(result.stdout == "" for result in results)
@requires_bash
def test_bash_fails_when_override_read_fails(tmp_path: Path) -> None:
repo = make_repo(tmp_path)
install_scripts(repo, SCRIPT)
override = repo / ".specify" / "templates" / "overrides"
override.mkdir(parents=True)
(override / f"{TEMPLATE}.md").write_text("# Override\n", encoding="utf-8")
shim_dir = tmp_path / "bin"
shim_dir.mkdir()
cat_shim = shim_dir / "cat"
cat_shim.write_text(
"#!/bin/sh\n"
"case \"$1\" in\n"
" */.specify/templates/overrides/*) exit 1 ;;\n"
"esac\n"
"exec /bin/cat \"$@\"\n",
encoding="utf-8",
)
cat_shim.chmod(0o755)
env = clean_env()
env["PATH"] = f"{shim_dir}{os.pathsep}{env.get('PATH', '')}"
result = run(bash_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo, env)
assert result.returncode != 0
assert result.stdout == ""
@requires_bash
@pytest.mark.parametrize(
"manifest_content",
[
"provides: [\n",
"",
"provides:\n templates:\n - null\n",
"provides:\n templates: {}\n",
"preset:\n id: wrap-pack\n",
"provides:\n templates: []\n",
f"""provides:
templates:
- type: template
name: {TEMPLATE}
file: null
strategy: wrap
""",
f"""provides:
templates:
- type: template
name: {TEMPLATE}
file: templates/{TEMPLATE}.md
strategy: 123
""",
f"""provides:
templates:
- type: template
name: {TEMPLATE}
file: templates/{TEMPLATE}.md
strategy: wrap
- type: template
name: unrelated-template
file: null
strategy: append
""",
f"""provides:
templates:
- name: {TEMPLATE}
file: templates/{TEMPLATE}.md
strategy: wrap
- type: template
name: unrelated-template
file: templates/other.md
""",
f"""provides:
templates:
- type: template
name: {TEMPLATE}
file: templates/{TEMPLATE}.md
strategy: wrap
- type: template
name: unrelated-template
""",
f"""provides:
templates:
- type: template
name: {TEMPLATE}
file: templates/{TEMPLATE}.md
strategy: wrap
- type: bogus
name: unrelated-template
file: templates/other.md
""",
f"""provides:
templates:
- type: template
name: {TEMPLATE}
file: templates/{TEMPLATE}.md
strategy: wrap
- type: template
name: unrelated-template
file: templates/other.md
strategy: merge
""",
],
ids=[
"invalid_yaml",
"empty_document",
"non_mapping_template_entry",
"non_list_templates",
"missing_provides",
"empty_templates",
"non_string_file",
"non_string_strategy",
"malformed_entry_after_match",
"entry_missing_type",
"entry_missing_file",
"unsupported_type",
"unsupported_strategy",
],
)
def test_all_variants_fail_for_malformed_preset_manifest(
tmp_path: Path,
manifest_content: str,
) -> None:
repo, _ = _setup_repo(tmp_path)
(
repo / ".specify" / "presets" / "wrap-pack" / "preset.yml"
).write_text(manifest_content, encoding="utf-8")
results = [
run(bash_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo),
run(py_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo),
]
if HAS_POWERSHELL:
results.append(run(ps_cmd(repo, SCRIPT, TEMPLATE, "-Json"), repo))
assert all(result.returncode != 0 for result in results)
assert all(result.stdout == "" for result in results)
+69 -11
View File
@@ -11,7 +11,9 @@ from tests.parity_helpers import (
HAS_POWERSHELL,
POWERSHELL_EXE,
bash_cmd,
break_wrap_layer,
clean_env,
install_composition_stack,
install_scripts,
json_stdout,
make_repo,
@@ -60,7 +62,57 @@ def test_python_fresh_copy_matches_bash(tmp_path: Path) -> None:
)
for repo in (repo_a, repo_b):
plan = repo / "specs" / "001-my-feature" / "plan.md"
assert plan.read_text(encoding="utf-8") == TEMPLATE_BODY
assert plan.read_bytes() == TEMPLATE_BODY.encode("utf-8")
@requires_bash
def test_all_variants_materialize_composed_plan_template(tmp_path: Path) -> None:
repos = [
_setup_repo(tmp_path, "bash"),
_setup_repo(tmp_path, "powershell"),
_setup_repo(tmp_path, "python"),
]
expected = ""
for current in repos:
expected = install_composition_stack(
current, "plan-template", TEMPLATE_BODY
)
results = [
run(bash_cmd(repos[0], SCRIPT, "--json"), repos[0]),
run(py_cmd(repos[2], SCRIPT, "--json"), repos[2]),
]
checked_repos = [repos[0], repos[2]]
if HAS_POWERSHELL:
results.insert(1, run(ps_cmd(repos[1], SCRIPT, "-Json"), repos[1]))
checked_repos.insert(1, repos[1])
assert all(result.returncode == 0 for result in results)
for current in checked_repos:
assert (
current / "specs" / "001-my-feature" / "plan.md"
).read_text(encoding="utf-8") == expected
@requires_bash
def test_all_variants_fail_for_broken_plan_composition(tmp_path: Path) -> None:
repos = [
_setup_repo(tmp_path, "bash"),
_setup_repo(tmp_path, "powershell"),
_setup_repo(tmp_path, "python"),
]
for current in repos:
install_composition_stack(current, "plan-template", TEMPLATE_BODY)
break_wrap_layer(current, "plan-template")
results = [
run(bash_cmd(repos[0], SCRIPT, "--json"), repos[0]),
run(py_cmd(repos[2], SCRIPT, "--json"), repos[2]),
]
if HAS_POWERSHELL:
results.append(run(ps_cmd(repos[1], SCRIPT, "-Json"), repos[1]))
assert all(result.returncode != 0 for result in results)
@requires_bash
@@ -119,13 +171,19 @@ def test_python_missing_template_matches_bash(tmp_path: Path) -> None:
@requires_bash
@pytest.mark.parametrize(
"registry",
("registry", "expected"),
[
'{"presets": {"alpha": {"priority": "high"}, "beta": {"priority": 1}}}',
'{"presets": {"alpha": {"priority": 2}, "beta": {"priority": 1}, "gamma": {"priority": null}}}',
"[]",
'{"presets":[]}',
'{"presets":null}',
(
'{"presets": {"alpha": {"priority": "high"}, "beta": {"priority": 1}}}',
"# beta plan\n",
),
(
'{"presets": {"alpha": {"priority": 2}, "beta": {"priority": 1}, "gamma": {"priority": null}}}',
"# beta plan\n",
),
("[]", "# alpha plan\n"),
('{"presets":[]}', "# alpha plan\n"),
('{"presets":null}', "# alpha plan\n"),
],
ids=[
"mixed_priorities",
@@ -135,10 +193,10 @@ def test_python_missing_template_matches_bash(tmp_path: Path) -> None:
"null_presets",
],
)
def test_all_variants_broken_registry_falls_back_to_dir_scan(
tmp_path: Path, registry: str
def test_all_variants_normalize_or_fallback_for_registry(
tmp_path: Path, registry: str, expected: str
) -> None:
"""Malformed registries fall back to the alphabetical directory scan."""
"""Priorities normalize canonically; malformed shapes fall back to directories."""
repos = [
_setup_repo(tmp_path, "bash", template=False),
_setup_repo(tmp_path, "powershell", template=False),
@@ -183,7 +241,7 @@ def test_all_variants_broken_registry_falls_back_to_dir_scan(
) == 1
for _, repo in results:
plan = repo / "specs" / "001-my-feature" / "plan.md"
assert plan.read_text(encoding="utf-8") == "# alpha plan\n"
assert plan.read_text(encoding="utf-8") == expected
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
+1 -1
View File
@@ -719,7 +719,7 @@ def test_setup_tasks_ps_core_template_resolved(tasks_repo: Path) -> None:
[exe, "-NoProfile", "-File", str(script), "-Json"],
cwd=tasks_repo,
capture_output=True,
text=True,
encoding="utf-8",
check=False,
env=_clean_env(),
)
+38
View File
@@ -10,7 +10,9 @@ from tests.conftest import requires_bash
from tests.parity_helpers import (
HAS_POWERSHELL,
bash_cmd,
break_wrap_layer,
clean_env,
install_composition_stack,
install_scripts,
json_stdout,
make_repo,
@@ -87,6 +89,42 @@ def test_python_override_template_wins_matches_bash(repo: Path) -> None:
assert json_stdout(py)["TASKS_TEMPLATE"].endswith("overrides/tasks-template.md")
@requires_bash
def test_all_variants_return_composed_tasks_template(repo: Path) -> None:
expected = install_composition_stack(
repo, "tasks-template", "# Tasks Template\n"
)
results = [
run(bash_cmd(repo, SCRIPT, "--json"), repo),
run(py_cmd(repo, SCRIPT, "--json"), repo),
]
if HAS_POWERSHELL:
results.append(run(ps_cmd(repo, SCRIPT, "-Json"), repo))
assert all(result.returncode == 0 for result in results)
assert all(
json_stdout(result)["TASKS_TEMPLATE_CONTENT"] == expected
for result in results
)
@requires_bash
def test_all_variants_fail_for_broken_tasks_composition(repo: Path) -> None:
install_composition_stack(repo, "tasks-template", "# Tasks Template\n")
break_wrap_layer(repo, "tasks-template")
results = [
run(bash_cmd(repo, SCRIPT, "--json"), repo),
run(py_cmd(repo, SCRIPT, "--json"), repo),
]
if HAS_POWERSHELL:
results.append(run(ps_cmd(repo, SCRIPT, "-Json"), repo))
assert all(result.returncode != 0 for result in results)
assert all(result.stdout == "" for result in results)
@requires_bash
@pytest.mark.parametrize(
"missing",