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
+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)"