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
co-authored by Copilot App
parent 1a60d1b6b8
commit 16cfab7724
45 changed files with 2721 additions and 361 deletions
+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")