16cfab7724
* 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
320 lines
13 KiB
PowerShell
320 lines
13 KiB
PowerShell
#!/usr/bin/env pwsh
|
|
# Create a new feature
|
|
[CmdletBinding()]
|
|
param(
|
|
[switch]$Json,
|
|
[switch]$AllowExistingBranch,
|
|
[switch]$DryRun,
|
|
[string]$ShortName,
|
|
[Parameter()]
|
|
[string]$Number = '',
|
|
[switch]$Timestamp,
|
|
[switch]$Help,
|
|
[Parameter(Position = 0, ValueFromRemainingArguments = $true)]
|
|
[string[]]$FeatureDescription
|
|
)
|
|
$ErrorActionPreference = 'Stop'
|
|
$maxBranchLength = 244
|
|
|
|
# Show help if requested
|
|
if ($Help) {
|
|
Write-Host "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>"
|
|
Write-Host ""
|
|
Write-Host "Options:"
|
|
Write-Host " -Json Output in JSON format"
|
|
Write-Host " -DryRun Compute feature name and paths without creating directories or files"
|
|
Write-Host " -AllowExistingBranch Reuse an existing feature directory if it already exists"
|
|
Write-Host " -ShortName <name> Provide a custom short name (2-4 words) for the feature"
|
|
Write-Host " -Number N Prefer a feature number (auto-corrected if its specs prefix exists)"
|
|
Write-Host " -Timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
|
|
Write-Host " -Help Show this help message"
|
|
Write-Host ""
|
|
Write-Host "Examples:"
|
|
Write-Host " ./create-new-feature.ps1 'Add user authentication system' -ShortName 'user-auth'"
|
|
Write-Host " ./create-new-feature.ps1 'Implement OAuth2 integration for API'"
|
|
Write-Host " ./create-new-feature.ps1 -Timestamp -ShortName 'user-auth' 'Add user authentication'"
|
|
exit 0
|
|
}
|
|
|
|
# Check if feature description provided
|
|
if (-not $FeatureDescription -or $FeatureDescription.Count -eq 0) {
|
|
Write-Error "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>"
|
|
exit 1
|
|
}
|
|
|
|
$featureDesc = ($FeatureDescription -join ' ').Trim()
|
|
|
|
# Validate description is not empty after trimming (e.g., user passed only whitespace)
|
|
if ([string]::IsNullOrWhiteSpace($featureDesc)) {
|
|
Write-Error "Error: Feature description cannot be empty or contain only whitespace"
|
|
exit 1
|
|
}
|
|
|
|
function Get-HighestNumberFromSpecs {
|
|
param([string]$SpecsDir)
|
|
|
|
[long]$highest = 0
|
|
if (Test-Path $SpecsDir) {
|
|
Get-ChildItem -Path $SpecsDir -Directory | ForEach-Object {
|
|
# Match sequential prefixes (>=3 digits), but skip timestamp dirs.
|
|
if ($_.Name -match '^(\d{3,})-' -and $_.Name -notmatch '^\d{8}-\d{6}-') {
|
|
[long]$num = 0
|
|
if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) {
|
|
$highest = $num
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return $highest
|
|
}
|
|
|
|
function Test-SpecPrefixInUse {
|
|
param(
|
|
[string]$SpecsDir,
|
|
[string]$FeatureNum
|
|
)
|
|
|
|
if (-not (Test-Path -LiteralPath $SpecsDir -PathType Container)) {
|
|
return $false
|
|
}
|
|
|
|
return $null -ne (Get-ChildItem -LiteralPath $SpecsDir -Directory -ErrorAction SilentlyContinue |
|
|
Where-Object { $_.Name -like "$FeatureNum-*" } |
|
|
Select-Object -First 1)
|
|
}
|
|
|
|
function ConvertTo-CleanBranchName {
|
|
param([string]$Name)
|
|
|
|
return $Name.ToLower() -replace '[^a-z0-9]', '-' -replace '-{2,}', '-' -replace '^-', '' -replace '-$', ''
|
|
}
|
|
|
|
function Get-FittedBranchName {
|
|
param(
|
|
[string]$FeatureNum,
|
|
[string]$BranchSuffix
|
|
)
|
|
|
|
$fittedName = "$FeatureNum-$BranchSuffix"
|
|
if ($fittedName.Length -gt $maxBranchLength) {
|
|
$prefixLength = $FeatureNum.Length + 1
|
|
$maxSuffixLength = $maxBranchLength - $prefixLength
|
|
$truncatedSuffix = $BranchSuffix.Substring(0, [Math]::Min($BranchSuffix.Length, $maxSuffixLength))
|
|
$truncatedSuffix = $truncatedSuffix -replace '-$', ''
|
|
$fittedName = "$FeatureNum-$truncatedSuffix"
|
|
}
|
|
|
|
return $fittedName
|
|
}
|
|
# Load common functions (includes Get-RepoRoot and Resolve-Template)
|
|
. "$PSScriptRoot/common.ps1"
|
|
|
|
# Use common.ps1 functions which prioritize .specify
|
|
$repoRoot = Get-RepoRoot
|
|
|
|
Set-Location $repoRoot
|
|
|
|
$specsDir = Join-Path $repoRoot 'specs'
|
|
if (-not $DryRun) {
|
|
New-Item -ItemType Directory -Path $specsDir -Force | Out-Null
|
|
}
|
|
|
|
# Function to generate branch name with stop word filtering and length filtering
|
|
function Get-BranchName {
|
|
param([string]$Description)
|
|
|
|
# Common stop words to filter out
|
|
$stopWords = @(
|
|
'i', 'a', 'an', 'the', 'to', 'for', 'of', 'in', 'on', 'at', 'by', 'with', 'from',
|
|
'is', 'are', 'was', 'were', 'be', 'been', 'being', 'have', 'has', 'had',
|
|
'do', 'does', 'did', 'will', 'would', 'should', 'could', 'can', 'may', 'might', 'must', 'shall',
|
|
'this', 'that', 'these', 'those', 'my', 'your', 'our', 'their',
|
|
'want', 'need', 'add', 'get', 'set'
|
|
)
|
|
|
|
# Convert to lowercase and extract words (alphanumeric only)
|
|
$cleanName = $Description.ToLower() -replace '[^a-z0-9\s]', ' '
|
|
$words = $cleanName -split '\s+' | Where-Object { $_ }
|
|
|
|
# Filter words: remove stop words and words shorter than 3 chars (unless they're uppercase acronyms in original)
|
|
$meaningfulWords = @()
|
|
foreach ($word in $words) {
|
|
# Skip stop words
|
|
if ($stopWords -contains $word) { continue }
|
|
|
|
# Keep words that are length >= 3 OR appear as uppercase in original (likely acronyms)
|
|
if ($word.Length -ge 3) {
|
|
$meaningfulWords += $word
|
|
} elseif ($Description -cmatch "\b$($word.ToUpper())\b") {
|
|
# Keep short words only if they appear as uppercase in original (likely
|
|
# acronyms). Use -cmatch so the comparison is case-sensitive, matching the
|
|
# bash script's case-sensitive grep; -match would be case-insensitive and
|
|
# would keep every short word.
|
|
$meaningfulWords += $word
|
|
}
|
|
}
|
|
|
|
# If we have meaningful words, use first 3-4 of them
|
|
if ($meaningfulWords.Count -gt 0) {
|
|
$maxWords = if ($meaningfulWords.Count -eq 4) { 4 } else { 3 }
|
|
$result = ($meaningfulWords | Select-Object -First $maxWords) -join '-'
|
|
return $result
|
|
} else {
|
|
# Fallback to original logic if no meaningful words found
|
|
$result = ConvertTo-CleanBranchName -Name $Description
|
|
$fallbackWords = ($result -split '-') | Where-Object { $_ } | Select-Object -First 3
|
|
return [string]::Join('-', $fallbackWords)
|
|
}
|
|
}
|
|
|
|
# Generate branch name
|
|
if ($ShortName) {
|
|
# Use provided short name, just clean it up
|
|
$branchSuffix = ConvertTo-CleanBranchName -Name $ShortName
|
|
} else {
|
|
# Generate from description with smart filtering
|
|
$branchSuffix = Get-BranchName -Description $featureDesc
|
|
}
|
|
|
|
# Treat an explicit empty string as omitted, matching the bash and Python twins.
|
|
$hasNumber = $PSBoundParameters.ContainsKey('Number') -and $Number -ne ''
|
|
|
|
# Warn if -Number and -Timestamp are both specified.
|
|
if ($Timestamp -and $hasNumber) {
|
|
[Console]::Error.WriteLine("[specify] Warning: -Number is ignored when -Timestamp is used")
|
|
$Number = ''
|
|
}
|
|
|
|
# Determine branch prefix
|
|
if ($Timestamp) {
|
|
$featureNum = Get-Date -Format 'yyyyMMdd-HHmmss'
|
|
$branchName = "$featureNum-$branchSuffix"
|
|
} else {
|
|
# Determine branch number from existing feature directories. Auto-detect only
|
|
# when -Number was not supplied; an explicit value (including 0) is honored,
|
|
# matching the bash twin's `[ -z "$BRANCH_NUMBER" ]` check.
|
|
[long]$resolvedNumber = 0
|
|
if (-not $hasNumber) {
|
|
$highestNumber = Get-HighestNumberFromSpecs -SpecsDir $specsDir
|
|
if ($highestNumber -eq [long]::MaxValue) {
|
|
Write-Error "Error: feature number must be between 0 and $([long]::MaxValue), got '9223372036854775808'"
|
|
exit 1
|
|
}
|
|
$resolvedNumber = $highestNumber + 1
|
|
} elseif ($Number -notmatch '^[0-9]+$') {
|
|
Write-Error "Error: -Number must be an unsigned integer, got '$Number'"
|
|
exit 1
|
|
} elseif (-not [long]::TryParse($Number, [ref]$resolvedNumber)) {
|
|
Write-Error "Error: -Number must be between 0 and $([long]::MaxValue), got '$Number'"
|
|
exit 1
|
|
}
|
|
|
|
$featureNum = ('{0:000}' -f $resolvedNumber)
|
|
|
|
# Treat an explicit number as a preference when its prefix is already used
|
|
# by a feature directory. Auto-detected numbers are already conflict-free.
|
|
$specConflict = $false
|
|
if ($hasNumber -and (Test-Path -LiteralPath $specsDir -PathType Container)) {
|
|
$requestedBranchName = Get-FittedBranchName -FeatureNum $featureNum -BranchSuffix $branchSuffix
|
|
$requestedDir = Join-Path $specsDir $requestedBranchName
|
|
if (-not $AllowExistingBranch -or -not (Test-Path -LiteralPath $requestedDir -PathType Container)) {
|
|
$specConflict = Test-SpecPrefixInUse -SpecsDir $specsDir -FeatureNum $featureNum
|
|
}
|
|
}
|
|
|
|
if ($specConflict) {
|
|
$requestedNum = $featureNum
|
|
$highestNumber = Get-HighestNumberFromSpecs -SpecsDir $specsDir
|
|
$resolvedNumber = $highestNumber
|
|
do {
|
|
if ($resolvedNumber -eq [long]::MaxValue) {
|
|
Write-Error "Error: feature number must be between 0 and $([long]::MaxValue), got '9223372036854775808'"
|
|
exit 1
|
|
}
|
|
$resolvedNumber++
|
|
$featureNum = ('{0:000}' -f $resolvedNumber)
|
|
} while (Test-SpecPrefixInUse -SpecsDir $specsDir -FeatureNum $featureNum)
|
|
[Console]::Error.WriteLine("[specify] Warning: -Number $requestedNum conflicts with an existing spec directory; using $featureNum instead")
|
|
}
|
|
|
|
}
|
|
|
|
# GitHub enforces a 244-byte limit on branch names
|
|
# Validate and truncate if necessary
|
|
$originalBranchName = "$featureNum-$branchSuffix"
|
|
$branchName = Get-FittedBranchName -FeatureNum $featureNum -BranchSuffix $branchSuffix
|
|
if ($branchName -ne $originalBranchName) {
|
|
[Console]::Error.WriteLine("[specify] Warning: Branch name exceeded GitHub's 244-byte limit")
|
|
[Console]::Error.WriteLine("[specify] Original: $originalBranchName ($($originalBranchName.Length) bytes)")
|
|
[Console]::Error.WriteLine("[specify] Truncated to: $branchName ($($branchName.Length) bytes)")
|
|
}
|
|
|
|
$featureDir = Join-Path $specsDir $branchName
|
|
$specFile = Join-Path $featureDir 'spec.md'
|
|
|
|
if (-not $DryRun) {
|
|
if ((Test-Path -LiteralPath $featureDir -PathType Container) -and -not $AllowExistingBranch) {
|
|
if ($Timestamp) {
|
|
Write-Error "Error: Feature directory '$featureDir' already exists. Rerun to get a new timestamp or use a different -ShortName."
|
|
} else {
|
|
Write-Error "Error: Feature directory '$featureDir' already exists. Please use a different feature name or specify a different number with -Number."
|
|
}
|
|
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 ($needsSpec) {
|
|
if ($null -ne $content) {
|
|
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
|
[System.IO.File]::WriteAllText($specFile, $content, $utf8NoBom)
|
|
} else {
|
|
# Match the bash twin (create-new-feature.sh): warn on stderr that no
|
|
# spec template was found before creating an empty spec file, so the
|
|
# missing-template signal is not silently swallowed on Windows.
|
|
[Console]::Error.WriteLine("Warning: Spec template not found; created empty spec file")
|
|
New-Item -ItemType File -Path $specFile -Force | Out-Null
|
|
}
|
|
}
|
|
|
|
# Persist to .specify/feature.json so downstream commands can find the feature
|
|
Save-FeatureJson -RepoRoot $repoRoot -FeatureDirectory $featureDir
|
|
|
|
# Set environment variables for the current session
|
|
$env:SPECIFY_FEATURE = $branchName
|
|
$env:SPECIFY_FEATURE_DIRECTORY = $featureDir
|
|
|
|
$quotedBranchName = "'" + $branchName.Replace("'", "''") + "'"
|
|
$quotedFeatureDir = "'" + $featureDir.Replace("'", "''") + "'"
|
|
$featureAssignment = '$env:SPECIFY_FEATURE = ' + $quotedBranchName
|
|
$directoryAssignment = '$env:SPECIFY_FEATURE_DIRECTORY = ' + $quotedFeatureDir
|
|
[Console]::Error.WriteLine("# To persist: $featureAssignment")
|
|
[Console]::Error.WriteLine("# $directoryAssignment")
|
|
}
|
|
|
|
if ($Json) {
|
|
$obj = [PSCustomObject]@{
|
|
BRANCH_NAME = $branchName
|
|
SPEC_FILE = $specFile
|
|
FEATURE_NUM = $featureNum
|
|
}
|
|
if ($DryRun) {
|
|
$obj | Add-Member -NotePropertyName 'DRY_RUN' -NotePropertyValue $true
|
|
}
|
|
$obj | ConvertTo-Json -Compress
|
|
} else {
|
|
Write-Output "BRANCH_NAME: $branchName"
|
|
Write-Output "SPEC_FILE: $specFile"
|
|
Write-Output "FEATURE_NUM: $featureNum"
|
|
if (-not $DryRun) {
|
|
Write-Output "# To persist in your shell: $featureAssignment"
|
|
Write-Output "# $directoryAssignment"
|
|
}
|
|
}
|